black formatting
All checks were successful
Python CI / test (push) Successful in 1m20s
Lint / lint (push) Successful in 1m4s
Tests / test (3.11) (push) Successful in 1m27s
Tests / test (3.12) (push) Successful in 2m25s
Tests / test (3.13) (push) Successful in 2m52s
Tests / test (3.14) (push) Successful in 1m9s

This commit is contained in:
Duncan Tourolle 2025-11-27 23:07:16 +01:00
parent 3b2301b547
commit f6ed11b0bc
107 changed files with 6940 additions and 4410 deletions

View File

@ -65,14 +65,10 @@ class ElementMaximizer:
# Calculate distances between rectangles # Calculate distances between rectangles
horizontal_gap = max( horizontal_gap = max(
other_x - (x + w), # Other is to the right other_x - (x + w), x - (other_x + other_w) # Other is to the right # Other is to the left
x - (other_x + other_w) # Other is to the left
) )
vertical_gap = max( vertical_gap = max(other_y - (y + h), y - (other_y + other_h)) # Other is below # Other is above
other_y - (y + h), # Other is below
y - (other_y + other_h) # Other is above
)
# If rectangles overlap or are too close in both dimensions # If rectangles overlap or are too close in both dimensions
if horizontal_gap < self.min_gap and vertical_gap < self.min_gap: if horizontal_gap < self.min_gap and vertical_gap < self.min_gap:
@ -80,8 +76,14 @@ class ElementMaximizer:
return False return False
def find_max_scale(self, elem_idx: int, current_scale: float, max_search_scale: float = 3.0, def find_max_scale(
tolerance: float = 0.001, max_iterations: int = 20) -> float: self,
elem_idx: int,
current_scale: float,
max_search_scale: float = 3.0,
tolerance: float = 0.001,
max_iterations: int = 20,
) -> float:
""" """
Use binary search to find the maximum scale factor for an element. Use binary search to find the maximum scale factor for an element.
@ -171,8 +173,10 @@ class ElementMaximizer:
ow, oh = other.size ow, oh = other.size
# Check if rectangles overlap (with min_gap consideration) # Check if rectangles overlap (with min_gap consideration)
if (abs((x + w/2) - (ox + ow/2)) < (w + ow)/2 + self.min_gap and if (
abs((y + h/2) - (oy + oh/2)) < (h + oh)/2 + self.min_gap): abs((x + w / 2) - (ox + ow / 2)) < (w + ow) / 2 + self.min_gap
and abs((y + h / 2) - (oy + oh / 2)) < (h + oh) / 2 + self.min_gap
):
return True return True
return False return False
@ -233,7 +237,9 @@ class ElementMaximizer:
self.center_element_horizontally(elem) self.center_element_horizontally(elem)
self.center_element_vertically(elem) self.center_element_vertically(elem)
def maximize(self, max_iterations: int = 100, growth_rate: float = 0.05) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]: def maximize(
self, max_iterations: int = 100, growth_rate: float = 0.05
) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
""" """
Execute the maximization algorithm. Execute the maximization algorithm.
@ -365,7 +371,9 @@ class AlignmentManager:
return changes return changes
@staticmethod @staticmethod
def align_horizontal_center(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]: def align_horizontal_center(
elements: List[BaseLayoutElement],
) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
""" """
Align all elements to horizontal center. Align all elements to horizontal center.
@ -413,7 +421,9 @@ class AlignmentManager:
return changes return changes
@staticmethod @staticmethod
def make_same_size(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]: def make_same_size(
elements: List[BaseLayoutElement],
) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
""" """
Make all elements the same size as the first element. Make all elements the same size as the first element.
@ -435,7 +445,9 @@ class AlignmentManager:
return changes return changes
@staticmethod @staticmethod
def make_same_width(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]: def make_same_width(
elements: List[BaseLayoutElement],
) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
""" """
Make all elements the same width as the first element. Make all elements the same width as the first element.
@ -457,7 +469,9 @@ class AlignmentManager:
return changes return changes
@staticmethod @staticmethod
def make_same_height(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]: def make_same_height(
elements: List[BaseLayoutElement],
) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
""" """
Make all elements the same height as the first element. Make all elements the same height as the first element.
@ -479,7 +493,9 @@ class AlignmentManager:
return changes return changes
@staticmethod @staticmethod
def distribute_horizontally(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]: def distribute_horizontally(
elements: List[BaseLayoutElement],
) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
""" """
Distribute elements evenly across horizontal span. Distribute elements evenly across horizontal span.
@ -613,7 +629,9 @@ class AlignmentManager:
return changes return changes
@staticmethod @staticmethod
def fit_to_page_width(element: BaseLayoutElement, page_width: float) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]: def fit_to_page_width(
element: BaseLayoutElement, page_width: float
) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
""" """
Resize element to fit page width while maintaining aspect ratio. Resize element to fit page width while maintaining aspect ratio.
@ -638,7 +656,9 @@ class AlignmentManager:
return (element, old_pos, old_size) return (element, old_pos, old_size)
@staticmethod @staticmethod
def fit_to_page_height(element: BaseLayoutElement, page_height: float) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]: def fit_to_page_height(
element: BaseLayoutElement, page_height: float
) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
""" """
Resize element to fit page height while maintaining aspect ratio. Resize element to fit page height while maintaining aspect ratio.
@ -663,7 +683,9 @@ class AlignmentManager:
return (element, old_pos, old_size) return (element, old_pos, old_size)
@staticmethod @staticmethod
def fit_to_page(element: BaseLayoutElement, page_width: float, page_height: float) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]: def fit_to_page(
element: BaseLayoutElement, page_width: float, page_height: float
) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
""" """
Resize element to fit within page dimensions while maintaining aspect ratio. Resize element to fit within page dimensions while maintaining aspect ratio.
@ -702,7 +724,7 @@ class AlignmentManager:
page_size: Tuple[float, float], page_size: Tuple[float, float],
min_gap: float = 2.0, min_gap: float = 2.0,
max_iterations: int = 100, max_iterations: int = 100,
growth_rate: float = 0.05 growth_rate: float = 0.05,
) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]: ) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
""" """
Maximize element sizes using a crystal growth algorithm. Maximize element sizes using a crystal growth algorithm.
@ -729,7 +751,7 @@ class AlignmentManager:
element: BaseLayoutElement, element: BaseLayoutElement,
page_size: Tuple[float, float], page_size: Tuple[float, float],
other_elements: List[BaseLayoutElement], other_elements: List[BaseLayoutElement],
min_gap: float = 10.0 min_gap: float = 10.0,
) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]: ) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
""" """
Expand a single element until it is min_gap away from page edges or other elements. Expand a single element until it is min_gap away from page edges or other elements.

View File

@ -6,9 +6,16 @@ import os
import shutil 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,
QListWidget, QListWidgetItem, QFileDialog, QGroupBox, QVBoxLayout,
QMessageBox QHBoxLayout,
QLabel,
QPushButton,
QListWidget,
QListWidgetItem,
QFileDialog,
QGroupBox,
QMessageBox,
) )
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
@ -124,10 +131,7 @@ class AssetHealDialog(QDialog):
def _add_search_path(self): def _add_search_path(self):
"""Add a search path""" """Add a search path"""
directory = QFileDialog.getExistingDirectory( directory = QFileDialog.getExistingDirectory(
self, self, "Select Search Path for Assets", "", QFileDialog.Option.ShowDirsOnly
"Select Search Path for Assets",
"",
QFileDialog.Option.ShowDirsOnly
) )
if directory: if directory:
@ -203,7 +207,7 @@ class AssetHealDialog(QDialog):
# Check if the found file needs to be imported # Check if the found file needs to be imported
# (i.e., it's not already in the assets folder) # (i.e., it's not already in the assets folder)
needs_import = True needs_import = True
if not os.path.isabs(asset_path) and asset_path.startswith('assets/'): if not os.path.isabs(asset_path) and asset_path.startswith("assets/"):
# It's already a relative assets path, just missing from disk # It's already a relative assets path, just missing from disk
# 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)

View File

@ -10,48 +10,48 @@ from pathlib import Path
class AssetManager: class AssetManager:
"""Manages project assets with automatic reference counting and cleanup""" """Manages project assets with automatic reference counting and cleanup"""
def __init__(self, project_folder: str): def __init__(self, project_folder: str):
""" """
Initialize AssetManager. Initialize AssetManager.
Args: Args:
project_folder: Root folder for the project project_folder: Root folder for the project
""" """
self.project_folder = project_folder self.project_folder = project_folder
self.assets_folder = os.path.join(project_folder, "assets") self.assets_folder = os.path.join(project_folder, "assets")
self.reference_counts: Dict[str, int] = {} # {relative_path: count} self.reference_counts: Dict[str, int] = {} # {relative_path: count}
# Create assets folder if it doesn't exist # Create assets folder if it doesn't exist
os.makedirs(self.assets_folder, exist_ok=True) os.makedirs(self.assets_folder, exist_ok=True)
def import_asset(self, source_path: str) -> str: def import_asset(self, source_path: str) -> str:
""" """
Import an asset into the project by copying it to the assets folder. Import an asset into the project by copying it to the assets folder.
Args: Args:
source_path: Path to the source file source_path: Path to the source file
Returns: Returns:
Relative path to the imported asset (e.g., "assets/photo_001.jpg") Relative path to the imported asset (e.g., "assets/photo_001.jpg")
""" """
if not os.path.exists(source_path): if not os.path.exists(source_path):
raise FileNotFoundError(f"Source file not found: {source_path}") raise FileNotFoundError(f"Source file not found: {source_path}")
# Get filename and extension # Get filename and extension
filename = os.path.basename(source_path) filename = os.path.basename(source_path)
name, ext = os.path.splitext(filename) name, ext = os.path.splitext(filename)
# Find a unique filename if there's a collision # Find a unique filename if there's a collision
counter = 1 counter = 1
dest_filename = filename dest_filename = filename
dest_path = os.path.join(self.assets_folder, dest_filename) dest_path = os.path.join(self.assets_folder, dest_filename)
while os.path.exists(dest_path): while os.path.exists(dest_path):
dest_filename = f"{name}_{counter:03d}{ext}" dest_filename = f"{name}_{counter:03d}{ext}"
dest_path = os.path.join(self.assets_folder, dest_filename) dest_path = os.path.join(self.assets_folder, dest_filename)
counter += 1 counter += 1
# Copy the file # Copy the file
shutil.copy2(source_path, dest_path) shutil.copy2(source_path, dest_path)
@ -65,17 +65,17 @@ class AssetManager:
# Return relative path for storage in elements # Return relative path for storage in elements
return relative_path return relative_path
def acquire_reference(self, asset_path: str): def acquire_reference(self, asset_path: str):
""" """
Increment the reference count for an asset. Increment the reference count for an asset.
Args: Args:
asset_path: Relative path to the asset asset_path: Relative path to the asset
""" """
if not asset_path: if not asset_path:
return return
if asset_path in self.reference_counts: if asset_path in self.reference_counts:
self.reference_counts[asset_path] += 1 self.reference_counts[asset_path] += 1
print(f"AssetManager: Acquired reference to {asset_path} (refs={self.reference_counts[asset_path]})") print(f"AssetManager: Acquired reference to {asset_path} (refs={self.reference_counts[asset_path]})")
@ -87,25 +87,25 @@ class AssetManager:
print(f"AssetManager: Acquired reference to existing asset {asset_path} (refs=1)") print(f"AssetManager: Acquired reference to existing asset {asset_path} (refs=1)")
else: else:
print(f"AssetManager: Warning - asset not found: {asset_path}") print(f"AssetManager: Warning - asset not found: {asset_path}")
def release_reference(self, asset_path: str): def release_reference(self, asset_path: str):
""" """
Decrement the reference count for an asset. Decrement the reference count for an asset.
If count reaches zero, delete the asset file. If count reaches zero, delete the asset file.
Args: Args:
asset_path: Relative path to the asset asset_path: Relative path to the asset
""" """
if not asset_path: if not asset_path:
return return
if asset_path not in self.reference_counts: if asset_path not in self.reference_counts:
print(f"AssetManager: Warning - attempting to release unknown asset: {asset_path}") print(f"AssetManager: Warning - attempting to release unknown asset: {asset_path}")
return return
self.reference_counts[asset_path] -= 1 self.reference_counts[asset_path] -= 1
print(f"AssetManager: Released reference to {asset_path} (refs={self.reference_counts[asset_path]})") print(f"AssetManager: Released reference to {asset_path} (refs={self.reference_counts[asset_path]})")
if self.reference_counts[asset_path] <= 0: if self.reference_counts[asset_path] <= 0:
# No more references - safe to delete # No more references - safe to delete
full_path = os.path.join(self.project_folder, asset_path) full_path = os.path.join(self.project_folder, asset_path)
@ -116,37 +116,35 @@ class AssetManager:
del self.reference_counts[asset_path] del self.reference_counts[asset_path]
except Exception as e: except Exception as e:
print(f"AssetManager: Error deleting asset {asset_path}: {e}") print(f"AssetManager: Error deleting asset {asset_path}: {e}")
def get_absolute_path(self, relative_path: str) -> str: def get_absolute_path(self, relative_path: str) -> str:
""" """
Convert a relative asset path to an absolute path. Convert a relative asset path to an absolute path.
Args: Args:
relative_path: Relative path from project folder relative_path: Relative path from project folder
Returns: Returns:
Absolute path to the asset Absolute path to the asset
""" """
return os.path.join(self.project_folder, relative_path) return os.path.join(self.project_folder, relative_path)
def get_reference_count(self, asset_path: str) -> int: def get_reference_count(self, asset_path: str) -> int:
""" """
Get the current reference count for an asset. Get the current reference count for an asset.
Args: Args:
asset_path: Relative path to the asset asset_path: Relative path to the asset
Returns: Returns:
Reference count (0 if not tracked) Reference count (0 if not tracked)
""" """
return self.reference_counts.get(asset_path, 0) return self.reference_counts.get(asset_path, 0)
def serialize(self) -> Dict: def serialize(self) -> Dict:
"""Serialize asset manager state""" """Serialize asset manager state"""
return { return {"reference_counts": self.reference_counts}
"reference_counts": self.reference_counts
}
def deserialize(self, data: Dict): def deserialize(self, data: Dict):
"""Deserialize asset manager state""" """Deserialize asset manager state"""
self.reference_counts = data.get("reference_counts", {}) self.reference_counts = data.get("reference_counts", {})

View File

@ -28,16 +28,14 @@ logger = logging.getLogger(__name__)
class LoadPriority(Enum): class LoadPriority(Enum):
"""Priority levels for load requests.""" """Priority levels for load requests."""
LOW = 0 # Offscreen, not visible
NORMAL = 1 # Potentially visible soon LOW = 0 # Offscreen, not visible
HIGH = 2 # Visible on screen NORMAL = 1 # Potentially visible soon
URGENT = 3 # User is actively interacting with HIGH = 2 # Visible on screen
URGENT = 3 # User is actively interacting with
def get_image_dimensions( def get_image_dimensions(image_path: str, max_size: Optional[int] = None) -> Optional[Tuple[int, int]]:
image_path: str,
max_size: Optional[int] = None
) -> Optional[Tuple[int, int]]:
""" """
Extract image dimensions without loading the full image. Extract image dimensions without loading the full image.
@ -78,6 +76,7 @@ def get_image_dimensions(
@dataclass(order=True) @dataclass(order=True)
class LoadRequest: class LoadRequest:
"""Request to load and process an image.""" """Request to load and process an image."""
priority: LoadPriority = field(compare=True) priority: LoadPriority = field(compare=True)
request_id: int = field(compare=True) # Tie-breaker for same priority request_id: int = field(compare=True) # Tie-breaker for same priority
path: Path = field(compare=False) path: Path = field(compare=False)
@ -111,7 +110,7 @@ class ImageCache:
"""Estimate memory size of PIL image in bytes.""" """Estimate memory size of PIL image in bytes."""
# PIL images are typically width * height * bytes_per_pixel # PIL images are typically width * height * bytes_per_pixel
# RGBA = 4 bytes, RGB = 3 bytes, L = 1 byte # RGBA = 4 bytes, RGB = 3 bytes, L = 1 byte
mode_sizes = {'RGBA': 4, 'RGB': 3, 'L': 1, 'LA': 2} mode_sizes = {"RGBA": 4, "RGB": 3, "L": 1, "LA": 2}
bytes_per_pixel = mode_sizes.get(img.mode, 4) bytes_per_pixel = mode_sizes.get(img.mode, 4)
return img.width * img.height * bytes_per_pixel return img.width * img.height * bytes_per_pixel
@ -164,8 +163,7 @@ class ImageCache:
self.current_memory_bytes -= old_size self.current_memory_bytes -= old_size
# Evict LRU items if needed # Evict LRU items if needed
while (self.current_memory_bytes + img_size > self.max_memory_bytes while self.current_memory_bytes + img_size > self.max_memory_bytes and len(self._cache) > 0:
and len(self._cache) > 0):
evicted_key, (evicted_img, evicted_size) = self._cache.popitem(last=False) evicted_key, (evicted_img, evicted_size) = self._cache.popitem(last=False)
self.current_memory_bytes -= evicted_size self.current_memory_bytes -= evicted_size
logger.debug(f"Cache EVICT: {evicted_key} ({evicted_size / 1024 / 1024:.1f}MB)") logger.debug(f"Cache EVICT: {evicted_key} ({evicted_size / 1024 / 1024:.1f}MB)")
@ -174,10 +172,12 @@ class ImageCache:
self._cache[key] = (img.copy(), img_size) self._cache[key] = (img.copy(), img_size)
self.current_memory_bytes += img_size self.current_memory_bytes += img_size
logger.debug(f"Cache PUT: {key} ({img_size / 1024 / 1024:.1f}MB) " logger.debug(
f"[Total: {self.current_memory_bytes / 1024 / 1024:.1f}MB / " f"Cache PUT: {key} ({img_size / 1024 / 1024:.1f}MB) "
f"{self.max_memory_bytes / 1024 / 1024:.1f}MB, " f"[Total: {self.current_memory_bytes / 1024 / 1024:.1f}MB / "
f"Items: {len(self._cache)}]") f"{self.max_memory_bytes / 1024 / 1024:.1f}MB, "
f"Items: {len(self._cache)}]"
)
def clear(self): def clear(self):
"""Clear entire cache.""" """Clear entire cache."""
@ -190,10 +190,10 @@ class ImageCache:
"""Get cache statistics.""" """Get cache statistics."""
with self._lock: with self._lock:
return { return {
'items': len(self._cache), "items": len(self._cache),
'memory_mb': self.current_memory_bytes / 1024 / 1024, "memory_mb": self.current_memory_bytes / 1024 / 1024,
'max_memory_mb': self.max_memory_bytes / 1024 / 1024, "max_memory_mb": self.max_memory_bytes / 1024 / 1024,
'utilization': (self.current_memory_bytes / self.max_memory_bytes) * 100 "utilization": (self.current_memory_bytes / self.max_memory_bytes) * 100,
} }
@ -213,7 +213,7 @@ class AsyncImageLoader(QObject):
# Signals for Qt integration # Signals for Qt integration
image_loaded = pyqtSignal(object, object, object) # (path, image, user_data) image_loaded = pyqtSignal(object, object, object) # (path, image, user_data)
load_failed = pyqtSignal(object, str, object) # (path, error_msg, user_data) load_failed = pyqtSignal(object, str, object) # (path, error_msg, user_data)
def __init__(self, cache: Optional[ImageCache] = None, max_workers: int = 4): def __init__(self, cache: Optional[ImageCache] = None, max_workers: int = 4):
""" """
@ -227,8 +227,7 @@ class AsyncImageLoader(QObject):
self.cache = cache or ImageCache() self.cache = cache or ImageCache()
self.max_workers = max_workers self.max_workers = max_workers
self.executor = ThreadPoolExecutor(max_workers=max_workers, self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="ImageLoader")
thread_name_prefix="ImageLoader")
# Priority queue and tracking # Priority queue and tracking
self._queue: asyncio.PriorityQueue = None # Created when event loop starts self._queue: asyncio.PriorityQueue = None # Created when event loop starts
@ -251,9 +250,9 @@ class AsyncImageLoader(QObject):
return return
self._shutdown = False self._shutdown = False
self._loop_thread = threading.Thread(target=self._run_event_loop, self._loop_thread = threading.Thread(
daemon=True, target=self._run_event_loop, daemon=True, name="AsyncImageLoader-EventLoop"
name="AsyncImageLoader-EventLoop") )
self._loop_thread.start() self._loop_thread.start()
logger.info("AsyncImageLoader event loop started") logger.info("AsyncImageLoader event loop started")
@ -354,12 +353,7 @@ class AsyncImageLoader(QObject):
# Load in thread pool (I/O bound) # Load in thread pool (I/O bound)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
img = await loop.run_in_executor( img = await loop.run_in_executor(self.executor, self._load_and_process_image, path, target_size)
self.executor,
self._load_and_process_image,
path,
target_size
)
# Cache result # Cache result
self.cache.put(path, img, target_size) self.cache.put(path, img, target_size)
@ -411,11 +405,13 @@ class AsyncImageLoader(QObject):
"""Emit load_failed signal (thread-safe).""" """Emit load_failed signal (thread-safe)."""
self.load_failed.emit(path, error_msg, user_data) self.load_failed.emit(path, error_msg, user_data)
def request_load(self, def request_load(
path: Path, self,
priority: LoadPriority = LoadPriority.NORMAL, path: Path,
target_size: Optional[Tuple[int, int]] = None, priority: LoadPriority = LoadPriority.NORMAL,
user_data: Any = None) -> bool: target_size: Optional[Tuple[int, int]] = None,
user_data: Any = None,
) -> bool:
""" """
Request image load with specified priority. Request image load with specified priority.
@ -446,7 +442,7 @@ class AsyncImageLoader(QObject):
request_id=self._next_request_id, request_id=self._next_request_id,
path=path, path=path,
target_size=target_size, target_size=target_size,
user_data=user_data user_data=user_data,
) )
self._next_request_id += 1 self._next_request_id += 1
@ -454,10 +450,7 @@ class AsyncImageLoader(QObject):
self._pending_requests[path] = request self._pending_requests[path] = request
# Submit to queue (thread-safe) # Submit to queue (thread-safe)
asyncio.run_coroutine_threadsafe( asyncio.run_coroutine_threadsafe(self._queue.put(request), self._loop)
self._queue.put(request),
self._loop
)
logger.debug(f"Queued load: {path} (priority: {priority.name})") logger.debug(f"Queued load: {path} (priority: {priority.name})")
return True return True
@ -494,9 +487,9 @@ class AsyncImageLoader(QObject):
"""Get loader statistics.""" """Get loader statistics."""
with self._lock: with self._lock:
return { return {
'pending': len(self._pending_requests), "pending": len(self._pending_requests),
'active': len(self._active_tasks), "active": len(self._active_tasks),
'cache': self.cache.get_stats() "cache": self.cache.get_stats(),
} }
@ -517,8 +510,8 @@ class AsyncPDFGenerator(QObject):
# Signals for Qt integration # Signals for Qt integration
progress_updated = pyqtSignal(int, int, str) # (current, total, message) progress_updated = pyqtSignal(int, int, str) # (current, total, message)
export_complete = pyqtSignal(bool, list) # (success, warnings) export_complete = pyqtSignal(bool, list) # (success, warnings)
export_failed = pyqtSignal(str) # (error_message) export_failed = pyqtSignal(str) # (error_message)
def __init__(self, image_cache: Optional[ImageCache] = None, max_workers: int = 2): def __init__(self, image_cache: Optional[ImageCache] = None, max_workers: int = 2):
""" """
@ -532,8 +525,7 @@ class AsyncPDFGenerator(QObject):
self.image_cache = image_cache or ImageCache() self.image_cache = image_cache or ImageCache()
self.max_workers = max_workers self.max_workers = max_workers
self.executor = ThreadPoolExecutor(max_workers=max_workers, self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="PDFGenerator")
thread_name_prefix="PDFGenerator")
# Export state # Export state
self._current_export: Optional[asyncio.Task] = None self._current_export: Optional[asyncio.Task] = None
@ -554,9 +546,9 @@ class AsyncPDFGenerator(QObject):
return return
self._shutdown = False self._shutdown = False
self._loop_thread = threading.Thread(target=self._run_event_loop, self._loop_thread = threading.Thread(
daemon=True, target=self._run_event_loop, daemon=True, name="AsyncPDFGenerator-EventLoop"
name="AsyncPDFGenerator-EventLoop") )
self._loop_thread.start() self._loop_thread.start()
logger.info("AsyncPDFGenerator event loop started") logger.info("AsyncPDFGenerator event loop started")
@ -621,8 +613,7 @@ class AsyncPDFGenerator(QObject):
# Submit export task # Submit export task
self._current_export = asyncio.run_coroutine_threadsafe( self._current_export = asyncio.run_coroutine_threadsafe(
self._export_pdf_async(project, output_path, export_dpi), self._export_pdf_async(project, output_path, export_dpi), self._loop
self._loop
) )
logger.info(f"PDF export started: {output_path}") logger.info(f"PDF export started: {output_path}")
@ -662,11 +653,7 @@ class AsyncPDFGenerator(QObject):
# Run export in thread pool # Run export in thread pool
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
success, warnings = await loop.run_in_executor( success, warnings = await loop.run_in_executor(
self.executor, self.executor, self._export_with_cache, exporter, output_path, progress_callback
self._export_with_cache,
exporter,
output_path,
progress_callback
) )
# Emit completion signal # Emit completion signal
@ -731,13 +718,9 @@ class AsyncPDFGenerator(QObject):
def is_exporting(self) -> bool: def is_exporting(self) -> bool:
"""Check if export is currently in progress.""" """Check if export is currently in progress."""
with self._lock: with self._lock:
return (self._current_export is not None return self._current_export is not None and not self._current_export.done()
and not self._current_export.done())
def get_stats(self) -> Dict[str, Any]: def get_stats(self) -> Dict[str, Any]:
"""Get generator statistics.""" """Get generator statistics."""
with self._lock: with self._lock:
return { return {"exporting": self.is_exporting(), "cache": self.image_cache.get_stats()}
'exporting': self.is_exporting(),
'cache': self.image_cache.get_stats()
}

View File

@ -18,7 +18,7 @@ from pyPhotoAlbum.version_manager import (
CURRENT_DATA_VERSION, CURRENT_DATA_VERSION,
check_version_compatibility, check_version_compatibility,
VersionCompatibility, VersionCompatibility,
DataMigration DataMigration,
) )
@ -78,7 +78,7 @@ class AsyncProjectLoader(QThread):
self.progress_updated.emit(10, 100, "Extracting project files...") self.progress_updated.emit(10, 100, "Extracting project files...")
# Extract ZIP contents with progress # Extract ZIP contents with progress
with zipfile.ZipFile(self.zip_path, 'r') as zipf: with zipfile.ZipFile(self.zip_path, "r") as zipf:
file_list = zipf.namelist() file_list = zipf.namelist()
total_files = len(file_list) total_files = len(file_list)
@ -91,10 +91,7 @@ class AsyncProjectLoader(QThread):
# Update progress every 10 files or on last file # Update progress every 10 files or on last file
if i % 10 == 0 or i == total_files - 1: if i % 10 == 0 or i == total_files - 1:
progress = 10 + int((i / total_files) * 30) # 10-40% progress = 10 + int((i / total_files) * 30) # 10-40%
self.progress_updated.emit( self.progress_updated.emit(progress, 100, f"Extracting files... ({i + 1}/{total_files})")
progress, 100,
f"Extracting files... ({i + 1}/{total_files})"
)
if self._cancelled: if self._cancelled:
return return
@ -103,12 +100,12 @@ class AsyncProjectLoader(QThread):
self.progress_updated.emit(45, 100, "Loading project data...") self.progress_updated.emit(45, 100, "Loading project data...")
# Load project.json # Load project.json
project_json_path = os.path.join(extract_to, 'project.json') project_json_path = os.path.join(extract_to, "project.json")
if not os.path.exists(project_json_path): if not os.path.exists(project_json_path):
self.load_failed.emit("Invalid project file: project.json not found") self.load_failed.emit("Invalid project file: project.json not found")
return return
with open(project_json_path, 'r') as f: with open(project_json_path, "r") as f:
project_data = json.load(f) project_data = json.load(f)
if self._cancelled: if self._cancelled:
@ -118,7 +115,7 @@ class AsyncProjectLoader(QThread):
self.progress_updated.emit(55, 100, "Checking version compatibility...") self.progress_updated.emit(55, 100, "Checking version compatibility...")
# Check version compatibility # Check version compatibility
file_version = project_data.get('data_version', project_data.get('serialization_version', '1.0')) file_version = project_data.get("data_version", project_data.get("serialization_version", "1.0"))
is_compatible, error_msg = check_version_compatibility(file_version, self.zip_path) is_compatible, error_msg = check_version_compatibility(file_version, self.zip_path)
if not is_compatible: if not is_compatible:
@ -141,7 +138,7 @@ class AsyncProjectLoader(QThread):
self.progress_updated.emit(70, 100, "Creating project...") self.progress_updated.emit(70, 100, "Creating project...")
# Create new project # Create new project
project_name = project_data.get('name', 'Untitled Project') project_name = project_data.get("name", "Untitled Project")
project = Project(name=project_name, folder_path=extract_to) project = Project(name=project_name, folder_path=extract_to)
# Deserialize project data # Deserialize project data
@ -197,14 +194,14 @@ class AsyncProjectLoader(QThread):
original_path = element.image_path original_path = element.image_path
# Skip if already a simple relative path # Skip if already a simple relative path
if not os.path.isabs(original_path) and not original_path.startswith('./projects/'): if not os.path.isabs(original_path) and not original_path.startswith("./projects/"):
continue continue
# Pattern 1: "./projects/XXX/assets/filename.jpg" -> "assets/filename.jpg" # Pattern 1: "./projects/XXX/assets/filename.jpg" -> "assets/filename.jpg"
if '/assets/' in original_path: if "/assets/" in original_path:
parts = original_path.split('/assets/') parts = original_path.split("/assets/")
if len(parts) == 2: if len(parts) == 2:
new_path = os.path.join('assets', parts[1]) new_path = os.path.join("assets", parts[1])
element.image_path = new_path element.image_path = new_path
normalized_count += 1 normalized_count += 1
continue continue
@ -222,9 +219,9 @@ class AsyncProjectLoader(QThread):
print(f"Normalized {normalized_count} asset paths") print(f"Normalized {normalized_count} asset paths")
def load_from_zip_async(zip_path: str, extract_to: Optional[str] = None, def load_from_zip_async(
progress_callback=None, complete_callback=None, zip_path: str, extract_to: Optional[str] = None, progress_callback=None, complete_callback=None, error_callback=None
error_callback=None) -> AsyncProjectLoader: ) -> AsyncProjectLoader:
""" """
Load a project from a ZIP file asynchronously. Load a project from a ZIP file asynchronously.

View File

@ -86,11 +86,11 @@ class AutosaveManager:
"project_name": project.name, "project_name": project.name,
"timestamp": datetime.now().isoformat(), "timestamp": datetime.now().isoformat(),
"checkpoint_path": str(checkpoint_path), "checkpoint_path": str(checkpoint_path),
"original_path": getattr(project, 'file_path', None), "original_path": getattr(project, "file_path", None),
} }
metadata_path = checkpoint_path.with_suffix('.json') metadata_path = checkpoint_path.with_suffix(".json")
with open(metadata_path, 'w') as f: with open(metadata_path, "w") as f:
json.dump(metadata, f, indent=2) json.dump(metadata, f, indent=2)
def list_checkpoints(self, project_name: Optional[str] = None) -> List[Tuple[Path, dict]]: def list_checkpoints(self, project_name: Optional[str] = None) -> List[Tuple[Path, dict]]:
@ -106,23 +106,23 @@ class AutosaveManager:
checkpoints = [] checkpoints = []
for checkpoint_file in self.CHECKPOINT_DIR.glob(f"{self.CHECKPOINT_PREFIX}*{self.CHECKPOINT_EXTENSION}"): for checkpoint_file in self.CHECKPOINT_DIR.glob(f"{self.CHECKPOINT_PREFIX}*{self.CHECKPOINT_EXTENSION}"):
metadata_file = checkpoint_file.with_suffix('.json') metadata_file = checkpoint_file.with_suffix(".json")
# Try to load metadata # Try to load metadata
metadata = {} metadata = {}
if metadata_file.exists(): if metadata_file.exists():
try: try:
with open(metadata_file, 'r') as f: with open(metadata_file, "r") as f:
metadata = json.load(f) metadata = json.load(f)
except: except:
pass pass
# Filter by project name if specified # Filter by project name if specified
if project_name is None or metadata.get('project_name') == project_name: if project_name is None or metadata.get("project_name") == project_name:
checkpoints.append((checkpoint_file, metadata)) checkpoints.append((checkpoint_file, metadata))
# Sort by timestamp (newest first) # Sort by timestamp (newest first)
checkpoints.sort(key=lambda x: x[1].get('timestamp', ''), reverse=True) checkpoints.sort(key=lambda x: x[1].get("timestamp", ""), reverse=True)
return checkpoints return checkpoints
def load_checkpoint(self, checkpoint_path: Path): def load_checkpoint(self, checkpoint_path: Path):
@ -157,7 +157,7 @@ class AutosaveManager:
checkpoint_path.unlink() checkpoint_path.unlink()
# Delete metadata file # Delete metadata file
metadata_path = checkpoint_path.with_suffix('.json') metadata_path = checkpoint_path.with_suffix(".json")
if metadata_path.exists(): if metadata_path.exists():
metadata_path.unlink() metadata_path.unlink()
@ -190,7 +190,7 @@ class AutosaveManager:
# Group checkpoints by project # Group checkpoints by project
for checkpoint_path, metadata in self.list_checkpoints(): for checkpoint_path, metadata in self.list_checkpoints():
project_name = metadata.get('project_name', 'unknown') project_name = metadata.get("project_name", "unknown")
if project_name not in checkpoints_by_project: if project_name not in checkpoints_by_project:
checkpoints_by_project[project_name] = [] checkpoints_by_project[project_name] = []
checkpoints_by_project[project_name].append((checkpoint_path, metadata)) checkpoints_by_project[project_name].append((checkpoint_path, metadata))
@ -198,11 +198,11 @@ class AutosaveManager:
# Clean up each project's checkpoints # Clean up each project's checkpoints
for project_name, checkpoints in checkpoints_by_project.items(): for project_name, checkpoints in checkpoints_by_project.items():
# Sort by timestamp (newest first) # Sort by timestamp (newest first)
checkpoints.sort(key=lambda x: x[1].get('timestamp', ''), reverse=True) checkpoints.sort(key=lambda x: x[1].get("timestamp", ""), reverse=True)
for idx, (checkpoint_path, metadata) in enumerate(checkpoints): for idx, (checkpoint_path, metadata) in enumerate(checkpoints):
# Delete if too old # Delete if too old
timestamp_str = metadata.get('timestamp') timestamp_str = metadata.get("timestamp")
if timestamp_str: if timestamp_str:
try: try:
timestamp = datetime.fromisoformat(timestamp_str) timestamp = datetime.fromisoformat(timestamp_str)

View File

@ -11,11 +11,11 @@ from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, T
def _normalize_asset_path(image_path: str, asset_manager) -> str: def _normalize_asset_path(image_path: str, asset_manager) -> str:
""" """
Convert absolute path to relative for asset manager. Convert absolute path to relative for asset manager.
Args: Args:
image_path: Image path (absolute or relative) image_path: Image path (absolute or relative)
asset_manager: AssetManager instance asset_manager: AssetManager instance
Returns: Returns:
Relative path suitable for asset manager Relative path suitable for asset manager
""" """
@ -29,79 +29,75 @@ def _normalize_asset_path(image_path: str, asset_manager) -> str:
class Command(ABC): class Command(ABC):
"""Abstract base class for all commands""" """Abstract base class for all commands"""
@abstractmethod @abstractmethod
def execute(self): def execute(self):
"""Execute the command""" """Execute the command"""
pass pass
@abstractmethod @abstractmethod
def undo(self): def undo(self):
"""Undo the command""" """Undo the command"""
pass pass
@abstractmethod @abstractmethod
def redo(self): def redo(self):
"""Redo the command (default implementation calls execute)""" """Redo the command (default implementation calls execute)"""
self.execute() self.execute()
@abstractmethod @abstractmethod
def serialize(self) -> Dict[str, Any]: def serialize(self) -> Dict[str, Any]:
"""Serialize command to dictionary for saving""" """Serialize command to dictionary for saving"""
pass pass
@staticmethod @staticmethod
@abstractmethod @abstractmethod
def deserialize(data: Dict[str, Any], project) -> 'Command': def deserialize(data: Dict[str, Any], project) -> "Command":
"""Deserialize command from dictionary""" """Deserialize command from dictionary"""
pass pass
class AddElementCommand(Command): class AddElementCommand(Command):
"""Command for adding an element to a page""" """Command for adding an element to a page"""
def __init__(self, page_layout, element: BaseLayoutElement, asset_manager=None): def __init__(self, page_layout, element: BaseLayoutElement, asset_manager=None):
self.page_layout = page_layout self.page_layout = page_layout
self.element = element self.element = element
self.executed = False self.executed = False
self.asset_manager = asset_manager self.asset_manager = asset_manager
# Acquire reference to asset when command is created # Acquire reference to asset when command is created
if self.asset_manager and isinstance(self.element, ImageData) and self.element.image_path: if self.asset_manager and isinstance(self.element, ImageData) and self.element.image_path:
rel_path = _normalize_asset_path(self.element.image_path, self.asset_manager) rel_path = _normalize_asset_path(self.element.image_path, self.asset_manager)
self.asset_manager.acquire_reference(rel_path) self.asset_manager.acquire_reference(rel_path)
def execute(self): def execute(self):
"""Add the element to the page""" """Add the element to the page"""
if not self.executed: if not self.executed:
self.page_layout.add_element(self.element) self.page_layout.add_element(self.element)
self.executed = True self.executed = True
def undo(self): def undo(self):
"""Remove the element from the page""" """Remove the element from the page"""
if self.executed: if self.executed:
self.page_layout.remove_element(self.element) self.page_layout.remove_element(self.element)
self.executed = False self.executed = False
def redo(self): def redo(self):
"""Re-add the element""" """Re-add the element"""
self.execute() self.execute()
def serialize(self) -> Dict[str, Any]: def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary""" """Serialize to dictionary"""
return { return {"type": "add_element", "element": self.element.serialize(), "executed": self.executed}
"type": "add_element",
"element": self.element.serialize(),
"executed": self.executed
}
@staticmethod @staticmethod
def deserialize(data: Dict[str, Any], project) -> 'AddElementCommand': def deserialize(data: Dict[str, Any], project) -> "AddElementCommand":
"""Deserialize from dictionary""" """Deserialize from dictionary"""
# Reconstruct element from serialized data # Reconstruct element from serialized data
elem_data = data["element"] elem_data = data["element"]
elem_type = elem_data.get("type") elem_type = elem_data.get("type")
if elem_type == "image": if elem_type == "image":
element = ImageData() element = ImageData()
elif elem_type == "placeholder": elif elem_type == "placeholder":
@ -110,9 +106,9 @@ class AddElementCommand(Command):
element = TextBoxData() element = TextBoxData()
else: else:
raise ValueError(f"Unknown element type: {elem_type}") raise ValueError(f"Unknown element type: {elem_type}")
element.deserialize(elem_data) element.deserialize(elem_data)
# Note: We need to find the correct page_layout # Note: We need to find the correct page_layout
# This will be handled by the CommandHistory deserializer # This will be handled by the CommandHistory deserializer
cmd = AddElementCommand(None, element) cmd = AddElementCommand(None, element)
@ -122,48 +118,44 @@ class AddElementCommand(Command):
class DeleteElementCommand(Command): class DeleteElementCommand(Command):
"""Command for deleting an element from a page""" """Command for deleting an element from a page"""
def __init__(self, page_layout, element: BaseLayoutElement, asset_manager=None): def __init__(self, page_layout, element: BaseLayoutElement, asset_manager=None):
self.page_layout = page_layout self.page_layout = page_layout
self.element = element self.element = element
self.executed = False self.executed = False
self.asset_manager = asset_manager self.asset_manager = asset_manager
# Acquire reference to asset to keep it alive while in undo history # Acquire reference to asset to keep it alive while in undo history
if self.asset_manager and isinstance(self.element, ImageData) and self.element.image_path: if self.asset_manager and isinstance(self.element, ImageData) and self.element.image_path:
rel_path = _normalize_asset_path(self.element.image_path, self.asset_manager) rel_path = _normalize_asset_path(self.element.image_path, self.asset_manager)
self.asset_manager.acquire_reference(rel_path) self.asset_manager.acquire_reference(rel_path)
def execute(self): def execute(self):
"""Remove the element from the page""" """Remove the element from the page"""
if not self.executed: if not self.executed:
self.page_layout.remove_element(self.element) self.page_layout.remove_element(self.element)
self.executed = True self.executed = True
def undo(self): def undo(self):
"""Re-add the element to the page""" """Re-add the element to the page"""
if self.executed: if self.executed:
self.page_layout.add_element(self.element) self.page_layout.add_element(self.element)
self.executed = False self.executed = False
def redo(self): def redo(self):
"""Re-remove the element""" """Re-remove the element"""
self.execute() self.execute()
def serialize(self) -> Dict[str, Any]: def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary""" """Serialize to dictionary"""
return { return {"type": "delete_element", "element": self.element.serialize(), "executed": self.executed}
"type": "delete_element",
"element": self.element.serialize(),
"executed": self.executed
}
@staticmethod @staticmethod
def deserialize(data: Dict[str, Any], project) -> 'DeleteElementCommand': def deserialize(data: Dict[str, Any], project) -> "DeleteElementCommand":
"""Deserialize from dictionary""" """Deserialize from dictionary"""
elem_data = data["element"] elem_data = data["element"]
elem_type = elem_data.get("type") elem_type = elem_data.get("type")
if elem_type == "image": if elem_type == "image":
element = ImageData() element = ImageData()
elif elem_type == "placeholder": elif elem_type == "placeholder":
@ -172,9 +164,9 @@ class DeleteElementCommand(Command):
element = TextBoxData() element = TextBoxData()
else: else:
raise ValueError(f"Unknown element type: {elem_type}") raise ValueError(f"Unknown element type: {elem_type}")
element.deserialize(elem_data) element.deserialize(elem_data)
cmd = DeleteElementCommand(None, element) cmd = DeleteElementCommand(None, element)
cmd.executed = data.get("executed", False) cmd.executed = data.get("executed", False)
return cmd return cmd
@ -182,39 +174,39 @@ class DeleteElementCommand(Command):
class MoveElementCommand(Command): class MoveElementCommand(Command):
"""Command for moving an element""" """Command for moving an element"""
def __init__(self, element: BaseLayoutElement, old_position: tuple, new_position: tuple): def __init__(self, element: BaseLayoutElement, old_position: tuple, new_position: tuple):
self.element = element self.element = element
self.old_position = old_position self.old_position = old_position
self.new_position = new_position self.new_position = new_position
def execute(self): def execute(self):
"""Move element to new position""" """Move element to new position"""
self.element.position = self.new_position self.element.position = self.new_position
def undo(self): def undo(self):
"""Move element back to old position""" """Move element back to old position"""
self.element.position = self.old_position self.element.position = self.old_position
def redo(self): def redo(self):
"""Move element to new position again""" """Move element to new position again"""
self.execute() self.execute()
def serialize(self) -> Dict[str, Any]: def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary""" """Serialize to dictionary"""
return { return {
"type": "move_element", "type": "move_element",
"element": self.element.serialize(), "element": self.element.serialize(),
"old_position": self.old_position, "old_position": self.old_position,
"new_position": self.new_position "new_position": self.new_position,
} }
@staticmethod @staticmethod
def deserialize(data: Dict[str, Any], project) -> 'MoveElementCommand': def deserialize(data: Dict[str, Any], project) -> "MoveElementCommand":
"""Deserialize from dictionary""" """Deserialize from dictionary"""
elem_data = data["element"] elem_data = data["element"]
elem_type = elem_data.get("type") elem_type = elem_data.get("type")
if elem_type == "image": if elem_type == "image":
element = ImageData() element = ImageData()
elif elem_type == "placeholder": elif elem_type == "placeholder":
@ -223,41 +215,38 @@ class MoveElementCommand(Command):
element = TextBoxData() element = TextBoxData()
else: else:
raise ValueError(f"Unknown element type: {elem_type}") raise ValueError(f"Unknown element type: {elem_type}")
element.deserialize(elem_data) element.deserialize(elem_data)
return MoveElementCommand( return MoveElementCommand(element, tuple(data["old_position"]), tuple(data["new_position"]))
element,
tuple(data["old_position"]),
tuple(data["new_position"])
)
class ResizeElementCommand(Command): class ResizeElementCommand(Command):
"""Command for resizing an element""" """Command for resizing an element"""
def __init__(self, element: BaseLayoutElement, old_position: tuple, old_size: tuple, def __init__(
new_position: tuple, new_size: tuple): self, element: BaseLayoutElement, old_position: tuple, old_size: tuple, new_position: tuple, new_size: tuple
):
self.element = element self.element = element
self.old_position = old_position self.old_position = old_position
self.old_size = old_size self.old_size = old_size
self.new_position = new_position self.new_position = new_position
self.new_size = new_size self.new_size = new_size
def execute(self): def execute(self):
"""Resize element to new size""" """Resize element to new size"""
self.element.position = self.new_position self.element.position = self.new_position
self.element.size = self.new_size self.element.size = self.new_size
def undo(self): def undo(self):
"""Resize element back to old size""" """Resize element back to old size"""
self.element.position = self.old_position self.element.position = self.old_position
self.element.size = self.old_size self.element.size = self.old_size
def redo(self): def redo(self):
"""Resize element to new size again""" """Resize element to new size again"""
self.execute() self.execute()
def serialize(self) -> Dict[str, Any]: def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary""" """Serialize to dictionary"""
return { return {
@ -266,15 +255,15 @@ class ResizeElementCommand(Command):
"old_position": self.old_position, "old_position": self.old_position,
"old_size": self.old_size, "old_size": self.old_size,
"new_position": self.new_position, "new_position": self.new_position,
"new_size": self.new_size "new_size": self.new_size,
} }
@staticmethod @staticmethod
def deserialize(data: Dict[str, Any], project) -> 'ResizeElementCommand': def deserialize(data: Dict[str, Any], project) -> "ResizeElementCommand":
"""Deserialize from dictionary""" """Deserialize from dictionary"""
elem_data = data["element"] elem_data = data["element"]
elem_type = elem_data.get("type") elem_type = elem_data.get("type")
if elem_type == "image": if elem_type == "image":
element = ImageData() element = ImageData()
elif elem_type == "placeholder": elif elem_type == "placeholder":
@ -283,15 +272,15 @@ class ResizeElementCommand(Command):
element = TextBoxData() element = TextBoxData()
else: else:
raise ValueError(f"Unknown element type: {elem_type}") raise ValueError(f"Unknown element type: {elem_type}")
element.deserialize(elem_data) element.deserialize(elem_data)
return ResizeElementCommand( return ResizeElementCommand(
element, element,
tuple(data["old_position"]), tuple(data["old_position"]),
tuple(data["old_size"]), tuple(data["old_size"]),
tuple(data["new_position"]), tuple(data["new_position"]),
tuple(data["new_size"]) tuple(data["new_size"]),
) )
@ -308,7 +297,7 @@ class RotateElementCommand(Command):
self.old_size = element.size self.old_size = element.size
# For ImageData, store the old PIL rotation state # For ImageData, store the old PIL rotation state
if hasattr(element, 'pil_rotation_90'): if hasattr(element, "pil_rotation_90"):
self.old_pil_rotation = element.pil_rotation_90 self.old_pil_rotation = element.pil_rotation_90
else: else:
self.old_pil_rotation = None self.old_pil_rotation = None
@ -344,9 +333,9 @@ class RotateElementCommand(Command):
self.element.position = (center_x - h / 2, center_y - w / 2) self.element.position = (center_x - h / 2, center_y - w / 2)
# Clear the texture so it will be reloaded with the new rotation # Clear the texture so it will be reloaded with the new rotation
if hasattr(self.element, '_texture_id'): if hasattr(self.element, "_texture_id"):
del self.element._texture_id del self.element._texture_id
if hasattr(self.element, '_async_load_requested'): if hasattr(self.element, "_async_load_requested"):
self.element._async_load_requested = False self.element._async_load_requested = False
# Keep visual rotation at 0 # Keep visual rotation at 0
@ -376,29 +365,29 @@ class RotateElementCommand(Command):
# For ImageData, restore PIL rotation and clear texture # For ImageData, restore PIL rotation and clear texture
if isinstance(self.element, ImageData) and self.old_pil_rotation is not None: if isinstance(self.element, ImageData) and self.old_pil_rotation is not None:
self.element.pil_rotation_90 = self.old_pil_rotation self.element.pil_rotation_90 = self.old_pil_rotation
if hasattr(self.element, '_texture_id'): if hasattr(self.element, "_texture_id"):
self.element._texture_id = None self.element._texture_id = None
self.element._async_load_requested = False self.element._async_load_requested = False
def redo(self): def redo(self):
"""Rotate element to new angle again""" """Rotate element to new angle again"""
self.execute() self.execute()
def serialize(self) -> Dict[str, Any]: def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary""" """Serialize to dictionary"""
return { return {
"type": "rotate_element", "type": "rotate_element",
"element": self.element.serialize(), "element": self.element.serialize(),
"old_rotation": self.old_rotation, "old_rotation": self.old_rotation,
"new_rotation": self.new_rotation "new_rotation": self.new_rotation,
} }
@staticmethod @staticmethod
def deserialize(data: Dict[str, Any], project) -> 'RotateElementCommand': def deserialize(data: Dict[str, Any], project) -> "RotateElementCommand":
"""Deserialize from dictionary""" """Deserialize from dictionary"""
elem_data = data["element"] elem_data = data["element"]
elem_type = elem_data.get("type") elem_type = elem_data.get("type")
if elem_type == "image": if elem_type == "image":
element = ImageData() element = ImageData()
elif elem_type == "placeholder": elif elem_type == "placeholder":
@ -407,78 +396,70 @@ class RotateElementCommand(Command):
element = TextBoxData() element = TextBoxData()
else: else:
raise ValueError(f"Unknown element type: {elem_type}") raise ValueError(f"Unknown element type: {elem_type}")
element.deserialize(elem_data) element.deserialize(elem_data)
return RotateElementCommand( return RotateElementCommand(element, data["old_rotation"], data["new_rotation"])
element,
data["old_rotation"],
data["new_rotation"]
)
class AdjustImageCropCommand(Command): class AdjustImageCropCommand(Command):
"""Command for adjusting image crop/pan within frame""" """Command for adjusting image crop/pan within frame"""
def __init__(self, element: ImageData, old_crop_info: tuple, new_crop_info: tuple): def __init__(self, element: ImageData, old_crop_info: tuple, new_crop_info: tuple):
self.element = element self.element = element
self.old_crop_info = old_crop_info self.old_crop_info = old_crop_info
self.new_crop_info = new_crop_info self.new_crop_info = new_crop_info
def execute(self): def execute(self):
"""Apply new crop info""" """Apply new crop info"""
self.element.crop_info = self.new_crop_info self.element.crop_info = self.new_crop_info
def undo(self): def undo(self):
"""Restore old crop info""" """Restore old crop info"""
self.element.crop_info = self.old_crop_info self.element.crop_info = self.old_crop_info
def redo(self): def redo(self):
"""Apply new crop info again""" """Apply new crop info again"""
self.execute() self.execute()
def serialize(self) -> Dict[str, Any]: def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary""" """Serialize to dictionary"""
return { return {
"type": "adjust_image_crop", "type": "adjust_image_crop",
"element": self.element.serialize(), "element": self.element.serialize(),
"old_crop_info": self.old_crop_info, "old_crop_info": self.old_crop_info,
"new_crop_info": self.new_crop_info "new_crop_info": self.new_crop_info,
} }
@staticmethod @staticmethod
def deserialize(data: Dict[str, Any], project) -> 'AdjustImageCropCommand': def deserialize(data: Dict[str, Any], project) -> "AdjustImageCropCommand":
"""Deserialize from dictionary""" """Deserialize from dictionary"""
elem_data = data["element"] elem_data = data["element"]
element = ImageData() element = ImageData()
element.deserialize(elem_data) element.deserialize(elem_data)
return AdjustImageCropCommand( return AdjustImageCropCommand(element, tuple(data["old_crop_info"]), tuple(data["new_crop_info"]))
element,
tuple(data["old_crop_info"]),
tuple(data["new_crop_info"])
)
class AlignElementsCommand(Command): class AlignElementsCommand(Command):
"""Command for aligning multiple elements""" """Command for aligning multiple elements"""
def __init__(self, changes: List[tuple]): def __init__(self, changes: List[tuple]):
""" """
Args: Args:
changes: List of (element, old_position) tuples changes: List of (element, old_position) tuples
""" """
self.changes = changes self.changes = changes
def execute(self): def execute(self):
"""Positions have already been set by AlignmentManager""" """Positions have already been set by AlignmentManager"""
pass pass
def undo(self): def undo(self):
"""Restore old positions""" """Restore old positions"""
for element, old_position in self.changes: for element, old_position in self.changes:
element.position = old_position element.position = old_position
def redo(self): def redo(self):
"""Re-apply alignment (positions are stored in current state)""" """Re-apply alignment (positions are stored in current state)"""
# Store current positions and restore them # Store current positions and restore them
@ -488,28 +469,22 @@ class AlignElementsCommand(Command):
# Then re-apply new positions # Then re-apply new positions
for element, new_position in new_positions: for element, new_position in new_positions:
element.position = new_position element.position = new_position
def serialize(self) -> Dict[str, Any]: def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary""" """Serialize to dictionary"""
return { return {
"type": "align_elements", "type": "align_elements",
"changes": [ "changes": [{"element": elem.serialize(), "old_position": old_pos} for elem, old_pos in self.changes],
{
"element": elem.serialize(),
"old_position": old_pos
}
for elem, old_pos in self.changes
]
} }
@staticmethod @staticmethod
def deserialize(data: Dict[str, Any], project) -> 'AlignElementsCommand': def deserialize(data: Dict[str, Any], project) -> "AlignElementsCommand":
"""Deserialize from dictionary""" """Deserialize from dictionary"""
changes = [] changes = []
for change_data in data.get("changes", []): for change_data in data.get("changes", []):
elem_data = change_data["element"] elem_data = change_data["element"]
elem_type = elem_data.get("type") elem_type = elem_data.get("type")
if elem_type == "image": if elem_type == "image":
element = ImageData() element = ImageData()
elif elem_type == "placeholder": elif elem_type == "placeholder":
@ -518,17 +493,17 @@ class AlignElementsCommand(Command):
element = TextBoxData() element = TextBoxData()
else: else:
continue continue
element.deserialize(elem_data) element.deserialize(elem_data)
old_position = tuple(change_data["old_position"]) old_position = tuple(change_data["old_position"])
changes.append((element, old_position)) changes.append((element, old_position))
return AlignElementsCommand(changes) return AlignElementsCommand(changes)
class ResizeElementsCommand(Command): class ResizeElementsCommand(Command):
"""Command for resizing multiple elements""" """Command for resizing multiple elements"""
def __init__(self, changes: List[tuple]): def __init__(self, changes: List[tuple]):
""" """
Args: Args:
@ -536,45 +511,41 @@ class ResizeElementsCommand(Command):
""" """
self.changes = changes self.changes = changes
self.new_states = [(elem, elem.position, elem.size) for elem, _, _ in changes] self.new_states = [(elem, elem.position, elem.size) for elem, _, _ in changes]
def execute(self): def execute(self):
"""Sizes have already been set by AlignmentManager""" """Sizes have already been set by AlignmentManager"""
pass pass
def undo(self): def undo(self):
"""Restore old positions and sizes""" """Restore old positions and sizes"""
for element, old_position, old_size in self.changes: for element, old_position, old_size in self.changes:
element.position = old_position element.position = old_position
element.size = old_size element.size = old_size
def redo(self): def redo(self):
"""Re-apply new sizes""" """Re-apply new sizes"""
for element, new_position, new_size in self.new_states: for element, new_position, new_size in self.new_states:
element.position = new_position element.position = new_position
element.size = new_size element.size = new_size
def serialize(self) -> Dict[str, Any]: def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary""" """Serialize to dictionary"""
return { return {
"type": "resize_elements", "type": "resize_elements",
"changes": [ "changes": [
{ {"element": elem.serialize(), "old_position": old_pos, "old_size": old_size}
"element": elem.serialize(),
"old_position": old_pos,
"old_size": old_size
}
for elem, old_pos, old_size in self.changes for elem, old_pos, old_size in self.changes
] ],
} }
@staticmethod @staticmethod
def deserialize(data: Dict[str, Any], project) -> 'ResizeElementsCommand': def deserialize(data: Dict[str, Any], project) -> "ResizeElementsCommand":
"""Deserialize from dictionary""" """Deserialize from dictionary"""
changes = [] changes = []
for change_data in data.get("changes", []): for change_data in data.get("changes", []):
elem_data = change_data["element"] elem_data = change_data["element"]
elem_type = elem_data.get("type") elem_type = elem_data.get("type")
if elem_type == "image": if elem_type == "image":
element = ImageData() element = ImageData()
elif elem_type == "placeholder": elif elem_type == "placeholder":
@ -583,57 +554,57 @@ class ResizeElementsCommand(Command):
element = TextBoxData() element = TextBoxData()
else: else:
continue continue
element.deserialize(elem_data) element.deserialize(elem_data)
old_position = tuple(change_data["old_position"]) old_position = tuple(change_data["old_position"])
old_size = tuple(change_data["old_size"]) old_size = tuple(change_data["old_size"])
changes.append((element, old_position, old_size)) changes.append((element, old_position, old_size))
return ResizeElementsCommand(changes) return ResizeElementsCommand(changes)
class ChangeZOrderCommand(Command): class ChangeZOrderCommand(Command):
"""Command for changing element z-order (list position)""" """Command for changing element z-order (list position)"""
def __init__(self, page_layout, element: BaseLayoutElement, old_index: int, new_index: int): def __init__(self, page_layout, element: BaseLayoutElement, old_index: int, new_index: int):
self.page_layout = page_layout self.page_layout = page_layout
self.element = element self.element = element
self.old_index = old_index self.old_index = old_index
self.new_index = new_index self.new_index = new_index
def execute(self): def execute(self):
"""Move element to new position in list""" """Move element to new position in list"""
elements = self.page_layout.elements elements = self.page_layout.elements
if self.element in elements: if self.element in elements:
elements.remove(self.element) elements.remove(self.element)
elements.insert(self.new_index, self.element) elements.insert(self.new_index, self.element)
def undo(self): def undo(self):
"""Move element back to old position in list""" """Move element back to old position in list"""
elements = self.page_layout.elements elements = self.page_layout.elements
if self.element in elements: if self.element in elements:
elements.remove(self.element) elements.remove(self.element)
elements.insert(self.old_index, self.element) elements.insert(self.old_index, self.element)
def redo(self): def redo(self):
"""Move element to new position again""" """Move element to new position again"""
self.execute() self.execute()
def serialize(self) -> Dict[str, Any]: def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary""" """Serialize to dictionary"""
return { return {
"type": "change_zorder", "type": "change_zorder",
"element": self.element.serialize(), "element": self.element.serialize(),
"old_index": self.old_index, "old_index": self.old_index,
"new_index": self.new_index "new_index": self.new_index,
} }
@staticmethod @staticmethod
def deserialize(data: Dict[str, Any], project) -> 'ChangeZOrderCommand': def deserialize(data: Dict[str, Any], project) -> "ChangeZOrderCommand":
"""Deserialize from dictionary""" """Deserialize from dictionary"""
elem_data = data["element"] elem_data = data["element"]
elem_type = elem_data.get("type") elem_type = elem_data.get("type")
if elem_type == "image": if elem_type == "image":
element = ImageData() element = ImageData()
elif elem_type == "placeholder": elif elem_type == "placeholder":
@ -642,25 +613,22 @@ class ChangeZOrderCommand(Command):
element = TextBoxData() element = TextBoxData()
else: else:
raise ValueError(f"Unknown element type: {elem_type}") raise ValueError(f"Unknown element type: {elem_type}")
element.deserialize(elem_data) element.deserialize(elem_data)
return ChangeZOrderCommand( return ChangeZOrderCommand(
None, # page_layout will be set by CommandHistory None, element, data["old_index"], data["new_index"] # page_layout will be set by CommandHistory
element,
data["old_index"],
data["new_index"]
) )
class StateChangeCommand(Command): class StateChangeCommand(Command):
""" """
Generic command for operations that change state. Generic command for operations that change state.
This command captures before/after snapshots of state and can restore them. This command captures before/after snapshots of state and can restore them.
Used by the @undoable_operation decorator. Used by the @undoable_operation decorator.
""" """
def __init__(self, description: str, restore_func, before_state: Any, after_state: Any = None): def __init__(self, description: str, restore_func, before_state: Any, after_state: Any = None):
""" """
Args: Args:
@ -673,31 +641,28 @@ class StateChangeCommand(Command):
self.restore_func = restore_func self.restore_func = restore_func
self.before_state = before_state self.before_state = before_state
self.after_state = after_state self.after_state = after_state
def execute(self): def execute(self):
"""State is already applied, just store after_state if not set""" """State is already applied, just store after_state if not set"""
# After state is captured by decorator after operation runs # After state is captured by decorator after operation runs
pass pass
def undo(self): def undo(self):
"""Restore to before state""" """Restore to before state"""
self.restore_func(self.before_state) self.restore_func(self.before_state)
def redo(self): def redo(self):
"""Restore to after state""" """Restore to after state"""
self.restore_func(self.after_state) self.restore_func(self.after_state)
def serialize(self) -> Dict[str, Any]: def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary""" """Serialize to dictionary"""
# For now, state change commands are not serialized # For now, state change commands are not serialized
# This could be enhanced later if needed # This could be enhanced later if needed
return { return {"type": "state_change", "description": self.description}
"type": "state_change",
"description": self.description
}
@staticmethod @staticmethod
def deserialize(data: Dict[str, Any], project) -> 'StateChangeCommand': def deserialize(data: Dict[str, Any], project) -> "StateChangeCommand":
"""Deserialize from dictionary""" """Deserialize from dictionary"""
# Not implemented - would need to serialize state # Not implemented - would need to serialize state
raise NotImplementedError("StateChangeCommand deserialization not yet supported") raise NotImplementedError("StateChangeCommand deserialization not yet supported")
@ -732,12 +697,12 @@ class CommandHistory:
if len(self.undo_stack) > self.max_history: if len(self.undo_stack) > self.max_history:
old_cmd = self.undo_stack.pop(0) old_cmd = self.undo_stack.pop(0)
self._release_command_assets(old_cmd) self._release_command_assets(old_cmd)
def _release_command_assets(self, command: Command): def _release_command_assets(self, command: Command):
"""Release asset references held by a command""" """Release asset references held by a command"""
if not self.asset_manager: if not self.asset_manager:
return return
# Release asset references for commands that hold them # Release asset references for commands that hold them
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:
@ -746,7 +711,7 @@ class CommandHistory:
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)
self.asset_manager.release_reference(asset_path) self.asset_manager.release_reference(asset_path)
def undo(self) -> bool: def undo(self) -> bool:
"""Undo the last command""" """Undo the last command"""
if not self.can_undo(): if not self.can_undo():
@ -776,15 +741,15 @@ class CommandHistory:
self.project.mark_dirty() self.project.mark_dirty()
return True return True
def can_undo(self) -> bool: def can_undo(self) -> bool:
"""Check if undo is available""" """Check if undo is available"""
return len(self.undo_stack) > 0 return len(self.undo_stack) > 0
def can_redo(self) -> bool: def can_redo(self) -> bool:
"""Check if redo is available""" """Check if redo is available"""
return len(self.redo_stack) > 0 return len(self.redo_stack) > 0
def clear(self): def clear(self):
"""Clear all history and release asset references""" """Clear all history and release asset references"""
# Release all asset references # Release all asset references
@ -792,36 +757,36 @@ class CommandHistory:
self._release_command_assets(cmd) self._release_command_assets(cmd)
for cmd in self.redo_stack: for cmd in self.redo_stack:
self._release_command_assets(cmd) self._release_command_assets(cmd)
self.undo_stack.clear() self.undo_stack.clear()
self.redo_stack.clear() self.redo_stack.clear()
def serialize(self) -> Dict[str, Any]: def serialize(self) -> Dict[str, Any]:
"""Serialize history to dictionary""" """Serialize history to dictionary"""
return { return {
"undo_stack": [cmd.serialize() for cmd in self.undo_stack], "undo_stack": [cmd.serialize() for cmd in self.undo_stack],
"redo_stack": [cmd.serialize() for cmd in self.redo_stack], "redo_stack": [cmd.serialize() for cmd in self.redo_stack],
"max_history": self.max_history "max_history": self.max_history,
} }
def deserialize(self, data: Dict[str, Any], project): def deserialize(self, data: Dict[str, Any], project):
"""Deserialize history from dictionary""" """Deserialize history from dictionary"""
self.max_history = data.get("max_history", 100) self.max_history = data.get("max_history", 100)
# Deserialize undo stack # Deserialize undo stack
self.undo_stack = [] self.undo_stack = []
for cmd_data in data.get("undo_stack", []): for cmd_data in data.get("undo_stack", []):
cmd = self._deserialize_command(cmd_data, project) cmd = self._deserialize_command(cmd_data, project)
if cmd: if cmd:
self.undo_stack.append(cmd) self.undo_stack.append(cmd)
# Deserialize redo stack # Deserialize redo stack
self.redo_stack = [] self.redo_stack = []
for cmd_data in data.get("redo_stack", []): for cmd_data in data.get("redo_stack", []):
cmd = self._deserialize_command(cmd_data, project) cmd = self._deserialize_command(cmd_data, project)
if cmd: if cmd:
self.redo_stack.append(cmd) self.redo_stack.append(cmd)
# Command type registry for deserialization # Command type registry for deserialization
_COMMAND_DESERIALIZERS = { _COMMAND_DESERIALIZERS = {
"add_element": AddElementCommand.deserialize, "add_element": AddElementCommand.deserialize,

View File

@ -10,10 +10,10 @@ from typing import Optional, Callable
class RibbonAction: class RibbonAction:
""" """
Decorator to mark methods as ribbon actions. Decorator to mark methods as ribbon actions.
This decorator stores metadata about UI actions that should appear in the ribbon. This decorator stores metadata about UI actions that should appear in the ribbon.
The metadata is used to auto-generate the ribbon configuration. The metadata is used to auto-generate the ribbon configuration.
Example: Example:
@RibbonAction( @RibbonAction(
label="New", label="New",
@ -26,7 +26,7 @@ class RibbonAction:
def new_project(self): def new_project(self):
... ...
""" """
def __init__( def __init__(
self, self,
label: str, label: str,
@ -37,11 +37,11 @@ class RibbonAction:
shortcut: Optional[str] = None, shortcut: Optional[str] = None,
requires_page: bool = False, requires_page: bool = False,
requires_selection: bool = False, requires_selection: bool = False,
min_selection: int = 0 min_selection: int = 0,
): ):
""" """
Initialize the ribbon action decorator. Initialize the ribbon action decorator.
Args: Args:
label: Button label text label: Button label text
tooltip: Tooltip text shown on hover tooltip: Tooltip text shown on hover
@ -62,35 +62,36 @@ class RibbonAction:
self.requires_page = requires_page self.requires_page = requires_page
self.requires_selection = requires_selection self.requires_selection = requires_selection
self.min_selection = min_selection self.min_selection = min_selection
def __call__(self, func: Callable) -> Callable: def __call__(self, func: Callable) -> Callable:
""" """
Decorate the function with ribbon action metadata. Decorate the function with ribbon action metadata.
Args: Args:
func: The function to decorate func: The function to decorate
Returns: Returns:
The decorated function with metadata attached The decorated function with metadata attached
""" """
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
return func(*args, **kwargs) return func(*args, **kwargs)
# Store metadata on wrapper function # Store metadata on wrapper function
wrapper._ribbon_action = { wrapper._ribbon_action = {
'label': self.label, "label": self.label,
'tooltip': self.tooltip, "tooltip": self.tooltip,
'tab': self.tab, "tab": self.tab,
'group': self.group, "group": self.group,
'icon': self.icon, "icon": self.icon,
'shortcut': self.shortcut, "shortcut": self.shortcut,
'action': func.__name__, "action": func.__name__,
'requires_page': self.requires_page, "requires_page": self.requires_page,
'requires_selection': self.requires_selection, "requires_selection": self.requires_selection,
'min_selection': self.min_selection "min_selection": self.min_selection,
} }
return wrapper return wrapper
@ -103,13 +104,13 @@ def ribbon_action(
shortcut: Optional[str] = None, shortcut: Optional[str] = None,
requires_page: bool = False, requires_page: bool = False,
requires_selection: bool = False, requires_selection: bool = False,
min_selection: int = 0 min_selection: int = 0,
) -> Callable: ) -> Callable:
""" """
Convenience function for the RibbonAction decorator. Convenience function for the RibbonAction decorator.
This provides a lowercase function-style interface to the decorator. This provides a lowercase function-style interface to the decorator.
Args: Args:
label: Button label text label: Button label text
tooltip: Tooltip text shown on hover tooltip: Tooltip text shown on hover
@ -120,7 +121,7 @@ def ribbon_action(
requires_page: Whether this action requires an active page requires_page: Whether this action requires an active page
requires_selection: Whether this action requires selected elements requires_selection: Whether this action requires selected elements
min_selection: Minimum number of selected elements required min_selection: Minimum number of selected elements required
Returns: Returns:
RibbonAction decorator instance RibbonAction decorator instance
""" """
@ -133,17 +134,17 @@ def ribbon_action(
shortcut=shortcut, shortcut=shortcut,
requires_page=requires_page, requires_page=requires_page,
requires_selection=requires_selection, requires_selection=requires_selection,
min_selection=min_selection min_selection=min_selection,
) )
class NumericalInput: class NumericalInput:
""" """
Decorator to mark methods that require numerical width/height inputs. Decorator to mark methods that require numerical width/height inputs.
This decorator stores metadata about numerical input fields that should This decorator stores metadata about numerical input fields that should
be presented in dialogs for methods that work with page dimensions. be presented in dialogs for methods that work with page dimensions.
Example: Example:
@numerical_input( @numerical_input(
fields=[ fields=[
@ -154,49 +155,48 @@ class NumericalInput:
def set_page_size(self, width, height): def set_page_size(self, width, height):
... ...
""" """
def __init__(self, fields: list): def __init__(self, fields: list):
""" """
Initialize the numerical input decorator. Initialize the numerical input decorator.
Args: Args:
fields: List of tuples, each containing: fields: List of tuples, each containing:
(param_name, label, unit, min_value, max_value) (param_name, label, unit, min_value, max_value)
""" """
self.fields = fields self.fields = fields
def __call__(self, func: Callable) -> Callable: def __call__(self, func: Callable) -> Callable:
""" """
Decorate the function with numerical input metadata. Decorate the function with numerical input metadata.
Args: Args:
func: The function to decorate func: The function to decorate
Returns: Returns:
The decorated function with metadata attached The decorated function with metadata attached
""" """
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
return func(*args, **kwargs) return func(*args, **kwargs)
# Store metadata on wrapper function # Store metadata on wrapper function
wrapper._numerical_input = { wrapper._numerical_input = {"fields": self.fields}
'fields': self.fields
}
return wrapper return wrapper
def numerical_input(fields: list) -> Callable: def numerical_input(fields: list) -> Callable:
""" """
Convenience function for the NumericalInput decorator. Convenience function for the NumericalInput decorator.
This provides a lowercase function-style interface to the decorator. This provides a lowercase function-style interface to the decorator.
Args: Args:
fields: List of tuples, each containing: fields: List of tuples, each containing:
(param_name, label, unit, min_value, max_value) (param_name, label, unit, min_value, max_value)
Returns: Returns:
NumericalInput decorator instance NumericalInput decorator instance
""" """
@ -206,10 +206,10 @@ def numerical_input(fields: list) -> Callable:
class UndoableOperation: class UndoableOperation:
""" """
Decorator to automatically create undo/redo commands for operations. Decorator to automatically create undo/redo commands for operations.
This decorator captures state before and after an operation, then creates This decorator captures state before and after an operation, then creates
a StateChangeCommand for undo/redo functionality. a StateChangeCommand for undo/redo functionality.
Example: Example:
@undoable_operation(capture='page_elements') @undoable_operation(capture='page_elements')
def apply_template(self): def apply_template(self):
@ -217,11 +217,11 @@ class UndoableOperation:
self.template_manager.apply_template(...) self.template_manager.apply_template(...)
# Decorator handles undo/redo automatically # Decorator handles undo/redo automatically
""" """
def __init__(self, capture: str = 'page_elements', description: str = None): def __init__(self, capture: str = "page_elements", description: str = None):
""" """
Initialize the undoable operation decorator. Initialize the undoable operation decorator.
Args: Args:
capture: What to capture for undo/redo: capture: What to capture for undo/redo:
- 'page_elements': Capture elements of current page - 'page_elements': Capture elements of current page
@ -230,89 +230,92 @@ class UndoableOperation:
""" """
self.capture = capture self.capture = capture
self.description = description self.description = description
def __call__(self, func: Callable) -> Callable: def __call__(self, func: Callable) -> Callable:
""" """
Decorate the function with automatic undo/redo. Decorate the function with automatic undo/redo.
Args: Args:
func: The function to decorate func: The function to decorate
Returns: Returns:
The decorated function The decorated function
""" """
@wraps(func) @wraps(func)
def wrapper(self_instance, *args, **kwargs): def wrapper(self_instance, *args, **kwargs):
# Get description # Get description
description = self.description or func.__name__.replace('_', ' ').title() description = self.description or func.__name__.replace("_", " ").title()
# Capture before state # Capture before state
before_state = self._capture_state(self_instance, self.capture) before_state = self._capture_state(self_instance, self.capture)
# Execute the operation # Execute the operation
result = func(self_instance, *args, **kwargs) result = func(self_instance, *args, **kwargs)
# Capture after state # Capture after state
after_state = self._capture_state(self_instance, self.capture) after_state = self._capture_state(self_instance, self.capture)
# Create restore function # Create restore function
def restore_state(state): def restore_state(state):
self._restore_state(self_instance, self.capture, state) self._restore_state(self_instance, self.capture, state)
# Update view after restoring # Update view after restoring
if hasattr(self_instance, 'update_view'): if hasattr(self_instance, "update_view"):
self_instance.update_view() self_instance.update_view()
# Create and execute command # Create and execute command
from pyPhotoAlbum.commands import StateChangeCommand from pyPhotoAlbum.commands import StateChangeCommand
cmd = StateChangeCommand(description, restore_state, before_state, after_state) cmd = StateChangeCommand(description, restore_state, before_state, after_state)
if hasattr(self_instance, 'project') and hasattr(self_instance.project, 'history'): if hasattr(self_instance, "project") and hasattr(self_instance.project, "history"):
self_instance.project.history.execute(cmd) self_instance.project.history.execute(cmd)
print(f"Undoable operation '{description}' executed") print(f"Undoable operation '{description}' executed")
return result return result
return wrapper return wrapper
def _capture_state(self, instance, capture_type: str): def _capture_state(self, instance, capture_type: str):
"""Capture current state based on capture type""" """Capture current state based on capture type"""
if capture_type == 'page_elements': if capture_type == "page_elements":
# Capture elements from current page # Capture elements from current page
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
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 []
return None return None
def _restore_state(self, instance, capture_type: str, state): def _restore_state(self, instance, capture_type: str, state):
"""Restore state based on capture type""" """Restore state based on capture type"""
if capture_type == 'page_elements': if capture_type == "page_elements":
# Restore elements to current page # Restore elements to current page
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 and state is not None: if current_page and state is not None:
# Clear existing elements # Clear existing elements
current_page.layout.elements.clear() current_page.layout.elements.clear()
# Restore elements from serialized state # Restore elements from serialized state
from pyPhotoAlbum.models import ImageData, PlaceholderData, TextBoxData from pyPhotoAlbum.models import ImageData, PlaceholderData, TextBoxData
for elem_data in state: for elem_data in state:
elem_type = elem_data.get('type') elem_type = elem_data.get("type")
if elem_type == 'image': if elem_type == "image":
elem = ImageData() elem = ImageData()
elif elem_type == 'placeholder': elif elem_type == "placeholder":
elem = PlaceholderData() elem = PlaceholderData()
elif elem_type == 'textbox': elif elem_type == "textbox":
elem = TextBoxData() elem = TextBoxData()
else: else:
continue continue
elem.deserialize(elem_data) elem.deserialize(elem_data)
current_page.layout.add_element(elem) current_page.layout.add_element(elem)
def undoable_operation(capture: str = 'page_elements', description: str = None) -> Callable: def undoable_operation(capture: str = "page_elements", description: str = None) -> Callable:
""" """
Convenience function for the UndoableOperation decorator. Convenience function for the UndoableOperation decorator.
@ -343,11 +346,7 @@ class DialogAction:
self.apply_page_setup(values) self.apply_page_setup(values)
""" """
def __init__( def __init__(self, dialog_class: type, requires_pages: bool = True):
self,
dialog_class: type,
requires_pages: bool = True
):
""" """
Initialize the dialog action decorator. Initialize the dialog action decorator.
@ -368,6 +367,7 @@ class DialogAction:
Returns: Returns:
The decorated function The decorated function
""" """
@wraps(func) @wraps(func)
def wrapper(self_instance, *args, **kwargs): def wrapper(self_instance, *args, **kwargs):
# Check preconditions # Check preconditions
@ -376,7 +376,7 @@ class DialogAction:
# Get initial page index if available # Get initial page index if available
initial_page_index = 0 initial_page_index = 0
if hasattr(self_instance, '_get_most_visible_page_index'): if hasattr(self_instance, "_get_most_visible_page_index"):
initial_page_index = self_instance._get_most_visible_page_index() initial_page_index = self_instance._get_most_visible_page_index()
# Create and show dialog # Create and show dialog
@ -384,17 +384,15 @@ class DialogAction:
# Create dialog # Create dialog
dialog = self.dialog_class( dialog = self.dialog_class(
parent=self_instance, parent=self_instance, project=self_instance.project, initial_page_index=initial_page_index, **kwargs
project=self_instance.project,
initial_page_index=initial_page_index,
**kwargs
) )
# Show dialog and get result # Show dialog and get result
from PyQt6.QtWidgets import QDialog from PyQt6.QtWidgets import QDialog
if dialog.exec() == QDialog.DialogCode.Accepted: if dialog.exec() == QDialog.DialogCode.Accepted:
# Get values from dialog # Get values from dialog
if hasattr(dialog, 'get_values'): if hasattr(dialog, "get_values"):
values = dialog.get_values() values = dialog.get_values()
# Call the decorated function with values # Call the decorated function with values
return func(self_instance, values, *args, **kwargs) return func(self_instance, values, *args, **kwargs)
@ -406,10 +404,7 @@ class DialogAction:
return wrapper return wrapper
def dialog_action( def dialog_action(dialog_class: type, requires_pages: bool = True) -> Callable:
dialog_class: type,
requires_pages: bool = True
) -> Callable:
""" """
Convenience function for the DialogAction decorator. Convenience function for the DialogAction decorator.

View File

@ -7,4 +7,4 @@ UI presentation logic separately from business logic.
from .page_setup_dialog import PageSetupDialog from .page_setup_dialog import PageSetupDialog
__all__ = ['PageSetupDialog'] __all__ = ["PageSetupDialog"]

View File

@ -8,9 +8,16 @@ separating presentation from business logic.
import math 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,
QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox, QVBoxLayout,
QComboBox, QCheckBox QHBoxLayout,
QLabel,
QDoubleSpinBox,
QSpinBox,
QPushButton,
QGroupBox,
QComboBox,
QCheckBox,
) )
from pyPhotoAlbum.project import Project from pyPhotoAlbum.project import Project
@ -23,12 +30,7 @@ class PageSetupDialog(QDialog):
including page size, DPI settings, and cover configuration. including page size, DPI settings, and cover configuration.
""" """
def __init__( def __init__(self, parent, project: Project, initial_page_index: int = 0):
self,
parent,
project: Project,
initial_page_index: int = 0
):
""" """
Initialize the page setup dialog. Initialize the page setup dialog.
@ -105,9 +107,7 @@ class PageSetupDialog(QDialog):
# Cover checkbox # Cover checkbox
self.cover_checkbox = QCheckBox("Designate as Cover") self.cover_checkbox = QCheckBox("Designate as Cover")
self.cover_checkbox.setToolTip( self.cover_checkbox.setToolTip("Mark this page as the book cover with wrap-around front/spine/back")
"Mark this page as the book cover with wrap-around front/spine/back"
)
layout.addWidget(self.cover_checkbox) layout.addWidget(self.cover_checkbox)
# Paper thickness # Paper thickness
@ -136,9 +136,7 @@ class PageSetupDialog(QDialog):
# Calculated spine width display # Calculated spine width display
self.spine_info_label = QLabel() self.spine_info_label = QLabel()
self.spine_info_label.setStyleSheet( self.spine_info_label.setStyleSheet("font-size: 9pt; color: #0066cc; padding: 5px;")
"font-size: 9pt; color: #0066cc; padding: 5px;"
)
self.spine_info_label.setWordWrap(True) self.spine_info_label.setWordWrap(True)
layout.addWidget(self.spine_info_label) layout.addWidget(self.spine_info_label)
@ -170,9 +168,7 @@ class PageSetupDialog(QDialog):
# Set as default checkbox # Set as default checkbox
self.set_default_checkbox = QCheckBox("Set as default for new pages") self.set_default_checkbox = QCheckBox("Set as default for new pages")
self.set_default_checkbox.setToolTip( self.set_default_checkbox.setToolTip("Update project default page size for future pages")
"Update project default page size for future pages"
)
layout.addWidget(self.set_default_checkbox) layout.addWidget(self.set_default_checkbox)
group.setLayout(layout) group.setLayout(layout)
@ -248,7 +244,7 @@ class PageSetupDialog(QDialog):
return return
selected_page = self.project.pages[index] selected_page = self.project.pages[index]
is_first_page = (index == 0) is_first_page = index == 0
# Show/hide cover settings based on page selection # Show/hide cover settings based on page selection
self._cover_group.setVisible(is_first_page) self._cover_group.setVisible(is_first_page)
@ -265,7 +261,7 @@ class PageSetupDialog(QDialog):
elif selected_page.is_double_spread: elif selected_page.is_double_spread:
display_width = ( display_width = (
selected_page.layout.base_width selected_page.layout.base_width
if hasattr(selected_page.layout, 'base_width') if hasattr(selected_page.layout, "base_width")
else selected_page.layout.size[0] / 2 else selected_page.layout.size[0] / 2
) )
else: else:
@ -284,16 +280,12 @@ class PageSetupDialog(QDialog):
"""Update the spine information display.""" """Update the spine information display."""
if self.cover_checkbox.isChecked(): if self.cover_checkbox.isChecked():
# Calculate spine width with current settings # Calculate spine width with current settings
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
)
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
page_width = self.project.page_size_mm[0] page_width = self.project.page_size_mm[0]
total_width = ( total_width = (page_width * 2) + spine_width + (self.bleed_spinbox.value() * 2)
(page_width * 2) + spine_width + (self.bleed_spinbox.value() * 2)
)
self.spine_info_label.setText( self.spine_info_label.setText(
f"Cover Layout: Front ({page_width:.0f}mm) + " f"Cover Layout: Front ({page_width:.0f}mm) + "
@ -317,14 +309,14 @@ class PageSetupDialog(QDialog):
selected_page = self.project.pages[selected_index] selected_page = self.project.pages[selected_index]
return { return {
'selected_index': selected_index, "selected_index": selected_index,
'selected_page': selected_page, "selected_page": selected_page,
'is_cover': self.cover_checkbox.isChecked(), "is_cover": self.cover_checkbox.isChecked(),
'paper_thickness_mm': self.thickness_spinbox.value(), "paper_thickness_mm": self.thickness_spinbox.value(),
'cover_bleed_mm': self.bleed_spinbox.value(), "cover_bleed_mm": self.bleed_spinbox.value(),
'width_mm': self.width_spinbox.value(), "width_mm": self.width_spinbox.value(),
'height_mm': self.height_spinbox.value(), "height_mm": self.height_spinbox.value(),
'working_dpi': self.working_dpi_spinbox.value(), "working_dpi": self.working_dpi_spinbox.value(),
'export_dpi': self.export_dpi_spinbox.value(), "export_dpi": self.export_dpi_spinbox.value(),
'set_as_default': self.set_default_checkbox.isChecked() "set_as_default": self.set_default_checkbox.isChecked(),
} }

View File

@ -24,16 +24,13 @@ try:
GL_LINE_LOOP, GL_LINE_LOOP,
GL_LINES, GL_LINES,
GL_TRIANGLE_FAN, GL_TRIANGLE_FAN,
# Colors # Colors
glColor3f, glColor3f,
glColor4f, glColor4f,
# Line state # Line state
glLineWidth, glLineWidth,
glLineStipple, glLineStipple,
GL_LINE_STIPPLE, GL_LINE_STIPPLE,
# General state # General state
glEnable, glEnable,
glDisable, glDisable,
@ -42,7 +39,6 @@ try:
GL_SRC_ALPHA, GL_SRC_ALPHA,
GL_ONE_MINUS_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA,
glBlendFunc, glBlendFunc,
# Textures # Textures
glGenTextures, glGenTextures,
glBindTexture, glBindTexture,
@ -56,7 +52,6 @@ try:
GL_TEXTURE_MAG_FILTER, GL_TEXTURE_MAG_FILTER,
GL_LINEAR, GL_LINEAR,
glTexCoord2f, glTexCoord2f,
# Matrix operations # Matrix operations
glPushMatrix, glPushMatrix,
glPopMatrix, glPopMatrix,
@ -64,24 +59,22 @@ try:
glTranslatef, glTranslatef,
glLoadIdentity, glLoadIdentity,
glRotatef, glRotatef,
# Clear operations # Clear operations
glClear, glClear,
glClearColor, glClearColor,
GL_COLOR_BUFFER_BIT, GL_COLOR_BUFFER_BIT,
GL_DEPTH_BUFFER_BIT, GL_DEPTH_BUFFER_BIT,
# Viewport # Viewport
glViewport, glViewport,
glMatrixMode, glMatrixMode,
glOrtho, glOrtho,
GL_PROJECTION, GL_PROJECTION,
GL_MODELVIEW, GL_MODELVIEW,
# Info/debug # Info/debug
glGetString, glGetString,
GL_VERSION, GL_VERSION,
) )
GL_AVAILABLE = True GL_AVAILABLE = True
except ImportError: except ImportError:

View File

@ -34,7 +34,7 @@ class GLWidget(
MouseInteractionMixin, MouseInteractionMixin,
UndoableInteractionMixin, UndoableInteractionMixin,
KeyboardNavigationMixin, KeyboardNavigationMixin,
QOpenGLWidget QOpenGLWidget,
): ):
"""OpenGL widget for pyPhotoAlbum rendering and user interaction """OpenGL widget for pyPhotoAlbum rendering and user interaction
@ -77,8 +77,8 @@ class GLWidget(
def _get_project_folder(self): def _get_project_folder(self):
"""Override AssetPathMixin to access project via main window.""" """Override AssetPathMixin to access project via main window."""
main_window = self.window() main_window = self.window()
if hasattr(main_window, 'project') and main_window.project: if hasattr(main_window, "project") and main_window.project:
return getattr(main_window.project, 'folder_path', None) return getattr(main_window.project, "folder_path", None)
return None return None
def keyPressEvent(self, event): def keyPressEvent(self, event):
@ -86,7 +86,7 @@ class GLWidget(
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:
if self.selected_element: if self.selected_element:
main_window = self.window() main_window = self.window()
if hasattr(main_window, 'delete_selected_element'): if hasattr(main_window, "delete_selected_element"):
main_window.delete_selected_element() main_window.delete_selected_element()
elif event.key() == Qt.Key.Key_Escape: elif event.key() == Qt.Key.Key_Escape:
@ -99,7 +99,7 @@ class GLWidget(
if self.selected_element: if self.selected_element:
self.rotation_mode = not self.rotation_mode self.rotation_mode = not self.rotation_mode
main_window = self.window() main_window = self.window()
if hasattr(main_window, 'show_status'): if hasattr(main_window, "show_status"):
mode_text = "Rotation Mode" if self.rotation_mode else "Move/Resize Mode" mode_text = "Rotation Mode" if self.rotation_mode else "Move/Resize Mode"
main_window.show_status(f"Switched to {mode_text}", 2000) main_window.show_status(f"Switched to {mode_text}", 2000)
print(f"Rotation mode: {self.rotation_mode}") print(f"Rotation mode: {self.rotation_mode}")

View File

@ -13,6 +13,7 @@ from PIL import Image
# Image Processing Utilities # Image Processing Utilities
# ============================================================================= # =============================================================================
def apply_pil_rotation(image: Image.Image, pil_rotation_90: int) -> Image.Image: def apply_pil_rotation(image: Image.Image, pil_rotation_90: int) -> Image.Image:
""" """
Apply 90-degree rotation increments to a PIL image. Apply 90-degree rotation increments to a PIL image.
@ -48,8 +49,8 @@ def convert_to_rgba(image: Image.Image) -> Image.Image:
Returns: Returns:
PIL Image in RGBA mode PIL Image in RGBA mode
""" """
if image.mode != 'RGBA': if image.mode != "RGBA":
return image.convert('RGBA') return image.convert("RGBA")
return image return image
@ -58,7 +59,7 @@ def calculate_center_crop_coords(
img_height: int, img_height: int,
target_width: float, target_width: float,
target_height: float, target_height: float,
crop_info: Tuple[float, float, float, float] = (0, 0, 1, 1) crop_info: Tuple[float, float, float, float] = (0, 0, 1, 1),
) -> Tuple[float, float, float, float]: ) -> Tuple[float, float, float, float]:
""" """
Calculate texture/crop coordinates for center-crop fitting an image to a target aspect ratio. Calculate texture/crop coordinates for center-crop fitting an image to a target aspect ratio.
@ -113,10 +114,7 @@ def calculate_center_crop_coords(
return (tx_min, ty_min, tx_max, ty_max) return (tx_min, ty_min, tx_max, ty_max)
def crop_image_to_coords( def crop_image_to_coords(image: Image.Image, coords: Tuple[float, float, float, float]) -> Image.Image:
image: Image.Image,
coords: Tuple[float, float, float, float]
) -> Image.Image:
""" """
Crop an image using normalized texture coordinates. Crop an image using normalized texture coordinates.
@ -139,9 +137,7 @@ def crop_image_to_coords(
def resize_to_fit( def resize_to_fit(
image: Image.Image, image: Image.Image, max_size: int, resample: Image.Resampling = Image.Resampling.LANCZOS
max_size: int,
resample: Image.Resampling = Image.Resampling.LANCZOS
) -> Image.Image: ) -> Image.Image:
""" """
Resize image to fit within max_size while preserving aspect ratio. Resize image to fit within max_size while preserving aspect ratio.

View File

@ -29,7 +29,8 @@ class LoadingWidget(QWidget):
self.setFixedSize(280, 80) self.setFixedSize(280, 80)
# Styling # Styling
self.setStyleSheet(""" self.setStyleSheet(
"""
QWidget { QWidget {
background-color: rgba(50, 50, 50, 230); background-color: rgba(50, 50, 50, 230);
border-radius: 8px; border-radius: 8px;
@ -54,7 +55,8 @@ class LoadingWidget(QWidget):
stop:1 rgba(100, 160, 210, 220)); stop:1 rgba(100, 160, 210, 220));
border-radius: 3px; border-radius: 3px;
} }
""") """
)
# Layout # Layout
layout = QVBoxLayout() layout = QVBoxLayout()

View File

@ -9,8 +9,14 @@ import sys
from datetime import datetime 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,
QStatusBar, QScrollBar, QHBoxLayout, QMessageBox QMainWindow,
QVBoxLayout,
QWidget,
QStatusBar,
QScrollBar,
QHBoxLayout,
QMessageBox,
) )
from PyQt6.QtCore import Qt, QSize, QTimer from PyQt6.QtCore import Qt, QSize, QTimer
from PyQt6.QtGui import QIcon from PyQt6.QtGui import QIcon
@ -58,12 +64,12 @@ class MainWindow(
): ):
""" """
Main application window using mixin architecture. Main application window using mixin architecture.
This class composes functionality from multiple mixins rather than This class composes functionality from multiple mixins rather than
implementing everything directly. The ribbon configuration is implementing everything directly. The ribbon configuration is
automatically generated from decorated methods in the mixins. automatically generated from decorated methods in the mixins.
""" """
def __init__(self): def __init__(self):
super().__init__() super().__init__()
@ -85,8 +91,8 @@ class MainWindow(
self._autosave_timer.start(5 * 60 * 1000) # 5 minutes in milliseconds self._autosave_timer.start(5 * 60 * 1000) # 5 minutes in milliseconds
# Add a sample page for demonstration # Add a sample page for demonstration
#self._add_sample_page() # self._add_sample_page()
def _init_state(self): def _init_state(self):
"""Initialize shared application state""" """Initialize shared application state"""
# Initialize project # Initialize project
@ -94,17 +100,18 @@ class MainWindow(
# Set asset resolution context # Set asset resolution context
from pyPhotoAlbum.models import set_asset_resolution_context from pyPhotoAlbum.models import set_asset_resolution_context
set_asset_resolution_context(self._project.folder_path) set_asset_resolution_context(self._project.folder_path)
# Initialize template manager # Initialize template manager
self._template_manager = TemplateManager() self._template_manager = TemplateManager()
def _init_ui(self): def _init_ui(self):
"""Initialize user interface""" """Initialize user interface"""
# Basic window setup # Basic window setup
self.setWindowTitle("pyPhotoAlbum") self.setWindowTitle("pyPhotoAlbum")
self.resize(1200, 800) self.resize(1200, 800)
# Set window icon # Set window icon
icon_path = Path(__file__).parent / "icons" / "icon.png" icon_path = Path(__file__).parent / "icons" / "icon.png"
print(f"Window icon path: {icon_path}") print(f"Window icon path: {icon_path}")
@ -113,20 +120,20 @@ class MainWindow(
icon = QIcon(str(icon_path)) icon = QIcon(str(icon_path))
print(f"Icon is null: {icon.isNull()}") print(f"Icon is null: {icon.isNull()}")
self.setWindowIcon(icon) self.setWindowIcon(icon)
# Create main widget with layout # Create main widget with layout
main_widget = QWidget() main_widget = QWidget()
main_layout = QVBoxLayout() main_layout = QVBoxLayout()
main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0) main_layout.setSpacing(0)
main_widget.setLayout(main_layout) main_widget.setLayout(main_layout)
# Build ribbon config from decorated methods # Build ribbon config from decorated methods
ribbon_config = build_ribbon_config(self.__class__) ribbon_config = build_ribbon_config(self.__class__)
# Print summary (for debugging) # Print summary (for debugging)
print_ribbon_summary(ribbon_config) print_ribbon_summary(ribbon_config)
# Create ribbon with auto-generated config # Create ribbon with auto-generated config
self.ribbon = RibbonWidget(self, ribbon_config) self.ribbon = RibbonWidget(self, ribbon_config)
main_layout.addWidget(self.ribbon, 0) main_layout.addWidget(self.ribbon, 0)
@ -164,13 +171,13 @@ class MainWindow(
canvas_widget.setLayout(canvas_layout) canvas_widget.setLayout(canvas_layout)
main_layout.addWidget(canvas_widget, 1) main_layout.addWidget(canvas_widget, 1)
self.setCentralWidget(main_widget) self.setCentralWidget(main_widget)
# Create status bar # Create status bar
self._status_bar = QStatusBar() self._status_bar = QStatusBar()
self.setStatusBar(self._status_bar) self.setStatusBar(self._status_bar)
# Register keyboard shortcuts # Register keyboard shortcuts
self._register_shortcuts() self._register_shortcuts()
@ -206,8 +213,8 @@ class MainWindow(
viewport_width = self._gl_widget.width() viewport_width = self._gl_widget.width()
viewport_height = self._gl_widget.height() viewport_height = self._gl_widget.height()
content_height = bounds['height'] content_height = bounds["height"]
content_width = bounds['width'] content_width = bounds["width"]
# Vertical scrollbar # Vertical scrollbar
# Scrollbar value 0 = top of content # Scrollbar value 0 = top of content
@ -257,7 +264,7 @@ class MainWindow(
print(f"Registered shortcut: {shortcut_str} -> {method_name}") print(f"Registered shortcut: {shortcut_str} -> {method_name}")
# Register additional Ctrl+Shift+Z shortcut for redo # Register additional Ctrl+Shift+Z shortcut for redo
if hasattr(self, 'redo'): if hasattr(self, "redo"):
redo_shortcut = QShortcut(QKeySequence("Ctrl+Shift+Z"), self) redo_shortcut = QShortcut(QKeySequence("Ctrl+Shift+Z"), self)
redo_shortcut.activated.connect(self.redo) redo_shortcut.activated.connect(self.redo)
print("Registered shortcut: Ctrl+Shift+Z -> redo") print("Registered shortcut: Ctrl+Shift+Z -> redo")
@ -265,31 +272,31 @@ class MainWindow(
def resizeEvent(self, event): def resizeEvent(self, event):
"""Handle window resize to reposition loading widget""" """Handle window resize to reposition loading widget"""
super().resizeEvent(event) super().resizeEvent(event)
if hasattr(self, '_loading_widget'): if hasattr(self, "_loading_widget"):
self._loading_widget.resizeParent() self._loading_widget.resizeParent()
def _add_sample_page(self): def _add_sample_page(self):
"""Add a sample page with some elements for demonstration""" """Add a sample page with some elements for demonstration"""
from pyPhotoAlbum.project import Page from pyPhotoAlbum.project import Page
from pyPhotoAlbum.page_layout import PageLayout, GridLayout from pyPhotoAlbum.page_layout import PageLayout, GridLayout
from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
# Create a page with project default size # Create a page with project default size
width_mm, height_mm = self.project.page_size_mm width_mm, height_mm = self.project.page_size_mm
page_layout = PageLayout(width=width_mm, height=height_mm) page_layout = PageLayout(width=width_mm, height=height_mm)
grid = GridLayout(rows=2, columns=2, spacing=20.0) grid = GridLayout(rows=2, columns=2, spacing=20.0)
page_layout.set_grid_layout(grid) page_layout.set_grid_layout(grid)
# Add some sample elements (scaled to new default size) # Add some sample elements (scaled to new default size)
image = ImageData(image_path="sample.jpg", x=20, y=20, width=50, height=50) image = ImageData(image_path="sample.jpg", x=20, y=20, width=50, height=50)
page_layout.add_element(image) page_layout.add_element(image)
text_box = TextBoxData(text_content="Sample Text", x=80, y=20, width=50, height=20) text_box = TextBoxData(text_content="Sample Text", x=80, y=20, width=50, height=20)
page_layout.add_element(text_box) page_layout.add_element(text_box)
placeholder = PlaceholderData(placeholder_type="image", x=20, y=80, width=50, height=50) placeholder = PlaceholderData(placeholder_type="image", x=20, y=80, width=50, height=50)
page_layout.add_element(placeholder) page_layout.add_element(placeholder)
# Create and add the page # Create and add the page
page = Page(layout=page_layout, page_number=1) page = Page(layout=page_layout, page_number=1)
page.manually_sized = False # Not manually sized, uses defaults page.manually_sized = False # Not manually sized, uses defaults
@ -315,8 +322,8 @@ class MainWindow(
return return
checkpoint_path, metadata = checkpoint_info checkpoint_path, metadata = checkpoint_info
project_name = metadata.get('project_name', 'Unknown') project_name = metadata.get("project_name", "Unknown")
timestamp_str = metadata.get('timestamp', 'Unknown time') timestamp_str = metadata.get("timestamp", "Unknown time")
# Parse timestamp for better display # Parse timestamp for better display
try: try:
@ -334,7 +341,7 @@ class MainWindow(
f"Time: {time_display}\n\n" f"Time: {time_display}\n\n"
f"Would you like to recover this checkpoint?", f"Would you like to recover this checkpoint?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.Yes QMessageBox.StandardButton.Yes,
) )
if reply == QMessageBox.StandardButton.Yes: if reply == QMessageBox.StandardButton.Yes:
@ -343,7 +350,7 @@ class MainWindow(
if success: if success:
# Replace current project with recovered one # Replace current project with recovered one
if hasattr(self, '_project') and self._project: if hasattr(self, "_project") and self._project:
self._project.cleanup() self._project.cleanup()
self._project = result self._project = result
@ -365,8 +372,10 @@ class MainWindow(
self, self,
"Unsaved Changes", "Unsaved Changes",
"You have unsaved changes. Would you like to save before exiting?", "You have unsaved changes. Would you like to save before exiting?",
QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Save QMessageBox.StandardButton.Save
| QMessageBox.StandardButton.Discard
| QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Save,
) )
if reply == QMessageBox.StandardButton.Save: if reply == QMessageBox.StandardButton.Save:
@ -390,7 +399,7 @@ class MainWindow(
self.project.cleanup() self.project.cleanup()
# Stop autosave timer # Stop autosave timer
if hasattr(self, '_autosave_timer'): if hasattr(self, "_autosave_timer"):
self._autosave_timer.stop() self._autosave_timer.stop()
# Cleanup old checkpoints # Cleanup old checkpoints
@ -402,12 +411,12 @@ class MainWindow(
def main(): def main():
"""Application entry point""" """Application entry point"""
app = QApplication(sys.argv) app = QApplication(sys.argv)
# Set application identity for proper taskbar/window manager integration # Set application identity for proper taskbar/window manager integration
app.setApplicationName("pyPhotoAlbum") app.setApplicationName("pyPhotoAlbum")
app.setApplicationDisplayName("pyPhotoAlbum") app.setApplicationDisplayName("pyPhotoAlbum")
app.setDesktopFileName("pyphotoalbum.desktop") app.setDesktopFileName("pyphotoalbum.desktop")
# Set application icon # Set application icon
icon_path = Path(__file__).parent / "icons" / "icon.png" icon_path = Path(__file__).parent / "icons" / "icon.png"
print(f"Application icon path: {icon_path}") print(f"Application icon path: {icon_path}")
@ -416,17 +425,17 @@ def main():
icon = QIcon(str(icon_path)) icon = QIcon(str(icon_path))
print(f"Icon is null: {icon.isNull()}") print(f"Icon is null: {icon.isNull()}")
app.setWindowIcon(icon) app.setWindowIcon(icon)
# Enable high DPI scaling # Enable high DPI scaling
try: try:
app.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True) app.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True)
app.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps, True) app.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps, True)
except AttributeError: except AttributeError:
pass # Qt version doesn't support these attributes pass # Qt version doesn't support these attributes
window = MainWindow() window = MainWindow()
window.show() window.show()
sys.exit(app.exec()) sys.exit(app.exec())

View File

@ -3,9 +3,21 @@ Merge dialog for resolving project conflicts visually
""" """
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QDialog,
QListWidget, QListWidgetItem, QSplitter, QWidget, QScrollArea, QVBoxLayout,
QRadioButton, QButtonGroup, QTextEdit, QComboBox, QGroupBox QHBoxLayout,
QPushButton,
QLabel,
QListWidget,
QListWidgetItem,
QSplitter,
QWidget,
QScrollArea,
QRadioButton,
QButtonGroup,
QTextEdit,
QComboBox,
QGroupBox,
) )
from PyQt6.QtCore import Qt, QSize, pyqtSignal from PyQt6.QtCore import Qt, QSize, pyqtSignal
from PyQt6.QtGui import QPixmap, QPainter, QColor, QFont, QPen from PyQt6.QtGui import QPixmap, QPainter, QColor, QFont, QPen
@ -22,10 +34,7 @@ class PagePreviewWidget(QWidget):
super().__init__(parent) super().__init__(parent)
self.page_data = page_data self.page_data = page_data
self.setMinimumSize(200, 280) self.setMinimumSize(200, 280)
self.setSizePolicy( self.setSizePolicy(self.sizePolicy().Policy.Expanding, self.sizePolicy().Policy.Expanding)
self.sizePolicy().Policy.Expanding,
self.sizePolicy().Policy.Expanding
)
def paintEvent(self, event): def paintEvent(self, event):
"""Render the page preview""" """Render the page preview"""
@ -356,8 +365,4 @@ class MergeDialog(QDialog):
Returns: Returns:
Merged project data dictionary Merged project data dictionary
""" """
return self.merge_manager.apply_resolutions( return self.merge_manager.apply_resolutions(self.our_project_data, self.their_project_data, self.resolutions)
self.our_project_data,
self.their_project_data,
self.resolutions
)

View File

@ -16,6 +16,7 @@ from datetime import datetime, timezone
class ConflictType(Enum): class ConflictType(Enum):
"""Types of merge conflicts""" """Types of merge conflicts"""
# Page-level conflicts # Page-level conflicts
PAGE_MODIFIED_BOTH = "page_modified_both" # Page modified in both versions PAGE_MODIFIED_BOTH = "page_modified_both" # Page modified in both versions
PAGE_DELETED_ONE = "page_deleted_one" # Page deleted in one version, modified in other PAGE_DELETED_ONE = "page_deleted_one" # Page deleted in one version, modified in other
@ -31,6 +32,7 @@ class ConflictType(Enum):
class MergeStrategy(Enum): class MergeStrategy(Enum):
"""Automatic merge resolution strategies""" """Automatic merge resolution strategies"""
LATEST_WINS = "latest_wins" # Most recent last_modified wins LATEST_WINS = "latest_wins" # Most recent last_modified wins
OURS = "ours" # Always use our version OURS = "ours" # Always use our version
THEIRS = "theirs" # Always use their version THEIRS = "theirs" # Always use their version
@ -40,6 +42,7 @@ class MergeStrategy(Enum):
@dataclass @dataclass
class ConflictInfo: class ConflictInfo:
"""Information about a single merge conflict""" """Information about a single merge conflict"""
conflict_type: ConflictType conflict_type: ConflictType
page_uuid: Optional[str] # UUID of the page (if page-level conflict) page_uuid: Optional[str] # UUID of the page (if page-level conflict)
element_uuid: Optional[str] # UUID of the element (if element-level conflict) element_uuid: Optional[str] # UUID of the element (if element-level conflict)
@ -79,9 +82,7 @@ class MergeManager:
return project_a_id == project_b_id return project_a_id == project_b_id
def detect_conflicts( def detect_conflicts(
self, self, our_project_data: Dict[str, Any], their_project_data: Dict[str, Any]
our_project_data: Dict[str, Any],
their_project_data: Dict[str, Any]
) -> List[ConflictInfo]: ) -> List[ConflictInfo]:
""" """
Detect conflicts between two versions of the same project. Detect conflicts between two versions of the same project.
@ -103,16 +104,18 @@ class MergeManager:
return self.conflicts return self.conflicts
def _detect_project_settings_conflicts( def _detect_project_settings_conflicts(self, our_data: Dict[str, Any], their_data: Dict[str, Any]):
self,
our_data: Dict[str, Any],
their_data: Dict[str, Any]
):
"""Detect conflicts in project-level settings.""" """Detect conflicts in project-level settings."""
# Settings that can conflict # Settings that can conflict
settings_keys = [ settings_keys = [
"name", "page_size_mm", "working_dpi", "export_dpi", "name",
"has_cover", "paper_thickness_mm", "cover_bleed_mm", "binding_type" "page_size_mm",
"working_dpi",
"export_dpi",
"has_cover",
"paper_thickness_mm",
"cover_bleed_mm",
"binding_type",
] ]
our_modified = our_data.get("last_modified") our_modified = our_data.get("last_modified")
@ -124,20 +127,18 @@ class MergeManager:
# If values differ, it's a conflict # If values differ, it's a conflict
if our_value != their_value: if our_value != their_value:
self.conflicts.append(ConflictInfo( self.conflicts.append(
conflict_type=ConflictType.SETTINGS_MODIFIED_BOTH, ConflictInfo(
page_uuid=None, conflict_type=ConflictType.SETTINGS_MODIFIED_BOTH,
element_uuid=None, page_uuid=None,
our_version={key: our_value, "last_modified": our_modified}, element_uuid=None,
their_version={key: their_value, "last_modified": their_modified}, our_version={key: our_value, "last_modified": our_modified},
description=f"Project setting '{key}' modified in both versions" their_version={key: their_value, "last_modified": their_modified},
)) description=f"Project setting '{key}' modified in both versions",
)
)
def _detect_page_conflicts( def _detect_page_conflicts(self, our_data: Dict[str, Any], their_data: Dict[str, Any]):
self,
our_data: Dict[str, Any],
their_data: Dict[str, Any]
):
"""Detect conflicts at page level.""" """Detect conflicts at page level."""
our_pages = {page["uuid"]: page for page in our_data.get("pages", [])} our_pages = {page["uuid"]: page for page in our_data.get("pages", [])}
their_pages = {page["uuid"]: page for page in their_data.get("pages", [])} their_pages = {page["uuid"]: page for page in their_data.get("pages", [])}
@ -164,12 +165,7 @@ class MergeManager:
# Unless we deleted it # Unless we deleted it
pass pass
def _detect_page_modification_conflicts( def _detect_page_modification_conflicts(self, page_uuid: str, our_page: Dict[str, Any], their_page: Dict[str, Any]):
self,
page_uuid: str,
our_page: Dict[str, Any],
their_page: Dict[str, Any]
):
"""Detect conflicts in a specific page.""" """Detect conflicts in a specific page."""
our_modified = our_page.get("last_modified") our_modified = our_page.get("last_modified")
their_modified = their_page.get("last_modified") their_modified = their_page.get("last_modified")
@ -180,14 +176,16 @@ class MergeManager:
# Check if one deleted, one modified # Check if one deleted, one modified
if our_page.get("deleted") != their_page.get("deleted"): if our_page.get("deleted") != their_page.get("deleted"):
self.conflicts.append(ConflictInfo( self.conflicts.append(
conflict_type=ConflictType.PAGE_DELETED_ONE, ConflictInfo(
page_uuid=page_uuid, conflict_type=ConflictType.PAGE_DELETED_ONE,
element_uuid=None, page_uuid=page_uuid,
our_version=our_page, element_uuid=None,
their_version=their_page, our_version=our_page,
description=f"Page deleted in one version but modified in the other" their_version=their_page,
)) description=f"Page deleted in one version but modified in the other",
)
)
return return
# Check page-level properties # Check page-level properties
@ -201,24 +199,21 @@ class MergeManager:
# Only flag as conflict if properties differ AND timestamps are identical # Only flag as conflict if properties differ AND timestamps are identical
# (See element conflict detection for detailed explanation of this strategy) # (See element conflict detection for detailed explanation of this strategy)
if page_modified and our_modified == their_modified: if page_modified and our_modified == their_modified:
self.conflicts.append(ConflictInfo( self.conflicts.append(
conflict_type=ConflictType.PAGE_MODIFIED_BOTH, ConflictInfo(
page_uuid=page_uuid, conflict_type=ConflictType.PAGE_MODIFIED_BOTH,
element_uuid=None, page_uuid=page_uuid,
our_version=our_page, element_uuid=None,
their_version=their_page, our_version=our_page,
description=f"Page properties modified with same timestamp (possible conflict)" their_version=their_page,
)) description=f"Page properties modified with same timestamp (possible conflict)",
)
)
# Check element-level conflicts # Check element-level conflicts
self._detect_element_conflicts(page_uuid, our_page, their_page) self._detect_element_conflicts(page_uuid, our_page, their_page)
def _detect_element_conflicts( def _detect_element_conflicts(self, page_uuid: str, our_page: Dict[str, Any], their_page: Dict[str, Any]):
self,
page_uuid: str,
our_page: Dict[str, Any],
their_page: Dict[str, Any]
):
"""Detect conflicts in elements within a page.""" """Detect conflicts in elements within a page."""
our_layout = our_page.get("layout", {}) our_layout = our_page.get("layout", {})
their_layout = their_page.get("layout", {}) their_layout = their_page.get("layout", {})
@ -238,16 +233,10 @@ class MergeManager:
continue continue
# Element exists in both - check for modifications # Element exists in both - check for modifications
self._detect_element_modification_conflicts( self._detect_element_modification_conflicts(page_uuid, elem_uuid, our_elem, their_elem)
page_uuid, elem_uuid, our_elem, their_elem
)
def _detect_element_modification_conflicts( def _detect_element_modification_conflicts(
self, self, page_uuid: str, elem_uuid: str, our_elem: Dict[str, Any], their_elem: Dict[str, Any]
page_uuid: str,
elem_uuid: str,
our_elem: Dict[str, Any],
their_elem: Dict[str, Any]
): ):
"""Detect conflicts in a specific element.""" """Detect conflicts in a specific element."""
our_modified = our_elem.get("last_modified") our_modified = our_elem.get("last_modified")
@ -259,14 +248,16 @@ class MergeManager:
# Check if one deleted, one modified # Check if one deleted, one modified
if our_elem.get("deleted") != their_elem.get("deleted"): if our_elem.get("deleted") != their_elem.get("deleted"):
self.conflicts.append(ConflictInfo( self.conflicts.append(
conflict_type=ConflictType.ELEMENT_DELETED_ONE, ConflictInfo(
page_uuid=page_uuid, conflict_type=ConflictType.ELEMENT_DELETED_ONE,
element_uuid=elem_uuid, page_uuid=page_uuid,
our_version=our_elem, element_uuid=elem_uuid,
their_version=their_elem, our_version=our_elem,
description=f"Element deleted in one version but modified in the other" their_version=their_elem,
)) description=f"Element deleted in one version but modified in the other",
)
)
return return
# Check element properties # Check element properties
@ -298,22 +289,21 @@ class MergeManager:
# Properties differ but timestamps match - this is unusual and might indicate # Properties differ but timestamps match - this is unusual and might indicate
# that both versions modified it at exactly the same time, or there's data corruption. # that both versions modified it at exactly the same time, or there's data corruption.
# Flag as conflict to be safe. # Flag as conflict to be safe.
self.conflicts.append(ConflictInfo( self.conflicts.append(
conflict_type=ConflictType.ELEMENT_MODIFIED_BOTH, ConflictInfo(
page_uuid=page_uuid, conflict_type=ConflictType.ELEMENT_MODIFIED_BOTH,
element_uuid=elem_uuid, page_uuid=page_uuid,
our_version=our_elem, element_uuid=elem_uuid,
their_version=their_elem, our_version=our_elem,
description=f"Element modified with same timestamp (possible conflict)" their_version=their_elem,
)) description=f"Element modified with same timestamp (possible conflict)",
)
)
# Note: If timestamps differ, we assume one version modified it and the other didn't. # Note: If timestamps differ, we assume one version modified it and the other didn't.
# The _merge_non_conflicting_changes method will automatically use the newer version. # The _merge_non_conflicting_changes method will automatically use the newer version.
def auto_resolve_conflicts( def auto_resolve_conflicts(self, strategy: MergeStrategy = MergeStrategy.LATEST_WINS) -> Dict[str, Any]:
self,
strategy: MergeStrategy = MergeStrategy.LATEST_WINS
) -> Dict[str, Any]:
""" """
Automatically resolve conflicts based on a strategy. Automatically resolve conflicts based on a strategy.
@ -353,10 +343,7 @@ class MergeManager:
return None return None
def apply_resolutions( def apply_resolutions(
self, self, our_project_data: Dict[str, Any], their_project_data: Dict[str, Any], resolutions: Dict[int, str]
our_project_data: Dict[str, Any],
their_project_data: Dict[str, Any],
resolutions: Dict[int, str]
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Apply conflict resolutions to create merged project. Apply conflict resolutions to create merged project.
@ -415,20 +402,12 @@ class MergeManager:
break break
break break
def _merge_non_conflicting_changes( def _merge_non_conflicting_changes(self, merged_data: Dict[str, Any], their_data: Dict[str, Any]):
self,
merged_data: Dict[str, Any],
their_data: Dict[str, Any]
):
"""Add non-conflicting pages and elements from their version.""" """Add non-conflicting pages and elements from their version."""
self._add_missing_pages(merged_data, their_data) self._add_missing_pages(merged_data, their_data)
self._merge_page_elements(merged_data, their_data) self._merge_page_elements(merged_data, their_data)
def _add_missing_pages( def _add_missing_pages(self, merged_data: Dict[str, Any], their_data: Dict[str, Any]):
self,
merged_data: Dict[str, Any],
their_data: Dict[str, Any]
):
"""Add pages that exist only in their version.""" """Add pages that exist only in their version."""
our_page_uuids = {page["uuid"] for page in merged_data.get("pages", [])} our_page_uuids = {page["uuid"] for page in merged_data.get("pages", [])}
@ -436,11 +415,7 @@ class MergeManager:
if their_page["uuid"] not in our_page_uuids: if their_page["uuid"] not in our_page_uuids:
merged_data["pages"].append(their_page) merged_data["pages"].append(their_page)
def _merge_page_elements( def _merge_page_elements(self, merged_data: Dict[str, Any], their_data: Dict[str, Any]):
self,
merged_data: Dict[str, Any],
their_data: Dict[str, Any]
):
"""For pages that exist in both versions, merge their elements.""" """For pages that exist in both versions, merge their elements."""
their_pages = {page["uuid"]: page for page in their_data.get("pages", [])} their_pages = {page["uuid"]: page for page in their_data.get("pages", [])}
@ -449,25 +424,15 @@ class MergeManager:
if not their_page: if not their_page:
continue continue
our_elements = { our_elements = {elem["uuid"]: elem for elem in our_page.get("layout", {}).get("elements", [])}
elem["uuid"]: elem
for elem in our_page.get("layout", {}).get("elements", [])
}
for their_elem in their_page.get("layout", {}).get("elements", []): for their_elem in their_page.get("layout", {}).get("elements", []):
self._merge_element( self._merge_element(
our_page=our_page, our_page=our_page, page_uuid=our_page["uuid"], their_elem=their_elem, our_elements=our_elements
page_uuid=our_page["uuid"],
their_elem=their_elem,
our_elements=our_elements
) )
def _merge_element( def _merge_element(
self, self, our_page: Dict[str, Any], page_uuid: str, their_elem: Dict[str, Any], our_elements: Dict[str, Any]
our_page: Dict[str, Any],
page_uuid: str,
their_elem: Dict[str, Any],
our_elements: Dict[str, Any]
): ):
"""Merge a single element from their version into our page.""" """Merge a single element from their version into our page."""
elem_uuid = their_elem["uuid"] elem_uuid = their_elem["uuid"]
@ -486,17 +451,10 @@ class MergeManager:
def _is_element_in_conflict(self, elem_uuid: str, page_uuid: str) -> bool: def _is_element_in_conflict(self, elem_uuid: str, page_uuid: str) -> bool:
"""Check if element was part of a conflict that was already resolved.""" """Check if element was part of a conflict that was already resolved."""
return any( return any(c.element_uuid == elem_uuid and c.page_uuid == page_uuid for c in self.conflicts)
c.element_uuid == elem_uuid and c.page_uuid == page_uuid
for c in self.conflicts
)
def _merge_by_timestamp( def _merge_by_timestamp(
self, self, our_page: Dict[str, Any], elem_uuid: str, their_elem: Dict[str, Any], our_elem: Dict[str, Any]
our_page: Dict[str, Any],
elem_uuid: str,
their_elem: Dict[str, Any],
our_elem: Dict[str, Any]
): ):
"""Use the more recently modified version of an element.""" """Use the more recently modified version of an element."""
our_modified = our_elem.get("last_modified") our_modified = our_elem.get("last_modified")
@ -513,10 +471,7 @@ class MergeManager:
break break
def concatenate_projects( def concatenate_projects(project_a_data: Dict[str, Any], project_b_data: Dict[str, Any]) -> Dict[str, Any]:
project_a_data: Dict[str, Any],
project_b_data: Dict[str, Any]
) -> Dict[str, Any]:
""" """
Concatenate two projects with different project_ids. Concatenate two projects with different project_ids.
@ -542,6 +497,8 @@ def concatenate_projects(
# Update last_modified to now # Update last_modified to now
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"
)
return merged_data return merged_data

View File

@ -5,4 +5,4 @@ Mixin modules for pyPhotoAlbum
from pyPhotoAlbum.mixins.base import ApplicationStateMixin from pyPhotoAlbum.mixins.base import ApplicationStateMixin
from pyPhotoAlbum.mixins.dialog_mixin import DialogMixin from pyPhotoAlbum.mixins.dialog_mixin import DialogMixin
__all__ = ['ApplicationStateMixin', 'DialogMixin'] __all__ = ["ApplicationStateMixin", "DialogMixin"]

View File

@ -14,7 +14,7 @@ class AssetDropMixin:
or updating ImageData elements. or updating ImageData elements.
""" """
IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp'] IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp"]
def dragEnterEvent(self, event): def dragEnterEvent(self, event):
"""Handle drag enter events""" """Handle drag enter events"""
@ -66,7 +66,7 @@ class AssetDropMixin:
def _handle_drop_on_element(self, image_path, target_element): def _handle_drop_on_element(self, image_path, target_element):
"""Handle dropping an image onto an existing element""" """Handle dropping an image onto an existing element"""
main_window = self.window() main_window = self.window()
if not (hasattr(main_window, 'project') and main_window.project): if not (hasattr(main_window, "project") and main_window.project):
return return
try: try:
@ -89,7 +89,7 @@ class AssetDropMixin:
y=placeholder.position[1], y=placeholder.position[1],
width=placeholder.size[0], width=placeholder.size[0],
height=placeholder.size[1], height=placeholder.size[1],
z_index=placeholder.z_index z_index=placeholder.z_index,
) )
if not main_window.project.pages: if not main_window.project.pages:
@ -104,7 +104,7 @@ 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"""
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
target_page, page_index, page_renderer = self._get_page_at(x, y) target_page, page_index, page_renderer = self._get_page_at(x, y)
@ -120,8 +120,7 @@ class AssetDropMixin:
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(
asset_path, target_page, page_index, page_renderer, asset_path, target_page, page_index, page_renderer, x, y, img_width, img_height, main_window
x, y, img_width, img_height, main_window
) )
except Exception as e: except Exception as e:
print(f"Error importing dropped image: {e}") print(f"Error importing dropped image: {e}")
@ -138,27 +137,18 @@ class AssetDropMixin:
# Fallback dimensions if image cannot be read # Fallback dimensions if image cannot be read
return 200, 150 return 200, 150
def _add_new_image_to_page(self, asset_path, target_page, page_index, def _add_new_image_to_page(
page_renderer, x, y, img_width, img_height, main_window): self, asset_path, target_page, page_index, page_renderer, x, y, img_width, img_height, main_window
):
"""Add a new image element to the target page (asset already imported)""" """Add a new image element to the target page (asset already imported)"""
if page_index >= 0: if page_index >= 0:
self.current_page_index = page_index self.current_page_index = page_index
page_local_x, page_local_y = page_renderer.screen_to_page(x, y) page_local_x, page_local_y = page_renderer.screen_to_page(x, y)
new_image = ImageData( new_image = ImageData(image_path=asset_path, x=page_local_x, y=page_local_y, width=img_width, height=img_height)
image_path=asset_path,
x=page_local_x,
y=page_local_y,
width=img_width,
height=img_height
)
cmd = AddElementCommand( cmd = AddElementCommand(target_page.layout, new_image, asset_manager=main_window.project.asset_manager)
target_page.layout,
new_image,
asset_manager=main_window.project.asset_manager
)
main_window.project.history.execute(cmd) main_window.project.history.execute(cmd)
print(f"Added new image to page {page_index + 1} at ({page_local_x:.1f}, {page_local_y:.1f}): {asset_path}") print(f"Added new image to page {page_index + 1} at ({page_local_x:.1f}, {page_local_y:.1f}): {asset_path}")

View File

@ -63,6 +63,6 @@ class AssetPathMixin:
Override this method if the project is accessed differently. Override this method if the project is accessed differently.
Default implementation uses self.project.folder_path. Default implementation uses self.project.folder_path.
""" """
if hasattr(self, 'project') and self.project: if hasattr(self, "project") and self.project:
return getattr(self.project, 'folder_path', None) return getattr(self.project, "folder_path", None)
return None return None

View File

@ -48,13 +48,13 @@ class AsyncLoadingMixin:
"""Cleanup async loading components.""" """Cleanup async loading components."""
logger.info("Cleaning up async loading system...") logger.info("Cleaning up async loading system...")
if hasattr(self, 'async_image_loader'): if hasattr(self, "async_image_loader"):
self.async_image_loader.stop() self.async_image_loader.stop()
if hasattr(self, 'async_pdf_generator'): if hasattr(self, "async_pdf_generator"):
self.async_pdf_generator.stop() self.async_pdf_generator.stop()
if hasattr(self, 'image_cache'): if hasattr(self, "image_cache"):
self.image_cache.clear() self.image_cache.clear()
logger.info("Async loading system cleaned up") logger.info("Async loading system cleaned up")
@ -70,7 +70,7 @@ class AsyncLoadingMixin:
""" """
logger.debug(f"Image loaded callback: {path}") logger.debug(f"Image loaded callback: {path}")
if user_data and hasattr(user_data, '_on_async_image_loaded'): if user_data and hasattr(user_data, "_on_async_image_loaded"):
user_data._on_async_image_loaded(image) user_data._on_async_image_loaded(image)
# Trigger re-render to show newly loaded image # Trigger re-render to show newly loaded image
@ -87,7 +87,7 @@ class AsyncLoadingMixin:
""" """
logger.warning(f"Image load failed: {path} - {error_msg}") logger.warning(f"Image load failed: {path} - {error_msg}")
if user_data and hasattr(user_data, '_on_async_image_load_failed'): if user_data and hasattr(user_data, "_on_async_image_load_failed"):
user_data._on_async_image_load_failed(error_msg) user_data._on_async_image_load_failed(error_msg)
def _on_pdf_progress(self, current: int, total: int, message: str): def _on_pdf_progress(self, current: int, total: int, message: str):
@ -102,7 +102,7 @@ class AsyncLoadingMixin:
logger.debug(f"PDF progress: {current}/{total} - {message}") logger.debug(f"PDF progress: {current}/{total} - {message}")
# Update progress dialog if it exists # Update progress dialog if it exists
if hasattr(self, '_pdf_progress_dialog') and self._pdf_progress_dialog: if hasattr(self, "_pdf_progress_dialog") and self._pdf_progress_dialog:
self._pdf_progress_dialog.setValue(current) self._pdf_progress_dialog.setValue(current)
self._pdf_progress_dialog.setLabelText(message) self._pdf_progress_dialog.setLabelText(message)
@ -117,19 +117,16 @@ class AsyncLoadingMixin:
logger.info(f"PDF export complete: success={success}, warnings={len(warnings)}") logger.info(f"PDF export complete: success={success}, warnings={len(warnings)}")
# Close progress dialog # Close progress dialog
if hasattr(self, '_pdf_progress_dialog') and self._pdf_progress_dialog: if hasattr(self, "_pdf_progress_dialog") and self._pdf_progress_dialog:
self._pdf_progress_dialog.close() self._pdf_progress_dialog.close()
self._pdf_progress_dialog = None self._pdf_progress_dialog = None
# Show completion message # Show completion message
main_window = self.window() main_window = self.window()
if hasattr(main_window, 'show_status'): if hasattr(main_window, "show_status"):
if success: if success:
if warnings: if warnings:
main_window.show_status( main_window.show_status(f"PDF exported successfully with {len(warnings)} warnings", 5000)
f"PDF exported successfully with {len(warnings)} warnings",
5000
)
else: else:
main_window.show_status("PDF exported successfully", 3000) main_window.show_status("PDF exported successfully", 3000)
else: else:
@ -145,13 +142,13 @@ class AsyncLoadingMixin:
logger.error(f"PDF export failed: {error_msg}") logger.error(f"PDF export failed: {error_msg}")
# Close progress dialog # Close progress dialog
if hasattr(self, '_pdf_progress_dialog') and self._pdf_progress_dialog: if hasattr(self, "_pdf_progress_dialog") and self._pdf_progress_dialog:
self._pdf_progress_dialog.close() self._pdf_progress_dialog.close()
self._pdf_progress_dialog = None self._pdf_progress_dialog = None
# Show error message # Show error message
main_window = self.window() main_window = self.window()
if hasattr(main_window, 'show_status'): if hasattr(main_window, "show_status"):
main_window.show_status(f"PDF export failed: {error_msg}", 5000) main_window.show_status(f"PDF export failed: {error_msg}", 5000)
def request_image_load(self, image_data, priority: LoadPriority = LoadPriority.NORMAL): def request_image_load(self, image_data, priority: LoadPriority = LoadPriority.NORMAL):
@ -162,7 +159,7 @@ class AsyncLoadingMixin:
image_data: ImageData element to load image_data: ImageData element to load
priority: Load priority level priority: Load priority level
""" """
if not hasattr(self, 'async_image_loader'): if not hasattr(self, "async_image_loader"):
logger.warning("Async image loader not initialized") logger.warning("Async image loader not initialized")
return return
@ -188,7 +185,7 @@ class AsyncLoadingMixin:
Path(image_full_path), Path(image_full_path),
priority=priority, priority=priority,
target_size=target_size, target_size=target_size,
user_data=image_data # Pass element for callback user_data=image_data, # Pass element for callback
) )
def export_pdf_async(self, project, output_path: str, export_dpi: int = 300): def export_pdf_async(self, project, output_path: str, export_dpi: int = 300):
@ -200,7 +197,7 @@ class AsyncLoadingMixin:
output_path: Output PDF file path output_path: Output PDF file path
export_dpi: Export DPI (default 300) export_dpi: Export DPI (default 300)
""" """
if not hasattr(self, 'async_pdf_generator'): if not hasattr(self, "async_pdf_generator"):
logger.warning("Async PDF generator not initialized") logger.warning("Async PDF generator not initialized")
return False return False
@ -208,18 +205,9 @@ class AsyncLoadingMixin:
from PyQt6.QtWidgets import QProgressDialog from PyQt6.QtWidgets import QProgressDialog
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
total_pages = sum( total_pages = sum(1 if page.is_cover else (2 if page.is_double_spread else 1) for page in project.pages)
1 if page.is_cover else (2 if page.is_double_spread else 1)
for page in project.pages
)
self._pdf_progress_dialog = QProgressDialog( self._pdf_progress_dialog = QProgressDialog("Exporting to PDF...", "Cancel", 0, total_pages, self)
"Exporting to PDF...",
"Cancel",
0,
total_pages,
self
)
self._pdf_progress_dialog.setWindowModality(Qt.WindowModality.WindowModal) self._pdf_progress_dialog.setWindowModality(Qt.WindowModality.WindowModal)
self._pdf_progress_dialog.setWindowTitle("PDF Export") self._pdf_progress_dialog.setWindowTitle("PDF Export")
self._pdf_progress_dialog.canceled.connect(self._on_pdf_cancel) self._pdf_progress_dialog.canceled.connect(self._on_pdf_cancel)
@ -232,17 +220,17 @@ class AsyncLoadingMixin:
"""Handle PDF export cancellation.""" """Handle PDF export cancellation."""
logger.info("User requested PDF export cancellation") logger.info("User requested PDF export cancellation")
if hasattr(self, 'async_pdf_generator'): if hasattr(self, "async_pdf_generator"):
self.async_pdf_generator.cancel_export() self.async_pdf_generator.cancel_export()
def get_async_stats(self) -> dict: def get_async_stats(self) -> dict:
"""Get async loading system statistics.""" """Get async loading system statistics."""
stats = {} stats = {}
if hasattr(self, 'async_image_loader'): if hasattr(self, "async_image_loader"):
stats['image_loader'] = self.async_image_loader.get_stats() stats["image_loader"] = self.async_image_loader.get_stats()
if hasattr(self, 'async_pdf_generator'): if hasattr(self, "async_pdf_generator"):
stats['pdf_generator'] = self.async_pdf_generator.get_stats() stats["pdf_generator"] = self.async_pdf_generator.get_stats()
return stats return stats

View File

@ -9,50 +9,50 @@ from PyQt6.QtWidgets import QStatusBar, QMessageBox
class ApplicationStateMixin: class ApplicationStateMixin:
""" """
Base mixin providing access to shared application state. Base mixin providing access to shared application state.
This mixin provides properties and helper methods for accessing This mixin provides properties and helper methods for accessing
core application objects that are shared across all operation mixins. core application objects that are shared across all operation mixins.
Required attributes (must be set by MainWindow): Required attributes (must be set by MainWindow):
_project: Project instance _project: Project instance
_gl_widget: GLWidget instance _gl_widget: GLWidget instance
_status_bar: QStatusBar instance _status_bar: QStatusBar instance
_template_manager: TemplateManager instance _template_manager: TemplateManager instance
""" """
@property @property
def project(self): def project(self):
"""Access to current project""" """Access to current project"""
if not hasattr(self, '_project'): if not hasattr(self, "_project"):
raise AttributeError("MainWindow must set _project attribute") raise AttributeError("MainWindow must set _project attribute")
return self._project return self._project
@project.setter @project.setter
def project(self, value): def project(self, value):
"""Set the current project""" """Set the current project"""
self._project = value self._project = value
@property @property
def gl_widget(self): def gl_widget(self):
"""Access to GL rendering widget""" """Access to GL rendering widget"""
if not hasattr(self, '_gl_widget'): if not hasattr(self, "_gl_widget"):
raise AttributeError("MainWindow must set _gl_widget attribute") raise AttributeError("MainWindow must set _gl_widget attribute")
return self._gl_widget return self._gl_widget
@property @property
def status_bar(self) -> QStatusBar: def status_bar(self) -> QStatusBar:
"""Access to status bar""" """Access to status bar"""
if not hasattr(self, '_status_bar'): if not hasattr(self, "_status_bar"):
raise AttributeError("MainWindow must set _status_bar attribute") raise AttributeError("MainWindow must set _status_bar attribute")
return self._status_bar return self._status_bar
@property @property
def template_manager(self): def template_manager(self):
"""Access to template manager""" """Access to template manager"""
if not hasattr(self, '_template_manager'): if not hasattr(self, "_template_manager"):
raise AttributeError("MainWindow must set _template_manager attribute") raise AttributeError("MainWindow must set _template_manager attribute")
return self._template_manager return self._template_manager
# Common helper methods # Common helper methods
def _get_most_visible_page_index(self): def _get_most_visible_page_index(self):
@ -62,7 +62,7 @@ class ApplicationStateMixin:
Returns: Returns:
int: Index of the most visible page int: Index of the most visible page
""" """
if not hasattr(self.gl_widget, '_page_renderers') or not self.gl_widget._page_renderers: if not hasattr(self.gl_widget, "_page_renderers") or not self.gl_widget._page_renderers:
return self.gl_widget.current_page_index return self.gl_widget.current_page_index
# Get viewport dimensions # Get viewport dimensions
@ -70,7 +70,7 @@ class ApplicationStateMixin:
viewport_center_y = viewport_height / 2 viewport_center_y = viewport_height / 2
# Find which page's center is closest to viewport center # Find which page's center is closest to viewport center
min_distance = float('inf') min_distance = float("inf")
best_page_index = self.gl_widget.current_page_index best_page_index = self.gl_widget.current_page_index
for renderer, page in self.gl_widget._page_renderers: for renderer, page in self.gl_widget._page_renderers:
@ -107,109 +107,106 @@ class ApplicationStateMixin:
return self.project.pages[index] return self.project.pages[index]
return None return None
def get_current_page_index(self) -> int: def get_current_page_index(self) -> int:
""" """
Get current page index. Get current page index.
Returns: Returns:
Current page index, or -1 if no page Current page index, or -1 if no page
""" """
if not self.project or not self.project.pages: if not self.project or not self.project.pages:
return -1 return -1
return self.gl_widget.current_page_index return self.gl_widget.current_page_index
def show_status(self, message: str, timeout: int = 2000): def show_status(self, message: str, timeout: int = 2000):
""" """
Show message in status bar. Show message in status bar.
Args: Args:
message: Message to display message: Message to display
timeout: Display duration in milliseconds timeout: Display duration in milliseconds
""" """
if self.status_bar: if self.status_bar:
self.status_bar.showMessage(message, timeout) self.status_bar.showMessage(message, timeout)
def show_error(self, title: str, message: str): def show_error(self, title: str, message: str):
""" """
Show error dialog. Show error dialog.
Args: Args:
title: Dialog title title: Dialog title
message: Error message message: Error message
""" """
QMessageBox.critical(self, title, message) QMessageBox.critical(self, title, message)
def show_warning(self, title: str, message: str): def show_warning(self, title: str, message: str):
""" """
Show warning dialog. Show warning dialog.
Args: Args:
title: Dialog title title: Dialog title
message: Warning message message: Warning message
""" """
QMessageBox.warning(self, title, message) QMessageBox.warning(self, title, message)
def show_info(self, title: str, message: str): def show_info(self, title: str, message: str):
""" """
Show information dialog. Show information dialog.
Args: Args:
title: Dialog title title: Dialog title
message: Information message message: Information message
""" """
QMessageBox.information(self, title, message) QMessageBox.information(self, title, message)
def require_page(self, show_warning: bool = True) -> bool: def require_page(self, show_warning: bool = True) -> bool:
""" """
Check if a page is available and optionally show warning. Check if a page is available and optionally show warning.
Args: Args:
show_warning: Whether to show warning dialog if no page exists show_warning: Whether to show warning dialog if no page exists
Returns: Returns:
True if page exists, False otherwise True if page exists, False otherwise
""" """
current_page = self.get_current_page() current_page = self.get_current_page()
if current_page is None: if current_page is None:
if show_warning: if show_warning:
self.show_warning("No Page", "Please create a page first.") self.show_warning("No Page", "Please create a page first.")
return False return False
return True return True
def require_selection(self, min_count: int = 1, show_warning: bool = True) -> bool: def require_selection(self, min_count: int = 1, show_warning: bool = True) -> bool:
""" """
Check if required number of elements are selected. Check if required number of elements are selected.
Args: Args:
min_count: Minimum number of selected elements required min_count: Minimum number of selected elements required
show_warning: Whether to show warning dialog if requirement not met show_warning: Whether to show warning dialog if requirement not met
Returns: Returns:
True if requirements met, False otherwise True if requirements met, False otherwise
""" """
selected_count = len(self.gl_widget.selected_elements) selected_count = len(self.gl_widget.selected_elements)
if selected_count < min_count: if selected_count < min_count:
if show_warning: if show_warning:
if min_count == 1: if min_count == 1:
self.show_info("No Selection", "Please select an element.") self.show_info("No Selection", "Please select an element.")
else: else:
self.show_info( self.show_info("Selection Required", f"Please select at least {min_count} elements.")
"Selection Required",
f"Please select at least {min_count} elements."
)
return False return False
return True return True
def update_view(self): def update_view(self):
"""Trigger GL widget update to refresh the view""" """Trigger GL widget update to refresh the view"""
if self.gl_widget: if self.gl_widget:
self.gl_widget.update() self.gl_widget.update()
# Update scrollbars to reflect new content # Update scrollbars to reflect new content
if hasattr(self, 'update_scrollbars'): if hasattr(self, "update_scrollbars"):
self.update_scrollbars() self.update_scrollbars()

View File

@ -16,12 +16,7 @@ class DialogMixin:
making it easier to create, test, and maintain complex dialogs. making it easier to create, test, and maintain complex dialogs.
""" """
def create_dialog( def create_dialog(self, dialog_class: type, title: Optional[str] = None, **kwargs) -> Optional[Any]:
self,
dialog_class: type,
title: Optional[str] = None,
**kwargs
) -> Optional[Any]:
""" """
Create and show a dialog, handling the result. Create and show a dialog, handling the result.
@ -43,18 +38,13 @@ class DialogMixin:
# Show dialog and handle result # Show dialog and handle result
if dialog.exec() == QDialog.DialogCode.Accepted: if dialog.exec() == QDialog.DialogCode.Accepted:
# Check if dialog has a get_values method # Check if dialog has a get_values method
if hasattr(dialog, 'get_values'): if hasattr(dialog, "get_values"):
return dialog.get_values() return dialog.get_values()
return True return True
return None return None
def show_dialog( def show_dialog(self, dialog_class: type, on_accept: Optional[Callable] = None, **kwargs) -> bool:
self,
dialog_class: type,
on_accept: Optional[Callable] = None,
**kwargs
) -> bool:
""" """
Show a dialog and execute callback on acceptance. Show a dialog and execute callback on acceptance.

View File

@ -27,11 +27,7 @@ class ElementManipulationMixin:
self.rotation_snap_angle: int = 15 # Default snap angle in degrees self.rotation_snap_angle: int = 15 # Default snap angle in degrees
# Snap state tracking # Snap state tracking
self.snap_state = { self.snap_state = {"is_snapped": False, "last_position": None, "last_size": None}
'is_snapped': False,
'last_position': None,
'last_size': None
}
def _resize_element(self, dx: float, dy: float): def _resize_element(self, dx: float, dy: float):
""" """
@ -49,7 +45,7 @@ class ElementManipulationMixin:
# Get the snapping system from the element's parent page # Get the snapping system from the element's parent page
main_window = self.window() main_window = self.window()
if not hasattr(self.selected_element, '_parent_page'): if not hasattr(self.selected_element, "_parent_page"):
self._resize_element_no_snap(dx, dy) self._resize_element_no_snap(dx, dy)
return return
@ -62,6 +58,7 @@ class ElementManipulationMixin:
# Apply snapping to resize # Apply snapping to resize
from pyPhotoAlbum.snapping import SnapResizeParams from pyPhotoAlbum.snapping import SnapResizeParams
params = SnapResizeParams( params = SnapResizeParams(
position=self.resize_start_pos, position=self.resize_start_pos,
size=self.resize_start_size, size=self.resize_start_size,
@ -70,7 +67,7 @@ class ElementManipulationMixin:
resize_handle=self.resize_handle, resize_handle=self.resize_handle,
page_size=page_size, page_size=page_size,
dpi=dpi, dpi=dpi,
project=main_window.project project=main_window.project,
) )
new_pos, new_size = snap_sys.snap_resize(params) new_pos, new_size = snap_sys.snap_resize(params)
@ -100,16 +97,16 @@ class ElementManipulationMixin:
start_x, start_y = self.resize_start_pos start_x, start_y = self.resize_start_pos
start_w, start_h = self.resize_start_size start_w, start_h = self.resize_start_size
if self.resize_handle == 'nw': if self.resize_handle == "nw":
self.selected_element.position = (start_x + dx, start_y + dy) self.selected_element.position = (start_x + dx, start_y + dy)
self.selected_element.size = (start_w - dx, start_h - dy) self.selected_element.size = (start_w - dx, start_h - dy)
elif self.resize_handle == 'ne': elif self.resize_handle == "ne":
self.selected_element.position = (start_x, start_y + dy) self.selected_element.position = (start_x, start_y + dy)
self.selected_element.size = (start_w + dx, start_h - dy) self.selected_element.size = (start_w + dx, start_h - dy)
elif self.resize_handle == 'sw': elif self.resize_handle == "sw":
self.selected_element.position = (start_x + dx, start_y) self.selected_element.position = (start_x + dx, start_y)
self.selected_element.size = (start_w - dx, start_h + dy) self.selected_element.size = (start_w - dx, start_h + dy)
elif self.resize_handle == 'se': elif self.resize_handle == "se":
self.selected_element.size = (start_w + dx, start_h + dy) self.selected_element.size = (start_w + dx, start_h + dy)
# Ensure minimum size # Ensure minimum size
@ -121,7 +118,9 @@ class ElementManipulationMixin:
w, _ = self.selected_element.size w, _ = self.selected_element.size
self.selected_element.size = (w, min_size) self.selected_element.size = (w, min_size)
def _transfer_element_to_page(self, element, source_page, target_page, mouse_x: float, mouse_y: float, target_renderer): def _transfer_element_to_page(
self, element, source_page, target_page, mouse_x: float, mouse_y: float, target_renderer
):
""" """
Transfer an element from one page to another during drag operation. Transfer an element from one page to another during drag operation.

View File

@ -54,7 +54,7 @@ class ElementSelectionMixin:
Returns: Returns:
BaseLayoutElement or None: The topmost element at the position, or None BaseLayoutElement or None: The topmost element at the position, or None
""" """
if not hasattr(self, '_page_renderers') or not self._page_renderers: if not hasattr(self, "_page_renderers") or not self._page_renderers:
return None return None
# Check each page from top to bottom (reverse z-order) # Check each page from top to bottom (reverse z-order)
@ -96,11 +96,11 @@ class ElementSelectionMixin:
return None return None
main_window = self.window() main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages: if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return None return None
# Get the PageRenderer for this element (stored when element was selected) # Get the PageRenderer for this element (stored when element was selected)
if not hasattr(self.selected_element, '_page_renderer'): if not hasattr(self.selected_element, "_page_renderer"):
return None return None
renderer = self.selected_element._page_renderer renderer = self.selected_element._page_renderer
@ -117,10 +117,10 @@ class ElementSelectionMixin:
# Check handles (no rotation transformation needed - images are already rotated) # Check handles (no rotation transformation needed - images are already rotated)
handles = { handles = {
'nw': (ex - handle_size/2, ey - handle_size/2), "nw": (ex - handle_size / 2, ey - handle_size / 2),
'ne': (ex + ew - handle_size/2, ey - handle_size/2), "ne": (ex + ew - handle_size / 2, ey - handle_size / 2),
'sw': (ex - handle_size/2, ey + eh - handle_size/2), "sw": (ex - handle_size / 2, ey + eh - handle_size / 2),
'se': (ex + ew - handle_size/2, ey + eh - handle_size/2), "se": (ex + ew - handle_size / 2, ey + eh - handle_size / 2),
} }
for name, (hx, hy) in handles.items(): for name, (hx, hy) in handles.items():

View File

@ -59,7 +59,7 @@ class MoveCommandBuilder(CommandBuilder):
def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool: def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
"""Check if position changed significantly.""" """Check if position changed significantly."""
old_pos = start_state.get('position') old_pos = start_state.get("position")
if old_pos is None: if old_pos is None:
return False return False
@ -68,7 +68,7 @@ class MoveCommandBuilder(CommandBuilder):
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]: def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
"""Build a MoveElementCommand.""" """Build a MoveElementCommand."""
old_pos = start_state.get('position') old_pos = start_state.get("position")
if old_pos is None: if old_pos is None:
return None return None
@ -79,6 +79,7 @@ class MoveCommandBuilder(CommandBuilder):
return None return None
from pyPhotoAlbum.commands import MoveElementCommand from pyPhotoAlbum.commands import MoveElementCommand
command = MoveElementCommand(element, old_pos, new_pos) command = MoveElementCommand(element, old_pos, new_pos)
self.log_command("Move", f"{old_pos}{new_pos}") self.log_command("Move", f"{old_pos}{new_pos}")
@ -90,8 +91,8 @@ class ResizeCommandBuilder(CommandBuilder):
def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool: def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
"""Check if position or size changed significantly.""" """Check if position or size changed significantly."""
old_pos = start_state.get('position') old_pos = start_state.get("position")
old_size = start_state.get('size') old_size = start_state.get("size")
if old_pos is None or old_size is None: if old_pos is None or old_size is None:
return False return False
@ -106,8 +107,8 @@ class ResizeCommandBuilder(CommandBuilder):
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]: def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
"""Build a ResizeElementCommand.""" """Build a ResizeElementCommand."""
old_pos = start_state.get('position') old_pos = start_state.get("position")
old_size = start_state.get('size') old_size = start_state.get("size")
if old_pos is None or old_size is None: if old_pos is None or old_size is None:
return None return None
@ -119,6 +120,7 @@ class ResizeCommandBuilder(CommandBuilder):
return None return None
from pyPhotoAlbum.commands import ResizeElementCommand from pyPhotoAlbum.commands import ResizeElementCommand
command = ResizeElementCommand(element, old_pos, old_size, new_pos, new_size) command = ResizeElementCommand(element, old_pos, old_size, new_pos, new_size)
self.log_command("Resize", f"{old_size}{new_size}") self.log_command("Resize", f"{old_size}{new_size}")
@ -130,7 +132,7 @@ class RotateCommandBuilder(CommandBuilder):
def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool: def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
"""Check if rotation changed significantly.""" """Check if rotation changed significantly."""
old_rotation = start_state.get('rotation') old_rotation = start_state.get("rotation")
if old_rotation is None: if old_rotation is None:
return False return False
@ -139,7 +141,7 @@ class RotateCommandBuilder(CommandBuilder):
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]: def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
"""Build a RotateElementCommand.""" """Build a RotateElementCommand."""
old_rotation = start_state.get('rotation') old_rotation = start_state.get("rotation")
if old_rotation is None: if old_rotation is None:
return None return None
@ -150,6 +152,7 @@ class RotateCommandBuilder(CommandBuilder):
return None return None
from pyPhotoAlbum.commands import RotateElementCommand from pyPhotoAlbum.commands import RotateElementCommand
command = RotateElementCommand(element, old_rotation, new_rotation) command = RotateElementCommand(element, old_rotation, new_rotation)
self.log_command("Rotation", f"{old_rotation:.1f}° → {new_rotation:.1f}°") self.log_command("Rotation", f"{old_rotation:.1f}° → {new_rotation:.1f}°")
@ -166,7 +169,7 @@ class ImagePanCommandBuilder(CommandBuilder):
if not isinstance(element, ImageData): if not isinstance(element, ImageData):
return False return False
old_crop = start_state.get('crop_info') old_crop = start_state.get("crop_info")
if old_crop is None: if old_crop is None:
return False return False
@ -181,7 +184,7 @@ class ImagePanCommandBuilder(CommandBuilder):
if not isinstance(element, ImageData): if not isinstance(element, ImageData):
return None return None
old_crop = start_state.get('crop_info') old_crop = start_state.get("crop_info")
if old_crop is None: if old_crop is None:
return None return None
@ -193,6 +196,7 @@ class ImagePanCommandBuilder(CommandBuilder):
return None return None
from pyPhotoAlbum.commands import AdjustImageCropCommand from pyPhotoAlbum.commands import AdjustImageCropCommand
command = AdjustImageCropCommand(element, old_crop, new_crop) command = AdjustImageCropCommand(element, old_crop, new_crop)
self.log_command("Image pan", f"{old_crop}{new_crop}") self.log_command("Image pan", f"{old_crop}{new_crop}")

View File

@ -12,7 +12,7 @@ from .interaction_command_builders import (
MoveCommandBuilder, MoveCommandBuilder,
ResizeCommandBuilder, ResizeCommandBuilder,
RotateCommandBuilder, RotateCommandBuilder,
ImagePanCommandBuilder ImagePanCommandBuilder,
) )
@ -31,10 +31,10 @@ class InteractionCommandFactory:
def _register_default_builders(self): def _register_default_builders(self):
"""Register the default command builders.""" """Register the default command builders."""
self.register_builder('move', MoveCommandBuilder()) self.register_builder("move", MoveCommandBuilder())
self.register_builder('resize', ResizeCommandBuilder()) self.register_builder("resize", ResizeCommandBuilder())
self.register_builder('rotate', RotateCommandBuilder()) self.register_builder("rotate", RotateCommandBuilder())
self.register_builder('image_pan', ImagePanCommandBuilder()) self.register_builder("image_pan", ImagePanCommandBuilder())
def register_builder(self, interaction_type: str, builder: CommandBuilder): def register_builder(self, interaction_type: str, builder: CommandBuilder):
""" """
@ -46,11 +46,9 @@ class InteractionCommandFactory:
""" """
self._builders[interaction_type] = builder self._builders[interaction_type] = builder
def create_command(self, def create_command(
interaction_type: str, self, interaction_type: str, element: BaseLayoutElement, start_state: dict, **kwargs
element: BaseLayoutElement, ) -> Optional[Any]:
start_state: dict,
**kwargs) -> Optional[Any]:
""" """
Create a command based on interaction type and state changes. Create a command based on interaction type and state changes.
@ -91,13 +89,15 @@ class InteractionState:
the code more maintainable. the code more maintainable.
""" """
def __init__(self, def __init__(
element: Optional[BaseLayoutElement] = None, self,
interaction_type: Optional[str] = None, element: Optional[BaseLayoutElement] = None,
position: Optional[tuple] = None, interaction_type: Optional[str] = None,
size: Optional[tuple] = None, position: Optional[tuple] = None,
rotation: Optional[float] = None, size: Optional[tuple] = None,
crop_info: Optional[tuple] = None): rotation: Optional[float] = None,
crop_info: Optional[tuple] = None,
):
""" """
Initialize interaction state. Initialize interaction state.
@ -125,13 +125,13 @@ class InteractionState:
""" """
state = {} state = {}
if self.position is not None: if self.position is not None:
state['position'] = self.position state["position"] = self.position
if self.size is not None: if self.size is not None:
state['size'] = self.size state["size"] = self.size
if self.rotation is not None: if self.rotation is not None:
state['rotation'] = self.rotation state["rotation"] = self.rotation
if self.crop_info is not None: if self.crop_info is not None:
state['crop_info'] = self.crop_info state["crop_info"] = self.crop_info
return state return state
def is_valid(self) -> bool: def is_valid(self) -> bool:

View File

@ -10,12 +10,12 @@ from .interaction_command_factory import InteractionCommandFactory, InteractionS
class UndoableInteractionMixin: class UndoableInteractionMixin:
""" """
Mixin providing automatic undo/redo for interactive mouse operations. Mixin providing automatic undo/redo for interactive mouse operations.
This mixin tracks the state of elements before interactive operations This mixin tracks the state of elements before interactive operations
(move, resize, rotate) and automatically creates appropriate Command (move, resize, rotate) and automatically creates appropriate Command
objects when the interaction completes. objects when the interaction completes.
""" """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -24,7 +24,7 @@ class UndoableInteractionMixin:
# Interaction state tracking # Interaction state tracking
self._interaction_state = InteractionState() self._interaction_state = InteractionState()
def _begin_move(self, element: BaseLayoutElement): def _begin_move(self, element: BaseLayoutElement):
""" """
Begin tracking a move operation. Begin tracking a move operation.
@ -33,9 +33,9 @@ class UndoableInteractionMixin:
element: The element being moved element: The element being moved
""" """
self._interaction_state.element = element self._interaction_state.element = element
self._interaction_state.interaction_type = 'move' self._interaction_state.interaction_type = "move"
self._interaction_state.position = element.position self._interaction_state.position = element.position
def _begin_resize(self, element: BaseLayoutElement): def _begin_resize(self, element: BaseLayoutElement):
""" """
Begin tracking a resize operation. Begin tracking a resize operation.
@ -44,10 +44,10 @@ class UndoableInteractionMixin:
element: The element being resized element: The element being resized
""" """
self._interaction_state.element = element self._interaction_state.element = element
self._interaction_state.interaction_type = 'resize' self._interaction_state.interaction_type = "resize"
self._interaction_state.position = element.position self._interaction_state.position = element.position
self._interaction_state.size = element.size self._interaction_state.size = element.size
def _begin_rotate(self, element: BaseLayoutElement): def _begin_rotate(self, element: BaseLayoutElement):
""" """
Begin tracking a rotate operation. Begin tracking a rotate operation.
@ -56,9 +56,9 @@ class UndoableInteractionMixin:
element: The element being rotated element: The element being rotated
""" """
self._interaction_state.element = element self._interaction_state.element = element
self._interaction_state.interaction_type = 'rotate' self._interaction_state.interaction_type = "rotate"
self._interaction_state.rotation = element.rotation self._interaction_state.rotation = element.rotation
def _begin_image_pan(self, element): def _begin_image_pan(self, element):
""" """
Begin tracking an image pan operation. Begin tracking an image pan operation.
@ -67,13 +67,14 @@ class UndoableInteractionMixin:
element: The ImageData element being panned element: The ImageData element being panned
""" """
from pyPhotoAlbum.models import ImageData from pyPhotoAlbum.models import ImageData
if not isinstance(element, ImageData): if not isinstance(element, ImageData):
return return
self._interaction_state.element = element self._interaction_state.element = element
self._interaction_state.interaction_type = 'image_pan' self._interaction_state.interaction_type = "image_pan"
self._interaction_state.crop_info = element.crop_info self._interaction_state.crop_info = element.crop_info
def _end_interaction(self): def _end_interaction(self):
""" """
End the current interaction and create appropriate undo/redo command. End the current interaction and create appropriate undo/redo command.
@ -88,7 +89,7 @@ class UndoableInteractionMixin:
# Get main window to access project history # Get main window to access project history
main_window = self.window() main_window = self.window()
if not hasattr(main_window, 'project'): if not hasattr(main_window, "project"):
self._clear_interaction_state() self._clear_interaction_state()
return return
@ -96,7 +97,7 @@ class UndoableInteractionMixin:
command = self._command_factory.create_command( command = self._command_factory.create_command(
interaction_type=self._interaction_state.interaction_type, interaction_type=self._interaction_state.interaction_type,
element=self._interaction_state.element, element=self._interaction_state.element,
start_state=self._interaction_state.to_dict() start_state=self._interaction_state.to_dict(),
) )
# Execute the command through history if one was created # Execute the command through history if one was created
@ -105,11 +106,11 @@ class UndoableInteractionMixin:
# Clear interaction state # Clear interaction state
self._clear_interaction_state() self._clear_interaction_state()
def _clear_interaction_state(self): def _clear_interaction_state(self):
"""Clear all interaction tracking state""" """Clear all interaction tracking state"""
self._interaction_state.clear() self._interaction_state.clear()
def _cancel_interaction(self): def _cancel_interaction(self):
"""Cancel the current interaction without creating a command""" """Cancel the current interaction without creating a command"""
self._clear_interaction_state() self._clear_interaction_state()

View File

@ -16,6 +16,7 @@ def significant_change(threshold: float = 0.1):
Returns: Returns:
None if change is insignificant, otherwise returns the command builder result None if change is insignificant, otherwise returns the command builder result
""" """
def decorator(func): def decorator(func):
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
@ -23,7 +24,9 @@ def significant_change(threshold: float = 0.1):
if result is None: if result is None:
return None return None
return result return result
return wrapper return wrapper
return decorator return decorator
@ -31,9 +34,9 @@ class ChangeValidator:
"""Validates whether changes are significant enough to create commands.""" """Validates whether changes are significant enough to create commands."""
@staticmethod @staticmethod
def position_changed(old_pos: Optional[Tuple[float, float]], def position_changed(
new_pos: Optional[Tuple[float, float]], old_pos: Optional[Tuple[float, float]], new_pos: Optional[Tuple[float, float]], threshold: float = 0.1
threshold: float = 0.1) -> bool: ) -> bool:
"""Check if position changed significantly.""" """Check if position changed significantly."""
if old_pos is None or new_pos is None: if old_pos is None or new_pos is None:
return False return False
@ -43,9 +46,9 @@ class ChangeValidator:
return dx > threshold or dy > threshold return dx > threshold or dy > threshold
@staticmethod @staticmethod
def size_changed(old_size: Optional[Tuple[float, float]], def size_changed(
new_size: Optional[Tuple[float, float]], old_size: Optional[Tuple[float, float]], new_size: Optional[Tuple[float, float]], threshold: float = 0.1
threshold: float = 0.1) -> bool: ) -> bool:
"""Check if size changed significantly.""" """Check if size changed significantly."""
if old_size is None or new_size is None: if old_size is None or new_size is None:
return False return False
@ -55,9 +58,7 @@ class ChangeValidator:
return dw > threshold or dh > threshold return dw > threshold or dh > threshold
@staticmethod @staticmethod
def rotation_changed(old_rotation: Optional[float], def rotation_changed(old_rotation: Optional[float], new_rotation: Optional[float], threshold: float = 0.1) -> bool:
new_rotation: Optional[float],
threshold: float = 0.1) -> bool:
"""Check if rotation changed significantly.""" """Check if rotation changed significantly."""
if old_rotation is None or new_rotation is None: if old_rotation is None or new_rotation is None:
return False return False
@ -65,9 +66,11 @@ class ChangeValidator:
return abs(new_rotation - old_rotation) > threshold return abs(new_rotation - old_rotation) > threshold
@staticmethod @staticmethod
def crop_changed(old_crop: Optional[Tuple[float, float, float, float]], def crop_changed(
new_crop: Optional[Tuple[float, float, float, float]], old_crop: Optional[Tuple[float, float, float, float]],
threshold: float = 0.001) -> bool: new_crop: Optional[Tuple[float, float, float, float]],
threshold: float = 0.001,
) -> bool:
"""Check if crop info changed significantly.""" """Check if crop info changed significantly."""
if old_crop is None or new_crop is None: if old_crop is None or new_crop is None:
return False return False
@ -85,8 +88,7 @@ class InteractionChangeDetector:
self.threshold = threshold self.threshold = threshold
self.validator = ChangeValidator() self.validator = ChangeValidator()
def detect_position_change(self, old_pos: Tuple[float, float], def detect_position_change(self, old_pos: Tuple[float, float], new_pos: Tuple[float, float]) -> Optional[dict]:
new_pos: Tuple[float, float]) -> Optional[dict]:
""" """
Detect position change and return change info. Detect position change and return change info.
@ -97,14 +99,13 @@ class InteractionChangeDetector:
return None return None
return { return {
'old_position': old_pos, "old_position": old_pos,
'new_position': new_pos, "new_position": new_pos,
'delta_x': new_pos[0] - old_pos[0], "delta_x": new_pos[0] - old_pos[0],
'delta_y': new_pos[1] - old_pos[1] "delta_y": new_pos[1] - old_pos[1],
} }
def detect_size_change(self, old_size: Tuple[float, float], def detect_size_change(self, old_size: Tuple[float, float], new_size: Tuple[float, float]) -> Optional[dict]:
new_size: Tuple[float, float]) -> Optional[dict]:
""" """
Detect size change and return change info. Detect size change and return change info.
@ -115,14 +116,13 @@ class InteractionChangeDetector:
return None return None
return { return {
'old_size': old_size, "old_size": old_size,
'new_size': new_size, "new_size": new_size,
'delta_width': new_size[0] - old_size[0], "delta_width": new_size[0] - old_size[0],
'delta_height': new_size[1] - old_size[1] "delta_height": new_size[1] - old_size[1],
} }
def detect_rotation_change(self, old_rotation: float, def detect_rotation_change(self, old_rotation: float, new_rotation: float) -> Optional[dict]:
new_rotation: float) -> Optional[dict]:
""" """
Detect rotation change and return change info. Detect rotation change and return change info.
@ -132,14 +132,11 @@ class InteractionChangeDetector:
if not self.validator.rotation_changed(old_rotation, new_rotation, self.threshold): if not self.validator.rotation_changed(old_rotation, new_rotation, self.threshold):
return None return None
return { return {"old_rotation": old_rotation, "new_rotation": new_rotation, "delta_angle": new_rotation - old_rotation}
'old_rotation': old_rotation,
'new_rotation': new_rotation,
'delta_angle': new_rotation - old_rotation
}
def detect_crop_change(self, old_crop: Tuple[float, float, float, float], def detect_crop_change(
new_crop: Tuple[float, float, float, float]) -> Optional[dict]: self, old_crop: Tuple[float, float, float, float], new_crop: Tuple[float, float, float, float]
) -> Optional[dict]:
""" """
Detect crop change and return change info. Detect crop change and return change info.
@ -149,8 +146,4 @@ class InteractionChangeDetector:
if not self.validator.crop_changed(old_crop, new_crop, threshold=0.001): if not self.validator.crop_changed(old_crop, new_crop, threshold=0.001):
return None return None
return { return {"old_crop": old_crop, "new_crop": new_crop, "delta": tuple(new_crop[i] - old_crop[i] for i in range(4))}
'old_crop': old_crop,
'new_crop': new_crop,
'delta': tuple(new_crop[i] - old_crop[i] for i in range(4))
}

View File

@ -16,7 +16,7 @@ class KeyboardNavigationMixin:
def _navigate_to_next_page(self): def _navigate_to_next_page(self):
"""Navigate to the next page using Page Down key""" """Navigate to the next page using Page Down key"""
main_window = self.window() main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages: if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return return
current_index = main_window._get_most_visible_page_index() current_index = main_window._get_most_visible_page_index()
@ -24,14 +24,14 @@ class KeyboardNavigationMixin:
next_page = main_window.project.pages[current_index + 1] next_page = main_window.project.pages[current_index + 1]
self._scroll_to_page(next_page, current_index + 1) self._scroll_to_page(next_page, current_index + 1)
if hasattr(main_window, 'show_status'): if hasattr(main_window, "show_status"):
page_name = main_window.project.get_page_display_name(next_page) page_name = main_window.project.get_page_display_name(next_page)
main_window.show_status(f"Navigated to {page_name}", 2000) main_window.show_status(f"Navigated to {page_name}", 2000)
def _navigate_to_previous_page(self): def _navigate_to_previous_page(self):
"""Navigate to the previous page using Page Up key""" """Navigate to the previous page using Page Up key"""
main_window = self.window() main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages: if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return return
current_index = main_window._get_most_visible_page_index() current_index = main_window._get_most_visible_page_index()
@ -39,7 +39,7 @@ class KeyboardNavigationMixin:
prev_page = main_window.project.pages[current_index - 1] prev_page = main_window.project.pages[current_index - 1]
self._scroll_to_page(prev_page, current_index - 1) self._scroll_to_page(prev_page, current_index - 1)
if hasattr(main_window, 'show_status'): if hasattr(main_window, "show_status"):
page_name = main_window.project.get_page_display_name(prev_page) page_name = main_window.project.get_page_display_name(prev_page)
main_window.show_status(f"Navigated to {page_name}", 2000) main_window.show_status(f"Navigated to {page_name}", 2000)
@ -52,7 +52,7 @@ class KeyboardNavigationMixin:
page_index: The index of the page in the project page_index: The index of the page in the project
""" """
main_window = self.window() main_window = self.window()
if not hasattr(main_window, 'project'): if not hasattr(main_window, "project"):
return return
dpi = main_window.project.working_dpi dpi = main_window.project.working_dpi
@ -79,14 +79,14 @@ class KeyboardNavigationMixin:
self.pan_offset[1] = target_pan_y self.pan_offset[1] = target_pan_y
# Clamp pan offset to content bounds # Clamp pan offset to content bounds
if hasattr(self, 'clamp_pan_offset'): if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset() self.clamp_pan_offset()
self.update() self.update()
# Update scrollbars if available # Update scrollbars if available
main_window = self.window() main_window = self.window()
if hasattr(main_window, 'update_scrollbars'): if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars() main_window.update_scrollbars()
def _move_viewport_with_arrow_keys(self, key): def _move_viewport_with_arrow_keys(self, key):
@ -109,14 +109,14 @@ class KeyboardNavigationMixin:
self.pan_offset[0] -= move_amount self.pan_offset[0] -= move_amount
# Clamp pan offset to content bounds # Clamp pan offset to content bounds
if hasattr(self, 'clamp_pan_offset'): if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset() self.clamp_pan_offset()
self.update() self.update()
# Update scrollbars if available # Update scrollbars if available
main_window = self.window() main_window = self.window()
if hasattr(main_window, 'update_scrollbars'): if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars() main_window.update_scrollbars()
def _move_selected_elements_with_arrow_keys(self, key): def _move_selected_elements_with_arrow_keys(self, key):
@ -127,7 +127,7 @@ class KeyboardNavigationMixin:
key: The Qt key code (Up, Down, Left, Right) key: The Qt key code (Up, Down, Left, Right)
""" """
main_window = self.window() main_window = self.window()
if not hasattr(main_window, 'project'): if not hasattr(main_window, "project"):
return return
# Movement amount in mm # Movement amount in mm
@ -151,7 +151,7 @@ class KeyboardNavigationMixin:
new_y = current_y + dy new_y = current_y + dy
# Apply snapping if element has a parent page # Apply snapping if element has a parent page
if hasattr(element, '_parent_page') and element._parent_page: if hasattr(element, "_parent_page") and element._parent_page:
page = element._parent_page page = element._parent_page
snap_sys = page.layout.snapping_system snap_sys = page.layout.snapping_system
page_size = page.layout.size page_size = page.layout.size
@ -162,7 +162,7 @@ class KeyboardNavigationMixin:
size=element.size, size=element.size,
page_size=page_size, page_size=page_size,
dpi=dpi, dpi=dpi,
project=main_window.project project=main_window.project,
) )
element.position = snapped_pos element.position = snapped_pos
else: else:
@ -170,7 +170,7 @@ class KeyboardNavigationMixin:
self.update() self.update()
if hasattr(main_window, 'show_status'): if hasattr(main_window, "show_status"):
count = len(self.selected_elements) count = len(self.selected_elements)
elem_text = "element" if count == 1 else "elements" elem_text = "element" if count == 1 else "elements"
main_window.show_status(f"Moved {count} {elem_text}", 1000) main_window.show_status(f"Moved {count} {elem_text}", 1000)

View File

@ -66,7 +66,9 @@ class MouseInteractionMixin:
element = self._get_element_at(x, y) element = self._get_element_at(x, y)
if element: if element:
print(f"DEBUG: Clicked on element: {element}, ctrl_pressed: {ctrl_pressed}, shift_pressed: {shift_pressed}") print(
f"DEBUG: Clicked on element: {element}, ctrl_pressed: {ctrl_pressed}, shift_pressed: {shift_pressed}"
)
# Check if Ctrl is pressed and element is ImageData - enter image pan mode # Check if Ctrl is pressed and element is ImageData - enter image pan mode
if ctrl_pressed and isinstance(element, ImageData) and not self.rotation_mode: if ctrl_pressed and isinstance(element, ImageData) and not self.rotation_mode:
# Enter image pan mode - pan image within frame # Enter image pan mode - pan image within frame
@ -129,7 +131,7 @@ class MouseInteractionMixin:
self.pan_offset[1] += dy self.pan_offset[1] += dy
# Clamp pan offset to content bounds # Clamp pan offset to content bounds
if hasattr(self, 'clamp_pan_offset'): if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset() self.clamp_pan_offset()
self.drag_start_pos = (x, y) self.drag_start_pos = (x, y)
@ -137,7 +139,7 @@ class MouseInteractionMixin:
# Update scrollbars if available # Update scrollbars if available
main_window = self.window() main_window = self.window()
if hasattr(main_window, 'update_scrollbars'): if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars() main_window.update_scrollbars()
return return
@ -152,7 +154,7 @@ class MouseInteractionMixin:
elif self.rotation_mode: elif self.rotation_mode:
# Rotation mode # Rotation mode
if not hasattr(self.selected_element, '_page_renderer'): if not hasattr(self.selected_element, "_page_renderer"):
return return
renderer = self.selected_element._page_renderer renderer = self.selected_element._page_renderer
@ -173,7 +175,7 @@ class MouseInteractionMixin:
self.selected_element.rotation = angle self.selected_element.rotation = angle
main_window = self.window() main_window = self.window()
if hasattr(main_window, 'show_status'): if hasattr(main_window, "show_status"):
main_window.show_status(f"Rotation: {angle:.1f}°", 100) main_window.show_status(f"Rotation: {angle:.1f}°", 100)
elif self.resize_handle: elif self.resize_handle:
@ -189,11 +191,13 @@ class MouseInteractionMixin:
# Move mode # Move mode
current_page, current_page_index, current_renderer = self._get_page_at(x, y) current_page, current_page_index, current_renderer = self._get_page_at(x, y)
if current_page and hasattr(self.selected_element, '_parent_page'): if current_page and hasattr(self.selected_element, "_parent_page"):
source_page = self.selected_element._parent_page source_page = self.selected_element._parent_page
if current_page is not source_page: if current_page is not source_page:
self._transfer_element_to_page(self.selected_element, source_page, current_page, x, y, current_renderer) self._transfer_element_to_page(
self.selected_element, source_page, current_page, x, y, current_renderer
)
else: else:
total_dx = (x - self.drag_start_pos[0]) / self.zoom_level total_dx = (x - self.drag_start_pos[0]) / self.zoom_level
total_dy = (y - self.drag_start_pos[1]) / self.zoom_level total_dy = (y - self.drag_start_pos[1]) / self.zoom_level
@ -211,7 +215,7 @@ class MouseInteractionMixin:
size=self.selected_element.size, size=self.selected_element.size,
page_size=page_size, page_size=page_size,
dpi=dpi, dpi=dpi,
project=main_window.project project=main_window.project,
) )
self.selected_element.position = snapped_pos self.selected_element.position = snapped_pos
@ -238,11 +242,7 @@ class MouseInteractionMixin:
self.rotation_start_angle = None self.rotation_start_angle = None
self.image_pan_mode = False self.image_pan_mode = False
self.image_pan_start_crop = None self.image_pan_start_crop = None
self.snap_state = { self.snap_state = {"is_snapped": False, "last_position": None, "last_size": None}
'is_snapped': False,
'last_position': None,
'last_size': None
}
self.setCursor(Qt.CursorShape.ArrowCursor) self.setCursor(Qt.CursorShape.ArrowCursor)
elif event.button() == Qt.MouseButton.MiddleButton: elif event.button() == Qt.MouseButton.MiddleButton:
@ -257,6 +257,7 @@ class MouseInteractionMixin:
element = self._get_element_at(x, y) element = self._get_element_at(x, y)
from pyPhotoAlbum.models import TextBoxData from pyPhotoAlbum.models import TextBoxData
if isinstance(element, TextBoxData): if isinstance(element, TextBoxData):
self._edit_text_element(element) self._edit_text_element(element)
return return
@ -293,23 +294,20 @@ class MouseInteractionMixin:
if self.is_dragging and self.drag_start_pos: if self.is_dragging and self.drag_start_pos:
pan_delta_x = self.pan_offset[0] - old_pan_x pan_delta_x = self.pan_offset[0] - old_pan_x
pan_delta_y = self.pan_offset[1] - old_pan_y pan_delta_y = self.pan_offset[1] - old_pan_y
self.drag_start_pos = ( self.drag_start_pos = (self.drag_start_pos[0] + pan_delta_x, self.drag_start_pos[1] + pan_delta_y)
self.drag_start_pos[0] + pan_delta_x,
self.drag_start_pos[1] + pan_delta_y
)
# Clamp pan offset to content bounds # Clamp pan offset to content bounds
if hasattr(self, 'clamp_pan_offset'): if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset() self.clamp_pan_offset()
self.update() self.update()
main_window = self.window() main_window = self.window()
if hasattr(main_window, 'status_bar'): if hasattr(main_window, "status_bar"):
main_window.status_bar.showMessage(f"Zoom: {int(self.zoom_level * 100)}%", 2000) main_window.status_bar.showMessage(f"Zoom: {int(self.zoom_level * 100)}%", 2000)
# Update scrollbars if available # Update scrollbars if available
if hasattr(main_window, 'update_scrollbars'): if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars() main_window.update_scrollbars()
else: else:
# Regular wheel: Vertical scroll # Regular wheel: Vertical scroll
@ -318,22 +316,19 @@ class MouseInteractionMixin:
self.pan_offset[1] += scroll_amount self.pan_offset[1] += scroll_amount
# Clamp pan offset to content bounds # Clamp pan offset to content bounds
if hasattr(self, 'clamp_pan_offset'): if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset() self.clamp_pan_offset()
# If dragging, adjust drag_start_pos to account for pan_offset change # If dragging, adjust drag_start_pos to account for pan_offset change
if self.is_dragging and self.drag_start_pos: if self.is_dragging and self.drag_start_pos:
pan_delta_y = self.pan_offset[1] - old_pan_y pan_delta_y = self.pan_offset[1] - old_pan_y
self.drag_start_pos = ( self.drag_start_pos = (self.drag_start_pos[0], self.drag_start_pos[1] + pan_delta_y)
self.drag_start_pos[0],
self.drag_start_pos[1] + pan_delta_y
)
self.update() self.update()
# Update scrollbars if available # Update scrollbars if available
main_window = self.window() main_window = self.window()
if hasattr(main_window, 'update_scrollbars'): if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars() main_window.update_scrollbars()
def _edit_text_element(self, text_element): def _edit_text_element(self, text_element):
@ -344,9 +339,9 @@ class MouseInteractionMixin:
if dialog.exec() == TextEditDialog.DialogCode.Accepted: if dialog.exec() == TextEditDialog.DialogCode.Accepted:
values = dialog.get_values() values = dialog.get_values()
text_element.text_content = values['text_content'] text_element.text_content = values["text_content"]
text_element.font_settings = values['font_settings'] text_element.font_settings = values["font_settings"]
text_element.alignment = values['alignment'] text_element.alignment = values["alignment"]
self.update() self.update()

View File

@ -15,15 +15,15 @@ from pyPhotoAlbum.mixins.operations.zorder_ops import ZOrderOperationsMixin
from pyPhotoAlbum.mixins.operations.merge_ops import MergeOperationsMixin from pyPhotoAlbum.mixins.operations.merge_ops import MergeOperationsMixin
__all__ = [ __all__ = [
'FileOperationsMixin', "FileOperationsMixin",
'EditOperationsMixin', "EditOperationsMixin",
'ElementOperationsMixin', "ElementOperationsMixin",
'PageOperationsMixin', "PageOperationsMixin",
'TemplateOperationsMixin', "TemplateOperationsMixin",
'ViewOperationsMixin', "ViewOperationsMixin",
'AlignmentOperationsMixin', "AlignmentOperationsMixin",
'DistributionOperationsMixin', "DistributionOperationsMixin",
'SizeOperationsMixin', "SizeOperationsMixin",
'ZOrderOperationsMixin', "ZOrderOperationsMixin",
'MergeOperationsMixin', "MergeOperationsMixin",
] ]

View File

@ -9,130 +9,130 @@ from pyPhotoAlbum.commands import AlignElementsCommand, ResizeElementsCommand
class AlignmentOperationsMixin: class AlignmentOperationsMixin:
"""Mixin providing element alignment operations""" """Mixin providing element alignment operations"""
def _get_selected_elements_list(self): def _get_selected_elements_list(self):
"""Get list of selected elements for alignment operations""" """Get list of selected elements for alignment operations"""
return list(self.gl_widget.selected_elements) if self.gl_widget.selected_elements else [] return list(self.gl_widget.selected_elements) if self.gl_widget.selected_elements else []
@ribbon_action( @ribbon_action(
label="Align Left", label="Align Left",
tooltip="Align selected elements to the left", tooltip="Align selected elements to the left",
tab="Arrange", tab="Arrange",
group="Align", group="Align",
requires_selection=True, requires_selection=True,
min_selection=2 min_selection=2,
) )
def align_left(self): def align_left(self):
"""Align selected elements to the left""" """Align selected elements to the left"""
elements = self._get_selected_elements_list() elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2): if not self.require_selection(min_count=2):
return return
changes = AlignmentManager.align_left(elements) changes = AlignmentManager.align_left(elements)
if changes: if changes:
cmd = AlignElementsCommand(changes) cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd) self.project.history.execute(cmd)
self.update_view() self.update_view()
self.show_status(f"Aligned {len(elements)} elements to left", 2000) self.show_status(f"Aligned {len(elements)} elements to left", 2000)
@ribbon_action( @ribbon_action(
label="Align Right", label="Align Right",
tooltip="Align selected elements to the right", tooltip="Align selected elements to the right",
tab="Arrange", tab="Arrange",
group="Align", group="Align",
requires_selection=True, requires_selection=True,
min_selection=2 min_selection=2,
) )
def align_right(self): def align_right(self):
"""Align selected elements to the right""" """Align selected elements to the right"""
elements = self._get_selected_elements_list() elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2): if not self.require_selection(min_count=2):
return return
changes = AlignmentManager.align_right(elements) changes = AlignmentManager.align_right(elements)
if changes: if changes:
cmd = AlignElementsCommand(changes) cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd) self.project.history.execute(cmd)
self.update_view() self.update_view()
self.show_status(f"Aligned {len(elements)} elements to right", 2000) self.show_status(f"Aligned {len(elements)} elements to right", 2000)
@ribbon_action( @ribbon_action(
label="Align Top", label="Align Top",
tooltip="Align selected elements to the top", tooltip="Align selected elements to the top",
tab="Arrange", tab="Arrange",
group="Align", group="Align",
requires_selection=True, requires_selection=True,
min_selection=2 min_selection=2,
) )
def align_top(self): def align_top(self):
"""Align selected elements to the top""" """Align selected elements to the top"""
elements = self._get_selected_elements_list() elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2): if not self.require_selection(min_count=2):
return return
changes = AlignmentManager.align_top(elements) changes = AlignmentManager.align_top(elements)
if changes: if changes:
cmd = AlignElementsCommand(changes) cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd) self.project.history.execute(cmd)
self.update_view() self.update_view()
self.show_status(f"Aligned {len(elements)} elements to top", 2000) self.show_status(f"Aligned {len(elements)} elements to top", 2000)
@ribbon_action( @ribbon_action(
label="Align Bottom", label="Align Bottom",
tooltip="Align selected elements to the bottom", tooltip="Align selected elements to the bottom",
tab="Arrange", tab="Arrange",
group="Align", group="Align",
requires_selection=True, requires_selection=True,
min_selection=2 min_selection=2,
) )
def align_bottom(self): def align_bottom(self):
"""Align selected elements to the bottom""" """Align selected elements to the bottom"""
elements = self._get_selected_elements_list() elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2): if not self.require_selection(min_count=2):
return return
changes = AlignmentManager.align_bottom(elements) changes = AlignmentManager.align_bottom(elements)
if changes: if changes:
cmd = AlignElementsCommand(changes) cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd) self.project.history.execute(cmd)
self.update_view() self.update_view()
self.show_status(f"Aligned {len(elements)} elements to bottom", 2000) self.show_status(f"Aligned {len(elements)} elements to bottom", 2000)
@ribbon_action( @ribbon_action(
label="Align H-Center", label="Align H-Center",
tooltip="Align selected elements to horizontal center", tooltip="Align selected elements to horizontal center",
tab="Arrange", tab="Arrange",
group="Align", group="Align",
requires_selection=True, requires_selection=True,
min_selection=2 min_selection=2,
) )
def align_horizontal_center(self): def align_horizontal_center(self):
"""Align selected elements to horizontal center""" """Align selected elements to horizontal center"""
elements = self._get_selected_elements_list() elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2): if not self.require_selection(min_count=2):
return return
changes = AlignmentManager.align_horizontal_center(elements) changes = AlignmentManager.align_horizontal_center(elements)
if changes: if changes:
cmd = AlignElementsCommand(changes) cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd) self.project.history.execute(cmd)
self.update_view() self.update_view()
self.show_status(f"Aligned {len(elements)} elements to horizontal center", 2000) self.show_status(f"Aligned {len(elements)} elements to horizontal center", 2000)
@ribbon_action( @ribbon_action(
label="Align V-Center", label="Align V-Center",
tooltip="Align selected elements to vertical center", tooltip="Align selected elements to vertical center",
tab="Arrange", tab="Arrange",
group="Align", group="Align",
requires_selection=True, requires_selection=True,
min_selection=2 min_selection=2,
) )
def align_vertical_center(self): def align_vertical_center(self):
"""Align selected elements to vertical center""" """Align selected elements to vertical center"""
elements = self._get_selected_elements_list() elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2): if not self.require_selection(min_count=2):
return return
changes = AlignmentManager.align_vertical_center(elements) changes = AlignmentManager.align_vertical_center(elements)
if changes: if changes:
cmd = AlignElementsCommand(changes) cmd = AlignElementsCommand(changes)
@ -146,7 +146,7 @@ class AlignmentOperationsMixin:
tab="Arrange", tab="Arrange",
group="Size", group="Size",
requires_selection=True, requires_selection=True,
min_selection=1 min_selection=1,
) )
def maximize_pattern(self): def maximize_pattern(self):
"""Maximize selected elements until they are close to borders or each other""" """Maximize selected elements until they are close to borders or each other"""

View File

@ -9,88 +9,88 @@ from pyPhotoAlbum.commands import AlignElementsCommand
class DistributionOperationsMixin: class DistributionOperationsMixin:
"""Mixin providing element distribution and spacing operations""" """Mixin providing element distribution and spacing operations"""
def _get_selected_elements_list(self): def _get_selected_elements_list(self):
"""Get list of selected elements for distribution operations""" """Get list of selected elements for distribution operations"""
return list(self.gl_widget.selected_elements) if self.gl_widget.selected_elements else [] return list(self.gl_widget.selected_elements) if self.gl_widget.selected_elements else []
@ribbon_action( @ribbon_action(
label="Distribute H", label="Distribute H",
tooltip="Distribute selected elements evenly horizontally", tooltip="Distribute selected elements evenly horizontally",
tab="Arrange", tab="Arrange",
group="Distribute", group="Distribute",
requires_selection=True, requires_selection=True,
min_selection=3 min_selection=3,
) )
def distribute_horizontally(self): def distribute_horizontally(self):
"""Distribute selected elements evenly horizontally""" """Distribute selected elements evenly horizontally"""
elements = self._get_selected_elements_list() elements = self._get_selected_elements_list()
if not self.require_selection(min_count=3): if not self.require_selection(min_count=3):
return return
changes = AlignmentManager.distribute_horizontally(elements) changes = AlignmentManager.distribute_horizontally(elements)
if changes: if changes:
cmd = AlignElementsCommand(changes) cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd) self.project.history.execute(cmd)
self.update_view() self.update_view()
self.show_status(f"Distributed {len(elements)} elements horizontally", 2000) self.show_status(f"Distributed {len(elements)} elements horizontally", 2000)
@ribbon_action( @ribbon_action(
label="Distribute V", label="Distribute V",
tooltip="Distribute selected elements evenly vertically", tooltip="Distribute selected elements evenly vertically",
tab="Arrange", tab="Arrange",
group="Distribute", group="Distribute",
requires_selection=True, requires_selection=True,
min_selection=3 min_selection=3,
) )
def distribute_vertically(self): def distribute_vertically(self):
"""Distribute selected elements evenly vertically""" """Distribute selected elements evenly vertically"""
elements = self._get_selected_elements_list() elements = self._get_selected_elements_list()
if not self.require_selection(min_count=3): if not self.require_selection(min_count=3):
return return
changes = AlignmentManager.distribute_vertically(elements) changes = AlignmentManager.distribute_vertically(elements)
if changes: if changes:
cmd = AlignElementsCommand(changes) cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd) self.project.history.execute(cmd)
self.update_view() self.update_view()
self.show_status(f"Distributed {len(elements)} elements vertically", 2000) self.show_status(f"Distributed {len(elements)} elements vertically", 2000)
@ribbon_action( @ribbon_action(
label="Space H", label="Space H",
tooltip="Space selected elements equally horizontally", tooltip="Space selected elements equally horizontally",
tab="Arrange", tab="Arrange",
group="Distribute", group="Distribute",
requires_selection=True, requires_selection=True,
min_selection=3 min_selection=3,
) )
def space_horizontally(self): def space_horizontally(self):
"""Space selected elements equally horizontally""" """Space selected elements equally horizontally"""
elements = self._get_selected_elements_list() elements = self._get_selected_elements_list()
if not self.require_selection(min_count=3): if not self.require_selection(min_count=3):
return return
changes = AlignmentManager.space_horizontally(elements) changes = AlignmentManager.space_horizontally(elements)
if changes: if changes:
cmd = AlignElementsCommand(changes) cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd) self.project.history.execute(cmd)
self.update_view() self.update_view()
self.show_status(f"Spaced {len(elements)} elements horizontally", 2000) self.show_status(f"Spaced {len(elements)} elements horizontally", 2000)
@ribbon_action( @ribbon_action(
label="Space V", label="Space V",
tooltip="Space selected elements equally vertically", tooltip="Space selected elements equally vertically",
tab="Arrange", tab="Arrange",
group="Distribute", group="Distribute",
requires_selection=True, requires_selection=True,
min_selection=3 min_selection=3,
) )
def space_vertically(self): def space_vertically(self):
"""Space selected elements equally vertically""" """Space selected elements equally vertically"""
elements = self._get_selected_elements_list() elements = self._get_selected_elements_list()
if not self.require_selection(min_count=3): if not self.require_selection(min_count=3):
return return
changes = AlignmentManager.space_vertically(elements) changes = AlignmentManager.space_vertically(elements)
if changes: if changes:
cmd = AlignElementsCommand(changes) cmd = AlignElementsCommand(changes)

View File

@ -8,14 +8,8 @@ from pyPhotoAlbum.commands import DeleteElementCommand, RotateElementCommand
class EditOperationsMixin: class EditOperationsMixin:
"""Mixin providing edit-related operations""" """Mixin providing edit-related operations"""
@ribbon_action( @ribbon_action(label="Undo", tooltip="Undo last action (Ctrl+Z)", tab="Home", group="Edit", shortcut="Ctrl+Z")
label="Undo",
tooltip="Undo last action (Ctrl+Z)",
tab="Home",
group="Edit",
shortcut="Ctrl+Z"
)
def undo(self): def undo(self):
"""Undo last action""" """Undo last action"""
if self.project.history.undo(): if self.project.history.undo():
@ -25,13 +19,9 @@ class EditOperationsMixin:
else: else:
self.show_status("Nothing to undo", 2000) self.show_status("Nothing to undo", 2000)
print("Nothing to undo") print("Nothing to undo")
@ribbon_action( @ribbon_action(
label="Redo", label="Redo", tooltip="Redo last action (Ctrl+Y or Ctrl+Shift+Z)", tab="Home", group="Edit", shortcut="Ctrl+Y"
tooltip="Redo last action (Ctrl+Y or Ctrl+Shift+Z)",
tab="Home",
group="Edit",
shortcut="Ctrl+Y"
) )
def redo(self): def redo(self):
"""Redo last action""" """Redo last action"""
@ -42,117 +32,113 @@ class EditOperationsMixin:
else: else:
self.show_status("Nothing to redo", 2000) self.show_status("Nothing to redo", 2000)
print("Nothing to redo") print("Nothing to redo")
@ribbon_action( @ribbon_action(
label="Delete", label="Delete",
tooltip="Delete selected element (Delete key)", tooltip="Delete selected element (Delete key)",
tab="Home", tab="Home",
group="Edit", group="Edit",
shortcut="Delete", shortcut="Delete",
requires_selection=True requires_selection=True,
) )
def delete_selected_element(self): def delete_selected_element(self):
"""Delete the currently selected element""" """Delete the currently selected element"""
if not self.require_selection(min_count=1): if not self.require_selection(min_count=1):
return return
current_page = self.get_current_page() current_page = self.get_current_page()
if not current_page: if not current_page:
return return
# Delete the first selected element (for backward compatibility) # Delete the first selected element (for backward compatibility)
# In the future, we could delete all selected elements # In the future, we could delete all selected elements
selected_element = next(iter(self.gl_widget.selected_elements)) selected_element = next(iter(self.gl_widget.selected_elements))
try: try:
cmd = DeleteElementCommand( cmd = DeleteElementCommand(current_page.layout, selected_element, asset_manager=self.project.asset_manager)
current_page.layout,
selected_element,
asset_manager=self.project.asset_manager
)
self.project.history.execute(cmd) self.project.history.execute(cmd)
# Clear selection # Clear selection
self.gl_widget.selected_elements.clear() self.gl_widget.selected_elements.clear()
# Update display # Update display
self.update_view() self.update_view()
self.show_status("Element deleted (Ctrl+Z to undo)", 2000) self.show_status("Element deleted (Ctrl+Z to undo)", 2000)
print("Deleted selected element") print("Deleted selected element")
except Exception as e: except Exception as e:
self.show_error("Error", f"Failed to delete element: {str(e)}") self.show_error("Error", f"Failed to delete element: {str(e)}")
print(f"Error deleting element: {e}") print(f"Error deleting element: {e}")
@ribbon_action( @ribbon_action(
label="Rotate Left", label="Rotate Left",
tooltip="Rotate selected element 90° counter-clockwise", tooltip="Rotate selected element 90° counter-clockwise",
tab="Home", tab="Home",
group="Transform", group="Transform",
requires_selection=True requires_selection=True,
) )
def rotate_left(self): def rotate_left(self):
"""Rotate selected element 90 degrees counter-clockwise""" """Rotate selected element 90 degrees counter-clockwise"""
if not self.require_selection(min_count=1): if not self.require_selection(min_count=1):
return return
selected_element = next(iter(self.gl_widget.selected_elements)) selected_element = next(iter(self.gl_widget.selected_elements))
old_rotation = selected_element.rotation old_rotation = selected_element.rotation
new_rotation = (old_rotation - 90) % 360 new_rotation = (old_rotation - 90) % 360
cmd = RotateElementCommand(selected_element, old_rotation, new_rotation) cmd = RotateElementCommand(selected_element, old_rotation, new_rotation)
self.project.history.execute(cmd) self.project.history.execute(cmd)
self.update_view() self.update_view()
self.show_status(f"Rotated left (Ctrl+Z to undo)", 2000) self.show_status(f"Rotated left (Ctrl+Z to undo)", 2000)
print(f"Rotated element left: {old_rotation}° → {new_rotation}°") print(f"Rotated element left: {old_rotation}° → {new_rotation}°")
@ribbon_action( @ribbon_action(
label="Rotate Right", label="Rotate Right",
tooltip="Rotate selected element 90° clockwise", tooltip="Rotate selected element 90° clockwise",
tab="Home", tab="Home",
group="Transform", group="Transform",
requires_selection=True requires_selection=True,
) )
def rotate_right(self): def rotate_right(self):
"""Rotate selected element 90 degrees clockwise""" """Rotate selected element 90 degrees clockwise"""
if not self.require_selection(min_count=1): if not self.require_selection(min_count=1):
return return
selected_element = next(iter(self.gl_widget.selected_elements)) selected_element = next(iter(self.gl_widget.selected_elements))
old_rotation = selected_element.rotation old_rotation = selected_element.rotation
new_rotation = (old_rotation + 90) % 360 new_rotation = (old_rotation + 90) % 360
cmd = RotateElementCommand(selected_element, old_rotation, new_rotation) cmd = RotateElementCommand(selected_element, old_rotation, new_rotation)
self.project.history.execute(cmd) self.project.history.execute(cmd)
self.update_view() self.update_view()
self.show_status(f"Rotated right (Ctrl+Z to undo)", 2000) self.show_status(f"Rotated right (Ctrl+Z to undo)", 2000)
print(f"Rotated element right: {old_rotation}° → {new_rotation}°") print(f"Rotated element right: {old_rotation}° → {new_rotation}°")
@ribbon_action( @ribbon_action(
label="Reset Rotation", label="Reset Rotation",
tooltip="Reset selected element rotation to 0°", tooltip="Reset selected element rotation to 0°",
tab="Home", tab="Home",
group="Transform", group="Transform",
requires_selection=True requires_selection=True,
) )
def reset_rotation(self): def reset_rotation(self):
"""Reset selected element rotation to 0 degrees""" """Reset selected element rotation to 0 degrees"""
if not self.require_selection(min_count=1): if not self.require_selection(min_count=1):
return return
selected_element = next(iter(self.gl_widget.selected_elements)) selected_element = next(iter(self.gl_widget.selected_elements))
old_rotation = selected_element.rotation old_rotation = selected_element.rotation
if old_rotation == 0: if old_rotation == 0:
self.show_status("Element already at 0°", 2000) self.show_status("Element already at 0°", 2000)
return return
cmd = RotateElementCommand(selected_element, old_rotation, 0) cmd = RotateElementCommand(selected_element, old_rotation, 0)
self.project.history.execute(cmd) self.project.history.execute(cmd)
self.update_view() self.update_view()
self.show_status("Reset rotation to 0° (Ctrl+Z to undo)", 2000) self.show_status("Reset rotation to 0° (Ctrl+Z to undo)", 2000)
print(f"Reset element rotation: {old_rotation}° → 0°") print(f"Reset element rotation: {old_rotation}° → 0°")

View File

@ -11,33 +11,26 @@ from pyPhotoAlbum.async_backend import get_image_dimensions
class ElementOperationsMixin: class ElementOperationsMixin:
"""Mixin providing element creation and manipulation operations""" """Mixin providing element creation and manipulation operations"""
@ribbon_action( @ribbon_action(
label="Image", label="Image", tooltip="Add an image to the current page", tab="Insert", group="Media", requires_page=True
tooltip="Add an image to the current page",
tab="Insert",
group="Media",
requires_page=True
) )
def add_image(self): def add_image(self):
"""Add an image to the current page""" """Add an image to the current page"""
if not self.require_page(): if not self.require_page():
return return
file_path, _ = QFileDialog.getOpenFileName( file_path, _ = QFileDialog.getOpenFileName(
self, self, "Select Image", "", "Image Files (*.jpg *.jpeg *.png *.gif *.bmp *.tiff *.webp);;All Files (*)"
"Select Image",
"",
"Image Files (*.jpg *.jpeg *.png *.gif *.bmp *.tiff *.webp);;All Files (*)"
) )
if not file_path: if not file_path:
return return
current_page = self.get_current_page() current_page = self.get_current_page()
if not current_page: if not current_page:
return return
try: try:
# 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)
@ -54,111 +47,87 @@ class ElementOperationsMixin:
# Create image element at center of page # Create image element at center of page
page_width_mm = current_page.layout.size[0] page_width_mm = current_page.layout.size[0]
page_height_mm = current_page.layout.size[1] page_height_mm = current_page.layout.size[1]
# Center position # Center position
x = (page_width_mm - img_width) / 2 x = (page_width_mm - img_width) / 2
y = (page_height_mm - img_height) / 2 y = (page_height_mm - img_height) / 2
new_image = ImageData( new_image = ImageData(image_path=asset_path, x=x, y=y, width=img_width, height=img_height)
image_path=asset_path,
x=x,
y=y,
width=img_width,
height=img_height
)
# Add element using command pattern for undo/redo # Add element using command pattern for undo/redo
cmd = AddElementCommand( cmd = AddElementCommand(current_page.layout, new_image, asset_manager=self.project.asset_manager)
current_page.layout,
new_image,
asset_manager=self.project.asset_manager
)
self.project.history.execute(cmd) self.project.history.execute(cmd)
self.update_view() self.update_view()
self.show_status("Added image (Ctrl+Z to undo)", 2000) self.show_status("Added image (Ctrl+Z to undo)", 2000)
print(f"Added image to page {self.get_current_page_index() + 1}: {asset_path}") print(f"Added image to page {self.get_current_page_index() + 1}: {asset_path}")
except Exception as e: except Exception as e:
self.show_error("Error", f"Failed to add image: {str(e)}") self.show_error("Error", f"Failed to add image: {str(e)}")
print(f"Error adding image: {e}") print(f"Error adding image: {e}")
@ribbon_action( @ribbon_action(
label="Text", label="Text", tooltip="Add a text box to the current page", tab="Insert", group="Media", requires_page=True
tooltip="Add a text box to the current page",
tab="Insert",
group="Media",
requires_page=True
) )
def add_text(self): def add_text(self):
"""Add text to the current page""" """Add text to the current page"""
if not self.require_page(): if not self.require_page():
return return
current_page = self.get_current_page() current_page = self.get_current_page()
if not current_page: if not current_page:
return return
# Create text box element at center of page # Create text box element at center of page
page_width_mm = current_page.layout.size[0] page_width_mm = current_page.layout.size[0]
page_height_mm = current_page.layout.size[1] page_height_mm = current_page.layout.size[1]
text_width = 200 text_width = 200
text_height = 50 text_height = 50
# Center position # Center position
x = (page_width_mm - text_width) / 2 x = (page_width_mm - text_width) / 2
y = (page_height_mm - text_height) / 2 y = (page_height_mm - text_height) / 2
new_text = TextBoxData( new_text = TextBoxData(text_content="New Text", x=x, y=y, width=text_width, height=text_height)
text_content="New Text",
x=x,
y=y,
width=text_width,
height=text_height
)
current_page.layout.add_element(new_text) current_page.layout.add_element(new_text)
self.update_view() self.update_view()
print(f"Added text box to page {self.get_current_page_index() + 1}") print(f"Added text box to page {self.get_current_page_index() + 1}")
@ribbon_action( @ribbon_action(
label="Placeholder", label="Placeholder",
tooltip="Add a placeholder to the current page", tooltip="Add a placeholder to the current page",
tab="Insert", tab="Insert",
group="Media", group="Media",
requires_page=True requires_page=True,
) )
def add_placeholder(self): def add_placeholder(self):
"""Add a placeholder to the current page""" """Add a placeholder to the current page"""
if not self.require_page(): if not self.require_page():
return return
current_page = self.get_current_page() current_page = self.get_current_page()
if not current_page: if not current_page:
return return
# Create placeholder element at center of page # Create placeholder element at center of page
page_width_mm = current_page.layout.size[0] page_width_mm = current_page.layout.size[0]
page_height_mm = current_page.layout.size[1] page_height_mm = current_page.layout.size[1]
placeholder_width = 200 placeholder_width = 200
placeholder_height = 150 placeholder_height = 150
# Center position # Center position
x = (page_width_mm - placeholder_width) / 2 x = (page_width_mm - placeholder_width) / 2
y = (page_height_mm - placeholder_height) / 2 y = (page_height_mm - placeholder_height) / 2
new_placeholder = PlaceholderData( new_placeholder = PlaceholderData(
placeholder_type="image", placeholder_type="image", x=x, y=y, width=placeholder_width, height=placeholder_height
x=x,
y=y,
width=placeholder_width,
height=placeholder_height
) )
current_page.layout.add_element(new_placeholder) current_page.layout.add_element(new_placeholder)
self.update_view() self.update_view()
print(f"Added placeholder to page {self.get_current_page_index() + 1}") print(f"Added placeholder to page {self.get_current_page_index() + 1}")

View File

@ -5,9 +5,19 @@ File operations mixin for pyPhotoAlbum
import os import os
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QFileDialog, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QFileDialog,
QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox, QRadioButton, QDialog,
QButtonGroup, QLineEdit, QTextEdit QVBoxLayout,
QHBoxLayout,
QLabel,
QDoubleSpinBox,
QSpinBox,
QPushButton,
QGroupBox,
QRadioButton,
QButtonGroup,
QLineEdit,
QTextEdit,
) )
from pyPhotoAlbum.decorators import ribbon_action, numerical_input from pyPhotoAlbum.decorators import ribbon_action, numerical_input
from pyPhotoAlbum.project import Project, Page from pyPhotoAlbum.project import Project, Page
@ -21,23 +31,17 @@ from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
class FileOperationsMixin: class FileOperationsMixin:
"""Mixin providing file-related operations""" """Mixin providing file-related operations"""
@ribbon_action( @ribbon_action(label="New", tooltip="Create a new project", tab="Home", group="File", shortcut="Ctrl+N")
label="New",
tooltip="Create a new project",
tab="Home",
group="File",
shortcut="Ctrl+N"
)
def new_project(self): def new_project(self):
"""Create a new project with initial setup dialog""" """Create a new project with initial setup dialog"""
# Create new project setup dialog # Create new project setup dialog
dialog = QDialog(self) dialog = QDialog(self)
dialog.setWindowTitle("New Project Setup") dialog.setWindowTitle("New Project Setup")
dialog.setMinimumWidth(450) dialog.setMinimumWidth(450)
layout = QVBoxLayout() layout = QVBoxLayout()
# Project name group # Project name group
name_group = QGroupBox("Project Name") name_group = QGroupBox("Project Name")
name_layout = QVBoxLayout() name_layout = QVBoxLayout()
@ -47,16 +51,16 @@ class FileOperationsMixin:
name_layout.addWidget(name_input) name_layout.addWidget(name_input)
name_group.setLayout(name_layout) name_group.setLayout(name_layout)
layout.addWidget(name_group) layout.addWidget(name_group)
# Default page size group # Default page size group
size_group = QGroupBox("Default Page Size") size_group = QGroupBox("Default Page Size")
size_layout = QVBoxLayout() size_layout = QVBoxLayout()
info_label = QLabel("This will be the default size for all new pages in this project.") info_label = QLabel("This will be the default size for all new pages in this project.")
info_label.setWordWrap(True) info_label.setWordWrap(True)
info_label.setStyleSheet("font-size: 9pt; color: gray;") info_label.setStyleSheet("font-size: 9pt; color: gray;")
size_layout.addWidget(info_label) size_layout.addWidget(info_label)
# Width # Width
width_layout = QHBoxLayout() width_layout = QHBoxLayout()
width_layout.addWidget(QLabel("Width:")) width_layout.addWidget(QLabel("Width:"))
@ -66,7 +70,7 @@ class FileOperationsMixin:
width_spinbox.setSuffix(" mm") width_spinbox.setSuffix(" mm")
width_layout.addWidget(width_spinbox) width_layout.addWidget(width_spinbox)
size_layout.addLayout(width_layout) size_layout.addLayout(width_layout)
# Height # Height
height_layout = QHBoxLayout() height_layout = QHBoxLayout()
height_layout.addWidget(QLabel("Height:")) height_layout.addWidget(QLabel("Height:"))
@ -76,37 +80,37 @@ class FileOperationsMixin:
height_spinbox.setSuffix(" mm") height_spinbox.setSuffix(" mm")
height_layout.addWidget(height_spinbox) height_layout.addWidget(height_spinbox)
size_layout.addLayout(height_layout) size_layout.addLayout(height_layout)
# Add common size presets # Add common size presets
presets_layout = QHBoxLayout() presets_layout = QHBoxLayout()
presets_layout.addWidget(QLabel("Presets:")) presets_layout.addWidget(QLabel("Presets:"))
def set_preset(w, h): def set_preset(w, h):
width_spinbox.setValue(w) width_spinbox.setValue(w)
height_spinbox.setValue(h) height_spinbox.setValue(h)
preset_a4 = QPushButton("A4 (210×297)") preset_a4 = QPushButton("A4 (210×297)")
preset_a4.clicked.connect(lambda: set_preset(210, 297)) preset_a4.clicked.connect(lambda: set_preset(210, 297))
presets_layout.addWidget(preset_a4) presets_layout.addWidget(preset_a4)
preset_a5 = QPushButton("A5 (148×210)") preset_a5 = QPushButton("A5 (148×210)")
preset_a5.clicked.connect(lambda: set_preset(148, 210)) preset_a5.clicked.connect(lambda: set_preset(148, 210))
presets_layout.addWidget(preset_a5) presets_layout.addWidget(preset_a5)
preset_square = QPushButton("Square (200×200)") preset_square = QPushButton("Square (200×200)")
preset_square.clicked.connect(lambda: set_preset(200, 200)) preset_square.clicked.connect(lambda: set_preset(200, 200))
presets_layout.addWidget(preset_square) presets_layout.addWidget(preset_square)
presets_layout.addStretch() presets_layout.addStretch()
size_layout.addLayout(presets_layout) size_layout.addLayout(presets_layout)
size_group.setLayout(size_layout) size_group.setLayout(size_layout)
layout.addWidget(size_group) layout.addWidget(size_group)
# DPI settings group # DPI settings group
dpi_group = QGroupBox("DPI Settings") dpi_group = QGroupBox("DPI Settings")
dpi_layout = QVBoxLayout() dpi_layout = QVBoxLayout()
# Working DPI # Working DPI
working_dpi_layout = QHBoxLayout() working_dpi_layout = QHBoxLayout()
working_dpi_layout.addWidget(QLabel("Working DPI:")) working_dpi_layout.addWidget(QLabel("Working DPI:"))
@ -115,7 +119,7 @@ class FileOperationsMixin:
working_dpi_spinbox.setValue(300) working_dpi_spinbox.setValue(300)
working_dpi_layout.addWidget(working_dpi_spinbox) working_dpi_layout.addWidget(working_dpi_spinbox)
dpi_layout.addLayout(working_dpi_layout) dpi_layout.addLayout(working_dpi_layout)
# Export DPI # Export DPI
export_dpi_layout = QHBoxLayout() export_dpi_layout = QHBoxLayout()
export_dpi_layout.addWidget(QLabel("Export DPI:")) export_dpi_layout.addWidget(QLabel("Export DPI:"))
@ -124,10 +128,10 @@ class FileOperationsMixin:
export_dpi_spinbox.setValue(300) export_dpi_spinbox.setValue(300)
export_dpi_layout.addWidget(export_dpi_spinbox) export_dpi_layout.addWidget(export_dpi_spinbox)
dpi_layout.addLayout(export_dpi_layout) dpi_layout.addLayout(export_dpi_layout)
dpi_group.setLayout(dpi_layout) dpi_group.setLayout(dpi_layout)
layout.addWidget(dpi_group) layout.addWidget(dpi_group)
# Buttons # Buttons
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
cancel_btn = QPushButton("Cancel") cancel_btn = QPushButton("Cancel")
@ -135,14 +139,14 @@ class FileOperationsMixin:
create_btn = QPushButton("Create Project") create_btn = QPushButton("Create Project")
create_btn.clicked.connect(dialog.accept) create_btn.clicked.connect(dialog.accept)
create_btn.setDefault(True) create_btn.setDefault(True)
button_layout.addStretch() button_layout.addStretch()
button_layout.addWidget(cancel_btn) button_layout.addWidget(cancel_btn)
button_layout.addWidget(create_btn) button_layout.addWidget(create_btn)
layout.addLayout(button_layout) layout.addLayout(button_layout)
dialog.setLayout(layout) dialog.setLayout(layout)
# Show dialog # Show dialog
if dialog.exec() == QDialog.DialogCode.Accepted: if dialog.exec() == QDialog.DialogCode.Accepted:
# Get values # Get values
@ -153,7 +157,7 @@ class FileOperationsMixin:
export_dpi = export_dpi_spinbox.value() export_dpi = export_dpi_spinbox.value()
# Cleanup old project if it exists # Cleanup old project if it exists
if hasattr(self, 'project') and self.project: if hasattr(self, "project") and self.project:
self.project.cleanup() self.project.cleanup()
# Create project with custom settings # Create project with custom settings
@ -167,34 +171,25 @@ class FileOperationsMixin:
# Update view # Update view
self.update_view() self.update_view()
self.show_status(f"New project created: {project_name} ({width_mm}×{height_mm} mm)") self.show_status(f"New project created: {project_name} ({width_mm}×{height_mm} mm)")
print(f"New project created: {project_name}, default page size: {width_mm}×{height_mm} mm") print(f"New project created: {project_name}, default page size: {width_mm}×{height_mm} mm")
else: else:
# User cancelled - keep current project # User cancelled - keep current project
print("New project creation cancelled") print("New project creation cancelled")
@ribbon_action( @ribbon_action(label="Open", tooltip="Open an existing project", tab="Home", group="File", shortcut="Ctrl+O")
label="Open",
tooltip="Open an existing project",
tab="Home",
group="File",
shortcut="Ctrl+O"
)
def open_project(self): def open_project(self):
"""Open an existing project with async loading and progress bar""" """Open an existing project with async loading and progress bar"""
file_path, _ = QFileDialog.getOpenFileName( file_path, _ = QFileDialog.getOpenFileName(
self, self, "Open Project", "", "pyPhotoAlbum Projects (*.ppz);;All Files (*)"
"Open Project",
"",
"pyPhotoAlbum Projects (*.ppz);;All Files (*)"
) )
if file_path: if file_path:
print(f"Opening project: {file_path}") print(f"Opening project: {file_path}")
# Create loading widget if not exists # Create loading widget if not exists
if not hasattr(self, '_loading_widget'): if not hasattr(self, "_loading_widget"):
self._loading_widget = LoadingWidget(self) self._loading_widget = LoadingWidget(self)
# Show loading widget # Show loading widget
@ -214,29 +209,29 @@ class FileOperationsMixin:
def _on_load_progress(self, current: int, total: int, message: str): def _on_load_progress(self, current: int, total: int, message: str):
"""Handle loading progress updates""" """Handle loading progress updates"""
if hasattr(self, '_loading_widget'): if hasattr(self, "_loading_widget"):
self._loading_widget.set_progress(current, total) self._loading_widget.set_progress(current, total)
self._loading_widget.set_status(message) self._loading_widget.set_status(message)
def _on_load_complete(self, project): def _on_load_complete(self, project):
"""Handle successful project load""" """Handle successful project load"""
# Cleanup old project if it exists # Cleanup old project if it exists
if hasattr(self, 'project') and self.project: if hasattr(self, "project") and self.project:
self.project.cleanup() self.project.cleanup()
# Set new project # Set new project
self.project = project self.project = project
# Set file path and mark as clean # Set file path and mark as clean
if hasattr(self, '_opening_file_path'): if hasattr(self, "_opening_file_path"):
self.project.file_path = self._opening_file_path self.project.file_path = self._opening_file_path
delattr(self, '_opening_file_path') delattr(self, "_opening_file_path")
self.project.mark_clean() self.project.mark_clean()
self.gl_widget.current_page_index = 0 # Reset to first page self.gl_widget.current_page_index = 0 # Reset to first page
# Hide loading widget # Hide loading widget
if hasattr(self, '_loading_widget'): if hasattr(self, "_loading_widget"):
self._loading_widget.hide_loading() self._loading_widget.hide_loading()
# Update view (this will trigger progressive image loading) # Update view (this will trigger progressive image loading)
@ -254,32 +249,23 @@ class FileOperationsMixin:
def _on_load_failed(self, error_msg: str): def _on_load_failed(self, error_msg: str):
"""Handle project load failure""" """Handle project load failure"""
# Hide loading widget # Hide loading widget
if hasattr(self, '_loading_widget'): if hasattr(self, "_loading_widget"):
self._loading_widget.hide_loading() self._loading_widget.hide_loading()
error_msg = f"Failed to open project: {error_msg}" error_msg = f"Failed to open project: {error_msg}"
self.show_status(error_msg) self.show_status(error_msg)
self.show_error("Load Failed", error_msg) self.show_error("Load Failed", error_msg)
print(error_msg) print(error_msg)
@ribbon_action( @ribbon_action(label="Save", tooltip="Save the current project", tab="Home", group="File", shortcut="Ctrl+S")
label="Save",
tooltip="Save the current project",
tab="Home",
group="File",
shortcut="Ctrl+S"
)
def save_project(self): def save_project(self):
"""Save the current project""" """Save the current project"""
# If project has a file path, use it; otherwise prompt for location # If project has a file path, use it; otherwise prompt for location
file_path = self.project.file_path if hasattr(self.project, 'file_path') and self.project.file_path else None file_path = self.project.file_path if hasattr(self.project, "file_path") and self.project.file_path else None
if not file_path: if not file_path:
file_path, _ = QFileDialog.getSaveFileName( file_path, _ = QFileDialog.getSaveFileName(
self, self, "Save Project", "", "pyPhotoAlbum Projects (*.ppz);;All Files (*)"
"Save Project",
"",
"pyPhotoAlbum Projects (*.ppz);;All Files (*)"
) )
if file_path: if file_path:
@ -298,12 +284,7 @@ class FileOperationsMixin:
self.show_status(error_msg) self.show_status(error_msg)
print(error_msg) print(error_msg)
@ribbon_action( @ribbon_action(label="Heal Assets", tooltip="Reconnect missing image assets", tab="Home", group="File")
label="Heal Assets",
tooltip="Reconnect missing image assets",
tab="Home",
group="File"
)
def heal_assets(self): def heal_assets(self):
"""Open the asset healing dialog to reconnect missing images""" """Open the asset healing dialog to reconnect missing images"""
dialog = AssetHealDialog(self.project, self) dialog = AssetHealDialog(self.project, self)
@ -348,10 +329,12 @@ class FileOperationsMixin:
msg.setWindowTitle("Missing Assets") msg.setWindowTitle("Missing Assets")
msg.setText(f"{len(missing_assets)} image(s) could not be found in the assets folder:") msg.setText(f"{len(missing_assets)} image(s) could not be found in the assets folder:")
msg.setInformativeText(asset_list) msg.setInformativeText(asset_list)
msg.setDetailedText("These images need to be reconnected using the 'Heal Assets' feature.\n\n" msg.setDetailedText(
"Go to: Home → Heal Assets\n\n" "These images need to be reconnected using the 'Heal Assets' feature.\n\n"
"Add search paths where the original images might be located, " "Go to: Home → Heal Assets\n\n"
"then click 'Attempt Healing' to find and import them.") "Add search paths where the original images might be located, "
"then click 'Attempt Healing' to find and import them."
)
msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Open) msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Open)
msg.button(QMessageBox.StandardButton.Open).setText("Open Heal Assets") msg.button(QMessageBox.StandardButton.Open).setText("Open Heal Assets")
@ -360,30 +343,22 @@ class FileOperationsMixin:
self.heal_assets() self.heal_assets()
@ribbon_action( @ribbon_action(
label="Project Settings", label="Project Settings", tooltip="Configure project-wide page size and defaults", tab="Home", group="File"
tooltip="Configure project-wide page size and defaults",
tab="Home",
group="File"
)
@numerical_input(
fields=[
('width', 'Width', 'mm', 10, 1000),
('height', 'Height', 'mm', 10, 1000)
]
) )
@numerical_input(fields=[("width", "Width", "mm", 10, 1000), ("height", "Height", "mm", 10, 1000)])
def project_settings(self): def project_settings(self):
"""Configure project-wide settings including default page size""" """Configure project-wide settings including default page size"""
# Create dialog # Create dialog
dialog = QDialog(self) dialog = QDialog(self)
dialog.setWindowTitle("Project Settings") dialog.setWindowTitle("Project Settings")
dialog.setMinimumWidth(500) dialog.setMinimumWidth(500)
layout = QVBoxLayout() layout = QVBoxLayout()
# Page size group # Page size group
size_group = QGroupBox("Default Page Size") size_group = QGroupBox("Default Page Size")
size_layout = QVBoxLayout() size_layout = QVBoxLayout()
# Width # Width
width_layout = QHBoxLayout() width_layout = QHBoxLayout()
width_layout.addWidget(QLabel("Width:")) width_layout.addWidget(QLabel("Width:"))
@ -393,7 +368,7 @@ class FileOperationsMixin:
width_spinbox.setSuffix(" mm") width_spinbox.setSuffix(" mm")
width_layout.addWidget(width_spinbox) width_layout.addWidget(width_spinbox)
size_layout.addLayout(width_layout) size_layout.addLayout(width_layout)
# Height # Height
height_layout = QHBoxLayout() height_layout = QHBoxLayout()
height_layout.addWidget(QLabel("Height:")) height_layout.addWidget(QLabel("Height:"))
@ -403,14 +378,14 @@ class FileOperationsMixin:
height_spinbox.setSuffix(" mm") height_spinbox.setSuffix(" mm")
height_layout.addWidget(height_spinbox) height_layout.addWidget(height_spinbox)
size_layout.addLayout(height_layout) size_layout.addLayout(height_layout)
size_group.setLayout(size_layout) size_group.setLayout(size_layout)
layout.addWidget(size_group) layout.addWidget(size_group)
# DPI settings group # DPI settings group
dpi_group = QGroupBox("DPI Settings") dpi_group = QGroupBox("DPI Settings")
dpi_layout = QVBoxLayout() dpi_layout = QVBoxLayout()
# Working DPI # Working DPI
working_dpi_layout = QHBoxLayout() working_dpi_layout = QHBoxLayout()
working_dpi_layout.addWidget(QLabel("Working DPI:")) working_dpi_layout.addWidget(QLabel("Working DPI:"))
@ -419,7 +394,7 @@ class FileOperationsMixin:
working_dpi_spinbox.setValue(self.project.working_dpi) working_dpi_spinbox.setValue(self.project.working_dpi)
working_dpi_layout.addWidget(working_dpi_spinbox) working_dpi_layout.addWidget(working_dpi_spinbox)
dpi_layout.addLayout(working_dpi_layout) dpi_layout.addLayout(working_dpi_layout)
# Export DPI # Export DPI
export_dpi_layout = QHBoxLayout() export_dpi_layout = QHBoxLayout()
export_dpi_layout.addWidget(QLabel("Export DPI:")) export_dpi_layout.addWidget(QLabel("Export DPI:"))
@ -428,48 +403,50 @@ class FileOperationsMixin:
export_dpi_spinbox.setValue(self.project.export_dpi) export_dpi_spinbox.setValue(self.project.export_dpi)
export_dpi_layout.addWidget(export_dpi_spinbox) export_dpi_layout.addWidget(export_dpi_spinbox)
dpi_layout.addLayout(export_dpi_layout) dpi_layout.addLayout(export_dpi_layout)
dpi_group.setLayout(dpi_layout) dpi_group.setLayout(dpi_layout)
layout.addWidget(dpi_group) layout.addWidget(dpi_group)
# Content scaling options (only if pages exist and size is changing) # Content scaling options (only if pages exist and size is changing)
scaling_group = None scaling_group = None
scaling_buttons = None scaling_buttons = None
if self.project.pages: if self.project.pages:
scaling_group = QGroupBox("Apply to Existing Pages") scaling_group = QGroupBox("Apply to Existing Pages")
scaling_layout = QVBoxLayout() scaling_layout = QVBoxLayout()
info_label = QLabel("How should existing content be adjusted?\n(Pages with manual sizing will not be affected)") info_label = QLabel(
"How should existing content be adjusted?\n(Pages with manual sizing will not be affected)"
)
info_label.setWordWrap(True) info_label.setWordWrap(True)
scaling_layout.addWidget(info_label) scaling_layout.addWidget(info_label)
scaling_buttons = QButtonGroup() scaling_buttons = QButtonGroup()
proportional_radio = QRadioButton("Resize proportionally (fit to smallest axis)") proportional_radio = QRadioButton("Resize proportionally (fit to smallest axis)")
proportional_radio.setToolTip("Scale content uniformly to fit the new page size") proportional_radio.setToolTip("Scale content uniformly to fit the new page size")
scaling_buttons.addButton(proportional_radio, 0) scaling_buttons.addButton(proportional_radio, 0)
scaling_layout.addWidget(proportional_radio) scaling_layout.addWidget(proportional_radio)
stretch_radio = QRadioButton("Resize on both axes (stretch)") stretch_radio = QRadioButton("Resize on both axes (stretch)")
stretch_radio.setToolTip("Scale width and height independently") stretch_radio.setToolTip("Scale width and height independently")
scaling_buttons.addButton(stretch_radio, 1) scaling_buttons.addButton(stretch_radio, 1)
scaling_layout.addWidget(stretch_radio) scaling_layout.addWidget(stretch_radio)
reposition_radio = QRadioButton("Keep content size, reposition to center") reposition_radio = QRadioButton("Keep content size, reposition to center")
reposition_radio.setToolTip("Maintain element sizes but center them on new page") reposition_radio.setToolTip("Maintain element sizes but center them on new page")
scaling_buttons.addButton(reposition_radio, 2) scaling_buttons.addButton(reposition_radio, 2)
scaling_layout.addWidget(reposition_radio) scaling_layout.addWidget(reposition_radio)
none_radio = QRadioButton("Don't adjust content (page size only)") none_radio = QRadioButton("Don't adjust content (page size only)")
none_radio.setToolTip("Only change page size, leave content as-is") none_radio.setToolTip("Only change page size, leave content as-is")
none_radio.setChecked(True) # Default none_radio.setChecked(True) # Default
scaling_buttons.addButton(none_radio, 3) scaling_buttons.addButton(none_radio, 3)
scaling_layout.addWidget(none_radio) scaling_layout.addWidget(none_radio)
scaling_group.setLayout(scaling_layout) scaling_group.setLayout(scaling_layout)
layout.addWidget(scaling_group) layout.addWidget(scaling_group)
# Buttons # Buttons
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
cancel_btn = QPushButton("Cancel") cancel_btn = QPushButton("Cancel")
@ -477,14 +454,14 @@ class FileOperationsMixin:
ok_btn = QPushButton("OK") ok_btn = QPushButton("OK")
ok_btn.clicked.connect(dialog.accept) ok_btn.clicked.connect(dialog.accept)
ok_btn.setDefault(True) ok_btn.setDefault(True)
button_layout.addStretch() button_layout.addStretch()
button_layout.addWidget(cancel_btn) button_layout.addWidget(cancel_btn)
button_layout.addWidget(ok_btn) button_layout.addWidget(ok_btn)
layout.addLayout(button_layout) layout.addLayout(button_layout)
dialog.setLayout(layout) dialog.setLayout(layout)
# Show dialog # Show dialog
if dialog.exec() == QDialog.DialogCode.Accepted: if dialog.exec() == QDialog.DialogCode.Accepted:
# Get new values # Get new values
@ -492,32 +469,32 @@ class FileOperationsMixin:
new_height = height_spinbox.value() new_height = height_spinbox.value()
new_working_dpi = working_dpi_spinbox.value() new_working_dpi = working_dpi_spinbox.value()
new_export_dpi = export_dpi_spinbox.value() new_export_dpi = export_dpi_spinbox.value()
# Determine scaling mode # Determine scaling mode
scaling_mode = 'none' scaling_mode = "none"
if scaling_buttons: if scaling_buttons:
selected_id = scaling_buttons.checkedId() selected_id = scaling_buttons.checkedId()
modes = {0: 'proportional', 1: 'stretch', 2: 'reposition', 3: 'none'} modes = {0: "proportional", 1: "stretch", 2: "reposition", 3: "none"}
scaling_mode = modes.get(selected_id, 'none') scaling_mode = modes.get(selected_id, "none")
# Apply settings # Apply settings
old_size = self.project.page_size_mm old_size = self.project.page_size_mm
self.project.page_size_mm = (new_width, new_height) self.project.page_size_mm = (new_width, new_height)
self.project.working_dpi = new_working_dpi self.project.working_dpi = new_working_dpi
self.project.export_dpi = new_export_dpi self.project.export_dpi = new_export_dpi
# Update existing pages (exclude manually sized ones) # Update existing pages (exclude manually sized ones)
if self.project.pages and old_size != (new_width, new_height): if self.project.pages and old_size != (new_width, new_height):
self._apply_page_size_to_project(old_size, (new_width, new_height), scaling_mode) self._apply_page_size_to_project(old_size, (new_width, new_height), scaling_mode)
self.update_view() self.update_view()
self.show_status(f"Project settings updated: {new_width}×{new_height} mm", 2000) self.show_status(f"Project settings updated: {new_width}×{new_height} mm", 2000)
print(f"Project settings updated: {new_width}×{new_height} mm, scaling mode: {scaling_mode}") print(f"Project settings updated: {new_width}×{new_height} mm, scaling mode: {scaling_mode}")
def _apply_page_size_to_project(self, old_size, new_size, scaling_mode): def _apply_page_size_to_project(self, old_size, new_size, scaling_mode):
""" """
Apply new page size to all non-manually-sized pages Apply new page size to all non-manually-sized pages
Args: Args:
old_size: Old page size (width, height) in mm old_size: Old page size (width, height) in mm
new_size: New page size (width, height) in mm new_size: New page size (width, height) in mm
@ -525,41 +502,41 @@ class FileOperationsMixin:
""" """
old_width, old_height = old_size old_width, old_height = old_size
new_width, new_height = new_size new_width, new_height = new_size
width_ratio = new_width / old_width if old_width > 0 else 1.0 width_ratio = new_width / old_width if old_width > 0 else 1.0
height_ratio = new_height / old_height if old_height > 0 else 1.0 height_ratio = new_height / old_height if old_height > 0 else 1.0
for page in self.project.pages: for page in self.project.pages:
# Skip manually sized pages # Skip manually sized pages
if page.manually_sized: if page.manually_sized:
continue continue
# Update page size # Update page size
old_page_width, old_page_height = page.layout.size old_page_width, old_page_height = page.layout.size
# For double spreads, maintain the 2x multiplier # For double spreads, maintain the 2x multiplier
if page.is_double_spread: if page.is_double_spread:
page.layout.size = (new_width * 2, new_height) page.layout.size = (new_width * 2, new_height)
else: else:
page.layout.size = (new_width, new_height) page.layout.size = (new_width, new_height)
# Apply content scaling based on mode # Apply content scaling based on mode
if scaling_mode == 'proportional': if scaling_mode == "proportional":
# Use smallest ratio to fit content # Use smallest ratio to fit content
scale = min(width_ratio, height_ratio) scale = min(width_ratio, height_ratio)
self._scale_page_elements(page, scale, scale) self._scale_page_elements(page, scale, scale)
elif scaling_mode == 'stretch': elif scaling_mode == "stretch":
# Scale independently on each axis # Scale independently on each axis
self._scale_page_elements(page, width_ratio, height_ratio) self._scale_page_elements(page, width_ratio, height_ratio)
elif scaling_mode == 'reposition': elif scaling_mode == "reposition":
# Keep size, center content # Keep size, center content
self._reposition_page_elements(page, old_size, new_size) self._reposition_page_elements(page, old_size, new_size)
# 'none' - do nothing to elements # 'none' - do nothing to elements
def _scale_page_elements(self, page, x_scale, y_scale): def _scale_page_elements(self, page, x_scale, y_scale):
""" """
Scale all elements on a page Scale all elements on a page
Args: Args:
page: Page object page: Page object
x_scale: Horizontal scale factor x_scale: Horizontal scale factor
@ -569,15 +546,15 @@ class FileOperationsMixin:
# Scale position # Scale position
x, y = element.position x, y = element.position
element.position = (x * x_scale, y * y_scale) element.position = (x * x_scale, y * y_scale)
# Scale size # Scale size
width, height = element.size width, height = element.size
element.size = (width * x_scale, height * y_scale) element.size = (width * x_scale, height * y_scale)
def _reposition_page_elements(self, page, old_size, new_size): def _reposition_page_elements(self, page, old_size, new_size):
""" """
Reposition elements to center them on the new page size Reposition elements to center them on the new page size
Args: Args:
page: Page object page: Page object
old_size: Old page size (width, height) in mm old_size: Old page size (width, height) in mm
@ -585,20 +562,15 @@ class FileOperationsMixin:
""" """
old_width, old_height = old_size old_width, old_height = old_size
new_width, new_height = new_size new_width, new_height = new_size
x_offset = (new_width - old_width) / 2.0 x_offset = (new_width - old_width) / 2.0
y_offset = (new_height - old_height) / 2.0 y_offset = (new_height - old_height) / 2.0
for element in page.layout.elements: for element in page.layout.elements:
x, y = element.position x, y = element.position
element.position = (x + x_offset, y + y_offset) element.position = (x + x_offset, y + y_offset)
@ribbon_action( @ribbon_action(label="Export PDF", tooltip="Export project to PDF", tab="Export", group="Export")
label="Export PDF",
tooltip="Export project to PDF",
tab="Export",
group="Export"
)
def export_pdf(self): def export_pdf(self):
"""Export project to PDF using async backend (non-blocking)""" """Export project to PDF using async backend (non-blocking)"""
# Check if we have pages to export # Check if we have pages to export
@ -607,19 +579,14 @@ class FileOperationsMixin:
return return
# Show file save dialog # Show file save dialog
file_path, _ = QFileDialog.getSaveFileName( file_path, _ = QFileDialog.getSaveFileName(self, "Export to PDF", "", "PDF Files (*.pdf);;All Files (*)")
self,
"Export to PDF",
"",
"PDF Files (*.pdf);;All Files (*)"
)
if not file_path: if not file_path:
return return
# Ensure .pdf extension # Ensure .pdf extension
if not file_path.lower().endswith('.pdf'): if not file_path.lower().endswith(".pdf"):
file_path += '.pdf' file_path += ".pdf"
# Use async PDF export (non-blocking, UI stays responsive) # Use async PDF export (non-blocking, UI stays responsive)
success = self.gl_widget.export_pdf_async(self.project, file_path, export_dpi=300) success = self.gl_widget.export_pdf_async(self.project, file_path, export_dpi=300)
@ -628,12 +595,7 @@ class FileOperationsMixin:
else: else:
self.show_status("PDF export failed to start", 3000) self.show_status("PDF export failed to start", 3000)
@ribbon_action( @ribbon_action(label="About", tooltip="About pyPhotoAlbum and data format version", tab="Home", group="File")
label="About",
tooltip="About pyPhotoAlbum and data format version",
tab="Home",
group="File"
)
def show_about(self): def show_about(self):
"""Show about dialog with version information""" """Show about dialog with version information"""
dialog = QDialog(self) dialog = QDialog(self)

View File

@ -20,7 +20,7 @@ class MergeOperationsMixin:
label="Merge Projects", label="Merge Projects",
tooltip="Merge another project file with the current project", tooltip="Merge another project file with the current project",
tab="File", tab="File",
group="Import/Export" group="Import/Export",
) )
def merge_projects(self): def merge_projects(self):
""" """
@ -35,22 +35,19 @@ class MergeOperationsMixin:
self, self,
"Unsaved Changes", "Unsaved Changes",
"You have unsaved changes in the current project. Save before merging?", "You have unsaved changes in the current project. Save before merging?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel,
) )
if reply == QMessageBox.StandardButton.Cancel: if reply == QMessageBox.StandardButton.Cancel:
return return
elif reply == QMessageBox.StandardButton.Yes: elif reply == QMessageBox.StandardButton.Yes:
# Save current project first # Save current project first
if hasattr(self, 'save_project'): if hasattr(self, "save_project"):
self.save_project() self.save_project()
# Select file to merge # Select file to merge
file_path, _ = QFileDialog.getOpenFileName( file_path, _ = QFileDialog.getOpenFileName(
self, self, "Select Project to Merge", "", "Photo Album Projects (*.ppz);;All Files (*)"
"Select Project to Merge",
"",
"Photo Album Projects (*.ppz);;All Files (*)"
) )
if not file_path: if not file_path:
@ -58,7 +55,7 @@ class MergeOperationsMixin:
try: try:
# Disable autosave during merge # Disable autosave during merge
if hasattr(self, '_autosave_timer'): if hasattr(self, "_autosave_timer"):
self._autosave_timer.stop() self._autosave_timer.stop()
# Load the other project # Load the other project
@ -82,14 +79,10 @@ class MergeOperationsMixin:
self._perform_concatenation(our_data, their_data) self._perform_concatenation(our_data, their_data)
except Exception as e: except Exception as e:
QMessageBox.critical( QMessageBox.critical(self, "Merge Error", f"Failed to merge projects:\n{str(e)}")
self,
"Merge Error",
f"Failed to merge projects:\n{str(e)}"
)
finally: finally:
# Re-enable autosave # Re-enable autosave
if hasattr(self, '_autosave_timer'): if hasattr(self, "_autosave_timer"):
self._autosave_timer.start() self._autosave_timer.start()
def _perform_merge_with_conflicts(self, our_data, their_data): def _perform_merge_with_conflicts(self, our_data, their_data):
@ -104,7 +97,7 @@ class MergeOperationsMixin:
self, self,
"No Conflicts", "No Conflicts",
"No conflicts detected. Merge projects automatically?", "No conflicts detected. Merge projects automatically?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
) )
if reply != QMessageBox.StandardButton.Yes: if reply != QMessageBox.StandardButton.Yes:
@ -117,11 +110,7 @@ class MergeOperationsMixin:
dialog = MergeDialog(our_data, their_data, self) dialog = MergeDialog(our_data, their_data, self)
if dialog.exec() != QMessageBox.DialogCode.Accepted: if dialog.exec() != QMessageBox.DialogCode.Accepted:
QMessageBox.information( QMessageBox.information(self, "Merge Cancelled", "Merge operation cancelled.")
self,
"Merge Cancelled",
"Merge operation cancelled."
)
return return
# Get merged data from dialog # Get merged data from dialog
@ -135,7 +124,7 @@ class MergeOperationsMixin:
"Merge Complete", "Merge Complete",
f"Projects merged successfully.\n" f"Projects merged successfully.\n"
f"Total pages: {len(merged_data.get('pages', []))}\n" f"Total pages: {len(merged_data.get('pages', []))}\n"
f"Resolved conflicts: {len(conflicts)}" f"Resolved conflicts: {len(conflicts)}",
) )
def _perform_concatenation(self, our_data, their_data): def _perform_concatenation(self, our_data, their_data):
@ -147,7 +136,7 @@ class MergeOperationsMixin:
f"{our_data.get('name', 'Untitled')}\n" f"{our_data.get('name', 'Untitled')}\n"
f"{their_data.get('name', 'Untitled')}\n\n" f"{their_data.get('name', 'Untitled')}\n\n"
f"Concatenate them (combine all pages)?", f"Concatenate them (combine all pages)?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
) )
if reply != QMessageBox.StandardButton.Yes: if reply != QMessageBox.StandardButton.Yes:
@ -162,8 +151,7 @@ class MergeOperationsMixin:
QMessageBox.information( QMessageBox.information(
self, self,
"Concatenation Complete", "Concatenation Complete",
f"Projects concatenated successfully.\n" f"Projects concatenated successfully.\n" f"Total pages: {len(merged_data.get('pages', []))}",
f"Total pages: {len(merged_data.get('pages', []))}"
) )
def _apply_merged_data(self, merged_data): def _apply_merged_data(self, merged_data):
@ -182,9 +170,9 @@ class MergeOperationsMixin:
new_project.mark_dirty() new_project.mark_dirty()
# Update UI # Update UI
if hasattr(self, 'gl_widget'): if hasattr(self, "gl_widget"):
self.gl_widget.set_project(new_project) self.gl_widget.set_project(new_project)
self.gl_widget.update() self.gl_widget.update()
if hasattr(self, 'status_bar'): if hasattr(self, "status_bar"):
self.status_bar.showMessage("Merge completed successfully", 3000) self.status_bar.showMessage("Merge completed successfully", 3000)

View File

@ -10,16 +10,11 @@ from pyPhotoAlbum.page_layout import PageLayout
class PageOperationsMixin: class PageOperationsMixin:
"""Mixin providing page management operations""" """Mixin providing page management operations"""
# Note: Previous/Next page navigation removed - now using scrollable multi-page view # Note: Previous/Next page navigation removed - now using scrollable multi-page view
# User can scroll through all pages vertically # User can scroll through all pages vertically
@ribbon_action( @ribbon_action(label="Add Page", tooltip="Add a new page to the project", tab="Layout", group="Page")
label="Add Page",
tooltip="Add a new page to the project",
tab="Layout",
group="Page"
)
def add_page(self): def add_page(self):
"""Add a new page to the project after the current page""" """Add a new page to the project after the current page"""
# Get the most visible page in viewport to determine insertion point # Get the most visible page in viewport to determine insertion point
@ -72,13 +67,8 @@ class PageOperationsMixin:
# Get display name for status message # Get display name for status message
new_page_name = self.project.get_page_display_name(new_page) new_page_name = self.project.get_page_display_name(new_page)
print(f"Added {new_page_name} at position {insert_index + 1} with size {width_mm}×{height_mm} mm") print(f"Added {new_page_name} at position {insert_index + 1} with size {width_mm}×{height_mm} mm")
@ribbon_action( @ribbon_action(label="Page Setup", tooltip="Configure page size and settings", tab="Layout", group="Page")
label="Page Setup",
tooltip="Configure page size and settings",
tab="Layout",
group="Page"
)
@dialog_action(dialog_class=PageSetupDialog, requires_pages=True) @dialog_action(dialog_class=PageSetupDialog, requires_pages=True)
def page_setup(self, values): def page_setup(self, values):
""" """
@ -90,17 +80,17 @@ class PageOperationsMixin:
Args: Args:
values: Dictionary of values from the dialog values: Dictionary of values from the dialog
""" """
selected_page = values['selected_page'] selected_page = values["selected_page"]
selected_index = values['selected_index'] selected_index = values["selected_index"]
# Update project cover settings # Update project cover settings
self.project.paper_thickness_mm = values['paper_thickness_mm'] self.project.paper_thickness_mm = values["paper_thickness_mm"]
self.project.cover_bleed_mm = values['cover_bleed_mm'] self.project.cover_bleed_mm = values["cover_bleed_mm"]
# Handle cover designation (only for first page) # Handle cover designation (only for first page)
if selected_index == 0: if selected_index == 0:
was_cover = selected_page.is_cover was_cover = selected_page.is_cover
is_cover = values['is_cover'] is_cover = values["is_cover"]
if was_cover != is_cover: if was_cover != is_cover:
selected_page.is_cover = is_cover selected_page.is_cover = is_cover
@ -116,8 +106,8 @@ class PageOperationsMixin:
print(f"Cover removed from page 1") print(f"Cover removed from page 1")
# Get new values # Get new values
width_mm = values['width_mm'] width_mm = values["width_mm"]
height_mm = values['height_mm'] height_mm = values["height_mm"]
# Don't allow manual size changes for covers # Don't allow manual size changes for covers
if not selected_page.is_cover: if not selected_page.is_cover:
@ -126,11 +116,11 @@ class PageOperationsMixin:
if selected_page.is_double_spread: if selected_page.is_double_spread:
old_base_width = ( old_base_width = (
selected_page.layout.base_width selected_page.layout.base_width
if hasattr(selected_page.layout, 'base_width') if hasattr(selected_page.layout, "base_width")
else selected_page.layout.size[0] / 2 else selected_page.layout.size[0] / 2
) )
old_height = selected_page.layout.size[1] old_height = selected_page.layout.size[1]
size_changed = (old_base_width != width_mm or old_height != height_mm) size_changed = old_base_width != width_mm or old_height != height_mm
if size_changed: if size_changed:
# Update double spread # Update double spread
@ -143,7 +133,7 @@ class PageOperationsMixin:
) )
else: else:
old_size = selected_page.layout.size old_size = selected_page.layout.size
size_changed = (old_size != (width_mm, height_mm)) size_changed = old_size != (width_mm, height_mm)
if size_changed: if size_changed:
# Update single page # Update single page
@ -151,16 +141,15 @@ class PageOperationsMixin:
selected_page.layout.base_width = width_mm selected_page.layout.base_width = width_mm
selected_page.manually_sized = True selected_page.manually_sized = True
print( print(
f"{self.project.get_page_display_name(selected_page)} " f"{self.project.get_page_display_name(selected_page)} " f"updated to {width_mm}×{height_mm} mm"
f"updated to {width_mm}×{height_mm} mm"
) )
# Update DPI settings # Update DPI settings
self.project.working_dpi = values['working_dpi'] self.project.working_dpi = values["working_dpi"]
self.project.export_dpi = values['export_dpi'] self.project.export_dpi = values["export_dpi"]
# Set as default if checkbox is checked # Set as default if checkbox is checked
if values['set_as_default']: if values["set_as_default"]:
self.project.page_size_mm = (width_mm, height_mm) self.project.page_size_mm = (width_mm, height_mm)
print(f"Project default page size set to {width_mm}×{height_mm} mm") print(f"Project default page size set to {width_mm}×{height_mm} mm")
@ -172,15 +161,12 @@ class PageOperationsMixin:
status_msg = f"{page_name} updated" status_msg = f"{page_name} updated"
else: else:
status_msg = f"{page_name} size: {width_mm}×{height_mm} mm" status_msg = f"{page_name} size: {width_mm}×{height_mm} mm"
if values['set_as_default']: if values["set_as_default"]:
status_msg += " (set as default)" status_msg += " (set as default)"
self.show_status(status_msg, 2000) self.show_status(status_msg, 2000)
@ribbon_action( @ribbon_action(
label="Toggle Spread", label="Toggle Spread", tooltip="Toggle double page spread for current page", tab="Layout", group="Page"
tooltip="Toggle double page spread for current page",
tab="Layout",
group="Page"
) )
def toggle_double_spread(self): def toggle_double_spread(self):
"""Toggle double spread for the current page""" """Toggle double spread for the current page"""
@ -195,31 +181,31 @@ class PageOperationsMixin:
page_index = 0 page_index = 0
current_page = self.project.pages[page_index] current_page = self.project.pages[page_index]
# Toggle the state # Toggle the state
is_double = not current_page.is_double_spread is_double = not current_page.is_double_spread
current_page.is_double_spread = is_double current_page.is_double_spread = is_double
# Mark as manually sized when toggling spread # Mark as manually sized when toggling spread
current_page.manually_sized = True current_page.manually_sized = True
# Update the page layout width # Update the page layout width
current_width = current_page.layout.size[0] current_width = current_page.layout.size[0]
current_height = current_page.layout.size[1] current_height = current_page.layout.size[1]
# Get base width (might already be doubled) # Get base width (might already be doubled)
if hasattr(current_page.layout, 'base_width'): if hasattr(current_page.layout, "base_width"):
base_width = current_page.layout.base_width base_width = current_page.layout.base_width
else: else:
# Assume current width is single if not marked as facing # Assume current width is single if not marked as facing
base_width = current_width / 2 if current_page.layout.is_facing_page else current_width base_width = current_width / 2 if current_page.layout.is_facing_page else current_width
# Set new width based on double spread state # Set new width based on double spread state
new_width = base_width * 2 if is_double else base_width new_width = base_width * 2 if is_double else base_width
current_page.layout.base_width = base_width current_page.layout.base_width = base_width
current_page.layout.is_facing_page = is_double current_page.layout.is_facing_page = is_double
current_page.layout.size = (new_width, current_height) current_page.layout.size = (new_width, current_height)
# Update display # Update display
self.update_view() self.update_view()
@ -227,13 +213,8 @@ class PageOperationsMixin:
page_name = self.project.get_page_display_name(current_page) page_name = self.project.get_page_display_name(current_page)
self.show_status(f"{page_name}: Double spread {status}, width = {new_width:.0f}mm", 2000) self.show_status(f"{page_name}: Double spread {status}, width = {new_width:.0f}mm", 2000)
print(f"{page_name}: Double spread {status}, width = {new_width}mm") print(f"{page_name}: Double spread {status}, width = {new_width}mm")
@ribbon_action( @ribbon_action(label="Remove Page", tooltip="Remove the currently selected page", tab="Layout", group="Page")
label="Remove Page",
tooltip="Remove the currently selected page",
tab="Layout",
group="Page"
)
def remove_page(self): def remove_page(self):
"""Remove the currently selected page""" """Remove the currently selected page"""
if len(self.project.pages) <= 1: if len(self.project.pages) <= 1:

View File

@ -9,60 +9,60 @@ from pyPhotoAlbum.commands import ResizeElementsCommand
class SizeOperationsMixin: class SizeOperationsMixin:
"""Mixin providing element sizing operations""" """Mixin providing element sizing operations"""
def _get_selected_elements_list(self): def _get_selected_elements_list(self):
"""Get list of selected elements for size operations""" """Get list of selected elements for size operations"""
return list(self.gl_widget.selected_elements) if self.gl_widget.selected_elements else [] return list(self.gl_widget.selected_elements) if self.gl_widget.selected_elements else []
@ribbon_action( @ribbon_action(
label="Same Size", label="Same Size",
tooltip="Make all selected elements the same size", tooltip="Make all selected elements the same size",
tab="Arrange", tab="Arrange",
group="Size", group="Size",
requires_selection=True, requires_selection=True,
min_selection=2 min_selection=2,
) )
def make_same_size(self): def make_same_size(self):
"""Make all selected elements the same size""" """Make all selected elements the same size"""
elements = self._get_selected_elements_list() elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2): if not self.require_selection(min_count=2):
return return
changes = AlignmentManager.make_same_size(elements) changes = AlignmentManager.make_same_size(elements)
if changes: if changes:
cmd = ResizeElementsCommand(changes) cmd = ResizeElementsCommand(changes)
self.project.history.execute(cmd) self.project.history.execute(cmd)
self.update_view() self.update_view()
self.show_status(f"Resized {len(elements)} elements to same size", 2000) self.show_status(f"Resized {len(elements)} elements to same size", 2000)
@ribbon_action( @ribbon_action(
label="Same Width", label="Same Width",
tooltip="Make all selected elements the same width", tooltip="Make all selected elements the same width",
tab="Arrange", tab="Arrange",
group="Size", group="Size",
requires_selection=True, requires_selection=True,
min_selection=2 min_selection=2,
) )
def make_same_width(self): def make_same_width(self):
"""Make all selected elements the same width""" """Make all selected elements the same width"""
elements = self._get_selected_elements_list() elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2): if not self.require_selection(min_count=2):
return return
changes = AlignmentManager.make_same_width(elements) changes = AlignmentManager.make_same_width(elements)
if changes: if changes:
cmd = ResizeElementsCommand(changes) cmd = ResizeElementsCommand(changes)
self.project.history.execute(cmd) self.project.history.execute(cmd)
self.update_view() self.update_view()
self.show_status(f"Resized {len(elements)} elements to same width", 2000) self.show_status(f"Resized {len(elements)} elements to same width", 2000)
@ribbon_action( @ribbon_action(
label="Same Height", label="Same Height",
tooltip="Make all selected elements the same height", tooltip="Make all selected elements the same height",
tab="Arrange", tab="Arrange",
group="Size", group="Size",
requires_selection=True, requires_selection=True,
min_selection=2 min_selection=2,
) )
def make_same_height(self): def make_same_height(self):
"""Make all selected elements the same height""" """Make all selected elements the same height"""
@ -83,7 +83,7 @@ class SizeOperationsMixin:
tab="Arrange", tab="Arrange",
group="Size", group="Size",
requires_selection=True, requires_selection=True,
min_selection=1 min_selection=1,
) )
def fit_to_width(self): def fit_to_width(self):
"""Fit selected element to page width""" """Fit selected element to page width"""
@ -114,7 +114,7 @@ class SizeOperationsMixin:
tab="Arrange", tab="Arrange",
group="Size", group="Size",
requires_selection=True, requires_selection=True,
min_selection=1 min_selection=1,
) )
def fit_to_height(self): def fit_to_height(self):
"""Fit selected element to page height""" """Fit selected element to page height"""
@ -145,7 +145,7 @@ class SizeOperationsMixin:
tab="Arrange", tab="Arrange",
group="Size", group="Size",
requires_selection=True, requires_selection=True,
min_selection=1 min_selection=1,
) )
def fit_to_page(self): def fit_to_page(self):
"""Fit selected element to page dimensions""" """Fit selected element to page dimensions"""
@ -176,7 +176,7 @@ class SizeOperationsMixin:
tab="Arrange", tab="Arrange",
group="Size", group="Size",
requires_selection=True, requires_selection=True,
min_selection=1 min_selection=1,
) )
def expand_image(self): def expand_image(self):
"""Expand selected image to fill available space""" """Expand selected image to fill available space"""
@ -195,16 +195,11 @@ class SizeOperationsMixin:
other_elements = [e for e in page.layout.elements if e is not element] other_elements = [e for e in page.layout.elements if e is not element]
# Use configurable min_gap (grid spacing from snapping system, default 10mm) # Use configurable min_gap (grid spacing from snapping system, default 10mm)
min_gap = getattr(page.layout.snapping_system, 'grid_spacing', 10.0) min_gap = getattr(page.layout.snapping_system, "grid_spacing", 10.0)
# Expand to bounds # Expand to bounds
page_width, page_height = page.layout.size page_width, page_height = page.layout.size
change = AlignmentManager.expand_to_bounds( change = AlignmentManager.expand_to_bounds(element, (page_width, page_height), other_elements, min_gap)
element,
(page_width, page_height),
other_elements,
min_gap
)
if change: if change:
cmd = ResizeElementsCommand([change]) cmd = ResizeElementsCommand([change])

View File

@ -3,82 +3,75 @@ Template operations mixin for pyPhotoAlbum
""" """
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QInputDialog, QDialog, QVBoxLayout, QLabel, QComboBox, QInputDialog,
QRadioButton, QButtonGroup, QPushButton, QHBoxLayout, QDialog,
QDoubleSpinBox QVBoxLayout,
QLabel,
QComboBox,
QRadioButton,
QButtonGroup,
QPushButton,
QHBoxLayout,
QDoubleSpinBox,
) )
from pyPhotoAlbum.decorators import ribbon_action, undoable_operation from pyPhotoAlbum.decorators import ribbon_action, undoable_operation
class TemplateOperationsMixin: class TemplateOperationsMixin:
"""Mixin providing template-related operations""" """Mixin providing template-related operations"""
@ribbon_action( @ribbon_action(
label="Save as Template", label="Save as Template",
tooltip="Save current page as a reusable template", tooltip="Save current page as a reusable template",
tab="Layout", tab="Layout",
group="Templates", group="Templates",
requires_page=True requires_page=True,
) )
def save_page_as_template(self): def save_page_as_template(self):
"""Save current page as a template""" """Save current page as a template"""
current_page = self.get_current_page() current_page = self.get_current_page()
if not current_page: if not current_page:
return return
# Check if page has any elements # Check if page has any elements
if not current_page.layout.elements: if not current_page.layout.elements:
self.show_warning("Empty Page", "Cannot save an empty page as a template.") self.show_warning("Empty Page", "Cannot save an empty page as a template.")
return return
# Ask for template name # Ask for template name
name, ok = QInputDialog.getText( name, ok = QInputDialog.getText(
self, self,
"Save Template", "Save Template",
"Enter template name:", "Enter template name:",
text=f"Template_{len(self.template_manager.list_templates()) + 1}" text=f"Template_{len(self.template_manager.list_templates()) + 1}",
) )
if not ok or not name: if not ok or not name:
return return
# Ask for optional description # Ask for optional description
description, ok = QInputDialog.getText( description, ok = QInputDialog.getText(self, "Template Description", "Enter description (optional):")
self,
"Template Description",
"Enter description (optional):"
)
if not ok: if not ok:
description = "" description = ""
try: try:
# Create template from page # Create template from page
template = self.template_manager.create_template_from_page( template = self.template_manager.create_template_from_page(current_page, name, description)
current_page,
name,
description
)
# Save template # Save template
self.template_manager.save_template(template) self.template_manager.save_template(template)
self.show_info( self.show_info("Template Saved", f"Template '{name}' has been saved successfully.")
"Template Saved",
f"Template '{name}' has been saved successfully."
)
print(f"Saved template: {name}") print(f"Saved template: {name}")
except Exception as e: except Exception as e:
self.show_error("Error", f"Failed to save template: {str(e)}") self.show_error("Error", f"Failed to save template: {str(e)}")
print(f"Error saving template: {e}") print(f"Error saving template: {e}")
@ribbon_action( @ribbon_action(
label="New from Template", label="New from Template", tooltip="Create a new page from a template", tab="Layout", group="Templates"
tooltip="Create a new page from a template",
tab="Layout",
group="Templates"
) )
def new_page_from_template(self): def new_page_from_template(self):
"""Create a new page from a template""" """Create a new page from a template"""
@ -87,8 +80,7 @@ class TemplateOperationsMixin:
if not templates: if not templates:
self.show_info( self.show_info(
"No Templates", "No Templates", "No templates available. Create a template first by using 'Save as Template'."
"No templates available. Create a template first by using 'Save as Template'."
) )
return return
@ -178,7 +170,7 @@ class TemplateOperationsMixin:
page_number=new_page_number, page_number=new_page_number,
target_size_mm=self.project.page_size_mm, target_size_mm=self.project.page_size_mm,
scale_mode=scale_mode, scale_mode=scale_mode,
margin_percent=margin_percent margin_percent=margin_percent,
) )
# Add to project # Add to project
@ -194,63 +186,62 @@ class TemplateOperationsMixin:
except Exception as e: except Exception as e:
self.show_error("Error", f"Failed to create page from template: {str(e)}") self.show_error("Error", f"Failed to create page from template: {str(e)}")
print(f"Error creating page from template: {e}") print(f"Error creating page from template: {e}")
@ribbon_action( @ribbon_action(
label="Apply Template", label="Apply Template",
tooltip="Apply a template layout to current page", tooltip="Apply a template layout to current page",
tab="Layout", tab="Layout",
group="Templates", group="Templates",
requires_page=True requires_page=True,
) )
@undoable_operation(capture='page_elements', description='Apply Template') @undoable_operation(capture="page_elements", description="Apply Template")
def apply_template_to_page(self): def apply_template_to_page(self):
"""Apply a template to the current page""" """Apply a template to the current page"""
current_page = self.get_current_page() current_page = self.get_current_page()
if not current_page: if not current_page:
return return
# Get available templates # Get available templates
templates = self.template_manager.list_templates() templates = self.template_manager.list_templates()
if not templates: if not templates:
self.show_info( self.show_info(
"No Templates", "No Templates", "No templates available. Create a template first by using 'Save as Template'."
"No templates available. Create a template first by using 'Save as Template'."
) )
return return
# Create dialog for template application options # Create dialog for template application options
dialog = QDialog(self) dialog = QDialog(self)
dialog.setWindowTitle("Apply Template") dialog.setWindowTitle("Apply Template")
dialog.setMinimumWidth(400) dialog.setMinimumWidth(400)
layout = QVBoxLayout() layout = QVBoxLayout()
# Template selection # Template selection
layout.addWidget(QLabel("Select Template:")) layout.addWidget(QLabel("Select Template:"))
template_combo = QComboBox() template_combo = QComboBox()
template_combo.addItems(templates) template_combo.addItems(templates)
layout.addWidget(template_combo) layout.addWidget(template_combo)
layout.addSpacing(10) layout.addSpacing(10)
# Mode selection # Mode selection
layout.addWidget(QLabel("Mode:")) layout.addWidget(QLabel("Mode:"))
mode_group = QButtonGroup(dialog) mode_group = QButtonGroup(dialog)
replace_radio = QRadioButton("Replace with placeholders") replace_radio = QRadioButton("Replace with placeholders")
replace_radio.setChecked(True) replace_radio.setChecked(True)
replace_radio.setToolTip("Clear page and add template placeholders") replace_radio.setToolTip("Clear page and add template placeholders")
mode_group.addButton(replace_radio, 0) mode_group.addButton(replace_radio, 0)
layout.addWidget(replace_radio) layout.addWidget(replace_radio)
reflow_radio = QRadioButton("Reflow existing content") reflow_radio = QRadioButton("Reflow existing content")
reflow_radio.setToolTip("Keep existing images and reposition to template slots") reflow_radio.setToolTip("Keep existing images and reposition to template slots")
mode_group.addButton(reflow_radio, 1) mode_group.addButton(reflow_radio, 1)
layout.addWidget(reflow_radio) layout.addWidget(reflow_radio)
layout.addSpacing(10) layout.addSpacing(10)
# Margin/Spacing percentage # Margin/Spacing percentage
layout.addWidget(QLabel("Margin/Spacing:")) layout.addWidget(QLabel("Margin/Spacing:"))
margin_layout = QHBoxLayout() margin_layout = QHBoxLayout()
@ -264,9 +255,9 @@ class TemplateOperationsMixin:
margin_layout.addWidget(margin_spinbox) margin_layout.addWidget(margin_spinbox)
margin_layout.addStretch() margin_layout.addStretch()
layout.addLayout(margin_layout) layout.addLayout(margin_layout)
layout.addSpacing(10) layout.addSpacing(10)
# Scaling selection # Scaling selection
layout.addWidget(QLabel("Scaling:")) layout.addWidget(QLabel("Scaling:"))
scale_group = QButtonGroup(dialog) scale_group = QButtonGroup(dialog)
@ -279,13 +270,13 @@ class TemplateOperationsMixin:
stretch_radio.setChecked(True) stretch_radio.setChecked(True)
scale_group.addButton(stretch_radio, 1) scale_group.addButton(stretch_radio, 1)
layout.addWidget(stretch_radio) layout.addWidget(stretch_radio)
center_radio = QRadioButton("Center (no scaling)") center_radio = QRadioButton("Center (no scaling)")
scale_group.addButton(center_radio, 2) scale_group.addButton(center_radio, 2)
layout.addWidget(center_radio) layout.addWidget(center_radio)
layout.addSpacing(20) layout.addSpacing(20)
# Buttons # Buttons
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
cancel_btn = QPushButton("Cancel") cancel_btn = QPushButton("Cancel")
@ -293,38 +284,34 @@ class TemplateOperationsMixin:
apply_btn = QPushButton("Apply") apply_btn = QPushButton("Apply")
apply_btn.clicked.connect(dialog.accept) apply_btn.clicked.connect(dialog.accept)
apply_btn.setDefault(True) apply_btn.setDefault(True)
button_layout.addStretch() button_layout.addStretch()
button_layout.addWidget(cancel_btn) button_layout.addWidget(cancel_btn)
button_layout.addWidget(apply_btn) button_layout.addWidget(apply_btn)
layout.addLayout(button_layout) layout.addLayout(button_layout)
dialog.setLayout(layout) dialog.setLayout(layout)
# Show dialog # Show dialog
if dialog.exec() != QDialog.DialogCode.Accepted: if dialog.exec() != QDialog.DialogCode.Accepted:
return return
# Get selections # Get selections
template_name = template_combo.currentText() template_name = template_combo.currentText()
mode_id = mode_group.checkedId() mode_id = mode_group.checkedId()
scale_id = scale_group.checkedId() scale_id = scale_group.checkedId()
margin_percent = margin_spinbox.value() margin_percent = margin_spinbox.value()
mode = "replace" if mode_id == 0 else "reflow" mode = "replace" if mode_id == 0 else "reflow"
scale_mode = ["proportional", "stretch", "center"][scale_id] scale_mode = ["proportional", "stretch", "center"][scale_id]
try: try:
# Load template # Load template
template = self.template_manager.load_template(template_name) template = self.template_manager.load_template(template_name)
# Apply template to page # Apply template to page
self.template_manager.apply_template_to_page( self.template_manager.apply_template_to_page(
template, template, current_page, mode=mode, scale_mode=scale_mode, margin_percent=margin_percent
current_page,
mode=mode,
scale_mode=scale_mode,
margin_percent=margin_percent
) )
# Update display # Update display
@ -332,7 +319,7 @@ class TemplateOperationsMixin:
self.show_status(f"Applied template '{template_name}' to current page", 3000) self.show_status(f"Applied template '{template_name}' to current page", 3000)
print(f"Applied template '{template_name}' with mode={mode}, scale_mode={scale_mode}") print(f"Applied template '{template_name}' with mode={mode}, scale_mode={scale_mode}")
except Exception as e: except Exception as e:
self.show_error("Error", f"Failed to apply template: {str(e)}") self.show_error("Error", f"Failed to apply template: {str(e)}")
print(f"Error applying template: {e}") print(f"Error applying template: {e}")

View File

@ -7,14 +7,8 @@ from pyPhotoAlbum.decorators import ribbon_action
class ViewOperationsMixin: class ViewOperationsMixin:
"""Mixin providing view-related operations""" """Mixin providing view-related operations"""
@ribbon_action( @ribbon_action(label="Zoom In", tooltip="Zoom in", tab="View", group="Zoom", shortcut="Ctrl++")
label="Zoom In",
tooltip="Zoom in",
tab="View",
group="Zoom",
shortcut="Ctrl++"
)
def zoom_in(self): def zoom_in(self):
"""Zoom in""" """Zoom in"""
self.gl_widget.zoom_level *= 1.2 self.gl_widget.zoom_level *= 1.2
@ -22,14 +16,8 @@ class ViewOperationsMixin:
self.gl_widget.zoom_level = 5.0 self.gl_widget.zoom_level = 5.0
self.update_view() self.update_view()
self.show_status(f"Zoom: {int(self.gl_widget.zoom_level * 100)}%", 2000) self.show_status(f"Zoom: {int(self.gl_widget.zoom_level * 100)}%", 2000)
@ribbon_action( @ribbon_action(label="Zoom Out", tooltip="Zoom out", tab="View", group="Zoom", shortcut="Ctrl+-")
label="Zoom Out",
tooltip="Zoom out",
tab="View",
group="Zoom",
shortcut="Ctrl+-"
)
def zoom_out(self): def zoom_out(self):
"""Zoom out""" """Zoom out"""
self.gl_widget.zoom_level /= 1.2 self.gl_widget.zoom_level /= 1.2
@ -37,47 +25,36 @@ class ViewOperationsMixin:
self.gl_widget.zoom_level = 0.1 self.gl_widget.zoom_level = 0.1
self.update_view() self.update_view()
self.show_status(f"Zoom: {int(self.gl_widget.zoom_level * 100)}%", 2000) self.show_status(f"Zoom: {int(self.gl_widget.zoom_level * 100)}%", 2000)
@ribbon_action( @ribbon_action(label="Fit to Window", tooltip="Fit page to window", tab="View", group="Zoom", shortcut="Ctrl+0")
label="Fit to Window",
tooltip="Fit page to window",
tab="View",
group="Zoom",
shortcut="Ctrl+0"
)
def zoom_fit(self): def zoom_fit(self):
"""Fit page to window""" """Fit page to window"""
if not self.project.pages: if not self.project.pages:
return return
current_page = self.project.pages[self.gl_widget.current_page_index] current_page = self.project.pages[self.gl_widget.current_page_index]
page_width_mm = current_page.layout.size[0] page_width_mm = current_page.layout.size[0]
page_height_mm = current_page.layout.size[1] page_height_mm = current_page.layout.size[1]
# Convert to pixels # Convert to pixels
dpi = self.project.working_dpi dpi = self.project.working_dpi
page_width_px = page_width_mm * dpi / 25.4 page_width_px = page_width_mm * dpi / 25.4
page_height_px = page_height_mm * dpi / 25.4 page_height_px = page_height_mm * dpi / 25.4
# Get widget size # Get widget size
widget_width = self.gl_widget.width() - 100 # Margins widget_width = self.gl_widget.width() - 100 # Margins
widget_height = self.gl_widget.height() - 100 widget_height = self.gl_widget.height() - 100
# Calculate zoom to fit # Calculate zoom to fit
zoom_w = widget_width / page_width_px zoom_w = widget_width / page_width_px
zoom_h = widget_height / page_height_px zoom_h = widget_height / page_height_px
self.gl_widget.zoom_level = min(zoom_w, zoom_h) self.gl_widget.zoom_level = min(zoom_w, zoom_h)
self.gl_widget.zoom_level = max(0.1, min(5.0, self.gl_widget.zoom_level)) self.gl_widget.zoom_level = max(0.1, min(5.0, self.gl_widget.zoom_level))
self.update_view() self.update_view()
self.show_status(f"Zoom: {int(self.gl_widget.zoom_level * 100)}%", 2000) self.show_status(f"Zoom: {int(self.gl_widget.zoom_level * 100)}%", 2000)
@ribbon_action( @ribbon_action(label="Toggle Grid Snap", tooltip="Toggle snapping to grid", tab="View", group="Snapping")
label="Toggle Grid Snap",
tooltip="Toggle snapping to grid",
tab="View",
group="Snapping"
)
def toggle_grid_snap(self): def toggle_grid_snap(self):
"""Toggle grid snapping""" """Toggle grid snapping"""
if not self.project: if not self.project:
@ -89,13 +66,8 @@ class ViewOperationsMixin:
self.update_view() self.update_view()
self.show_status(f"Grid snapping {status}", 2000) self.show_status(f"Grid snapping {status}", 2000)
print(f"Grid snapping {status}") print(f"Grid snapping {status}")
@ribbon_action( @ribbon_action(label="Toggle Edge Snap", tooltip="Toggle snapping to page edges", tab="View", group="Snapping")
label="Toggle Edge Snap",
tooltip="Toggle snapping to page edges",
tab="View",
group="Snapping"
)
def toggle_edge_snap(self): def toggle_edge_snap(self):
"""Toggle edge snapping""" """Toggle edge snapping"""
if not self.project: if not self.project:
@ -107,13 +79,8 @@ class ViewOperationsMixin:
self.update_view() self.update_view()
self.show_status(f"Edge snapping {status}", 2000) self.show_status(f"Edge snapping {status}", 2000)
print(f"Edge snapping {status}") print(f"Edge snapping {status}")
@ribbon_action( @ribbon_action(label="Toggle Guide Snap", tooltip="Toggle snapping to guides", tab="View", group="Snapping")
label="Toggle Guide Snap",
tooltip="Toggle snapping to guides",
tab="View",
group="Snapping"
)
def toggle_guide_snap(self): def toggle_guide_snap(self):
"""Toggle guide snapping""" """Toggle guide snapping"""
if not self.project: if not self.project:
@ -125,13 +92,8 @@ class ViewOperationsMixin:
self.update_view() self.update_view()
self.show_status(f"Guide snapping {status}", 2000) self.show_status(f"Guide snapping {status}", 2000)
print(f"Guide snapping {status}") print(f"Guide snapping {status}")
@ribbon_action( @ribbon_action(label="Show Grid", tooltip="Toggle visibility of grid lines", tab="View", group="Snapping")
label="Show Grid",
tooltip="Toggle visibility of grid lines",
tab="View",
group="Snapping"
)
def toggle_show_grid(self): def toggle_show_grid(self):
"""Toggle grid visibility""" """Toggle grid visibility"""
if not self.project: if not self.project:
@ -144,12 +106,7 @@ class ViewOperationsMixin:
self.show_status(f"Grid {status}", 2000) self.show_status(f"Grid {status}", 2000)
print(f"Grid {status}") print(f"Grid {status}")
@ribbon_action( @ribbon_action(label="Show Guides", tooltip="Toggle visibility of guide lines", tab="View", group="Snapping")
label="Show Guides",
tooltip="Toggle visibility of guide lines",
tab="View",
group="Snapping"
)
def toggle_snap_lines(self): def toggle_snap_lines(self):
"""Toggle guide lines visibility""" """Toggle guide lines visibility"""
if not self.project: if not self.project:
@ -161,71 +118,53 @@ class ViewOperationsMixin:
self.update_view() self.update_view()
self.show_status(f"Guides {status}", 2000) self.show_status(f"Guides {status}", 2000)
print(f"Guides {status}") print(f"Guides {status}")
@ribbon_action( @ribbon_action(label="Add H Guide", tooltip="Add horizontal guide at page center", tab="View", group="Guides")
label="Add H Guide",
tooltip="Add horizontal guide at page center",
tab="View",
group="Guides"
)
def add_horizontal_guide(self): def add_horizontal_guide(self):
"""Add a horizontal guide at page center""" """Add a horizontal guide at page center"""
current_page = self.get_current_page() current_page = self.get_current_page()
if not current_page: if not current_page:
return return
# Add guide at vertical center (in mm) # Add guide at vertical center (in mm)
center_y = current_page.layout.size[1] / 2.0 center_y = current_page.layout.size[1] / 2.0
current_page.layout.snapping_system.add_guide(center_y, 'horizontal') current_page.layout.snapping_system.add_guide(center_y, "horizontal")
self.update_view() self.update_view()
self.show_status(f"Added horizontal guide at {center_y:.1f} mm", 2000) self.show_status(f"Added horizontal guide at {center_y:.1f} mm", 2000)
print(f"Added horizontal guide at {center_y:.1f} mm") print(f"Added horizontal guide at {center_y:.1f} mm")
@ribbon_action( @ribbon_action(label="Add V Guide", tooltip="Add vertical guide at page center", tab="View", group="Guides")
label="Add V Guide",
tooltip="Add vertical guide at page center",
tab="View",
group="Guides"
)
def add_vertical_guide(self): def add_vertical_guide(self):
"""Add a vertical guide at page center""" """Add a vertical guide at page center"""
current_page = self.get_current_page() current_page = self.get_current_page()
if not current_page: if not current_page:
return return
# Add guide at horizontal center (in mm) # Add guide at horizontal center (in mm)
center_x = current_page.layout.size[0] / 2.0 center_x = current_page.layout.size[0] / 2.0
current_page.layout.snapping_system.add_guide(center_x, 'vertical') current_page.layout.snapping_system.add_guide(center_x, "vertical")
self.update_view() self.update_view()
self.show_status(f"Added vertical guide at {center_x:.1f} mm", 2000) self.show_status(f"Added vertical guide at {center_x:.1f} mm", 2000)
print(f"Added vertical guide at {center_x:.1f} mm") print(f"Added vertical guide at {center_x:.1f} mm")
@ribbon_action( @ribbon_action(label="Clear Guides", tooltip="Clear all guides from current page", tab="View", group="Guides")
label="Clear Guides",
tooltip="Clear all guides from current page",
tab="View",
group="Guides"
)
def clear_guides(self): def clear_guides(self):
"""Clear all guides from current page""" """Clear all guides from current page"""
current_page = self.get_current_page() current_page = self.get_current_page()
if not current_page: if not current_page:
return return
guide_count = len(current_page.layout.snapping_system.guides) guide_count = len(current_page.layout.snapping_system.guides)
current_page.layout.snapping_system.clear_guides() current_page.layout.snapping_system.clear_guides()
self.update_view() self.update_view()
self.show_status(f"Cleared {guide_count} guides", 2000) self.show_status(f"Cleared {guide_count} guides", 2000)
print(f"Cleared {guide_count} guides") print(f"Cleared {guide_count} guides")
@ribbon_action( @ribbon_action(
label="Set Grid Size...", label="Set Grid Size...", tooltip="Configure grid spacing for snapping", tab="View", group="Snapping"
tooltip="Configure grid spacing for snapping",
tab="View",
group="Snapping"
) )
def set_grid_size(self): def set_grid_size(self):
"""Open dialog to set grid size""" """Open dialog to set grid size"""
@ -295,67 +234,49 @@ class ViewOperationsMixin:
self.update_view() self.update_view()
self.show_status(f"Grid size: {new_grid_size:.1f}mm, Threshold: {new_threshold:.1f}mm", 2000) self.show_status(f"Grid size: {new_grid_size:.1f}mm, Threshold: {new_threshold:.1f}mm", 2000)
print(f"Updated grid settings - Size: {new_grid_size:.1f}mm, Threshold: {new_threshold:.1f}mm") print(f"Updated grid settings - Size: {new_grid_size:.1f}mm, Threshold: {new_threshold:.1f}mm")
# ===== Layout Tab Snapping Controls ===== # ===== Layout Tab Snapping Controls =====
# These provide easy access to snapping features during layout work # These provide easy access to snapping features during layout work
@ribbon_action( @ribbon_action(
label="Grid Snap", label="Grid Snap",
tooltip="Enable/disable snapping to grid (Ctrl+G)", tooltip="Enable/disable snapping to grid (Ctrl+G)",
tab="Layout", tab="Layout",
group="Snapping", group="Snapping",
shortcut="Ctrl+G" shortcut="Ctrl+G",
) )
def layout_toggle_grid_snap(self): def layout_toggle_grid_snap(self):
"""Toggle grid snapping (Layout tab)""" """Toggle grid snapping (Layout tab)"""
self.toggle_grid_snap() self.toggle_grid_snap()
@ribbon_action( @ribbon_action(
label="Edge Snap", label="Edge Snap",
tooltip="Enable/disable snapping to page edges (Ctrl+E)", tooltip="Enable/disable snapping to page edges (Ctrl+E)",
tab="Layout", tab="Layout",
group="Snapping", group="Snapping",
shortcut="Ctrl+E" shortcut="Ctrl+E",
) )
def layout_toggle_edge_snap(self): def layout_toggle_edge_snap(self):
"""Toggle edge snapping (Layout tab)""" """Toggle edge snapping (Layout tab)"""
self.toggle_edge_snap() self.toggle_edge_snap()
@ribbon_action( @ribbon_action(label="Guide Snap", tooltip="Enable/disable snapping to guides", tab="Layout", group="Snapping")
label="Guide Snap",
tooltip="Enable/disable snapping to guides",
tab="Layout",
group="Snapping"
)
def layout_toggle_guide_snap(self): def layout_toggle_guide_snap(self):
"""Toggle guide snapping (Layout tab)""" """Toggle guide snapping (Layout tab)"""
self.toggle_guide_snap() self.toggle_guide_snap()
@ribbon_action( @ribbon_action(label="Show Grid", tooltip="Toggle visibility of grid lines", tab="Layout", group="Snapping")
label="Show Grid",
tooltip="Toggle visibility of grid lines",
tab="Layout",
group="Snapping"
)
def layout_toggle_show_grid(self): def layout_toggle_show_grid(self):
"""Toggle grid visibility (Layout tab)""" """Toggle grid visibility (Layout tab)"""
self.toggle_show_grid() self.toggle_show_grid()
@ribbon_action( @ribbon_action(label="Show Guides", tooltip="Toggle visibility of guide lines", tab="Layout", group="Snapping")
label="Show Guides",
tooltip="Toggle visibility of guide lines",
tab="Layout",
group="Snapping"
)
def layout_toggle_snap_lines(self): def layout_toggle_snap_lines(self):
"""Toggle guide lines visibility (Layout tab)""" """Toggle guide lines visibility (Layout tab)"""
self.toggle_snap_lines() self.toggle_snap_lines()
@ribbon_action( @ribbon_action(
label="Grid Settings...", label="Grid Settings...", tooltip="Configure grid size and snap threshold", tab="Layout", group="Snapping"
tooltip="Configure grid size and snap threshold",
tab="Layout",
group="Snapping"
) )
def layout_set_grid_size(self): def layout_set_grid_size(self):
"""Open grid settings dialog (Layout tab)""" """Open grid settings dialog (Layout tab)"""

View File

@ -8,159 +8,159 @@ from pyPhotoAlbum.commands import ChangeZOrderCommand
class ZOrderOperationsMixin: class ZOrderOperationsMixin:
"""Mixin providing z-order/layer control operations""" """Mixin providing z-order/layer control operations"""
@ribbon_action( @ribbon_action(
label="Bring to Front", label="Bring to Front",
tooltip="Bring selected element to front", tooltip="Bring selected element to front",
tab="Arrange", tab="Arrange",
group="Order", group="Order",
shortcut="Ctrl+Shift+]", shortcut="Ctrl+Shift+]",
requires_selection=True requires_selection=True,
) )
def bring_to_front(self): def bring_to_front(self):
"""Bring selected element to front (end of list)""" """Bring selected element to front (end of list)"""
if not self.gl_widget.selected_element: if not self.gl_widget.selected_element:
return return
current_page = self.get_current_page() current_page = self.get_current_page()
if not current_page: if not current_page:
return return
element = self.gl_widget.selected_element element = self.gl_widget.selected_element
elements = current_page.layout.elements elements = current_page.layout.elements
if element not in elements: if element not in elements:
return return
old_index = elements.index(element) old_index = elements.index(element)
new_index = len(elements) - 1 new_index = len(elements) - 1
if old_index == new_index: if old_index == new_index:
self.show_status("Element is already at front", 2000) self.show_status("Element is already at front", 2000)
return return
# Create and execute command # Create and execute command
cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index) cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index)
self.project.history.execute(cmd) self.project.history.execute(cmd)
self.update_view() self.update_view()
self.show_status("Brought element to front (Ctrl+Z to undo)", 2000) self.show_status("Brought element to front (Ctrl+Z to undo)", 2000)
print(f"Brought element to front: {old_index}{new_index}") print(f"Brought element to front: {old_index}{new_index}")
@ribbon_action( @ribbon_action(
label="Send to Back", label="Send to Back",
tooltip="Send selected element to back", tooltip="Send selected element to back",
tab="Arrange", tab="Arrange",
group="Order", group="Order",
shortcut="Ctrl+Shift+[", shortcut="Ctrl+Shift+[",
requires_selection=True requires_selection=True,
) )
def send_to_back(self): def send_to_back(self):
"""Send selected element to back (start of list)""" """Send selected element to back (start of list)"""
if not self.gl_widget.selected_element: if not self.gl_widget.selected_element:
return return
current_page = self.get_current_page() current_page = self.get_current_page()
if not current_page: if not current_page:
return return
element = self.gl_widget.selected_element element = self.gl_widget.selected_element
elements = current_page.layout.elements elements = current_page.layout.elements
if element not in elements: if element not in elements:
return return
old_index = elements.index(element) old_index = elements.index(element)
new_index = 0 new_index = 0
if old_index == new_index: if old_index == new_index:
self.show_status("Element is already at back", 2000) self.show_status("Element is already at back", 2000)
return return
# Create and execute command # Create and execute command
cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index) cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index)
self.project.history.execute(cmd) self.project.history.execute(cmd)
self.update_view() self.update_view()
self.show_status("Sent element to back (Ctrl+Z to undo)", 2000) self.show_status("Sent element to back (Ctrl+Z to undo)", 2000)
print(f"Sent element to back: {old_index}{new_index}") print(f"Sent element to back: {old_index}{new_index}")
@ribbon_action( @ribbon_action(
label="Bring Forward", label="Bring Forward",
tooltip="Bring selected element forward one layer", tooltip="Bring selected element forward one layer",
tab="Arrange", tab="Arrange",
group="Order", group="Order",
shortcut="Ctrl+]", shortcut="Ctrl+]",
requires_selection=True requires_selection=True,
) )
def bring_forward(self): def bring_forward(self):
"""Move selected element forward one position in list""" """Move selected element forward one position in list"""
if not self.gl_widget.selected_element: if not self.gl_widget.selected_element:
return return
current_page = self.get_current_page() current_page = self.get_current_page()
if not current_page: if not current_page:
return return
element = self.gl_widget.selected_element element = self.gl_widget.selected_element
elements = current_page.layout.elements elements = current_page.layout.elements
if element not in elements: if element not in elements:
return return
old_index = elements.index(element) old_index = elements.index(element)
new_index = old_index + 1 new_index = old_index + 1
if new_index >= len(elements): if new_index >= len(elements):
self.show_status("Element is already at front", 2000) self.show_status("Element is already at front", 2000)
return return
# Create and execute command # Create and execute command
cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index) cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index)
self.project.history.execute(cmd) self.project.history.execute(cmd)
self.update_view() self.update_view()
self.show_status("Brought element forward (Ctrl+Z to undo)", 2000) self.show_status("Brought element forward (Ctrl+Z to undo)", 2000)
print(f"Brought element forward: {old_index}{new_index}") print(f"Brought element forward: {old_index}{new_index}")
@ribbon_action( @ribbon_action(
label="Send Backward", label="Send Backward",
tooltip="Send selected element backward one layer", tooltip="Send selected element backward one layer",
tab="Arrange", tab="Arrange",
group="Order", group="Order",
shortcut="Ctrl+[", shortcut="Ctrl+[",
requires_selection=True requires_selection=True,
) )
def send_backward(self): def send_backward(self):
"""Move selected element backward one position in list""" """Move selected element backward one position in list"""
if not self.gl_widget.selected_element: if not self.gl_widget.selected_element:
return return
current_page = self.get_current_page() current_page = self.get_current_page()
if not current_page: if not current_page:
return return
element = self.gl_widget.selected_element element = self.gl_widget.selected_element
elements = current_page.layout.elements elements = current_page.layout.elements
if element not in elements: if element not in elements:
return return
old_index = elements.index(element) old_index = elements.index(element)
new_index = old_index - 1 new_index = old_index - 1
if new_index < 0: if new_index < 0:
self.show_status("Element is already at back", 2000) self.show_status("Element is already at back", 2000)
return return
# Create and execute command # Create and execute command
cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index) cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index)
self.project.history.execute(cmd) self.project.history.execute(cmd)
self.update_view() self.update_view()
self.show_status("Sent element backward (Ctrl+Z to undo)", 2000) self.show_status("Sent element backward (Ctrl+Z to undo)", 2000)
print(f"Sent element backward: {old_index}{new_index}") print(f"Sent element backward: {old_index}{new_index}")
@ribbon_action( @ribbon_action(
label="Swap Order", label="Swap Order",
tooltip="Swap z-order of two selected elements", tooltip="Swap z-order of two selected elements",
@ -168,21 +168,21 @@ class ZOrderOperationsMixin:
group="Order", group="Order",
shortcut="Ctrl+Shift+X", shortcut="Ctrl+Shift+X",
requires_selection=True, requires_selection=True,
min_selection=2 min_selection=2,
) )
def swap_order(self): def swap_order(self):
"""Swap the z-order of two selected elements""" """Swap the z-order of two selected elements"""
if len(self.gl_widget.selected_elements) != 2: if len(self.gl_widget.selected_elements) != 2:
self.show_status("Please select exactly 2 elements to swap", 2000) self.show_status("Please select exactly 2 elements to swap", 2000)
return return
current_page = self.get_current_page() current_page = self.get_current_page()
if not current_page: if not current_page:
return return
elements = current_page.layout.elements elements = current_page.layout.elements
selected = list(self.gl_widget.selected_elements) selected = list(self.gl_widget.selected_elements)
# Get indices of both elements # Get indices of both elements
try: try:
index1 = elements.index(selected[0]) index1 = elements.index(selected[0])
@ -190,10 +190,10 @@ class ZOrderOperationsMixin:
except ValueError: except ValueError:
self.show_status("Selected elements not found on current page", 2000) self.show_status("Selected elements not found on current page", 2000)
return return
# Swap them in the list # Swap them in the list
elements[index1], elements[index2] = elements[index2], elements[index1] elements[index1], elements[index2] = elements[index2], elements[index1]
self.update_view() self.update_view()
self.show_status(f"Swapped z-order of elements", 2000) self.show_status(f"Swapped z-order of elements", 2000)
print(f"Swapped elements at indices {index1} and {index2}") print(f"Swapped elements at indices {index1} and {index2}")

View File

@ -33,11 +33,11 @@ class PageNavigationMixin:
Returns: Returns:
Tuple of (page, page_index, renderer) or (None, -1, None) if no page at coordinates Tuple of (page, page_index, renderer) or (None, -1, None) if no page at coordinates
""" """
if not hasattr(self, '_page_renderers') or not self._page_renderers: if not hasattr(self, "_page_renderers") or not self._page_renderers:
return None, -1, None return None, -1, None
main_window = self.window() main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages: if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return None, -1, None return None, -1, None
# Check each page to find which one contains the coordinates # Check each page to find which one contains the coordinates
@ -57,7 +57,7 @@ class PageNavigationMixin:
List of tuples (page_type, page_or_ghost_data, y_offset) List of tuples (page_type, page_or_ghost_data, y_offset)
""" """
main_window = self.window() main_window = self.window()
if not hasattr(main_window, 'project'): if not hasattr(main_window, "project"):
return [] return []
dpi = main_window.project.working_dpi dpi = main_window.project.working_dpi
@ -77,7 +77,7 @@ class PageNavigationMixin:
# First, render cover if it exists # First, render cover if it exists
for page in main_window.project.pages: for page in main_window.project.pages:
if page.is_cover: if page.is_cover:
result.append(('page', page, current_y)) result.append(("page", page, current_y))
# Calculate cover height in pixels # Calculate cover height in pixels
page_height_mm = page.layout.size[1] page_height_mm = page.layout.size[1]
@ -91,7 +91,7 @@ class PageNavigationMixin:
layout_with_ghosts = main_window.project.calculate_page_layout_with_ghosts() layout_with_ghosts = main_window.project.calculate_page_layout_with_ghosts()
for page_type, page_obj, logical_pos in layout_with_ghosts: for page_type, page_obj, logical_pos in layout_with_ghosts:
if page_type == 'page': if page_type == "page":
# Regular page (single or double spread) # Regular page (single or double spread)
result.append((page_type, page_obj, current_y)) result.append((page_type, page_obj, current_y))
@ -103,7 +103,7 @@ class PageNavigationMixin:
# Move to next position (add height + spacing) # Move to next position (add height + spacing)
current_y += page_height_px + spacing_px current_y += page_height_px + spacing_px
elif page_type == 'ghost': elif page_type == "ghost":
# Ghost page - use default page size # Ghost page - use default page size
page_size_mm = main_window.project.page_size_mm page_size_mm = main_window.project.page_size_mm
from pyPhotoAlbum.models import GhostPageData from pyPhotoAlbum.models import GhostPageData
@ -131,11 +131,11 @@ class PageNavigationMixin:
Returns: Returns:
bool: True if a ghost page was clicked and a new page was created bool: True if a ghost page was clicked and a new page was created
""" """
if not hasattr(self, '_page_renderers'): if not hasattr(self, "_page_renderers"):
return False return False
main_window = self.window() main_window = self.window()
if not hasattr(main_window, 'project'): if not hasattr(main_window, "project"):
return False return False
# Get page positions which includes ghosts # Get page positions which includes ghosts
@ -144,7 +144,7 @@ class PageNavigationMixin:
# Check each position for ghost pages # Check each position for ghost pages
for idx, (page_type, page_or_ghost, y_offset) in enumerate(page_positions): for idx, (page_type, page_or_ghost, y_offset) in enumerate(page_positions):
# Skip non-ghost pages # Skip non-ghost pages
if page_type != 'ghost': if page_type != "ghost":
continue continue
ghost = page_or_ghost ghost = page_or_ghost
@ -156,20 +156,21 @@ class PageNavigationMixin:
screen_y = (y_offset * self.zoom_level) + self.pan_offset[1] screen_y = (y_offset * self.zoom_level) + self.pan_offset[1]
from pyPhotoAlbum.page_renderer import PageRenderer from pyPhotoAlbum.page_renderer import PageRenderer
renderer = PageRenderer( renderer = PageRenderer(
page_width_mm=ghost_width_mm, page_width_mm=ghost_width_mm,
page_height_mm=ghost_height_mm, page_height_mm=ghost_height_mm,
screen_x=screen_x, screen_x=screen_x,
screen_y=screen_y, screen_y=screen_y,
dpi=dpi, dpi=dpi,
zoom=self.zoom_level zoom=self.zoom_level,
) )
# Check if click is anywhere on the ghost page (entire page is clickable) # Check if click is anywhere on the ghost page (entire page is clickable)
if renderer.is_point_in_page(x, y): if renderer.is_point_in_page(x, y):
# User clicked the ghost page! # User clicked the ghost page!
# Calculate the insertion index (count real pages before this ghost in page_positions) # Calculate the insertion index (count real pages before this ghost in page_positions)
insert_index = sum(1 for i, (pt, _, _) in enumerate(page_positions) if i < idx and pt == 'page') insert_index = sum(1 for i, (pt, _, _) in enumerate(page_positions) if i < idx and pt == "page")
print(f"Ghost page clicked at index {insert_index} - inserting new page in place") print(f"Ghost page clicked at index {insert_index} - inserting new page in place")
@ -181,10 +182,9 @@ class PageNavigationMixin:
new_page_number = insert_index + 1 new_page_number = insert_index + 1
new_page = Page( new_page = Page(
layout=PageLayout( layout=PageLayout(
width=main_window.project.page_size_mm[0], width=main_window.project.page_size_mm[0], height=main_window.project.page_size_mm[1]
height=main_window.project.page_size_mm[1]
), ),
page_number=new_page_number page_number=new_page_number,
) )
# Insert the page at the correct position # Insert the page at the correct position
@ -209,10 +209,10 @@ class PageNavigationMixin:
y: Screen Y coordinate y: Screen Y coordinate
""" """
main_window = self.window() main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages: if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return return
if not hasattr(self, '_page_renderers') or not self._page_renderers: if not hasattr(self, "_page_renderers") or not self._page_renderers:
return return
# Get total page count (accounting for double spreads = 2 pages each) # Get total page count (accounting for double spreads = 2 pages each)
@ -228,7 +228,7 @@ class PageNavigationMixin:
if page.is_double_spread: if page.is_double_spread:
side = renderer.get_sub_page_at(x, is_facing_page=True) side = renderer.get_sub_page_at(x, is_facing_page=True)
page_nums = page.get_page_numbers() page_nums = page.get_page_numbers()
if side == 'left': if side == "left":
current_page_info = f"Page {page_nums[0]}" current_page_info = f"Page {page_nums[0]}"
else: else:
current_page_info = f"Page {page_nums[1]}" current_page_info = f"Page {page_nums[1]}"
@ -237,8 +237,10 @@ class PageNavigationMixin:
break break
# Update status bar # Update status bar
if hasattr(main_window, 'status_bar'): if hasattr(main_window, "status_bar"):
if current_page_info: if current_page_info:
main_window.status_bar.showMessage(f"{current_page_info} of {total_pages} | Zoom: {int(self.zoom_level * 100)}%") main_window.status_bar.showMessage(
f"{current_page_info} of {total_pages} | Zoom: {int(self.zoom_level * 100)}%"
)
else: else:
main_window.status_bar.showMessage(f"Total pages: {total_pages} | Zoom: {int(self.zoom_level * 100)}%") main_window.status_bar.showMessage(f"Total pages: {total_pages} | Zoom: {int(self.zoom_level * 100)}%")

View File

@ -26,7 +26,7 @@ class RenderingMixin:
glLoadIdentity() glLoadIdentity()
main_window = self.window() main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages: if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return return
# Set initial zoom and center the page if not done yet # Set initial zoom and center the page if not done yet
@ -37,7 +37,7 @@ class RenderingMixin:
# Update scrollbars now that we have content bounds # Update scrollbars now that we have content bounds
main_window = self.window() main_window = self.window()
if hasattr(main_window, 'update_scrollbars'): if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars() main_window.update_scrollbars()
dpi = main_window.project.working_dpi dpi = main_window.project.working_dpi
@ -55,7 +55,7 @@ class RenderingMixin:
for page_info in page_positions: for page_info in page_positions:
page_type, page_or_ghost, y_offset = page_info page_type, page_or_ghost, y_offset = page_info
if page_type == 'page': if page_type == "page":
page = page_or_ghost page = page_or_ghost
page_width_mm, page_height_mm = page.layout.size page_width_mm, page_height_mm = page.layout.size
@ -68,7 +68,7 @@ class RenderingMixin:
screen_x=screen_x, screen_x=screen_x,
screen_y=screen_y, screen_y=screen_y,
dpi=dpi, dpi=dpi,
zoom=self.zoom_level zoom=self.zoom_level,
) )
self._page_renderers.append((renderer, page)) self._page_renderers.append((renderer, page))
@ -79,7 +79,7 @@ class RenderingMixin:
page.layout.render(dpi=dpi, project=main_window.project) page.layout.render(dpi=dpi, project=main_window.project)
renderer.end_render() renderer.end_render()
elif page_type == 'ghost': elif page_type == "ghost":
ghost = page_or_ghost ghost = page_or_ghost
ghost_width_mm, ghost_height_mm = ghost.page_size ghost_width_mm, ghost_height_mm = ghost.page_size
@ -92,14 +92,14 @@ class RenderingMixin:
screen_x=screen_x, screen_x=screen_x,
screen_y=screen_y, screen_y=screen_y,
dpi=dpi, dpi=dpi,
zoom=self.zoom_level zoom=self.zoom_level,
) )
self._render_ghost_page(ghost, renderer) self._render_ghost_page(ghost, renderer)
# Update PageRenderer references for selected elements # Update PageRenderer references for selected elements
for element in self.selected_elements: for element in self.selected_elements:
if hasattr(element, '_parent_page'): if hasattr(element, "_parent_page"):
for renderer, page in self._page_renderers: for renderer, page in self._page_renderers:
if page is element._parent_page: if page is element._parent_page:
element._page_renderer = renderer element._page_renderer = renderer
@ -118,10 +118,10 @@ class RenderingMixin:
return return
main_window = self.window() main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages: if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return return
if not hasattr(element, '_page_renderer'): if not hasattr(element, "_page_renderer"):
return return
renderer = element._page_renderer renderer = element._page_renderer
@ -188,10 +188,10 @@ class RenderingMixin:
glEnd() glEnd()
else: else:
handles = [ handles = [
(x - handle_size/2, y - handle_size/2), (x - handle_size / 2, y - handle_size / 2),
(x + w - handle_size/2, y - handle_size/2), (x + w - handle_size / 2, y - handle_size / 2),
(x - handle_size/2, y + h - handle_size/2), (x - handle_size / 2, y + h - handle_size / 2),
(x + w - handle_size/2, y + h - handle_size/2), (x + w - handle_size / 2, y + h - handle_size / 2),
] ]
glColor3f(1.0, 1.0, 1.0) glColor3f(1.0, 1.0, 1.0)
@ -214,7 +214,7 @@ class RenderingMixin:
def _render_text_overlays(self): def _render_text_overlays(self):
"""Render text content for TextBoxData elements using QPainter overlay""" """Render text content for TextBoxData elements using QPainter overlay"""
if not hasattr(self, '_page_renderers') or not self._page_renderers: if not hasattr(self, "_page_renderers") or not self._page_renderers:
return return
painter = QPainter(self) painter = QPainter(self)
@ -236,12 +236,12 @@ class RenderingMixin:
screen_w = w * renderer.zoom screen_w = w * renderer.zoom
screen_h = h * renderer.zoom screen_h = h * renderer.zoom
font_family = element.font_settings.get('family', 'Arial') font_family = element.font_settings.get("family", "Arial")
font_size = int(element.font_settings.get('size', 12) * renderer.zoom) font_size = int(element.font_settings.get("size", 12) * renderer.zoom)
font = QFont(font_family, font_size) font = QFont(font_family, font_size)
painter.setFont(font) painter.setFont(font)
font_color = element.font_settings.get('color', (0, 0, 0)) font_color = element.font_settings.get("color", (0, 0, 0))
if all(isinstance(c, int) and c > 1 for c in font_color): if all(isinstance(c, int) and c > 1 for c in font_color):
color = QColor(*font_color) color = QColor(*font_color)
else: else:
@ -260,9 +260,9 @@ class RenderingMixin:
rect = QRectF(screen_x, screen_y, screen_w, screen_h) rect = QRectF(screen_x, screen_y, screen_w, screen_h)
alignment = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop alignment = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop
if element.alignment == 'center': if element.alignment == "center":
alignment = Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop alignment = Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop
elif element.alignment == 'right': elif element.alignment == "right":
alignment = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop alignment = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop
text_flags = Qt.TextFlag.TextWordWrap text_flags = Qt.TextFlag.TextWordWrap

View File

@ -61,7 +61,7 @@ class ViewportMixin:
# Update scrollbars when viewport size changes # Update scrollbars when viewport size changes
main_window = self.window() main_window = self.window()
if hasattr(main_window, 'update_scrollbars'): if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars() main_window.update_scrollbars()
def _calculate_fit_to_screen_zoom(self): def _calculate_fit_to_screen_zoom(self):
@ -72,7 +72,7 @@ class ViewportMixin:
float: Zoom level (1.0 = 100%, 0.5 = 50%, etc.) float: Zoom level (1.0 = 100%, 0.5 = 50%, etc.)
""" """
main_window = self.window() main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages: if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return 1.0 return 1.0
window_width = self.width() window_width = self.width()
@ -106,7 +106,7 @@ class ViewportMixin:
list: [x_offset, y_offset] to center the page list: [x_offset, y_offset] to center the page
""" """
main_window = self.window() main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages: if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return [0, 0] return [0, 0]
window_width = self.width() window_width = self.width()
@ -141,8 +141,8 @@ class ViewportMixin:
dict: {'min_x', 'max_x', 'min_y', 'max_y', 'width', 'height'} in pixels dict: {'min_x', 'max_x', 'min_y', 'max_y', 'width', 'height'} in pixels
""" """
main_window = self.window() main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages: if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return {'min_x': 0, 'max_x': 800, 'min_y': 0, 'max_y': 600, 'width': 800, 'height': 600} return {"min_x": 0, "max_x": 800, "min_y": 0, "max_y": 600, "width": 800, "height": 600}
dpi = main_window.project.working_dpi dpi = main_window.project.working_dpi
PAGE_MARGIN = 50 PAGE_MARGIN = 50
@ -167,12 +167,12 @@ class ViewportMixin:
total_height += PAGE_MARGIN total_height += PAGE_MARGIN
return { return {
'min_x': 0, "min_x": 0,
'max_x': total_width, "max_x": total_width,
'min_y': 0, "min_y": 0,
'max_y': total_height, "max_y": total_height,
'width': total_width, "width": total_width,
'height': total_height "height": total_height,
} }
def clamp_pan_offset(self): def clamp_pan_offset(self):
@ -188,7 +188,7 @@ class ViewportMixin:
when zooming on pages of different widths. when zooming on pages of different widths.
""" """
main_window = self.window() main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages: if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return return
viewport_width = self.width() viewport_width = self.width()
@ -200,7 +200,7 @@ class ViewportMixin:
# Vertical clamping # Vertical clamping
bounds = self.get_content_bounds() bounds = self.get_content_bounds()
content_height = bounds['height'] content_height = bounds["height"]
# Save original pan_offset[1] BEFORE clamping for page selection # Save original pan_offset[1] BEFORE clamping for page selection
# This prevents clamping from changing which page we think we're on # This prevents clamping from changing which page we think we're on

View File

@ -13,13 +13,39 @@ from PIL import Image
from pyPhotoAlbum.image_utils import apply_pil_rotation, calculate_center_crop_coords from pyPhotoAlbum.image_utils import apply_pil_rotation, calculate_center_crop_coords
from pyPhotoAlbum.gl_imports import ( from pyPhotoAlbum.gl_imports import (
GL_AVAILABLE, glBegin, glEnd, glVertex2f, glColor3f, glColor4f, GL_AVAILABLE,
GL_QUADS, GL_LINE_LOOP, glEnable, glDisable, GL_TEXTURE_2D, glBegin,
glBindTexture, glTexCoord2f, glTexParameteri, GL_TEXTURE_MIN_FILTER, glEnd,
GL_TEXTURE_MAG_FILTER, GL_LINEAR, glGenTextures, glTexImage2D, glVertex2f,
GL_RGBA, GL_UNSIGNED_BYTE, glDeleteTextures, glGetString, GL_VERSION, glColor3f,
glLineStipple, GL_LINE_STIPPLE, glPushMatrix, glPopMatrix, glColor4f,
glTranslatef, glRotatef, GL_BLEND, glBlendFunc, GL_SRC_ALPHA, 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, GL_ONE_MINUS_SRC_ALPHA,
) )
@ -48,10 +74,13 @@ def get_asset_search_paths() -> Tuple[Optional[str], List[str]]:
"""Get the current asset resolution context.""" """Get the current asset resolution context."""
return _primary_project_folder, _asset_search_paths return _primary_project_folder, _asset_search_paths
class BaseLayoutElement(ABC): class BaseLayoutElement(ABC):
"""Abstract base class for all layout elements""" """Abstract base class for all layout elements"""
def __init__(self, x: float = 0, y: float = 0, width: float = 100, height: float = 100, rotation: float = 0, z_index: int = 0): def __init__(
self, x: float = 0, y: float = 0, width: float = 100, height: float = 100, rotation: float = 0, z_index: int = 0
):
self.position = (x, y) self.position = (x, y)
self.size = (width, height) self.size = (width, height)
self.rotation = rotation self.rotation = rotation
@ -118,11 +147,17 @@ class BaseLayoutElement(ABC):
"""Deserialize from a dictionary""" """Deserialize from a dictionary"""
pass pass
class ImageData(BaseLayoutElement): class ImageData(BaseLayoutElement):
"""Class to store image data and properties""" """Class to store image data and properties"""
def __init__(self, image_path: str = "", crop_info: Optional[Tuple] = None, def __init__(
image_dimensions: Optional[Tuple[int, int]] = None, **kwargs): self,
image_path: str = "",
crop_info: Optional[Tuple] = None,
image_dimensions: Optional[Tuple[int, int]] = None,
**kwargs,
):
super().__init__(**kwargs) super().__init__(**kwargs)
self.image_path = image_path self.image_path = image_path
self.crop_info = crop_info or (0, 0, 1, 1) # Default: no crop self.crop_info = crop_info or (0, 0, 1, 1) # Default: no crop
@ -190,15 +225,15 @@ class ImageData(BaseLayoutElement):
# Create texture from pending image if one exists (deferred from async load) # Create texture from pending image if one exists (deferred from async load)
# Texture creation must happen during render when GL context is active # Texture creation must happen during render when GL context is active
if hasattr(self, '_pending_pil_image') and self._pending_pil_image is not None: if hasattr(self, "_pending_pil_image") and self._pending_pil_image is not None:
self._create_texture_from_pending_image() self._create_texture_from_pending_image()
# Use cached texture if available # Use cached texture if available
if hasattr(self, '_texture_id') and self._texture_id: if hasattr(self, "_texture_id") and self._texture_id:
texture_id = self._texture_id texture_id = self._texture_id
# Get image dimensions (from loaded texture or metadata) # Get image dimensions (from loaded texture or metadata)
if hasattr(self, '_img_width') and hasattr(self, '_img_height'): if hasattr(self, "_img_width") and hasattr(self, "_img_height"):
img_width, img_height = self._img_width, self._img_height img_width, img_height = self._img_width, self._img_height
elif self.image_dimensions: elif self.image_dimensions:
img_width, img_height = self.image_dimensions img_width, img_height = self.image_dimensions
@ -207,9 +242,7 @@ class ImageData(BaseLayoutElement):
img_width, img_height = int(w), int(h) img_width, img_height = int(w), int(h)
# Calculate texture coordinates for center crop with element's crop_info # Calculate texture coordinates for center crop with element's crop_info
tx_min, ty_min, tx_max, ty_max = calculate_center_crop_coords( tx_min, ty_min, tx_max, ty_max = calculate_center_crop_coords(img_width, img_height, w, h, self.crop_info)
img_width, img_height, w, h, self.crop_info
)
# Enable texturing and draw with crop # Enable texturing and draw with crop
glEnable(GL_TEXTURE_2D) glEnable(GL_TEXTURE_2D)
@ -217,14 +250,18 @@ class ImageData(BaseLayoutElement):
glColor4f(1.0, 1.0, 1.0, 1.0) # White color to show texture as-is glColor4f(1.0, 1.0, 1.0, 1.0) # White color to show texture as-is
glBegin(GL_QUADS) glBegin(GL_QUADS)
glTexCoord2f(tx_min, ty_min); glVertex2f(x, y) glTexCoord2f(tx_min, ty_min)
glTexCoord2f(tx_max, ty_min); glVertex2f(x + w, y) glVertex2f(x, y)
glTexCoord2f(tx_max, ty_max); glVertex2f(x + w, y + h) glTexCoord2f(tx_max, ty_min)
glTexCoord2f(tx_min, ty_max); glVertex2f(x, y + h) glVertex2f(x + w, y)
glTexCoord2f(tx_max, ty_max)
glVertex2f(x + w, y + h)
glTexCoord2f(tx_min, ty_max)
glVertex2f(x, y + h)
glEnd() glEnd()
glDisable(GL_TEXTURE_2D) glDisable(GL_TEXTURE_2D)
# If no image or loading failed, draw placeholder # If no image or loading failed, draw placeholder
if not texture_id: if not texture_id:
glColor3f(0.7, 0.85, 1.0) # Light blue glColor3f(0.7, 0.85, 1.0) # Light blue
@ -234,7 +271,7 @@ class ImageData(BaseLayoutElement):
glVertex2f(x + w, y + h) glVertex2f(x + w, y + h)
glVertex2f(x, y + h) glVertex2f(x, y + h)
glEnd() glEnd()
# Draw border # Draw border
glColor3f(0.0, 0.0, 0.0) # Black border glColor3f(0.0, 0.0, 0.0) # Black border
glBegin(GL_LINE_LOOP) glBegin(GL_LINE_LOOP)
@ -254,7 +291,7 @@ class ImageData(BaseLayoutElement):
"z_index": self.z_index, "z_index": self.z_index,
"image_path": self.image_path, "image_path": self.image_path,
"crop_info": self.crop_info, "crop_info": self.crop_info,
"pil_rotation_90": getattr(self, 'pil_rotation_90', 0) "pil_rotation_90": getattr(self, "pil_rotation_90", 0),
} }
# Include image dimensions metadata if available # Include image dimensions metadata if available
if self.image_dimensions: if self.image_dimensions:
@ -313,7 +350,7 @@ class ImageData(BaseLayoutElement):
logger.debug(f"ImageData: Async load completed for {self.image_path}, size: {pil_image.size}") logger.debug(f"ImageData: Async load completed for {self.image_path}, size: {pil_image.size}")
# 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:
pil_image = apply_pil_rotation(pil_image, self.pil_rotation_90) pil_image = apply_pil_rotation(pil_image, self.pil_rotation_90)
logger.debug(f"ImageData: Applied PIL rotation {self.pil_rotation_90 * 90}° to {self.image_path}") logger.debug(f"ImageData: Applied PIL rotation {self.pil_rotation_90 * 90}° to {self.image_path}")
@ -339,7 +376,7 @@ class ImageData(BaseLayoutElement):
Create OpenGL texture from pending PIL image. Create OpenGL texture from pending PIL image.
Called during render() when GL context is active. Called during render() when GL context is active.
""" """
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
try: try:
@ -355,11 +392,11 @@ class ImageData(BaseLayoutElement):
pil_image = self._pending_pil_image pil_image = self._pending_pil_image
# Ensure RGBA format for GL_RGBA texture (defensive check) # Ensure RGBA format for GL_RGBA texture (defensive check)
if pil_image.mode != 'RGBA': if pil_image.mode != "RGBA":
pil_image = pil_image.convert('RGBA') pil_image = pil_image.convert("RGBA")
# Delete old texture if it exists # Delete old texture if it exists
if hasattr(self, '_texture_id') and self._texture_id: if hasattr(self, "_texture_id") and self._texture_id:
glDeleteTextures([self._texture_id]) glDeleteTextures([self._texture_id])
# Create GPU texture from pre-processed PIL image # Create GPU texture from pre-processed PIL image
@ -369,8 +406,9 @@ class ImageData(BaseLayoutElement):
glBindTexture(GL_TEXTURE_2D, texture_id) glBindTexture(GL_TEXTURE_2D, texture_id)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, pil_image.width, pil_image.height, glTexImage2D(
0, GL_RGBA, GL_UNSIGNED_BYTE, img_data) GL_TEXTURE_2D, 0, GL_RGBA, pil_image.width, pil_image.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, img_data
)
# Cache texture # Cache texture
self._texture_id = texture_id self._texture_id = texture_id
@ -380,8 +418,8 @@ class ImageData(BaseLayoutElement):
self._pending_pil_image = None self._pending_pil_image = None
# Clear the warning flag if we successfully created the texture # Clear the warning flag if we successfully created the texture
if hasattr(self, '_gl_context_warned'): if hasattr(self, "_gl_context_warned"):
delattr(self, '_gl_context_warned') delattr(self, "_gl_context_warned")
logger.info(f"ImageData: Successfully created texture for {self.image_path}") logger.info(f"ImageData: Successfully created texture for {self.image_path}")
return True return True
@ -390,11 +428,13 @@ class ImageData(BaseLayoutElement):
error_str = str(e) error_str = str(e)
# Check if this is a GL context error (err 1282 = GL_INVALID_OPERATION) # Check if this is a GL context error (err 1282 = GL_INVALID_OPERATION)
# These are typically caused by no GL context being current # These are typically caused by no GL context being current
if 'GLError' in error_str and '1282' in error_str: if "GLError" in error_str and "1282" in error_str:
# GL context not ready - keep pending image and try again next render # GL context not ready - keep pending image and try again next render
# Don't spam the console with repeated messages # Don't spam the console with repeated messages
if not hasattr(self, '_gl_context_warned'): if not hasattr(self, "_gl_context_warned"):
logger.warning(f"ImageData: GL context error (1282) for {self.image_path}, will retry on next render") logger.warning(
f"ImageData: GL context error (1282) for {self.image_path}, will retry on next render"
)
self._gl_context_warned = True self._gl_context_warned = True
return False return False
else: else:
@ -415,6 +455,7 @@ class ImageData(BaseLayoutElement):
self._async_loading = False self._async_loading = False
self._async_load_requested = False self._async_load_requested = False
class PlaceholderData(BaseLayoutElement): class PlaceholderData(BaseLayoutElement):
"""Class to store placeholder data""" """Class to store placeholder data"""
@ -428,7 +469,7 @@ class PlaceholderData(BaseLayoutElement):
x, y = self.position x, y = self.position
w, h = self.size w, h = self.size
# Apply rotation if needed # Apply rotation if needed
if self.rotation != 0: if self.rotation != 0:
glPushMatrix() glPushMatrix()
@ -440,7 +481,7 @@ class PlaceholderData(BaseLayoutElement):
glTranslatef(-w / 2, -h / 2, 0) glTranslatef(-w / 2, -h / 2, 0)
# Now render at origin (rotation pivot is at element center) # Now render at origin (rotation pivot is at element center)
x, y = 0, 0 x, y = 0, 0
# Draw a light gray rectangle as placeholder background # Draw a light gray rectangle as placeholder background
glColor3f(0.9, 0.9, 0.9) # Light gray glColor3f(0.9, 0.9, 0.9) # Light gray
glBegin(GL_QUADS) glBegin(GL_QUADS)
@ -449,7 +490,7 @@ class PlaceholderData(BaseLayoutElement):
glVertex2f(x + w, y + h) glVertex2f(x + w, y + h)
glVertex2f(x, y + h) glVertex2f(x, y + h)
glEnd() glEnd()
# Draw dashed border for placeholder # Draw dashed border for placeholder
glEnable(GL_LINE_STIPPLE) glEnable(GL_LINE_STIPPLE)
glLineStipple(1, 0x00FF) # Dashed pattern glLineStipple(1, 0x00FF) # Dashed pattern
@ -461,7 +502,7 @@ class PlaceholderData(BaseLayoutElement):
glVertex2f(x, y + h) glVertex2f(x, y + h)
glEnd() glEnd()
glDisable(GL_LINE_STIPPLE) glDisable(GL_LINE_STIPPLE)
# Pop matrix if we pushed for rotation # Pop matrix if we pushed for rotation
if self.rotation != 0: if self.rotation != 0:
glPopMatrix() glPopMatrix()
@ -475,7 +516,7 @@ class PlaceholderData(BaseLayoutElement):
"rotation": self.rotation, "rotation": self.rotation,
"z_index": self.z_index, "z_index": self.z_index,
"placeholder_type": self.placeholder_type, "placeholder_type": self.placeholder_type,
"default_content": self.default_content "default_content": self.default_content,
} }
# Add base fields (v3.0+) # Add base fields (v3.0+)
data.update(self._serialize_base_fields()) data.update(self._serialize_base_fields())
@ -493,6 +534,7 @@ class PlaceholderData(BaseLayoutElement):
self.placeholder_type = data.get("placeholder_type", "image") self.placeholder_type = data.get("placeholder_type", "image")
self.default_content = data.get("default_content", "") self.default_content = data.get("default_content", "")
class TextBoxData(BaseLayoutElement): class TextBoxData(BaseLayoutElement):
"""Class to store text box data""" """Class to store text box data"""
@ -506,7 +548,7 @@ class TextBoxData(BaseLayoutElement):
"""Render the text box using OpenGL""" """Render the text box using OpenGL"""
x, y = self.position x, y = self.position
w, h = self.size w, h = self.size
# Apply rotation if needed # Apply rotation if needed
if self.rotation != 0: if self.rotation != 0:
glPushMatrix() glPushMatrix()
@ -518,11 +560,11 @@ class TextBoxData(BaseLayoutElement):
glTranslatef(-w / 2, -h / 2, 0) glTranslatef(-w / 2, -h / 2, 0)
# Now render at origin (rotation pivot is at element center) # Now render at origin (rotation pivot is at element center)
x, y = 0, 0 x, y = 0, 0
# Enable alpha blending for transparency # Enable alpha blending for transparency
glEnable(GL_BLEND) glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
# Draw a semi-transparent yellow rectangle as text box background # Draw a semi-transparent yellow rectangle as text box background
glColor4f(1.0, 1.0, 0.7, 0.3) # Light yellow with 30% opacity glColor4f(1.0, 1.0, 0.7, 0.3) # Light yellow with 30% opacity
glBegin(GL_QUADS) glBegin(GL_QUADS)
@ -531,9 +573,9 @@ class TextBoxData(BaseLayoutElement):
glVertex2f(x + w, y + h) glVertex2f(x + w, y + h)
glVertex2f(x, y + h) glVertex2f(x, y + h)
glEnd() glEnd()
glDisable(GL_BLEND) glDisable(GL_BLEND)
# Draw border # Draw border
glColor3f(0.0, 0.0, 0.0) # Black border glColor3f(0.0, 0.0, 0.0) # Black border
glBegin(GL_LINE_LOOP) glBegin(GL_LINE_LOOP)
@ -542,11 +584,11 @@ class TextBoxData(BaseLayoutElement):
glVertex2f(x + w, y + h) glVertex2f(x + w, y + h)
glVertex2f(x, y + h) glVertex2f(x, y + h)
glEnd() glEnd()
# Pop matrix if we pushed for rotation # Pop matrix if we pushed for rotation
if self.rotation != 0: if self.rotation != 0:
glPopMatrix() glPopMatrix()
# Note: Text content is rendered using QPainter overlay in GLWidget.paintGL() # Note: Text content is rendered using QPainter overlay in GLWidget.paintGL()
def serialize(self) -> Dict[str, Any]: def serialize(self) -> Dict[str, Any]:
@ -559,7 +601,7 @@ class TextBoxData(BaseLayoutElement):
"z_index": self.z_index, "z_index": self.z_index,
"text_content": self.text_content, "text_content": self.text_content,
"font_settings": self.font_settings, "font_settings": self.font_settings,
"alignment": self.alignment "alignment": self.alignment,
} }
# Add base fields (v3.0+) # Add base fields (v3.0+)
data.update(self._serialize_base_fields()) data.update(self._serialize_base_fields())
@ -578,6 +620,7 @@ class TextBoxData(BaseLayoutElement):
self.font_settings = data.get("font_settings", {"family": "Arial", "size": 12, "color": (0, 0, 0)}) self.font_settings = data.get("font_settings", {"family": "Arial", "size": 12, "color": (0, 0, 0)})
self.alignment = data.get("alignment", "left") self.alignment = data.get("alignment", "left")
class GhostPageData(BaseLayoutElement): class GhostPageData(BaseLayoutElement):
"""Class to represent a ghost page placeholder for alignment in double-page spreads""" """Class to represent a ghost page placeholder for alignment in double-page spreads"""
@ -592,17 +635,17 @@ class GhostPageData(BaseLayoutElement):
# 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
x, y = 0, 0 x, y = 0, 0
# Calculate dimensions from page_size (in mm) - assume 300 DPI for now # Calculate dimensions from page_size (in mm) - assume 300 DPI for now
# This will be overridden by proper size calculation in PageRenderer # This will be overridden by proper size calculation in PageRenderer
dpi = 300 # Default DPI for rendering dpi = 300 # Default DPI for rendering
w = self.page_size[0] * dpi / 25.4 w = self.page_size[0] * dpi / 25.4
h = self.page_size[1] * dpi / 25.4 h = self.page_size[1] * dpi / 25.4
# Enable alpha blending for transparency # Enable alpha blending for transparency
glEnable(GL_BLEND) glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
# Draw a light grey semi-transparent rectangle as ghost page background # Draw a light grey semi-transparent rectangle as ghost page background
glColor4f(0.8, 0.8, 0.8, 0.5) # Light grey with 50% opacity glColor4f(0.8, 0.8, 0.8, 0.5) # Light grey with 50% opacity
glBegin(GL_QUADS) glBegin(GL_QUADS)
@ -611,9 +654,9 @@ class GhostPageData(BaseLayoutElement):
glVertex2f(x + w, y + h) glVertex2f(x + w, y + h)
glVertex2f(x, y + h) glVertex2f(x, y + h)
glEnd() glEnd()
glDisable(GL_BLEND) glDisable(GL_BLEND)
# Draw dashed border # Draw dashed border
glEnable(GL_LINE_STIPPLE) glEnable(GL_LINE_STIPPLE)
glLineStipple(2, 0x0F0F) # Dashed pattern glLineStipple(2, 0x0F0F) # Dashed pattern
@ -625,7 +668,7 @@ class GhostPageData(BaseLayoutElement):
glVertex2f(x, y + h) glVertex2f(x, y + h)
glEnd() glEnd()
glDisable(GL_LINE_STIPPLE) glDisable(GL_LINE_STIPPLE)
# Note: "Click to Add Page" text is rendered using QPainter overlay in GLWidget # Note: "Click to Add Page" text is rendered using QPainter overlay in GLWidget
# The entire page is clickable # The entire page is clickable
@ -640,12 +683,7 @@ class GhostPageData(BaseLayoutElement):
def serialize(self) -> Dict[str, Any]: def serialize(self) -> Dict[str, Any]:
"""Serialize ghost page data to dictionary""" """Serialize ghost page data to dictionary"""
data = { data = {"type": "ghostpage", "position": self.position, "size": self.size, "page_size": self.page_size}
"type": "ghostpage",
"position": self.position,
"size": self.size,
"page_size": self.page_size
}
# Add base fields (v3.0+) # Add base fields (v3.0+)
data.update(self._serialize_base_fields()) data.update(self._serialize_base_fields())
return data return data

View File

@ -6,19 +6,32 @@ 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 ( from pyPhotoAlbum.gl_imports import (
glBegin, glEnd, glVertex2f, glColor3f, glColor4f, glBegin,
GL_QUADS, GL_LINE_LOOP, GL_LINES, glLineWidth, glEnd,
glEnable, glDisable, GL_DEPTH_TEST, GL_BLEND, glVertex2f,
glBlendFunc, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, 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"""
def __init__(self, width: float = 210, height: float = 297, is_facing_page: bool = False): def __init__(self, width: float = 210, height: float = 297, is_facing_page: bool = False):
""" """
Initialize page layout. Initialize page layout.
Args: Args:
width: Width in mm (doubled automatically if is_facing_page=True) width: Width in mm (doubled automatically if is_facing_page=True)
height: Height in mm height: Height in mm
@ -41,7 +54,7 @@ class PageLayout:
"""Remove a layout element from the page""" """Remove a layout element from the page"""
self.elements.remove(element) self.elements.remove(element)
def set_grid_layout(self, grid: 'GridLayout'): def set_grid_layout(self, grid: "GridLayout"):
"""Set a grid layout for the page""" """Set a grid layout for the page"""
self.grid_layout = grid self.grid_layout = grid
@ -58,15 +71,15 @@ class PageLayout:
""" """
# Disable depth testing for 2D rendering # Disable depth testing for 2D rendering
glDisable(GL_DEPTH_TEST) glDisable(GL_DEPTH_TEST)
# Convert size from mm to pixels based on DPI # Convert size from mm to pixels based on DPI
width_px = self.size[0] * dpi / 25.4 width_px = self.size[0] * dpi / 25.4
height_px = self.size[1] * dpi / 25.4 height_px = self.size[1] * dpi / 25.4
# All rendering is at page origin (0, 0) in page-local coordinates # All rendering is at page origin (0, 0) in page-local coordinates
page_x = 0 page_x = 0
page_y = 0 page_y = 0
# Draw drop shadow FIRST (behind everything) # Draw drop shadow FIRST (behind everything)
shadow_offset = 5 shadow_offset = 5
glColor3f(0.5, 0.5, 0.5) glColor3f(0.5, 0.5, 0.5)
@ -76,14 +89,14 @@ class PageLayout:
glVertex2f(page_x + width_px + shadow_offset, page_y + height_px + shadow_offset) glVertex2f(page_x + width_px + shadow_offset, page_y + height_px + shadow_offset)
glVertex2f(page_x + shadow_offset, page_y + height_px + shadow_offset) glVertex2f(page_x + shadow_offset, page_y + height_px + shadow_offset)
glEnd() glEnd()
glBegin(GL_QUADS) glBegin(GL_QUADS)
glVertex2f(page_x + width_px, page_y + shadow_offset) glVertex2f(page_x + width_px, page_y + shadow_offset)
glVertex2f(page_x + width_px + shadow_offset, page_y + shadow_offset) glVertex2f(page_x + width_px + shadow_offset, page_y + shadow_offset)
glVertex2f(page_x + width_px + shadow_offset, page_y + height_px) glVertex2f(page_x + width_px + shadow_offset, page_y + height_px)
glVertex2f(page_x + width_px, page_y + height_px) glVertex2f(page_x + width_px, page_y + height_px)
glEnd() glEnd()
# Draw page background (slightly off-white to distinguish from canvas) # Draw page background (slightly off-white to distinguish from canvas)
glColor3f(0.98, 0.98, 0.98) glColor3f(0.98, 0.98, 0.98)
glBegin(GL_QUADS) glBegin(GL_QUADS)
@ -97,11 +110,11 @@ class PageLayout:
# For ImageData elements, request async loading if available # For ImageData elements, request async loading if available
for element in self.elements: for element in self.elements:
# Check if this is an ImageData element that needs async loading # Check if this is an ImageData element that needs async loading
if isinstance(element, ImageData) and not hasattr(element, '_texture_id'): if isinstance(element, ImageData) and not hasattr(element, "_texture_id"):
# Try to get async loader from a parent widget # Try to get async loader from a parent widget
if hasattr(self, '_async_loader'): if hasattr(self, "_async_loader"):
loader = self._async_loader loader = self._async_loader
elif hasattr(self, '_parent_widget') and hasattr(self._parent_widget, 'async_image_loader'): elif hasattr(self, "_parent_widget") and hasattr(self._parent_widget, "async_image_loader"):
loader = self._parent_widget.async_image_loader loader = self._parent_widget.async_image_loader
else: else:
loader = None loader = None
@ -109,14 +122,15 @@ class PageLayout:
# Request async load if loader is available and not already requested # Request async load if loader is available and not already requested
if loader and not element._async_load_requested: if loader and not element._async_load_requested:
from pyPhotoAlbum.async_backend import LoadPriority from pyPhotoAlbum.async_backend import LoadPriority
# Determine priority based on visibility (HIGH for now, can be refined) # Determine priority based on visibility (HIGH for now, can be refined)
if hasattr(self._parent_widget, 'request_image_load'): if hasattr(self._parent_widget, "request_image_load"):
self._parent_widget.request_image_load(element, priority=LoadPriority.HIGH) self._parent_widget.request_image_load(element, priority=LoadPriority.HIGH)
element._async_load_requested = True element._async_load_requested = True
element._async_loading = True element._async_loading = True
element.render() element.render()
# Draw page border LAST (on top of everything) # Draw page border LAST (on top of everything)
glColor3f(0.7, 0.7, 0.7) glColor3f(0.7, 0.7, 0.7)
glLineWidth(2.0) glLineWidth(2.0)
@ -127,7 +141,7 @@ class PageLayout:
glVertex2f(page_x, page_y + height_px) glVertex2f(page_x, page_y + height_px)
glEnd() glEnd()
glLineWidth(1.0) glLineWidth(1.0)
# Draw center line for facing pages # Draw center line for facing pages
if self.is_facing_page: if self.is_facing_page:
center_x = page_x + (width_px / 2) center_x = page_x + (width_px / 2)
@ -138,13 +152,13 @@ class PageLayout:
glVertex2f(center_x, page_y + height_px) glVertex2f(center_x, page_y + height_px)
glEnd() glEnd()
glLineWidth(1.0) glLineWidth(1.0)
# Always render snap lines (grid shows when show_grid is on, guides show when show_snap_lines is on) # Always render snap lines (grid shows when show_grid is on, guides show when show_snap_lines is on)
self._render_snap_lines(dpi, page_x, page_y, project) self._render_snap_lines(dpi, page_x, page_y, project)
# Re-enable depth testing # Re-enable depth testing
glEnable(GL_DEPTH_TEST) glEnable(GL_DEPTH_TEST)
def _render_snap_lines(self, dpi: int, page_x: float, page_y: float, 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)"""
# Use project settings if available, otherwise fall back to local snapping_system # Use project settings if available, otherwise fall back to local snapping_system
@ -169,6 +183,7 @@ class PageLayout:
# Create a temporary snapping system with project settings to get snap lines # Create a temporary snapping system with project settings to get snap lines
from pyPhotoAlbum.snapping import SnappingSystem from pyPhotoAlbum.snapping import SnappingSystem
temp_snap_sys = SnappingSystem(snap_threshold_mm=snap_threshold_mm) temp_snap_sys = SnappingSystem(snap_threshold_mm=snap_threshold_mm)
temp_snap_sys.grid_size_mm = grid_size_mm temp_snap_sys.grid_size_mm = grid_size_mm
temp_snap_sys.snap_to_grid = snap_to_grid temp_snap_sys.snap_to_grid = snap_to_grid
@ -183,12 +198,12 @@ class PageLayout:
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
# Draw grid lines (darker gray with transparency) - visible when show_grid is enabled # Draw grid lines (darker gray with transparency) - visible when show_grid is enabled
if show_grid and snap_lines['grid']: if show_grid and snap_lines["grid"]:
glColor4f(0.6, 0.6, 0.6, 0.4) # Gray with 40% opacity glColor4f(0.6, 0.6, 0.6, 0.4) # Gray with 40% opacity
glLineWidth(1.0) glLineWidth(1.0)
for orientation, position in snap_lines['grid']: for orientation, position in snap_lines["grid"]:
glBegin(GL_LINES) glBegin(GL_LINES)
if orientation == 'vertical': if orientation == "vertical":
glVertex2f(page_x + position, page_y) glVertex2f(page_x + position, page_y)
glVertex2f(page_x + position, page_y + self.size[1] * dpi / 25.4) glVertex2f(page_x + position, page_y + self.size[1] * dpi / 25.4)
else: # horizontal else: # horizontal
@ -197,12 +212,12 @@ class PageLayout:
glEnd() glEnd()
# Draw guides (cyan, more visible with transparency) - only show when show_snap_lines is on # Draw guides (cyan, more visible with transparency) - only show when show_snap_lines is on
if show_snap_lines and snap_lines['guides']: if show_snap_lines and snap_lines["guides"]:
glColor4f(0.0, 0.7, 0.9, 0.8) # Cyan with 80% opacity glColor4f(0.0, 0.7, 0.9, 0.8) # Cyan with 80% opacity
glLineWidth(1.5) glLineWidth(1.5)
for orientation, position in snap_lines['guides']: for orientation, position in snap_lines["guides"]:
glBegin(GL_LINES) glBegin(GL_LINES)
if orientation == 'vertical': if orientation == "vertical":
glVertex2f(page_x + position, page_y) glVertex2f(page_x + position, page_y)
glVertex2f(page_x + position, page_y + self.size[1] * dpi / 25.4) glVertex2f(page_x + position, page_y + self.size[1] * dpi / 25.4)
else: # horizontal else: # horizontal
@ -223,7 +238,7 @@ class PageLayout:
"elements": [elem.serialize() for elem in self.elements], "elements": [elem.serialize() for elem in self.elements],
"grid_layout": self.grid_layout.serialize() if self.grid_layout else None, "grid_layout": self.grid_layout.serialize() if self.grid_layout else None,
"snapping_system": self.snapping_system.serialize(), "snapping_system": self.snapping_system.serialize(),
"show_snap_lines": self.show_snap_lines "show_snap_lines": self.show_snap_lines,
} }
def deserialize(self, data: Dict[str, Any]): def deserialize(self, data: Dict[str, Any]):
@ -250,7 +265,7 @@ class PageLayout:
elem.deserialize(elem_data) elem.deserialize(elem_data)
elem_list.append(elem) elem_list.append(elem)
# Sort by z_index to establish proper list order (lower z_index = earlier in list = behind) # Sort by z_index to establish proper list order (lower z_index = earlier in list = behind)
elem_list.sort(key=lambda e: e.z_index) elem_list.sort(key=lambda e: e.z_index)
self.elements = elem_list self.elements = elem_list
@ -260,14 +275,15 @@ class PageLayout:
if grid_data: if grid_data:
self.grid_layout = GridLayout() self.grid_layout = GridLayout()
self.grid_layout.deserialize(grid_data) self.grid_layout.deserialize(grid_data)
# Deserialize snapping system # Deserialize snapping system
snap_data = data.get("snapping_system") snap_data = data.get("snapping_system")
if snap_data: if snap_data:
self.snapping_system.deserialize(snap_data) self.snapping_system.deserialize(snap_data)
self.show_snap_lines = data.get("show_snap_lines", True) self.show_snap_lines = data.get("show_snap_lines", True)
class GridLayout: class GridLayout:
"""Class to manage grid layouts""" """Class to manage grid layouts"""
@ -281,7 +297,9 @@ class GridLayout:
"""Merge cells in the grid""" """Merge cells in the grid"""
self.merged_cells.append((row, col)) self.merged_cells.append((row, col))
def get_cell_position(self, row: int, col: int, page_width: float = 800, page_height: float = 600) -> Tuple[float, float]: def get_cell_position(
self, row: int, col: int, page_width: float = 800, page_height: float = 600
) -> Tuple[float, float]:
"""Get the position of a grid cell""" """Get the position of a grid cell"""
cell_width = (page_width - (self.spacing * (self.columns + 1))) / self.columns cell_width = (page_width - (self.spacing * (self.columns + 1))) / self.columns
cell_height = (page_height - (self.spacing * (self.rows + 1))) / self.rows cell_height = (page_height - (self.spacing * (self.rows + 1))) / self.rows
@ -300,12 +318,7 @@ class GridLayout:
def serialize(self) -> Dict[str, Any]: def serialize(self) -> Dict[str, Any]:
"""Serialize grid layout to dictionary""" """Serialize grid layout to dictionary"""
return { return {"rows": self.rows, "columns": self.columns, "spacing": self.spacing, "merged_cells": self.merged_cells}
"rows": self.rows,
"columns": self.columns,
"spacing": self.spacing,
"merged_cells": self.merged_cells
}
def deserialize(self, data: Dict[str, Any]): def deserialize(self, data: Dict[str, Any]):
"""Deserialize from dictionary""" """Deserialize from dictionary"""

View File

@ -17,21 +17,17 @@ from pyPhotoAlbum.gl_imports import glPushMatrix, glPopMatrix, glScalef, glTrans
class PageRenderer: class PageRenderer:
""" """
Handles rendering and coordinate transformations for a single page. Handles rendering and coordinate transformations for a single page.
This class encapsulates all coordinate transformations needed to render This class encapsulates all coordinate transformations needed to render
a page and its elements consistently. a page and its elements consistently.
""" """
def __init__(self, def __init__(
page_width_mm: float, self, page_width_mm: float, page_height_mm: float, screen_x: float, screen_y: float, dpi: int, zoom: float
page_height_mm: float, ):
screen_x: float,
screen_y: float,
dpi: int,
zoom: float):
""" """
Initialize a page renderer. Initialize a page renderer.
Args: Args:
page_width_mm: Page width in millimeters page_width_mm: Page width in millimeters
page_height_mm: Page height in millimeters page_height_mm: Page height in millimeters
@ -46,81 +42,83 @@ class PageRenderer:
self.screen_y = screen_y self.screen_y = screen_y
self.dpi = dpi self.dpi = dpi
self.zoom = zoom self.zoom = zoom
# Calculate page dimensions in pixels # Calculate page dimensions in pixels
self.page_width_px = page_width_mm * dpi / 25.4 self.page_width_px = page_width_mm * dpi / 25.4
self.page_height_px = page_height_mm * dpi / 25.4 self.page_height_px = page_height_mm * dpi / 25.4
# Calculate screen dimensions (with zoom applied) # Calculate screen dimensions (with zoom applied)
self.screen_width = self.page_width_px * zoom self.screen_width = self.page_width_px * zoom
self.screen_height = self.page_height_px * zoom self.screen_height = self.page_height_px * zoom
def page_to_screen(self, page_x: float, page_y: float) -> Tuple[float, float]: def page_to_screen(self, page_x: float, page_y: float) -> Tuple[float, float]:
""" """
Convert page-local coordinates (in pixels) to screen coordinates. Convert page-local coordinates (in pixels) to screen coordinates.
Args: Args:
page_x: X coordinate in page-local space (pixels) page_x: X coordinate in page-local space (pixels)
page_y: Y coordinate in page-local space (pixels) page_y: Y coordinate in page-local space (pixels)
Returns: Returns:
Tuple of (screen_x, screen_y) Tuple of (screen_x, screen_y)
""" """
screen_x = self.screen_x + page_x * self.zoom screen_x = self.screen_x + page_x * self.zoom
screen_y = self.screen_y + page_y * self.zoom screen_y = self.screen_y + page_y * self.zoom
return (screen_x, screen_y) return (screen_x, screen_y)
def screen_to_page(self, screen_x: float, screen_y: float) -> Tuple[float, float]: def screen_to_page(self, screen_x: float, screen_y: float) -> Tuple[float, float]:
""" """
Convert screen coordinates to page-local coordinates (in pixels). Convert screen coordinates to page-local coordinates (in pixels).
Args: Args:
screen_x: X coordinate in screen space screen_x: X coordinate in screen space
screen_y: Y coordinate in screen space screen_y: Y coordinate in screen space
Returns: Returns:
Tuple of (page_x, page_y) in pixels, or None if outside page bounds Tuple of (page_x, page_y) in pixels, or None if outside page bounds
""" """
page_x = (screen_x - self.screen_x) / self.zoom page_x = (screen_x - self.screen_x) / self.zoom
page_y = (screen_y - self.screen_y) / self.zoom page_y = (screen_y - self.screen_y) / self.zoom
return (page_x, page_y) return (page_x, page_y)
def is_point_in_page(self, screen_x: float, screen_y: float) -> bool: def is_point_in_page(self, screen_x: float, screen_y: float) -> bool:
""" """
Check if a screen coordinate is within the page bounds. Check if a screen coordinate is within the page bounds.
Args: Args:
screen_x: X coordinate in screen space screen_x: X coordinate in screen space
screen_y: Y coordinate in screen space screen_y: Y coordinate in screen space
Returns: Returns:
True if the point is within the page bounds True if the point is within the page bounds
""" """
return (self.screen_x <= screen_x <= self.screen_x + self.screen_width and return (
self.screen_y <= screen_y <= self.screen_y + self.screen_height) self.screen_x <= screen_x <= self.screen_x + self.screen_width
and self.screen_y <= screen_y <= self.screen_y + self.screen_height
)
def get_sub_page_at(self, screen_x: float, is_facing_page: bool) -> Optional[str]: def get_sub_page_at(self, screen_x: float, is_facing_page: bool) -> Optional[str]:
""" """
For facing page spreads, determine if mouse is on left or right page. For facing page spreads, determine if mouse is on left or right page.
Args: Args:
screen_x: X coordinate in screen space screen_x: X coordinate in screen space
is_facing_page: Whether this is a facing page spread is_facing_page: Whether this is a facing page spread
Returns: Returns:
'left' or 'right' for facing pages, None for single pages 'left' or 'right' for facing pages, None for single pages
""" """
if not is_facing_page: if not is_facing_page:
return None return None
# Calculate the center line of the spread # Calculate the center line of the spread
center_x = self.screen_x + self.screen_width / 2 center_x = self.screen_x + self.screen_width / 2
if screen_x < center_x: if screen_x < center_x:
return 'left' return "left"
else: else:
return 'right' return "right"
def begin_render(self): def begin_render(self):
""" """
Set up OpenGL transformations for rendering this page. Set up OpenGL transformations for rendering this page.
@ -131,27 +129,27 @@ class PageRenderer:
glScalef(self.zoom, self.zoom, 1.0) glScalef(self.zoom, self.zoom, 1.0)
# Translate to page position (in zoomed coordinates) # Translate to page position (in zoomed coordinates)
glTranslatef(self.screen_x / self.zoom, self.screen_y / self.zoom, 0.0) glTranslatef(self.screen_x / self.zoom, self.screen_y / self.zoom, 0.0)
def end_render(self): def end_render(self):
""" """
Clean up OpenGL transformations after rendering this page. Clean up OpenGL transformations after rendering this page.
Call this after rendering page content. Call this after rendering page content.
""" """
glPopMatrix() glPopMatrix()
def get_page_bounds_screen(self) -> Tuple[float, float, float, float]: def get_page_bounds_screen(self) -> Tuple[float, float, float, float]:
""" """
Get the page bounds in screen coordinates. Get the page bounds in screen coordinates.
Returns: Returns:
Tuple of (x, y, width, height) in screen space Tuple of (x, y, width, height) in screen space
""" """
return (self.screen_x, self.screen_y, self.screen_width, self.screen_height) return (self.screen_x, self.screen_y, self.screen_width, self.screen_height)
def get_page_bounds_page(self) -> Tuple[float, float, float, float]: def get_page_bounds_page(self) -> Tuple[float, float, float, float]:
""" """
Get the page bounds in page-local coordinates. Get the page bounds in page-local coordinates.
Returns: Returns:
Tuple of (x, y, width, height) in page-local space (pixels) Tuple of (x, y, width, height) in page-local space (pixels)
""" """

View File

@ -25,6 +25,7 @@ from pyPhotoAlbum.image_utils import (
@dataclass @dataclass
class RenderContext: class RenderContext:
"""Parameters for rendering an image element""" """Parameters for rendering an image element"""
canvas: canvas.Canvas canvas: canvas.Canvas
image_element: ImageData image_element: ImageData
x_pt: float x_pt: float
@ -41,6 +42,7 @@ class RenderContext:
@dataclass @dataclass
class SplitRenderParams: class SplitRenderParams:
"""Parameters for rendering a split element""" """Parameters for rendering a split element"""
canvas: canvas.Canvas canvas: canvas.Canvas
element: any element: any
x_offset_mm: float x_offset_mm: float
@ -53,15 +55,15 @@ class SplitRenderParams:
class PDFExporter: class PDFExporter:
"""Handles PDF export of photo album projects""" """Handles PDF export of photo album projects"""
# Conversion constants # Conversion constants
MM_TO_POINTS = 2.834645669 # 1mm = 2.834645669 points MM_TO_POINTS = 2.834645669 # 1mm = 2.834645669 points
SPLIT_THRESHOLD_RATIO = 0.002 # 1:500 threshold for tiny elements SPLIT_THRESHOLD_RATIO = 0.002 # 1:500 threshold for tiny elements
def __init__(self, project, export_dpi: int = 300): def __init__(self, project, export_dpi: int = 300):
""" """
Initialize PDF exporter with a project. Initialize PDF exporter with a project.
Args: Args:
project: The Project instance to export project: The Project instance to export
export_dpi: Target DPI for images in the PDF (default 300 for print quality) export_dpi: Target DPI for images in the PDF (default 300 for print quality)
@ -71,48 +73,46 @@ class PDFExporter:
self.export_dpi = export_dpi self.export_dpi = export_dpi
self.warnings = [] self.warnings = []
self.current_pdf_page = 1 self.current_pdf_page = 1
def export(self, output_path: str, progress_callback=None) -> Tuple[bool, List[str]]: def export(self, output_path: str, progress_callback=None) -> Tuple[bool, List[str]]:
""" """
Export the project to PDF. Export the project to PDF.
Args: Args:
output_path: Path where PDF should be saved output_path: Path where PDF should be saved
progress_callback: Optional callback(current, total, message) for progress updates progress_callback: Optional callback(current, total, message) for progress updates
Returns: Returns:
Tuple of (success: bool, warnings: List[str]) Tuple of (success: bool, warnings: List[str])
""" """
self.warnings = [] self.warnings = []
self.current_pdf_page = 1 self.current_pdf_page = 1
try: try:
# Calculate total pages for progress (cover counts as 1) # Calculate total pages for progress (cover counts as 1)
total_pages = sum( total_pages = sum(
1 if page.is_cover else (2 if page.is_double_spread else 1) 1 if page.is_cover else (2 if page.is_double_spread else 1) for page in self.project.pages
for page in self.project.pages
) )
# Get page dimensions from project (in mm) # Get page dimensions from project (in mm)
page_width_mm, page_height_mm = self.project.page_size_mm page_width_mm, page_height_mm = self.project.page_size_mm
# Convert to PDF points # Convert to PDF points
page_width_pt = page_width_mm * self.MM_TO_POINTS page_width_pt = page_width_mm * self.MM_TO_POINTS
page_height_pt = page_height_mm * self.MM_TO_POINTS page_height_pt = page_height_mm * self.MM_TO_POINTS
# Create PDF canvas # Create PDF canvas
c = canvas.Canvas(output_path, pagesize=(page_width_pt, page_height_pt)) c = canvas.Canvas(output_path, pagesize=(page_width_pt, page_height_pt))
# Process each page # Process each page
pages_processed = 0 pages_processed = 0
for page in self.project.pages: for page in self.project.pages:
# Get display name for progress # Get display name for progress
page_name = self.project.get_page_display_name(page) page_name = self.project.get_page_display_name(page)
if progress_callback: if progress_callback:
progress_callback(pages_processed, total_pages, progress_callback(pages_processed, total_pages, f"Exporting {page_name}...")
f"Exporting {page_name}...")
if page.is_cover: if page.is_cover:
# Export cover as single page with wrap-around design # Export cover as single page with wrap-around design
self._export_cover(c, page, page_width_pt, page_height_pt) self._export_cover(c, page, page_width_pt, page_height_pt)
@ -124,9 +124,8 @@ class PDFExporter:
c.showPage() # Finish current page c.showPage() # Finish current page
self.current_pdf_page += 1 self.current_pdf_page += 1
if progress_callback: if progress_callback:
progress_callback(pages_processed, total_pages, progress_callback(pages_processed, total_pages, f"Inserting blank page for alignment...")
f"Inserting blank page for alignment...")
# Export spread as two pages # Export spread as two pages
self._export_spread(c, page, page_width_pt, page_height_pt) self._export_spread(c, page, page_width_pt, page_height_pt)
pages_processed += 2 pages_processed += 2
@ -134,112 +133,109 @@ class PDFExporter:
# Export single page # Export single page
self._export_single_page(c, page, page_width_pt, page_height_pt) self._export_single_page(c, page, page_width_pt, page_height_pt)
pages_processed += 1 pages_processed += 1
# Save PDF # Save PDF
c.save() c.save()
if progress_callback: if progress_callback:
progress_callback(total_pages, total_pages, "Export complete!") progress_callback(total_pages, total_pages, "Export complete!")
return True, self.warnings return True, self.warnings
except Exception as e: except Exception as e:
self.warnings.append(f"Export failed: {str(e)}") self.warnings.append(f"Export failed: {str(e)}")
return False, self.warnings return False, self.warnings
def _export_cover(self, c: canvas.Canvas, page, page_width_pt: float, def _export_cover(self, c: canvas.Canvas, page, page_width_pt: float, page_height_pt: float):
page_height_pt: float):
""" """
Export a cover page to PDF. Export a cover page to PDF.
Cover has different dimensions (wrap-around: front + spine + back + bleed). Cover has different dimensions (wrap-around: front + spine + back + bleed).
""" """
# Get cover dimensions (already calculated in page.layout.size) # Get cover dimensions (already calculated in page.layout.size)
cover_width_mm, cover_height_mm = page.layout.size cover_width_mm, cover_height_mm = page.layout.size
# Convert to PDF points # Convert to PDF points
cover_width_pt = cover_width_mm * self.MM_TO_POINTS cover_width_pt = cover_width_mm * self.MM_TO_POINTS
cover_height_pt = cover_height_mm * self.MM_TO_POINTS cover_height_pt = cover_height_mm * self.MM_TO_POINTS
# Create a new page with cover dimensions # Create a new page with cover dimensions
c.setPageSize((cover_width_pt, cover_height_pt)) c.setPageSize((cover_width_pt, cover_height_pt))
# Render all elements on the cover # Render all elements on the cover
for element in sorted(page.layout.elements, key=lambda x: x.z_index): for element in sorted(page.layout.elements, key=lambda x: x.z_index):
self._render_element(c, element, 0, cover_width_pt, cover_height_pt, "Cover") self._render_element(c, element, 0, cover_width_pt, cover_height_pt, "Cover")
# Draw guide lines for front/spine/back zones # Draw guide lines for front/spine/back zones
self._draw_cover_guides(c, cover_width_pt, cover_height_pt) self._draw_cover_guides(c, cover_width_pt, cover_height_pt)
c.showPage() # Finish cover page c.showPage() # Finish cover page
self.current_pdf_page += 1 self.current_pdf_page += 1
# Reset page size for content pages # Reset page size for content pages
c.setPageSize((page_width_pt, page_height_pt)) c.setPageSize((page_width_pt, page_height_pt))
def _draw_cover_guides(self, c: canvas.Canvas, cover_width_pt: float, cover_height_pt: float): def _draw_cover_guides(self, c: canvas.Canvas, cover_width_pt: float, cover_height_pt: float):
"""Draw guide lines for cover zones (front/spine/back)""" """Draw guide lines for cover zones (front/spine/back)"""
from reportlab.lib.colors import lightgrey from reportlab.lib.colors import lightgrey
# Calculate zone boundaries # Calculate zone boundaries
bleed_pt = self.project.cover_bleed_mm * self.MM_TO_POINTS bleed_pt = self.project.cover_bleed_mm * self.MM_TO_POINTS
page_width_pt = self.project.page_size_mm[0] * self.MM_TO_POINTS page_width_pt = self.project.page_size_mm[0] * self.MM_TO_POINTS
spine_width_pt = self.project.calculate_spine_width() * self.MM_TO_POINTS spine_width_pt = self.project.calculate_spine_width() * self.MM_TO_POINTS
# Zone boundaries (from left to right) # Zone boundaries (from left to right)
# Bleed | Back | Spine | Front | Bleed # Bleed | Back | Spine | Front | Bleed
back_start = bleed_pt back_start = bleed_pt
spine_start = bleed_pt + page_width_pt spine_start = bleed_pt + page_width_pt
front_start = bleed_pt + page_width_pt + spine_width_pt front_start = bleed_pt + page_width_pt + spine_width_pt
front_end = bleed_pt + page_width_pt + spine_width_pt + page_width_pt front_end = bleed_pt + page_width_pt + spine_width_pt + page_width_pt
# Draw dashed lines at zone boundaries # Draw dashed lines at zone boundaries
c.saveState() c.saveState()
c.setStrokeColor(lightgrey) c.setStrokeColor(lightgrey)
c.setDash(3, 3) c.setDash(3, 3)
c.setLineWidth(0.5) c.setLineWidth(0.5)
# Back/Spine boundary # Back/Spine boundary
c.line(spine_start, 0, spine_start, cover_height_pt) c.line(spine_start, 0, spine_start, cover_height_pt)
# Spine/Front boundary # Spine/Front boundary
c.line(front_start, 0, front_start, cover_height_pt) c.line(front_start, 0, front_start, cover_height_pt)
# Bleed boundaries (outer edges) # Bleed boundaries (outer edges)
if bleed_pt > 0: if bleed_pt > 0:
c.line(back_start, 0, back_start, cover_height_pt) c.line(back_start, 0, back_start, cover_height_pt)
c.line(front_end, 0, front_end, cover_height_pt) c.line(front_end, 0, front_end, cover_height_pt)
c.restoreState() c.restoreState()
def _export_single_page(self, c: canvas.Canvas, page, page_width_pt: float, def _export_single_page(self, c: canvas.Canvas, page, page_width_pt: float, page_height_pt: float):
page_height_pt: float):
"""Export a single page to PDF""" """Export a single page to PDF"""
# Render all elements # Render all elements
for element in sorted(page.layout.elements, key=lambda x: x.z_index): for element in sorted(page.layout.elements, key=lambda x: x.z_index):
self._render_element(c, element, 0, page_width_pt, page_height_pt, page.page_number) self._render_element(c, element, 0, page_width_pt, page_height_pt, page.page_number)
c.showPage() # Finish this page c.showPage() # Finish this page
self.current_pdf_page += 1 self.current_pdf_page += 1
def _export_spread(self, c: canvas.Canvas, page, page_width_pt: float, def _export_spread(self, c: canvas.Canvas, page, page_width_pt: float, page_height_pt: float):
page_height_pt: float):
"""Export a double-page spread as two PDF pages""" """Export a double-page spread as two PDF pages"""
# Get center line position in mm # Get center line position in mm
page_width_mm = self.project.page_size_mm[0] page_width_mm = self.project.page_size_mm[0]
center_mm = page_width_mm # Center of the spread (which is 2x width) center_mm = page_width_mm # Center of the spread (which is 2x width)
# Convert center line to pixels for comparison # Convert center line to pixels for comparison
dpi = self.project.working_dpi dpi = self.project.working_dpi
center_px = center_mm * dpi / 25.4 center_px = center_mm * dpi / 25.4
# Calculate threshold for tiny elements (1:500) in pixels # Calculate threshold for tiny elements (1:500) in pixels
threshold_px = page_width_mm * self.SPLIT_THRESHOLD_RATIO * dpi / 25.4 threshold_px = page_width_mm * self.SPLIT_THRESHOLD_RATIO * dpi / 25.4
# Process elements for left page # Process elements for left page
for element in sorted(page.layout.elements, key=lambda x: x.z_index): for element in sorted(page.layout.elements, key=lambda x: x.z_index):
element_x_px, element_y_px = element.position element_x_px, element_y_px = element.position
element_width_px, element_height_px = element.size element_width_px, element_height_px = element.size
# Check if element is on left page, right page, or spanning (compare in pixels) # Check if element is on left page, right page, or spanning (compare in pixels)
if element_x_px + element_width_px <= center_px + threshold_px: if element_x_px + element_width_px <= center_px + threshold_px:
# Entirely on left page # Entirely on left page
@ -257,23 +253,22 @@ class PDFExporter:
page_width_pt=page_width_pt, page_width_pt=page_width_pt,
page_height_pt=page_height_pt, page_height_pt=page_height_pt,
page_number=page.page_number, page_number=page.page_number,
side='left' side="left",
) )
self._render_split_element(params) self._render_split_element(params)
c.showPage() # Finish left page c.showPage() # Finish left page
self.current_pdf_page += 1 self.current_pdf_page += 1
# Process elements for right page # Process elements for right page
for element in sorted(page.layout.elements, key=lambda x: x.z_index): for element in sorted(page.layout.elements, key=lambda x: x.z_index):
element_x_px, element_y_px = element.position element_x_px, element_y_px = element.position
element_width_px, element_height_px = element.size element_width_px, element_height_px = element.size
# Check if element is on right page or spanning (compare in pixels) # Check if element is on right page or spanning (compare in pixels)
if element_x_px >= center_px - threshold_px and element_x_px + element_width_px > center_px: if element_x_px >= center_px - threshold_px and element_x_px + element_width_px > center_px:
# Entirely on right page or mostly on right # Entirely on right page or mostly on right
self._render_element(c, element, center_mm, page_width_pt, page_height_pt, self._render_element(c, element, center_mm, page_width_pt, page_height_pt, page.page_number + 1)
page.page_number + 1)
elif element_x_px < center_px and element_x_px + element_width_px > center_px + threshold_px: elif element_x_px < center_px and element_x_px + element_width_px > center_px + threshold_px:
# Spanning element - render right portion # Spanning element - render right portion
params = SplitRenderParams( params = SplitRenderParams(
@ -284,18 +279,25 @@ class PDFExporter:
page_width_pt=page_width_pt, page_width_pt=page_width_pt,
page_height_pt=page_height_pt, page_height_pt=page_height_pt,
page_number=page.page_number + 1, page_number=page.page_number + 1,
side='right' side="right",
) )
self._render_split_element(params) self._render_split_element(params)
c.showPage() # Finish right page c.showPage() # Finish right page
self.current_pdf_page += 1 self.current_pdf_page += 1
def _render_element(self, c: canvas.Canvas, element, x_offset_mm: float, def _render_element(
page_width_pt: float, page_height_pt: float, page_number: int): self,
c: canvas.Canvas,
element,
x_offset_mm: float,
page_width_pt: float,
page_height_pt: float,
page_number: int,
):
""" """
Render a single element on the PDF canvas. Render a single element on the PDF canvas.
Args: Args:
c: ReportLab canvas c: ReportLab canvas
element: The layout element to render element: The layout element to render
@ -307,27 +309,27 @@ class PDFExporter:
# Skip placeholders # Skip placeholders
if isinstance(element, PlaceholderData): if isinstance(element, PlaceholderData):
return return
# Get element position and size (in PIXELS from OpenGL coordinates) # Get element position and size (in PIXELS from OpenGL coordinates)
element_x_px, element_y_px = element.position element_x_px, element_y_px = element.position
element_width_px, element_height_px = element.size element_width_px, element_height_px = element.size
# Convert from pixels to mm using the working DPI # Convert from pixels to mm using the working DPI
dpi = self.project.working_dpi dpi = self.project.working_dpi
element_x_mm = element_x_px * 25.4 / dpi element_x_mm = element_x_px * 25.4 / dpi
element_y_mm = element_y_px * 25.4 / dpi element_y_mm = element_y_px * 25.4 / dpi
element_width_mm = element_width_px * 25.4 / dpi element_width_mm = element_width_px * 25.4 / dpi
element_height_mm = element_height_px * 25.4 / dpi element_height_mm = element_height_px * 25.4 / dpi
# Adjust x position for offset (now in mm) # Adjust x position for offset (now in mm)
adjusted_x_mm = element_x_mm - x_offset_mm adjusted_x_mm = element_x_mm - x_offset_mm
# Convert to PDF points and flip Y coordinate (PDF origin is bottom-left) # Convert to PDF points and flip Y coordinate (PDF origin is bottom-left)
x_pt = adjusted_x_mm * self.MM_TO_POINTS x_pt = adjusted_x_mm * self.MM_TO_POINTS
y_pt = page_height_pt - (element_y_mm * self.MM_TO_POINTS) - (element_height_mm * self.MM_TO_POINTS) y_pt = page_height_pt - (element_y_mm * self.MM_TO_POINTS) - (element_height_mm * self.MM_TO_POINTS)
width_pt = element_width_mm * self.MM_TO_POINTS width_pt = element_width_mm * self.MM_TO_POINTS
height_pt = element_height_mm * self.MM_TO_POINTS height_pt = element_height_mm * self.MM_TO_POINTS
if isinstance(element, ImageData): if isinstance(element, ImageData):
ctx = RenderContext( ctx = RenderContext(
canvas=c, canvas=c,
@ -336,12 +338,12 @@ class PDFExporter:
y_pt=y_pt, y_pt=y_pt,
width_pt=width_pt, width_pt=width_pt,
height_pt=height_pt, height_pt=height_pt,
page_number=page_number page_number=page_number,
) )
self._render_image(ctx) self._render_image(ctx)
elif isinstance(element, TextBoxData): elif isinstance(element, TextBoxData):
self._render_textbox(c, element, x_pt, y_pt, width_pt, height_pt) self._render_textbox(c, element, x_pt, y_pt, width_pt, height_pt)
def _render_split_element(self, params: SplitRenderParams): def _render_split_element(self, params: SplitRenderParams):
""" """
Render a split element (only the portion on one side of the split line). Render a split element (only the portion on one side of the split line).
@ -352,7 +354,7 @@ class PDFExporter:
# Skip placeholders # Skip placeholders
if isinstance(params.element, PlaceholderData): if isinstance(params.element, PlaceholderData):
return return
# Get element position and size in pixels # Get element position and size in pixels
element_x_px, element_y_px = params.element.position element_x_px, element_y_px = params.element.position
element_width_px, element_height_px = params.element.size element_width_px, element_height_px = params.element.size
@ -366,7 +368,7 @@ class PDFExporter:
if isinstance(params.element, ImageData): if isinstance(params.element, ImageData):
# Calculate which portion of the image to render # Calculate which portion of the image to render
if params.side == 'left': if params.side == "left":
# Render from element start to split line # Render from element start to split line
crop_width_mm = params.split_line_mm - element_x_mm crop_width_mm = params.split_line_mm - element_x_mm
crop_x_start = 0 crop_x_start = 0
@ -402,7 +404,7 @@ class PDFExporter:
crop_left=crop_x_start / element_width_mm, crop_left=crop_x_start / element_width_mm,
crop_right=(crop_x_start + crop_width_mm) / element_width_mm, crop_right=(crop_x_start + crop_width_mm) / element_width_mm,
original_width_pt=original_width_pt, original_width_pt=original_width_pt,
original_height_pt=original_height_pt original_height_pt=original_height_pt,
) )
self._render_image(ctx) self._render_image(ctx)
@ -410,11 +412,18 @@ class PDFExporter:
# For text boxes spanning the split, we'll render the whole text on the side # For text boxes spanning the split, we'll render the whole text on the side
# where most of it appears (simpler than trying to split text) # where most of it appears (simpler than trying to split text)
element_center_mm = element_x_mm + element_width_mm / 2 element_center_mm = element_x_mm + element_width_mm / 2
if (params.side == 'left' and element_center_mm < params.split_line_mm) or \ if (params.side == "left" and element_center_mm < params.split_line_mm) or (
(params.side == 'right' and element_center_mm >= params.split_line_mm): params.side == "right" and element_center_mm >= params.split_line_mm
self._render_element(params.canvas, params.element, params.x_offset_mm, ):
params.page_width_pt, params.page_height_pt, params.page_number) self._render_element(
params.canvas,
params.element,
params.x_offset_mm,
params.page_width_pt,
params.page_height_pt,
params.page_number,
)
def _render_image(self, ctx: RenderContext): def _render_image(self, ctx: RenderContext):
""" """
Render an image element on the PDF canvas. Render an image element on the PDF canvas.
@ -438,7 +447,7 @@ class PDFExporter:
img = convert_to_rgba(img) img = convert_to_rgba(img)
# Apply PIL-level rotation if needed # 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:
img = apply_pil_rotation(img, ctx.image_element.pil_rotation_90) img = apply_pil_rotation(img, ctx.image_element.pil_rotation_90)
# Get element's crop_info and combine with split cropping if applicable # Get element's crop_info and combine with split cropping if applicable
@ -458,27 +467,28 @@ class PDFExporter:
# Calculate center crop coordinates # Calculate center crop coordinates
img_width, img_height = img.size img_width, img_height = img.size
crop_coords = calculate_center_crop_coords( crop_coords = calculate_center_crop_coords(
img_width, img_height, img_width,
target_width, target_height, img_height,
(final_crop_x_min, crop_y_min, final_crop_x_max, crop_y_max) target_width,
target_height,
(final_crop_x_min, crop_y_min, final_crop_x_max, crop_y_max),
) )
# Crop the image # Crop the image
cropped_img = crop_image_to_coords(img, crop_coords) 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
# Calculate target dimensions in pixels based on physical size and export DPI # Calculate target dimensions in pixels based on physical size and export DPI
target_width_px = int((ctx.width_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4) target_width_px = int((ctx.width_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
target_height_px = int((ctx.height_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4) target_height_px = int((ctx.height_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
# Only downsample if current image is larger than target # Only downsample if current image is larger than target
# Don't upscale small images as that would reduce quality # Don't upscale small images as that would reduce quality
current_width, current_height = cropped_img.size current_width, current_height = cropped_img.size
if current_width > target_width_px or current_height > target_height_px: if current_width > target_width_px or current_height > target_height_px:
# Use LANCZOS resampling for high quality downsampling # Use LANCZOS resampling for high quality downsampling
cropped_img = cropped_img.resize((target_width_px, target_height_px), cropped_img = cropped_img.resize((target_width_px, target_height_px), Image.Resampling.LANCZOS)
Image.Resampling.LANCZOS)
# Note: Rotation is applied at the canvas level (below), not here # Note: Rotation is applied at the canvas level (below), not here
# to avoid double-rotation issues # to avoid double-rotation issues
@ -495,12 +505,20 @@ class PDFExporter:
ctx.canvas.rotate(-ctx.image_element.rotation) ctx.canvas.rotate(-ctx.image_element.rotation)
ctx.canvas.translate(-ctx.width_pt / 2, -ctx.height_pt / 2) ctx.canvas.translate(-ctx.width_pt / 2, -ctx.height_pt / 2)
# Draw at origin after transformation # Draw at origin after transformation
ctx.canvas.drawImage(ImageReader(cropped_img), 0, 0, ctx.width_pt, ctx.height_pt, ctx.canvas.drawImage(
mask='auto', preserveAspectRatio=False) ImageReader(cropped_img), 0, 0, ctx.width_pt, ctx.height_pt, mask="auto", preserveAspectRatio=False
)
else: else:
# Draw without rotation # Draw without rotation
ctx.canvas.drawImage(ImageReader(cropped_img), ctx.x_pt, ctx.y_pt, ctx.width_pt, ctx.height_pt, ctx.canvas.drawImage(
mask='auto', preserveAspectRatio=False) ImageReader(cropped_img),
ctx.x_pt,
ctx.y_pt,
ctx.width_pt,
ctx.height_pt,
mask="auto",
preserveAspectRatio=False,
)
ctx.canvas.restoreState() ctx.canvas.restoreState()
@ -508,9 +526,10 @@ class PDFExporter:
warning = f"Page {ctx.page_number}: Error rendering image {ctx.image_element.image_path}: {str(e)}" warning = f"Page {ctx.page_number}: Error rendering image {ctx.image_element.image_path}: {str(e)}"
print(f"WARNING: {warning}") print(f"WARNING: {warning}")
self.warnings.append(warning) self.warnings.append(warning)
def _render_textbox(self, c: canvas.Canvas, text_element: 'TextBoxData', def _render_textbox(
x_pt: float, y_pt: float, width_pt: float, height_pt: float): self, c: canvas.Canvas, text_element: "TextBoxData", x_pt: float, y_pt: float, width_pt: float, height_pt: float
):
""" """
Render a text box element on the PDF canvas with transparent background. Render a text box element on the PDF canvas with transparent background.
Text is word-wrapped to fit within the box boundaries. Text is word-wrapped to fit within the box boundaries.
@ -524,9 +543,9 @@ class PDFExporter:
return return
# Get font settings # Get font settings
font_family = text_element.font_settings.get('family', 'Helvetica') font_family = text_element.font_settings.get("family", "Helvetica")
font_size_px = text_element.font_settings.get('size', 12) font_size_px = text_element.font_settings.get("size", 12)
font_color = text_element.font_settings.get('color', (0, 0, 0)) font_color = text_element.font_settings.get("color", (0, 0, 0))
# Convert font size from pixels to PDF points (same conversion as element dimensions) # Convert font size from pixels to PDF points (same conversion as element dimensions)
# Font size is stored in pixels at working_dpi, same as element position/size # Font size is stored in pixels at working_dpi, same as element position/size
@ -536,34 +555,32 @@ class PDFExporter:
# Map common font names to ReportLab standard fonts # Map common font names to ReportLab standard fonts
font_map = { font_map = {
'Arial': 'Helvetica', "Arial": "Helvetica",
'Times New Roman': 'Times-Roman', "Times New Roman": "Times-Roman",
'Courier New': 'Courier', "Courier New": "Courier",
} }
font_family = font_map.get(font_family, font_family) font_family = font_map.get(font_family, font_family)
# Normalize color to hex for Paragraph style # Normalize color to hex for Paragraph style
if all(isinstance(x, int) and x > 1 for x in font_color): if all(isinstance(x, int) and x > 1 for x in font_color):
color_hex = '#{:02x}{:02x}{:02x}'.format(*font_color) color_hex = "#{:02x}{:02x}{:02x}".format(*font_color)
else: else:
# Convert 0-1 range to 0-255 then to hex # Convert 0-1 range to 0-255 then to hex
color_hex = '#{:02x}{:02x}{:02x}'.format( color_hex = "#{:02x}{:02x}{:02x}".format(
int(font_color[0] * 255), int(font_color[0] * 255), int(font_color[1] * 255), int(font_color[2] * 255)
int(font_color[1] * 255),
int(font_color[2] * 255)
) )
# Map alignment to ReportLab constants # Map alignment to ReportLab constants
alignment_map = { alignment_map = {
'left': TA_LEFT, "left": TA_LEFT,
'center': TA_CENTER, "center": TA_CENTER,
'right': TA_RIGHT, "right": TA_RIGHT,
} }
text_alignment = alignment_map.get(text_element.alignment, TA_LEFT) text_alignment = alignment_map.get(text_element.alignment, TA_LEFT)
# Create paragraph style with word wrapping # Create paragraph style with word wrapping
style = ParagraphStyle( style = ParagraphStyle(
'textbox', "textbox",
fontName=font_family, fontName=font_family,
fontSize=font_size, fontSize=font_size,
leading=font_size * 1.2, # Line spacing (120% of font size) leading=font_size * 1.2, # Line spacing (120% of font size)
@ -573,10 +590,10 @@ class PDFExporter:
# Escape special XML characters and convert newlines to <br/> tags # Escape special XML characters and convert newlines to <br/> tags
text_content = text_element.text_content text_content = text_element.text_content
text_content = text_content.replace('&', '&amp;') text_content = text_content.replace("&", "&amp;")
text_content = text_content.replace('<', '&lt;') text_content = text_content.replace("<", "&lt;")
text_content = text_content.replace('>', '&gt;') text_content = text_content.replace(">", "&gt;")
text_content = text_content.replace('\n', '<br/>') text_content = text_content.replace("\n", "<br/>")
# Create paragraph with the text # Create paragraph with the text
para = Paragraph(text_content, style) para = Paragraph(text_content, style)

View File

@ -11,6 +11,7 @@ from pyPhotoAlbum.page_layout import PageLayout
from pyPhotoAlbum.commands import CommandHistory from pyPhotoAlbum.commands import CommandHistory
from pyPhotoAlbum.asset_manager import AssetManager from pyPhotoAlbum.asset_manager import AssetManager
class Page: class Page:
"""Class representing a single page in the photo album""" """Class representing a single page in the photo album"""
@ -39,7 +40,7 @@ class Page:
# Deletion tracking for merge (v3.0+) # Deletion tracking for merge (v3.0+)
self.deleted = False self.deleted = False
self.deleted_at: Optional[str] = None self.deleted_at: Optional[str] = None
# Create layout with appropriate width # Create layout with appropriate width
if layout is None: if layout is None:
self.layout = PageLayout(is_facing_page=is_double_spread) self.layout = PageLayout(is_facing_page=is_double_spread)
@ -51,21 +52,21 @@ class Page:
self.layout.is_facing_page = is_double_spread self.layout.is_facing_page = is_double_spread
height = self.layout.size[1] height = self.layout.size[1]
# Use the base_width if available, otherwise derive it # Use the base_width if available, otherwise derive it
if hasattr(self.layout, 'base_width'): if hasattr(self.layout, "base_width"):
base_width = self.layout.base_width base_width = self.layout.base_width
else: else:
# If base_width not set, assume current width is correct # If base_width not set, assume current width is correct
# and derive base_width from current state # and derive base_width from current state
base_width = self.layout.size[0] / 2 if not is_double_spread else self.layout.size[0] base_width = self.layout.size[0] / 2 if not is_double_spread else self.layout.size[0]
self.layout.base_width = base_width self.layout.base_width = base_width
# Set the new width based on facing page state # Set the new width based on facing page state
self.layout.size = (base_width * 2 if is_double_spread else base_width, height) self.layout.size = (base_width * 2 if is_double_spread else base_width, height)
def get_page_numbers(self) -> List[int]: def get_page_numbers(self) -> List[int]:
""" """
Get the page numbers this page represents. Get the page numbers this page represents.
Returns: Returns:
List of page numbers (2 numbers for spreads, 1 for single pages) List of page numbers (2 numbers for spreads, 1 for single pages)
""" """
@ -73,7 +74,7 @@ class Page:
return [self.page_number, self.page_number + 1] return [self.page_number, self.page_number + 1]
else: else:
return [self.page_number] return [self.page_number]
def get_page_count(self) -> int: def get_page_count(self) -> int:
""" """
Get the number of physical pages this represents. Get the number of physical pages this represents.
@ -133,6 +134,7 @@ class Page:
self.layout = PageLayout() self.layout = PageLayout()
self.layout.deserialize(layout_data) self.layout.deserialize(layout_data)
class Project: class Project:
"""Class representing the entire photo album project""" """Class representing the entire photo album project"""
@ -230,37 +232,33 @@ class Project:
if self.has_cover and self.pages: if self.has_cover and self.pages:
self.update_cover_dimensions() self.update_cover_dimensions()
self.mark_dirty() self.mark_dirty()
def calculate_spine_width(self) -> float: def calculate_spine_width(self) -> float:
""" """
Calculate spine width based on page count and paper thickness. Calculate spine width based on page count and paper thickness.
For saddle stitch binding: For saddle stitch binding:
- Each sheet = 4 pages (2 pages per side when folded) - Each sheet = 4 pages (2 pages per side when folded)
- Spine width = (Number of sheets × Paper thickness × 2) - Spine width = (Number of sheets × Paper thickness × 2)
Returns: Returns:
Spine width in mm Spine width in mm
""" """
if not self.has_cover: if not self.has_cover:
return 0.0 return 0.0
# Count content pages (excluding cover) # Count content pages (excluding cover)
content_page_count = sum( content_page_count = sum(page.get_page_count() for page in self.pages if not page.is_cover)
page.get_page_count()
for page in self.pages
if not page.is_cover
)
if self.binding_type == "saddle_stitch": if self.binding_type == "saddle_stitch":
# Calculate number of sheets (each sheet = 4 pages) # Calculate number of sheets (each sheet = 4 pages)
sheets = math.ceil(content_page_count / 4) sheets = math.ceil(content_page_count / 4)
# Spine width = sheets × paper thickness × 2 (folded) # Spine width = sheets × paper thickness × 2 (folded)
spine_width = sheets * self.paper_thickness_mm * 2 spine_width = sheets * self.paper_thickness_mm * 2
return spine_width return spine_width
return 0.0 return 0.0
def update_cover_dimensions(self): def update_cover_dimensions(self):
""" """
Update cover page dimensions based on current page count and settings. Update cover page dimensions based on current page count and settings.
@ -268,66 +266,68 @@ class Project:
""" """
if not self.has_cover or not self.pages: if not self.has_cover or not self.pages:
return return
# Find cover page (should be first page) # Find cover page (should be first page)
cover_page = None cover_page = None
for page in self.pages: for page in self.pages:
if page.is_cover: if page.is_cover:
cover_page = page cover_page = page
break break
if not cover_page: if not cover_page:
return return
# Get standard page dimensions # Get standard page dimensions
page_width_mm, page_height_mm = self.page_size_mm page_width_mm, page_height_mm = self.page_size_mm
# Calculate spine width # Calculate spine width
spine_width = self.calculate_spine_width() spine_width = self.calculate_spine_width()
# Calculate cover dimensions # Calculate cover dimensions
# Cover = Front + Spine + Back + Bleed on all sides # Cover = Front + Spine + Back + Bleed on all sides
cover_width = (page_width_mm * 2) + spine_width + (self.cover_bleed_mm * 2) cover_width = (page_width_mm * 2) + spine_width + (self.cover_bleed_mm * 2)
cover_height = page_height_mm + (self.cover_bleed_mm * 2) cover_height = page_height_mm + (self.cover_bleed_mm * 2)
# Update cover page layout # Update cover page layout
cover_page.layout.size = (cover_width, cover_height) cover_page.layout.size = (cover_width, cover_height)
cover_page.layout.base_width = page_width_mm # Store base width for reference cover_page.layout.base_width = page_width_mm # Store base width for reference
cover_page.manually_sized = True # Mark as manually sized cover_page.manually_sized = True # Mark as manually sized
print(f"Cover dimensions updated: {cover_width:.1f} × {cover_height:.1f} mm " print(
f"(Front: {page_width_mm}, Spine: {spine_width:.2f}, Back: {page_width_mm}, " f"Cover dimensions updated: {cover_width:.1f} × {cover_height:.1f} mm "
f"Bleed: {self.cover_bleed_mm})") f"(Front: {page_width_mm}, Spine: {spine_width:.2f}, Back: {page_width_mm}, "
f"Bleed: {self.cover_bleed_mm})"
)
def get_page_display_name(self, page: Page) -> str: def get_page_display_name(self, page: Page) -> str:
""" """
Get display name for a page. Get display name for a page.
Args: Args:
page: The page to get the display name for page: The page to get the display name for
Returns: Returns:
Display name like "Cover", "Page 1", "Pages 1-2", etc. Display name like "Cover", "Page 1", "Pages 1-2", etc.
""" """
if page.is_cover: if page.is_cover:
return "Cover" return "Cover"
# Calculate adjusted page number (excluding cover from count) # Calculate adjusted page number (excluding cover from count)
adjusted_num = page.page_number adjusted_num = page.page_number
if self.has_cover: if self.has_cover:
# Subtract 1 to account for cover # Subtract 1 to account for cover
adjusted_num = page.page_number - 1 adjusted_num = page.page_number - 1
if page.is_double_spread: if page.is_double_spread:
return f"Pages {adjusted_num}-{adjusted_num + 1}" return f"Pages {adjusted_num}-{adjusted_num + 1}"
else: else:
return f"Page {adjusted_num}" return f"Page {adjusted_num}"
def calculate_page_layout_with_ghosts(self) -> List[Tuple[str, Any, int]]: def calculate_page_layout_with_ghosts(self) -> List[Tuple[str, Any, int]]:
""" """
Calculate page layout including ghost pages for alignment. Calculate page layout including ghost pages for alignment.
Excludes cover from spread calculations. Excludes cover from spread calculations.
Returns: Returns:
List of tuples (page_type, page_or_ghost, logical_position) List of tuples (page_type, page_or_ghost, logical_position)
where page_type is 'page' or 'ghost', where page_type is 'page' or 'ghost',
@ -335,10 +335,10 @@ class Project:
logical_position is the position in the album (1=right, 2=left, etc.) logical_position is the position in the album (1=right, 2=left, etc.)
""" """
from pyPhotoAlbum.models import GhostPageData from pyPhotoAlbum.models import GhostPageData
layout = [] layout = []
current_position = 1 # Start at position 1 (right page) current_position = 1 # Start at position 1 (right page)
for page in self.pages: for page in self.pages:
# Skip cover in spread calculations # Skip cover in spread calculations
if page.is_cover: if page.is_cover:
@ -354,32 +354,32 @@ class Project:
elif not page.is_double_spread and current_position % 2 == 1: elif not page.is_double_spread and current_position % 2 == 1:
# Current position is odd (right page), single page is fine # Current position is odd (right page), single page is fine
pass pass
# Actually, let me reconsider the logic: # Actually, let me reconsider the logic:
# In a photobook: # In a photobook:
# - Position 1 is the right page (when opened, first content page) # - Position 1 is the right page (when opened, first content page)
# - Position 2 is the left page of the next spread # - Position 2 is the left page of the next spread
# - Position 3 is the right page of the next spread # - Position 3 is the right page of the next spread
# - etc. # - etc.
# #
# Double spreads occupy TWO positions (both left and right of a spread) # Double spreads occupy TWO positions (both left and right of a spread)
# They must start on an even position (left side) so they span across both pages # They must start on an even position (left side) so they span across both pages
# Check if this is a double spread starting at an odd position # Check if this is a double spread starting at an odd position
if page.is_double_spread and current_position % 2 == 1: if page.is_double_spread and current_position % 2 == 1:
# Need to insert a ghost page to push the double spread to next position # Need to insert a ghost page to push the double spread to next position
layout.append(('ghost', None, current_position)) layout.append(("ghost", None, current_position))
current_position += 1 current_position += 1
# Add the actual page # Add the actual page
layout.append(('page', page, current_position)) layout.append(("page", page, current_position))
# Update position based on page type # Update position based on page type
if page.is_double_spread: if page.is_double_spread:
current_position += 2 # Double spread takes 2 positions current_position += 2 # Double spread takes 2 positions
else: else:
current_position += 1 # Single page takes 1 position current_position += 1 # Single page takes 1 position
return layout return layout
def render_all_pages(self): def render_all_pages(self):
@ -453,7 +453,7 @@ class Project:
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
self.created = data.get("created", now) self.created = data.get("created", now)
self.last_modified = data.get("last_modified", now) self.last_modified = data.get("last_modified", now)
self.pages = [] self.pages = []
# Deserialize asset manager # Deserialize asset manager
@ -461,13 +461,13 @@ class Project:
asset_data = data.get("asset_manager") asset_data = data.get("asset_manager")
if asset_data: if asset_data:
self.asset_manager.deserialize(asset_data) self.asset_manager.deserialize(asset_data)
# Deserialize pages # Deserialize pages
for page_data in data.get("pages", []): for page_data in data.get("pages", []):
page = Page() page = Page()
page.deserialize(page_data) page.deserialize(page_data)
self.pages.append(page) self.pages.append(page)
# Deserialize command history with asset manager and project reference # Deserialize command history with asset manager and project reference
history_data = data.get("history") history_data = data.get("history")
if history_data: if history_data:

View File

@ -14,7 +14,7 @@ from pyPhotoAlbum.version_manager import (
CURRENT_DATA_VERSION, CURRENT_DATA_VERSION,
check_version_compatibility, check_version_compatibility,
VersionCompatibility, VersionCompatibility,
DataMigration DataMigration,
) )
@ -44,7 +44,7 @@ def _import_external_images(project: Project):
# Absolute path - definitely external # Absolute path - definitely external
is_external = True is_external = True
external_path = element.image_path external_path = element.image_path
elif not element.image_path.startswith('assets/'): elif not element.image_path.startswith("assets/"):
# Relative path but not in assets folder # Relative path but not in assets folder
# Check if it exists relative to project folder # Check if it exists relative to project folder
full_path = os.path.join(project.folder_path, element.image_path) full_path = os.path.join(project.folder_path, element.image_path)
@ -91,15 +91,15 @@ def _normalize_asset_paths(project: Project, project_folder: str):
original_path = element.image_path original_path = element.image_path
# Skip if already a simple relative path (assets/...) # Skip if already a simple relative path (assets/...)
if not os.path.isabs(original_path) and not original_path.startswith('./projects/'): if not os.path.isabs(original_path) and not original_path.startswith("./projects/"):
continue continue
# Try to extract just the filename or relative path from assets folder # Try to extract just the filename or relative path from assets folder
# Pattern 1: "./projects/XXX/assets/filename.jpg" -> "assets/filename.jpg" # Pattern 1: "./projects/XXX/assets/filename.jpg" -> "assets/filename.jpg"
if '/assets/' in original_path: if "/assets/" in original_path:
parts = original_path.split('/assets/') parts = original_path.split("/assets/")
if len(parts) == 2: if len(parts) == 2:
new_path = os.path.join('assets', parts[1]) new_path = os.path.join("assets", parts[1])
element.image_path = new_path element.image_path = new_path
normalized_count += 1 normalized_count += 1
print(f"Normalized path: {original_path} -> {new_path}") print(f"Normalized path: {original_path} -> {new_path}")
@ -133,8 +133,8 @@ def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]:
""" """
try: try:
# Ensure .ppz extension # Ensure .ppz extension
if not zip_path.lower().endswith('.ppz'): if not zip_path.lower().endswith(".ppz"):
zip_path += '.ppz' zip_path += ".ppz"
# Check for and import any external images before saving # Check for and import any external images before saving
_import_external_images(project) _import_external_images(project)
@ -143,14 +143,14 @@ def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]:
project_data = project.serialize() project_data = project.serialize()
# Add version information # Add version information
project_data['serialization_version'] = SERIALIZATION_VERSION # Legacy field project_data["serialization_version"] = SERIALIZATION_VERSION # Legacy field
project_data['data_version'] = CURRENT_DATA_VERSION # New versioning system project_data["data_version"] = CURRENT_DATA_VERSION # New versioning system
# Create ZIP file # Create ZIP file
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
# Write project.json with stable sorting for git-friendly diffs # Write project.json with stable sorting for git-friendly diffs
project_json = json.dumps(project_data, indent=2, sort_keys=True) project_json = json.dumps(project_data, indent=2, sort_keys=True)
zipf.writestr('project.json', project_json) zipf.writestr("project.json", project_json)
# Add all files from the assets folder # Add all files from the assets folder
assets_folder = project.asset_manager.assets_folder assets_folder = project.asset_manager.assets_folder
@ -201,20 +201,20 @@ def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Project:
os.makedirs(extract_to, exist_ok=True) os.makedirs(extract_to, exist_ok=True)
# Extract ZIP contents # Extract ZIP contents
with zipfile.ZipFile(zip_path, 'r') as zipf: with zipfile.ZipFile(zip_path, "r") as zipf:
zipf.extractall(extract_to) zipf.extractall(extract_to)
# Load project.json # Load project.json
project_json_path = os.path.join(extract_to, 'project.json') project_json_path = os.path.join(extract_to, "project.json")
if not os.path.exists(project_json_path): if not os.path.exists(project_json_path):
raise ValueError("Invalid project file: project.json not found") raise ValueError("Invalid project file: project.json not found")
with open(project_json_path, 'r') as f: with open(project_json_path, "r") as f:
project_data = json.load(f) project_data = json.load(f)
# Check version compatibility # Check version compatibility
# Try new version field first, fall back to legacy field # Try new version field first, fall back to legacy field
file_version = project_data.get('data_version', project_data.get('serialization_version', '1.0')) file_version = project_data.get("data_version", project_data.get("serialization_version", "1.0"))
# Check if version is compatible # Check if version is compatible
is_compatible, error_msg = check_version_compatibility(file_version, zip_path) is_compatible, error_msg = check_version_compatibility(file_version, zip_path)
@ -230,7 +230,7 @@ def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Project:
print(f"Note: Loading project with version {file_version}, current version is {CURRENT_DATA_VERSION}") print(f"Note: Loading project with version {file_version}, current version is {CURRENT_DATA_VERSION}")
# Create new project # Create new project
project_name = project_data.get('name', 'Untitled Project') project_name = project_data.get("name", "Untitled Project")
project = Project(name=project_name, folder_path=extract_to) project = Project(name=project_name, folder_path=extract_to)
# Deserialize project data # Deserialize project data
@ -254,6 +254,7 @@ def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Project:
# Set asset resolution context for ImageData rendering # Set asset resolution context for ImageData rendering
# Only set project folder - search paths are reserved for healing functionality # Only set project folder - search paths are reserved for healing functionality
from pyPhotoAlbum.models import set_asset_resolution_context from pyPhotoAlbum.models import set_asset_resolution_context
set_asset_resolution_context(extract_to) set_asset_resolution_context(extract_to)
print(f"Project loaded from {zip_path} to {extract_to}") print(f"Project loaded from {zip_path} to {extract_to}")
@ -263,25 +264,25 @@ def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Project:
def get_project_info(zip_path: str) -> Optional[dict]: def get_project_info(zip_path: str) -> Optional[dict]:
""" """
Get basic information about a project without fully loading it. Get basic information about a project without fully loading it.
Args: Args:
zip_path: Path to the ZIP file zip_path: Path to the ZIP file
Returns: Returns:
Dictionary with project info, or None if error Dictionary with project info, or None if error
""" """
try: try:
with zipfile.ZipFile(zip_path, 'r') as zipf: with zipfile.ZipFile(zip_path, "r") as zipf:
# Read project.json # Read project.json
project_json = zipf.read('project.json').decode('utf-8') project_json = zipf.read("project.json").decode("utf-8")
project_data = json.loads(project_json) project_data = json.loads(project_json)
return { return {
'name': project_data.get('name', 'Unknown'), "name": project_data.get("name", "Unknown"),
'version': project_data.get('serialization_version', 'Unknown'), "version": project_data.get("serialization_version", "Unknown"),
'page_count': len(project_data.get('pages', [])), "page_count": len(project_data.get("pages", [])),
'page_size_mm': project_data.get('page_size_mm', (0, 0)), "page_size_mm": project_data.get("page_size_mm", (0, 0)),
'working_dpi': project_data.get('working_dpi', 300), "working_dpi": project_data.get("working_dpi", 300),
} }
except Exception as e: except Exception as e:
print(f"Error reading project info: {e}") print(f"Error reading project info: {e}")

View File

@ -12,14 +12,14 @@ from collections import defaultdict
def build_ribbon_config(window_class: Type) -> Dict[str, Any]: def build_ribbon_config(window_class: Type) -> Dict[str, Any]:
""" """
Extract decorated methods and build ribbon configuration. Extract decorated methods and build ribbon configuration.
This function scans all methods in the window class and its mixins This function scans all methods in the window class and its mixins
for methods decorated with @ribbon_action, then builds a nested for methods decorated with @ribbon_action, then builds a nested
configuration structure suitable for the RibbonWidget. configuration structure suitable for the RibbonWidget.
Args: Args:
window_class: The MainWindow class with decorated methods window_class: The MainWindow class with decorated methods
Returns: Returns:
Dictionary containing the ribbon configuration with structure: Dictionary containing the ribbon configuration with structure:
{ {
@ -42,59 +42,61 @@ def build_ribbon_config(window_class: Type) -> Dict[str, Any]:
""" """
# Structure to collect actions by tab and group # Structure to collect actions by tab and group
tabs = defaultdict(lambda: defaultdict(list)) tabs = defaultdict(lambda: defaultdict(list))
# Scan all methods in the class and its bases (mixins) # Scan all methods in the class and its bases (mixins)
for attr_name in dir(window_class): for attr_name in dir(window_class):
try: try:
attr = getattr(window_class, attr_name) attr = getattr(window_class, attr_name)
# Check if this attribute has ribbon action metadata # Check if this attribute has ribbon action metadata
if hasattr(attr, '_ribbon_action'): if hasattr(attr, "_ribbon_action"):
action_data = attr._ribbon_action action_data = attr._ribbon_action
# Extract tab and group information # Extract tab and group information
tab_name = action_data['tab'] tab_name = action_data["tab"]
group_name = action_data['group'] group_name = action_data["group"]
# Add action to the appropriate tab and group # Add action to the appropriate tab and group
tabs[tab_name][group_name].append({ tabs[tab_name][group_name].append(
'label': action_data['label'], {
'action': action_data['action'], "label": action_data["label"],
'tooltip': action_data['tooltip'], "action": action_data["action"],
'icon': action_data.get('icon'), "tooltip": action_data["tooltip"],
'shortcut': action_data.get('shortcut'), "icon": action_data.get("icon"),
}) "shortcut": action_data.get("shortcut"),
}
)
except (AttributeError, TypeError): except (AttributeError, TypeError):
# Skip attributes that can't be inspected # Skip attributes that can't be inspected
continue continue
# Convert to the expected ribbon config format # Convert to the expected ribbon config format
ribbon_config = {} ribbon_config = {}
# Define tab order (tabs will appear in this order) # Define tab order (tabs will appear in this order)
tab_order = ['Home', 'Insert', 'Layout', 'Arrange', 'View', 'Export'] tab_order = ["Home", "Insert", "Layout", "Arrange", "View", "Export"]
# Add tabs in the defined order, then add any remaining tabs # Add tabs in the defined order, then add any remaining tabs
all_tabs = list(tabs.keys()) all_tabs = list(tabs.keys())
ordered_tabs = [t for t in tab_order if t in all_tabs] ordered_tabs = [t for t in tab_order if t in all_tabs]
ordered_tabs.extend([t for t in all_tabs if t not in tab_order]) ordered_tabs.extend([t for t in all_tabs if t not in tab_order])
for tab_name in ordered_tabs: for tab_name in ordered_tabs:
groups_dict = tabs[tab_name] groups_dict = tabs[tab_name]
# Convert groups dictionary to list format # Convert groups dictionary to list format
groups_list = [] groups_list = []
# Define group order per tab (if needed) # Define group order per tab (if needed)
group_orders = { group_orders = {
'Home': ['File', 'Edit'], "Home": ["File", "Edit"],
'Insert': ['Media'], "Insert": ["Media"],
'Layout': ['Navigation', 'Page', 'Templates'], "Layout": ["Navigation", "Page", "Templates"],
'Arrange': ['Align', 'Size', 'Distribute'], "Arrange": ["Align", "Size", "Distribute"],
'View': ['Zoom'], "View": ["Zoom"],
'Export': ['Export'], "Export": ["Export"],
} }
# Get the group order for this tab, or use alphabetical # Get the group order for this tab, or use alphabetical
if tab_name in group_orders: if tab_name in group_orders:
group_order = group_orders[tab_name] group_order = group_orders[tab_name]
@ -103,139 +105,128 @@ def build_ribbon_config(window_class: Type) -> Dict[str, Any]:
group_order.extend([g for g in all_groups if g not in group_order]) group_order.extend([g for g in all_groups if g not in group_order])
else: else:
group_order = sorted(groups_dict.keys()) group_order = sorted(groups_dict.keys())
for group_name in group_order: for group_name in group_order:
if group_name in groups_dict: if group_name in groups_dict:
actions = groups_dict[group_name] actions = groups_dict[group_name]
groups_list.append({ groups_list.append({"name": group_name, "actions": actions})
'name': group_name,
'actions': actions ribbon_config[tab_name] = {"groups": groups_list}
})
ribbon_config[tab_name] = {
'groups': groups_list
}
return ribbon_config return ribbon_config
def get_keyboard_shortcuts(window_class: Type) -> Dict[str, str]: def get_keyboard_shortcuts(window_class: Type) -> Dict[str, str]:
""" """
Extract keyboard shortcuts from decorated methods. Extract keyboard shortcuts from decorated methods.
Args: Args:
window_class: The MainWindow class with decorated methods window_class: The MainWindow class with decorated methods
Returns: Returns:
Dictionary mapping shortcut strings to method names Dictionary mapping shortcut strings to method names
Example: {'Ctrl+N': 'new_project', 'Ctrl+S': 'save_project'} Example: {'Ctrl+N': 'new_project', 'Ctrl+S': 'save_project'}
""" """
shortcuts = {} shortcuts = {}
for attr_name in dir(window_class): for attr_name in dir(window_class):
try: try:
attr = getattr(window_class, attr_name) attr = getattr(window_class, attr_name)
if hasattr(attr, '_ribbon_action'): if hasattr(attr, "_ribbon_action"):
action_data = attr._ribbon_action action_data = attr._ribbon_action
shortcut = action_data.get('shortcut') shortcut = action_data.get("shortcut")
if shortcut: if shortcut:
shortcuts[shortcut] = action_data['action'] shortcuts[shortcut] = action_data["action"]
except (AttributeError, TypeError): except (AttributeError, TypeError):
continue continue
return shortcuts return shortcuts
def validate_ribbon_config(config: Dict[str, Any]) -> List[str]: def validate_ribbon_config(config: Dict[str, Any]) -> List[str]:
""" """
Validate the ribbon configuration structure. Validate the ribbon configuration structure.
Args: Args:
config: The ribbon configuration dictionary config: The ribbon configuration dictionary
Returns: Returns:
List of validation error messages (empty if valid) List of validation error messages (empty if valid)
""" """
errors = [] errors = []
if not isinstance(config, dict): if not isinstance(config, dict):
errors.append("Config must be a dictionary") errors.append("Config must be a dictionary")
return errors return errors
for tab_name, tab_data in config.items(): for tab_name, tab_data in config.items():
if not isinstance(tab_data, dict): if not isinstance(tab_data, dict):
errors.append(f"Tab '{tab_name}' data must be a dictionary") errors.append(f"Tab '{tab_name}' data must be a dictionary")
continue continue
if 'groups' not in tab_data: if "groups" not in tab_data:
errors.append(f"Tab '{tab_name}' missing 'groups' key") errors.append(f"Tab '{tab_name}' missing 'groups' key")
continue continue
groups = tab_data['groups'] groups = tab_data["groups"]
if not isinstance(groups, list): if not isinstance(groups, list):
errors.append(f"Tab '{tab_name}' groups must be a list") errors.append(f"Tab '{tab_name}' groups must be a list")
continue continue
for i, group in enumerate(groups): for i, group in enumerate(groups):
if not isinstance(group, dict): if not isinstance(group, dict):
errors.append(f"Tab '{tab_name}' group {i} must be a dictionary") errors.append(f"Tab '{tab_name}' group {i} must be a dictionary")
continue continue
if 'name' not in group: if "name" not in group:
errors.append(f"Tab '{tab_name}' group {i} missing 'name'") errors.append(f"Tab '{tab_name}' group {i} missing 'name'")
if 'actions' not in group: if "actions" not in group:
errors.append(f"Tab '{tab_name}' group {i} missing 'actions'") errors.append(f"Tab '{tab_name}' group {i} missing 'actions'")
continue continue
actions = group['actions'] actions = group["actions"]
if not isinstance(actions, list): if not isinstance(actions, list):
errors.append(f"Tab '{tab_name}' group {i} actions must be a list") errors.append(f"Tab '{tab_name}' group {i} actions must be a list")
continue continue
for j, action in enumerate(actions): for j, action in enumerate(actions):
if not isinstance(action, dict): if not isinstance(action, dict):
errors.append(f"Tab '{tab_name}' group {i} action {j} must be a dictionary") errors.append(f"Tab '{tab_name}' group {i} action {j} must be a dictionary")
continue continue
required_keys = ['label', 'action', 'tooltip'] required_keys = ["label", "action", "tooltip"]
for key in required_keys: for key in required_keys:
if key not in action: if key not in action:
errors.append( errors.append(f"Tab '{tab_name}' group {i} action {j} missing '{key}'")
f"Tab '{tab_name}' group {i} action {j} missing '{key}'"
)
return errors return errors
def print_ribbon_summary(config: Dict[str, Any]): def print_ribbon_summary(config: Dict[str, Any]):
""" """
Print a summary of the ribbon configuration. Print a summary of the ribbon configuration.
Args: Args:
config: The ribbon configuration dictionary config: The ribbon configuration dictionary
""" """
print("\n=== Ribbon Configuration Summary ===\n") print("\n=== Ribbon Configuration Summary ===\n")
total_tabs = len(config) total_tabs = len(config)
total_groups = sum(len(tab_data['groups']) for tab_data in config.values()) total_groups = sum(len(tab_data["groups"]) for tab_data in config.values())
total_actions = sum( total_actions = sum(len(group["actions"]) for tab_data in config.values() for group in tab_data["groups"])
len(group['actions'])
for tab_data in config.values()
for group in tab_data['groups']
)
print(f"Total Tabs: {total_tabs}") print(f"Total Tabs: {total_tabs}")
print(f"Total Groups: {total_groups}") print(f"Total Groups: {total_groups}")
print(f"Total Actions: {total_actions}\n") print(f"Total Actions: {total_actions}\n")
for tab_name, tab_data in config.items(): for tab_name, tab_data in config.items():
print(f"📑 {tab_name}") print(f"📑 {tab_name}")
for group in tab_data['groups']: for group in tab_data["groups"]:
print(f" 📦 {group['name']} ({len(group['actions'])} actions)") print(f" 📦 {group['name']} ({len(group['actions'])} actions)")
for action in group['actions']: for action in group["actions"]:
shortcut = f" ({action['shortcut']})" if action.get('shortcut') else "" shortcut = f" ({action['shortcut']})" if action.get("shortcut") else ""
print(f"{action['label']}{shortcut}") print(f"{action['label']}{shortcut}")
print() print()

View File

@ -17,10 +17,11 @@ class RibbonWidget(QWidget):
# Use provided config or fall back to importing the old one # Use provided config or fall back to importing the old one
if ribbon_config is None: if ribbon_config is None:
from ribbon_config import RIBBON_CONFIG from ribbon_config import RIBBON_CONFIG
self.ribbon_config = RIBBON_CONFIG self.ribbon_config = RIBBON_CONFIG
else: else:
self.ribbon_config = ribbon_config self.ribbon_config = ribbon_config
# Main layout # Main layout
main_layout = QVBoxLayout() main_layout = QVBoxLayout()
main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setContentsMargins(0, 0, 0, 0)

View File

@ -11,28 +11,24 @@ from dataclasses import dataclass
@dataclass @dataclass
class Guide: class Guide:
"""Represents a snapping guide (vertical or horizontal line)""" """Represents a snapping guide (vertical or horizontal line)"""
position: float # Position in mm position: float # Position in mm
orientation: str # 'vertical' or 'horizontal' orientation: str # 'vertical' or 'horizontal'
def serialize(self) -> dict: def serialize(self) -> dict:
"""Serialize guide to dictionary""" """Serialize guide to dictionary"""
return { return {"position": self.position, "orientation": self.orientation}
"position": self.position,
"orientation": self.orientation
}
@staticmethod @staticmethod
def deserialize(data: dict) -> 'Guide': def deserialize(data: dict) -> "Guide":
"""Deserialize guide from dictionary""" """Deserialize guide from dictionary"""
return Guide( return Guide(position=data.get("position", 0), orientation=data.get("orientation", "vertical"))
position=data.get("position", 0),
orientation=data.get("orientation", "vertical")
)
@dataclass @dataclass
class SnapResizeParams: class SnapResizeParams:
"""Parameters for snap resize operations""" """Parameters for snap resize operations"""
position: Tuple[float, float] position: Tuple[float, float]
size: Tuple[float, float] size: Tuple[float, float]
dx: float dx: float
@ -45,11 +41,11 @@ class SnapResizeParams:
class SnappingSystem: class SnappingSystem:
"""Manages snapping behavior for layout elements""" """Manages snapping behavior for layout elements"""
def __init__(self, snap_threshold_mm: float = 5.0): def __init__(self, snap_threshold_mm: float = 5.0):
""" """
Initialize snapping system Initialize snapping system
Args: Args:
snap_threshold_mm: Distance in mm within which snapping occurs snap_threshold_mm: Distance in mm within which snapping occurs
""" """
@ -59,28 +55,30 @@ class SnappingSystem:
self.snap_to_edges = True self.snap_to_edges = True
self.snap_to_guides = True self.snap_to_guides = True
self.guides: List[Guide] = [] self.guides: List[Guide] = []
def add_guide(self, position: float, orientation: str): def add_guide(self, position: float, orientation: str):
"""Add a new guide""" """Add a new guide"""
guide = Guide(position=position, orientation=orientation) guide = Guide(position=position, orientation=orientation)
self.guides.append(guide) self.guides.append(guide)
return guide return guide
def remove_guide(self, guide: Guide): def remove_guide(self, guide: Guide):
"""Remove a guide""" """Remove a guide"""
if guide in self.guides: if guide in self.guides:
self.guides.remove(guide) self.guides.remove(guide)
def clear_guides(self): def clear_guides(self):
"""Remove all guides""" """Remove all guides"""
self.guides.clear() self.guides.clear()
def snap_position(self, def snap_position(
position: Tuple[float, float], self,
size: Tuple[float, float], position: Tuple[float, float],
page_size: Tuple[float, float], size: Tuple[float, float],
dpi: int = 300, page_size: Tuple[float, float],
project=None) -> Tuple[float, float]: dpi: int = 300,
project=None,
) -> Tuple[float, float]:
""" """
Apply snapping to a position using combined distance threshold Apply snapping to a position using combined distance threshold
@ -114,43 +112,47 @@ class SnappingSystem:
# Convert threshold from mm to pixels # Convert threshold from mm to pixels
snap_threshold_px = snap_threshold_mm * dpi / 25.4 snap_threshold_px = snap_threshold_mm * dpi / 25.4
# Collect all potential snap points for both edges of the element # Collect all potential snap points for both edges of the element
snap_points = [] snap_points = []
# 1. Page edge snap points # 1. Page edge snap points
if snap_to_edges: if snap_to_edges:
page_width_px = page_width_mm * dpi / 25.4 page_width_px = page_width_mm * dpi / 25.4
page_height_px = page_height_mm * dpi / 25.4 page_height_px = page_height_mm * dpi / 25.4
# Corners where element's top-left can snap # Corners where element's top-left can snap
snap_points.extend([ snap_points.extend(
(0, 0), # Top-left corner [
(page_width_px - width, 0), # Top-right corner (0, 0), # Top-left corner
(0, page_height_px - height), # Bottom-left corner (page_width_px - width, 0), # Top-right corner
(page_width_px - width, page_height_px - height), # Bottom-right corner (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) # Edge positions (element aligned to edge on one axis)
snap_points.extend([ snap_points.extend(
(0, y), # Left edge [
(page_width_px - width, y), # Right edge (0, y), # Left edge
(x, 0), # Top edge (page_width_px - width, y), # Right edge
(x, page_height_px - height), # Bottom edge (x, 0), # Top edge
]) (x, page_height_px - height), # Bottom edge
]
)
# 2. Grid snap points # 2. Grid snap points
if snap_to_grid: if snap_to_grid:
grid_size_px = grid_size_mm * dpi / 25.4 grid_size_px = grid_size_mm * dpi / 25.4
page_width_px = page_width_mm * dpi / 25.4 page_width_px = page_width_mm * dpi / 25.4
page_height_px = page_height_mm * dpi / 25.4 page_height_px = page_height_mm * dpi / 25.4
# Calculate grid intersection points within range # Calculate grid intersection points within range
x_start = max(0, int((x - snap_threshold_px) / grid_size_px)) * grid_size_px x_start = max(0, int((x - snap_threshold_px) / grid_size_px)) * grid_size_px
x_end = min(page_width_px, int((x + snap_threshold_px) / grid_size_px + 1) * grid_size_px) x_end = min(page_width_px, int((x + snap_threshold_px) / grid_size_px + 1) * grid_size_px)
y_start = max(0, int((y - snap_threshold_px) / grid_size_px)) * grid_size_px y_start = max(0, int((y - snap_threshold_px) / grid_size_px)) * grid_size_px
y_end = min(page_height_px, int((y + snap_threshold_px) / grid_size_px + 1) * grid_size_px) y_end = min(page_height_px, int((y + snap_threshold_px) / grid_size_px + 1) * grid_size_px)
grid_x = x_start grid_x = x_start
while grid_x <= x_end: while grid_x <= x_end:
grid_y = y_start grid_y = y_start
@ -163,12 +165,12 @@ class SnappingSystem:
snap_points.append((grid_x, grid_y - height)) snap_points.append((grid_x, grid_y - height))
grid_y += grid_size_px grid_y += grid_size_px
grid_x += grid_size_px grid_x += grid_size_px
# 3. Guide snap points # 3. Guide snap points
if snap_to_guides: if snap_to_guides:
vertical_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == 'vertical'] vertical_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == "vertical"]
horizontal_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == 'horizontal'] horizontal_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == "horizontal"]
# Guide intersections (when both vertical and horizontal guides exist) # Guide intersections (when both vertical and horizontal guides exist)
for vg in vertical_guides: for vg in vertical_guides:
for hg in horizontal_guides: for hg in horizontal_guides:
@ -177,23 +179,23 @@ class SnappingSystem:
snap_points.append((vg - width, hg)) snap_points.append((vg - width, hg))
snap_points.append((vg, hg - height)) snap_points.append((vg, hg - height))
snap_points.append((vg - width, hg - height)) snap_points.append((vg - width, hg - height))
# Find the nearest snap point using Euclidean distance # Find the nearest snap point using Euclidean distance
best_snap_point = None best_snap_point = None
best_distance = snap_threshold_px best_distance = snap_threshold_px
for snap_x, snap_y in snap_points: for snap_x, snap_y in snap_points:
distance = math.sqrt((x - snap_x) ** 2 + (y - snap_y) ** 2) distance = math.sqrt((x - snap_x) ** 2 + (y - snap_y) ** 2)
if distance < best_distance: if distance < best_distance:
best_snap_point = (snap_x, snap_y) best_snap_point = (snap_x, snap_y)
best_distance = distance best_distance = distance
# Return snapped position or original position # Return snapped position or original position
if best_snap_point: if best_snap_point:
return best_snap_point return best_snap_point
else: else:
return (x, y) return (x, y)
def snap_resize(self, params: SnapResizeParams) -> Tuple[Tuple[float, float], Tuple[float, float]]: def snap_resize(self, params: SnapResizeParams) -> Tuple[Tuple[float, float], Tuple[float, float]]:
""" """
Apply snapping during resize operations Apply snapping during resize operations
@ -222,32 +224,32 @@ class SnappingSystem:
new_width, new_height = width, height new_width, new_height = width, height
# Apply resize based on handle # Apply resize based on handle
if params.resize_handle in ['nw', 'n', 'ne']: if params.resize_handle in ["nw", "n", "ne"]:
# Top edge moving # Top edge moving
new_y = y + params.dy new_y = y + params.dy
new_height = height - params.dy new_height = height - params.dy
if params.resize_handle in ['sw', 's', 'se']: if params.resize_handle in ["sw", "s", "se"]:
# Bottom edge moving # Bottom edge moving
new_height = height + params.dy new_height = height + params.dy
if params.resize_handle in ['nw', 'w', 'sw']: if params.resize_handle in ["nw", "w", "sw"]:
# Left edge moving # Left edge moving
new_x = x + params.dx new_x = x + params.dx
new_width = width - params.dx new_width = width - params.dx
if params.resize_handle in ['ne', 'e', 'se']: if params.resize_handle in ["ne", "e", "se"]:
# Right edge moving # Right edge moving
new_width = width + params.dx new_width = width + params.dx
# Now apply snapping to the edges that are being moved # Now apply snapping to the edges that are being moved
# Use _snap_edge_to_targets consistently for all edges # Use _snap_edge_to_targets consistently for all edges
# Snap left edge (for nw, w, sw handles) # Snap left edge (for nw, w, sw handles)
if params.resize_handle in ['nw', 'w', 'sw']: if params.resize_handle in ["nw", "w", "sw"]:
# Try to snap the left edge # Try to snap the left edge
snapped_left = self._snap_edge_to_targets( snapped_left = self._snap_edge_to_targets(
new_x, page_width_mm, params.dpi, snap_threshold_px, 'vertical', params.project new_x, page_width_mm, params.dpi, snap_threshold_px, "vertical", params.project
) )
if snapped_left is not None: if snapped_left is not None:
# Adjust width to compensate for position change # Adjust width to compensate for position change
@ -256,21 +258,21 @@ class SnappingSystem:
new_width += width_adjustment new_width += width_adjustment
# Snap right edge (for ne, e, se handles) # Snap right edge (for ne, e, se handles)
if params.resize_handle in ['ne', 'e', 'se']: if params.resize_handle in ["ne", "e", "se"]:
# Calculate right edge position # Calculate right edge position
right_edge = new_x + new_width right_edge = new_x + new_width
# Try to snap the right edge # Try to snap the right edge
snapped_right = self._snap_edge_to_targets( snapped_right = self._snap_edge_to_targets(
right_edge, page_width_mm, params.dpi, snap_threshold_px, 'vertical', params.project right_edge, page_width_mm, params.dpi, snap_threshold_px, "vertical", params.project
) )
if snapped_right is not None: if snapped_right is not None:
new_width = snapped_right - new_x new_width = snapped_right - new_x
# Snap top edge (for nw, n, ne handles) # Snap top edge (for nw, n, ne handles)
if params.resize_handle in ['nw', 'n', 'ne']: if params.resize_handle in ["nw", "n", "ne"]:
# Try to snap the top edge # Try to snap the top edge
snapped_top = self._snap_edge_to_targets( snapped_top = self._snap_edge_to_targets(
new_y, page_height_mm, params.dpi, snap_threshold_px, 'horizontal', params.project new_y, page_height_mm, params.dpi, snap_threshold_px, "horizontal", params.project
) )
if snapped_top is not None: if snapped_top is not None:
# Adjust height to compensate for position change # Adjust height to compensate for position change
@ -279,30 +281,32 @@ class SnappingSystem:
new_height += height_adjustment new_height += height_adjustment
# Snap bottom edge (for sw, s, se handles) # Snap bottom edge (for sw, s, se handles)
if params.resize_handle in ['sw', 's', 'se']: if params.resize_handle in ["sw", "s", "se"]:
# Calculate bottom edge position # Calculate bottom edge position
bottom_edge = new_y + new_height bottom_edge = new_y + new_height
# Try to snap the bottom edge # Try to snap the bottom edge
snapped_bottom = self._snap_edge_to_targets( snapped_bottom = self._snap_edge_to_targets(
bottom_edge, page_height_mm, params.dpi, snap_threshold_px, 'horizontal', params.project bottom_edge, page_height_mm, params.dpi, snap_threshold_px, "horizontal", params.project
) )
if snapped_bottom is not None: if snapped_bottom is not None:
new_height = snapped_bottom - new_y new_height = snapped_bottom - new_y
# Ensure minimum size # Ensure minimum size
min_size = 10 # Minimum 10 pixels min_size = 10 # Minimum 10 pixels
new_width = max(new_width, min_size) new_width = max(new_width, min_size)
new_height = max(new_height, min_size) new_height = max(new_height, min_size)
return ((new_x, new_y), (new_width, new_height)) return ((new_x, new_y), (new_width, new_height))
def _snap_edge_to_targets(self, def _snap_edge_to_targets(
edge_position: float, self,
page_size_mm: float, edge_position: float,
dpi: int, page_size_mm: float,
snap_threshold_px: float, dpi: int,
orientation: str, snap_threshold_px: float,
project=None) -> Optional[float]: orientation: str,
project=None,
) -> Optional[float]:
""" """
Snap an edge position to available targets (grid, edges, guides) Snap an edge position to available targets (grid, edges, guides)
@ -354,28 +358,24 @@ class SnappingSystem:
if guide.orientation == orientation: if guide.orientation == orientation:
guide_pos_px = guide.position * dpi / 25.4 guide_pos_px = guide.position * dpi / 25.4
snap_candidates.append((guide_pos_px, abs(edge_position - guide_pos_px))) snap_candidates.append((guide_pos_px, abs(edge_position - guide_pos_px)))
# Find the best snap candidate within threshold # Find the best snap candidate within threshold
best_snap = None best_snap = None
best_distance = snap_threshold_px best_distance = snap_threshold_px
for snap_pos, distance in snap_candidates: for snap_pos, distance in snap_candidates:
if distance < best_distance: if distance < best_distance:
best_snap = snap_pos best_snap = snap_pos
best_distance = distance best_distance = distance
return best_snap return best_snap
def _snap_axis(self, def _snap_axis(
position: float, self, position: float, size: float, page_size_mm: float, dpi: int, snap_threshold_px: float, orientation: str
size: float, ) -> float:
page_size_mm: float,
dpi: int,
snap_threshold_px: float,
orientation: str) -> float:
""" """
Snap along a single axis Snap along a single axis
Args: Args:
position: Current position along axis in pixels position: Current position along axis in pixels
size: Element size along axis in pixels size: Element size along axis in pixels
@ -383,115 +383,108 @@ class SnappingSystem:
dpi: DPI for conversion dpi: DPI for conversion
snap_threshold_px: Snap threshold in pixels snap_threshold_px: Snap threshold in pixels
orientation: 'vertical' for x-axis, 'horizontal' for y-axis orientation: 'vertical' for x-axis, 'horizontal' for y-axis
Returns: Returns:
Snapped position in pixels Snapped position in pixels
""" """
snap_candidates = [] snap_candidates = []
# 1. Page edge snapping # 1. Page edge snapping
if self.snap_to_edges: if self.snap_to_edges:
# Snap to start edge (0) # Snap to start edge (0)
snap_candidates.append((0, abs(position - 0))) snap_candidates.append((0, abs(position - 0)))
# Snap to end edge # Snap to end edge
page_size_px = page_size_mm * dpi / 25.4 page_size_px = page_size_mm * dpi / 25.4
snap_candidates.append((page_size_px - size, abs(position - (page_size_px - size)))) snap_candidates.append((page_size_px - size, abs(position - (page_size_px - size))))
# Also snap element's far edge to page edge # Also snap element's far edge to page edge
snap_candidates.append((page_size_px - size, abs((position + size) - page_size_px))) snap_candidates.append((page_size_px - size, abs((position + size) - page_size_px)))
# 2. Grid snapping # 2. Grid snapping
if self.snap_to_grid: if self.snap_to_grid:
grid_size_px = self.grid_size_mm * dpi / 25.4 grid_size_px = self.grid_size_mm * dpi / 25.4
# Snap to nearest grid line # Snap to nearest grid line
nearest_grid = round(position / grid_size_px) * grid_size_px nearest_grid = round(position / grid_size_px) * grid_size_px
snap_candidates.append((nearest_grid, abs(position - nearest_grid))) snap_candidates.append((nearest_grid, abs(position - nearest_grid)))
# Also try snapping element's far edge to grid # Also try snapping element's far edge to grid
element_end = position + size element_end = position + size
nearest_grid_end = round(element_end / grid_size_px) * grid_size_px nearest_grid_end = round(element_end / grid_size_px) * grid_size_px
snap_candidates.append((nearest_grid_end - size, abs(element_end - nearest_grid_end))) snap_candidates.append((nearest_grid_end - size, abs(element_end - nearest_grid_end)))
# 3. Guide snapping # 3. Guide snapping
if self.snap_to_guides: if self.snap_to_guides:
for guide in self.guides: for guide in self.guides:
if guide.orientation == orientation: if guide.orientation == orientation:
guide_pos_px = guide.position * dpi / 25.4 guide_pos_px = guide.position * dpi / 25.4
# Snap start edge to guide # Snap start edge to guide
snap_candidates.append((guide_pos_px, abs(position - guide_pos_px))) snap_candidates.append((guide_pos_px, abs(position - guide_pos_px)))
# Snap end edge to guide # Snap end edge to guide
element_end = position + size element_end = position + size
snap_candidates.append((guide_pos_px - size, abs(element_end - guide_pos_px))) snap_candidates.append((guide_pos_px - size, abs(element_end - guide_pos_px)))
# Find the best snap candidate within threshold # Find the best snap candidate within threshold
best_snap = None best_snap = None
best_distance = snap_threshold_px best_distance = snap_threshold_px
for snap_pos, distance in snap_candidates: for snap_pos, distance in snap_candidates:
if distance < best_distance: if distance < best_distance:
best_snap = snap_pos best_snap = snap_pos
best_distance = distance best_distance = distance
return best_snap if best_snap is not None else position return best_snap if best_snap is not None else position
def get_snap_lines(self, page_size: Tuple[float, float], dpi: int = 300) -> dict: def get_snap_lines(self, page_size: Tuple[float, float], dpi: int = 300) -> dict:
""" """
Get all snap lines for visualization Get all snap lines for visualization
Args: Args:
page_size: Page size (width, height) in mm page_size: Page size (width, height) in mm
dpi: DPI for conversion dpi: DPI for conversion
Returns: Returns:
Dictionary with 'grid', 'edges', and 'guides' lists Dictionary with 'grid', 'edges', and 'guides' lists
""" """
page_width_mm, page_height_mm = page_size page_width_mm, page_height_mm = page_size
page_width_px = page_width_mm * dpi / 25.4 page_width_px = page_width_mm * dpi / 25.4
page_height_px = page_height_mm * dpi / 25.4 page_height_px = page_height_mm * dpi / 25.4
result = { result = {"grid": [], "edges": [], "guides": []}
'grid': [],
'edges': [],
'guides': []
}
# Grid lines # Grid lines
if self.snap_to_grid: if self.snap_to_grid:
grid_size_px = self.grid_size_mm * dpi / 25.4 grid_size_px = self.grid_size_mm * dpi / 25.4
# Vertical grid lines # Vertical grid lines
x = 0 x = 0
while x <= page_width_px: while x <= page_width_px:
result['grid'].append(('vertical', x)) result["grid"].append(("vertical", x))
x += grid_size_px x += grid_size_px
# Horizontal grid lines # Horizontal grid lines
y = 0 y = 0
while y <= page_height_px: while y <= page_height_px:
result['grid'].append(('horizontal', y)) result["grid"].append(("horizontal", y))
y += grid_size_px y += grid_size_px
# Edge lines # Edge lines
if self.snap_to_edges: if self.snap_to_edges:
result['edges'].extend([ result["edges"].extend(
('vertical', 0), [("vertical", 0), ("vertical", page_width_px), ("horizontal", 0), ("horizontal", page_height_px)]
('vertical', page_width_px), )
('horizontal', 0),
('horizontal', page_height_px)
])
# Guide lines # Guide lines
if self.snap_to_guides: if self.snap_to_guides:
for guide in self.guides: for guide in self.guides:
guide_pos_px = guide.position * dpi / 25.4 guide_pos_px = guide.position * dpi / 25.4
result['guides'].append((guide.orientation, guide_pos_px)) result["guides"].append((guide.orientation, guide_pos_px))
return result return result
def serialize(self) -> dict: def serialize(self) -> dict:
"""Serialize snapping system to dictionary""" """Serialize snapping system to dictionary"""
return { return {
@ -500,9 +493,9 @@ class SnappingSystem:
"snap_to_grid": self.snap_to_grid, "snap_to_grid": self.snap_to_grid,
"snap_to_edges": self.snap_to_edges, "snap_to_edges": self.snap_to_edges,
"snap_to_guides": self.snap_to_guides, "snap_to_guides": self.snap_to_guides,
"guides": [guide.serialize() for guide in self.guides] "guides": [guide.serialize() for guide in self.guides],
} }
def deserialize(self, data: dict): def deserialize(self, data: dict):
"""Deserialize from dictionary""" """Deserialize from dictionary"""
self.snap_threshold_mm = data.get("snap_threshold_mm", 5.0) self.snap_threshold_mm = data.get("snap_threshold_mm", 5.0)
@ -510,7 +503,7 @@ class SnappingSystem:
self.snap_to_grid = data.get("snap_to_grid", False) self.snap_to_grid = data.get("snap_to_grid", False)
self.snap_to_edges = data.get("snap_to_edges", True) self.snap_to_edges = data.get("snap_to_edges", True)
self.snap_to_guides = data.get("snap_to_guides", True) self.snap_to_guides = data.get("snap_to_guides", True)
self.guides = [] self.guides = []
for guide_data in data.get("guides", []): for guide_data in data.get("guides", []):
self.guides.append(Guide.deserialize(guide_data)) self.guides.append(Guide.deserialize(guide_data))

View File

@ -14,7 +14,9 @@ from pyPhotoAlbum.project import Page
class Template: class Template:
"""Class representing a page layout template""" """Class representing a page layout template"""
def __init__(self, name: str = "Untitled Template", description: str = "", page_size_mm: Tuple[float, float] = (210, 297)): def __init__(
self, name: str = "Untitled Template", description: str = "", page_size_mm: Tuple[float, float] = (210, 297)
):
self.name = name self.name = name
self.description = description self.description = description
self.page_size_mm = page_size_mm self.page_size_mm = page_size_mm
@ -30,16 +32,16 @@ class Template:
"name": self.name, "name": self.name,
"description": self.description, "description": self.description,
"page_size_mm": self.page_size_mm, "page_size_mm": self.page_size_mm,
"elements": [elem.serialize() for elem in self.elements] "elements": [elem.serialize() for elem in self.elements],
} }
@classmethod @classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Template': def from_dict(cls, data: Dict[str, Any]) -> "Template":
"""Deserialize template from dictionary""" """Deserialize template from dictionary"""
template = cls( template = cls(
name=data.get("name", "Untitled Template"), name=data.get("name", "Untitled Template"),
description=data.get("description", ""), description=data.get("description", ""),
page_size_mm=tuple(data.get("page_size_mm", (210, 297))) page_size_mm=tuple(data.get("page_size_mm", (210, 297))),
) )
# Deserialize elements # Deserialize elements
@ -59,13 +61,13 @@ class Template:
def save_to_file(self, file_path: str): def save_to_file(self, file_path: str):
"""Save template to JSON file""" """Save template to JSON file"""
with open(file_path, 'w') as f: with open(file_path, "w") as f:
json.dump(self.to_dict(), f, indent=2) json.dump(self.to_dict(), f, indent=2)
@classmethod @classmethod
def load_from_file(cls, file_path: str) -> 'Template': def load_from_file(cls, file_path: str) -> "Template":
"""Load template from JSON file""" """Load template from JSON file"""
with open(file_path, 'r') as f: with open(file_path, "r") as f:
data = json.load(f) data = json.load(f)
return cls.from_dict(data) return cls.from_dict(data)
@ -94,7 +96,7 @@ class TemplateManager:
def _ensure_templates_directory(self): def _ensure_templates_directory(self):
"""Create templates directory if it doesn't exist""" """Create templates directory if it doesn't exist"""
self.templates_dir.mkdir(parents=True, exist_ok=True) self.templates_dir.mkdir(parents=True, exist_ok=True)
# Also ensure built-in templates directory exists # Also ensure built-in templates directory exists
builtin_dir = self._get_builtin_templates_directory() builtin_dir = self._get_builtin_templates_directory()
builtin_dir.mkdir(parents=True, exist_ok=True) builtin_dir.mkdir(parents=True, exist_ok=True)
@ -102,33 +104,33 @@ class TemplateManager:
def list_templates(self) -> List[str]: def list_templates(self) -> List[str]:
"""List all available template names (embedded + user + built-in)""" """List all available template names (embedded + user + built-in)"""
templates = [] templates = []
# List embedded templates (priority) # List embedded templates (priority)
if self.project and self.project.embedded_templates: if self.project and self.project.embedded_templates:
for template_name in self.project.embedded_templates.keys(): for template_name in self.project.embedded_templates.keys():
templates.append(f"[Embedded] {template_name}") templates.append(f"[Embedded] {template_name}")
# List user templates # List user templates
if self.templates_dir.exists(): if self.templates_dir.exists():
for file in self.templates_dir.glob("*.json"): for file in self.templates_dir.glob("*.json"):
templates.append(file.stem) templates.append(file.stem)
# List built-in templates # List built-in templates
builtin_dir = self._get_builtin_templates_directory() builtin_dir = self._get_builtin_templates_directory()
if builtin_dir.exists(): if builtin_dir.exists():
for file in builtin_dir.glob("*.json"): for file in builtin_dir.glob("*.json"):
template_name = f"[Built-in] {file.stem}" template_name = f"[Built-in] {file.stem}"
templates.append(template_name) templates.append(template_name)
return sorted(templates) return sorted(templates)
def load_template(self, name: str) -> Template: def load_template(self, name: str) -> Template:
""" """
Load a template by name with priority: embedded > user > built-in. Load a template by name with priority: embedded > user > built-in.
Args: Args:
name: Template name (may include prefix like '[Embedded]' or '[Built-in]') name: Template name (may include prefix like '[Embedded]' or '[Built-in]')
Returns: Returns:
Template instance Template instance
""" """
@ -139,12 +141,12 @@ class TemplateManager:
template_data = self.project.embedded_templates[actual_name] template_data = self.project.embedded_templates[actual_name]
return Template.from_dict(template_data) return Template.from_dict(template_data)
raise FileNotFoundError(f"Embedded template '{actual_name}' not found") raise FileNotFoundError(f"Embedded template '{actual_name}' not found")
# Check embedded templates even without prefix (for backward compatibility) # Check embedded templates even without prefix (for backward compatibility)
if self.project and name in self.project.embedded_templates: if self.project and name in self.project.embedded_templates:
template_data = self.project.embedded_templates[name] template_data = self.project.embedded_templates[name]
return Template.from_dict(template_data) return Template.from_dict(template_data)
# Check if it's a built-in template # Check if it's a built-in template
if name.startswith("[Built-in] "): if name.startswith("[Built-in] "):
actual_name = name.replace("[Built-in] ", "") actual_name = name.replace("[Built-in] ", "")
@ -152,16 +154,16 @@ class TemplateManager:
else: else:
# User template # User template
template_path = self.templates_dir / f"{name}.json" template_path = self.templates_dir / f"{name}.json"
if not template_path.exists(): if not template_path.exists():
raise FileNotFoundError(f"Template '{name}' not found") raise FileNotFoundError(f"Template '{name}' not found")
return Template.load_from_file(str(template_path)) return Template.load_from_file(str(template_path))
def save_template(self, template: Template, embed_in_project: bool = False): def save_template(self, template: Template, embed_in_project: bool = False):
""" """
Save a template to filesystem or embed in project. Save a template to filesystem or embed in project.
Args: Args:
template: Template to save template: Template to save
embed_in_project: If True, embed in project instead of saving to filesystem embed_in_project: If True, embed in project instead of saving to filesystem
@ -179,7 +181,7 @@ class TemplateManager:
"""Delete a template (embedded or user templates only)""" """Delete a template (embedded or user templates only)"""
if name.startswith("[Built-in] "): if name.startswith("[Built-in] "):
raise PermissionError("Cannot delete built-in templates") raise PermissionError("Cannot delete built-in templates")
# Check if it's an embedded template # Check if it's an embedded template
if name.startswith("[Embedded] "): if name.startswith("[Embedded] "):
actual_name = name.replace("[Embedded] ", "") actual_name = name.replace("[Embedded] ", "")
@ -188,7 +190,7 @@ class TemplateManager:
print(f"Removed embedded template '{actual_name}'") print(f"Removed embedded template '{actual_name}'")
return return
raise FileNotFoundError(f"Embedded template '{actual_name}' not found") raise FileNotFoundError(f"Embedded template '{actual_name}' not found")
# User template from filesystem # User template from filesystem
template_path = self.templates_dir / f"{name}.json" template_path = self.templates_dir / f"{name}.json"
if template_path.exists(): if template_path.exists():
@ -197,26 +199,22 @@ class TemplateManager:
def embed_template(self, template: Template): def embed_template(self, template: Template):
""" """
Embed a template in the project. Embed a template in the project.
Args: Args:
template: Template to embed template: Template to embed
""" """
if not self.project: if not self.project:
raise RuntimeError("No project associated with this TemplateManager") raise RuntimeError("No project associated with this TemplateManager")
self.project.embedded_templates[template.name] = template.to_dict() self.project.embedded_templates[template.name] = template.to_dict()
print(f"Embedded template '{template.name}' in project") print(f"Embedded template '{template.name}' in project")
def create_template_from_page(self, page: Page, name: str, description: str = "") -> Template: def create_template_from_page(self, page: Page, name: str, description: str = "") -> Template:
""" """
Create a template from an existing page. Create a template from an existing page.
Converts all ImageData elements to PlaceholderData. Converts all ImageData elements to PlaceholderData.
""" """
template = Template( template = Template(name=name, description=description, page_size_mm=page.layout.size)
name=name,
description=description,
page_size_mm=page.layout.size
)
# Convert elements # Convert elements
for element in page.layout.elements: for element in page.layout.elements:
@ -229,7 +227,7 @@ class TemplateManager:
width=element.size[0], width=element.size[0],
height=element.size[1], height=element.size[1],
rotation=element.rotation, rotation=element.rotation,
z_index=element.z_index z_index=element.z_index,
) )
template.add_element(placeholder) template.add_element(placeholder)
elif isinstance(element, TextBoxData): elif isinstance(element, TextBoxData):
@ -243,7 +241,7 @@ class TemplateManager:
width=element.size[0], width=element.size[0],
height=element.size[1], height=element.size[1],
rotation=element.rotation, rotation=element.rotation,
z_index=element.z_index z_index=element.z_index,
) )
template.add_element(text_box) template.add_element(text_box)
elif isinstance(element, PlaceholderData): elif isinstance(element, PlaceholderData):
@ -256,7 +254,7 @@ class TemplateManager:
width=element.size[0], width=element.size[0],
height=element.size[1], height=element.size[1],
rotation=element.rotation, rotation=element.rotation,
z_index=element.z_index z_index=element.z_index,
) )
template.add_element(placeholder) template.add_element(placeholder)
@ -268,32 +266,32 @@ class TemplateManager:
from_size: Tuple[float, float], from_size: Tuple[float, float],
to_size: Tuple[float, float], to_size: Tuple[float, float],
scale_mode: str = "proportional", scale_mode: str = "proportional",
margin_percent: float = 0.0 margin_percent: float = 0.0,
) -> List[BaseLayoutElement]: ) -> List[BaseLayoutElement]:
""" """
Scale template elements to fit target page size with adjustable margins. Scale template elements to fit target page size with adjustable margins.
Args: Args:
elements: List of elements to scale elements: List of elements to scale
from_size: Original template size (width, height) in mm from_size: Original template size (width, height) in mm
to_size: Target page size (width, height) in mm to_size: Target page size (width, height) in mm
scale_mode: "proportional", "stretch", or "center" scale_mode: "proportional", "stretch", or "center"
margin_percent: Percentage of page size to use for margins (0-10%) margin_percent: Percentage of page size to use for margins (0-10%)
Returns: Returns:
List of scaled elements List of scaled elements
""" """
from_width, from_height = from_size from_width, from_height = from_size
to_width, to_height = to_size to_width, to_height = to_size
# Calculate target margins from percentage # Calculate target margins from percentage
margin_x = to_width * (margin_percent / 100.0) margin_x = to_width * (margin_percent / 100.0)
margin_y = to_height * (margin_percent / 100.0) margin_y = to_height * (margin_percent / 100.0)
# Available content area after margins # Available content area after margins
content_width = to_width - (2 * margin_x) content_width = to_width - (2 * margin_x)
content_height = to_height - (2 * margin_y) content_height = to_height - (2 * margin_y)
# Calculate scale factors based on mode # Calculate scale factors based on mode
if scale_mode == "stretch": if scale_mode == "stretch":
# Stretch to fill content area independently in each dimension # Stretch to fill content area independently in each dimension
@ -323,14 +321,13 @@ class TemplateManager:
# Create a new element of the same type # Create a new element of the same type
if isinstance(element, PlaceholderData): if isinstance(element, PlaceholderData):
new_elem = PlaceholderData( new_elem = PlaceholderData(
placeholder_type=element.placeholder_type, placeholder_type=element.placeholder_type, default_content=element.default_content
default_content=element.default_content
) )
elif isinstance(element, TextBoxData): elif isinstance(element, TextBoxData):
new_elem = TextBoxData( new_elem = TextBoxData(
text_content=element.text_content, text_content=element.text_content,
font_settings=element.font_settings.copy() if element.font_settings else None, font_settings=element.font_settings.copy() if element.font_settings else None,
alignment=element.alignment alignment=element.alignment,
) )
else: else:
continue # Skip other types continue # Skip other types
@ -339,14 +336,8 @@ class TemplateManager:
old_x, old_y = element.position old_x, old_y = element.position
old_w, old_h = element.size old_w, old_h = element.size
new_elem.position = ( new_elem.position = (old_x * scale_x + offset_x, old_y * scale_y + offset_y)
old_x * scale_x + offset_x, new_elem.size = (old_w * scale_x, old_h * scale_y)
old_y * scale_y + offset_y
)
new_elem.size = (
old_w * scale_x,
old_h * scale_y
)
new_elem.rotation = element.rotation new_elem.rotation = element.rotation
new_elem.z_index = element.z_index new_elem.z_index = element.z_index
@ -362,15 +353,9 @@ class TemplateManager:
for elem in scaled_elements: for elem in scaled_elements:
# Convert position from mm to pixels # Convert position from mm to pixels
elem.position = ( elem.position = (elem.position[0] * mm_to_px, elem.position[1] * mm_to_px)
elem.position[0] * mm_to_px,
elem.position[1] * mm_to_px
)
# Convert size from mm to pixels # Convert size from mm to pixels
elem.size = ( elem.size = (elem.size[0] * mm_to_px, elem.size[1] * mm_to_px)
elem.size[0] * mm_to_px,
elem.size[1] * mm_to_px
)
return scaled_elements return scaled_elements
@ -381,15 +366,15 @@ class TemplateManager:
mode: str = "replace", mode: str = "replace",
scale_mode: str = "proportional", scale_mode: str = "proportional",
margin_percent: float = 2.5, margin_percent: float = 2.5,
auto_embed: bool = True auto_embed: bool = True,
): ):
""" """
Apply template to an existing page with adjustable margins. Apply template to an existing page with adjustable margins.
Args: Args:
template: Template to apply template: Template to apply
page: Target page page: Target page
mode: "replace" to clear page and add placeholders, mode: "replace" to clear page and add placeholders,
"reflow" to keep existing content and reposition "reflow" to keep existing content and reposition
scale_mode: "proportional", "stretch", or "center" scale_mode: "proportional", "stretch", or "center"
margin_percent: Percentage of page size to use for margins (0-10%) margin_percent: Percentage of page size to use for margins (0-10%)
@ -399,44 +384,36 @@ class TemplateManager:
if auto_embed and self.project: if auto_embed and self.project:
if template.name not in self.project.embedded_templates: if template.name not in self.project.embedded_templates:
self.embed_template(template) self.embed_template(template)
if mode == "replace": if mode == "replace":
# Clear existing elements # Clear existing elements
page.layout.elements.clear() page.layout.elements.clear()
# Scale template elements to fit page # Scale template elements to fit page
scaled_elements = self.scale_template_elements( scaled_elements = self.scale_template_elements(
template.elements, template.elements, template.page_size_mm, page.layout.size, scale_mode, margin_percent
template.page_size_mm,
page.layout.size,
scale_mode,
margin_percent
) )
# Add scaled elements to page # Add scaled elements to page
for element in scaled_elements: for element in scaled_elements:
page.layout.add_element(element) page.layout.add_element(element)
elif mode == "reflow": elif mode == "reflow":
# Keep existing content but reposition to template slots # Keep existing content but reposition to template slots
existing_images = [e for e in page.layout.elements if isinstance(e, ImageData)] existing_images = [e for e in page.layout.elements if isinstance(e, ImageData)]
existing_text = [e for e in page.layout.elements if isinstance(e, TextBoxData)] existing_text = [e for e in page.layout.elements if isinstance(e, TextBoxData)]
# Get template placeholders (scaled) # Get template placeholders (scaled)
scaled_elements = self.scale_template_elements( scaled_elements = self.scale_template_elements(
template.elements, template.elements, template.page_size_mm, page.layout.size, scale_mode, margin_percent
template.page_size_mm,
page.layout.size,
scale_mode,
margin_percent
) )
template_placeholders = [e for e in scaled_elements if isinstance(e, PlaceholderData)] template_placeholders = [e for e in scaled_elements if isinstance(e, PlaceholderData)]
template_text = [e for e in scaled_elements if isinstance(e, TextBoxData)] template_text = [e for e in scaled_elements if isinstance(e, TextBoxData)]
# Clear page # Clear page
page.layout.elements.clear() page.layout.elements.clear()
# Reflow images into placeholder slots # Reflow images into placeholder slots
for i, placeholder in enumerate(template_placeholders): for i, placeholder in enumerate(template_placeholders):
if i < len(existing_images): if i < len(existing_images):
@ -449,11 +426,11 @@ class TemplateManager:
else: else:
# Add placeholder if no more images # Add placeholder if no more images
page.layout.add_element(placeholder) page.layout.add_element(placeholder)
# Add remaining images (if any) at their original positions # Add remaining images (if any) at their original positions
for img in existing_images[len(template_placeholders):]: for img in existing_images[len(template_placeholders) :]:
page.layout.add_element(img) page.layout.add_element(img)
# Add template text boxes # Add template text boxes
for text_elem in template_text: for text_elem in template_text:
page.layout.add_element(text_elem) page.layout.add_element(text_elem)
@ -465,7 +442,7 @@ class TemplateManager:
target_size_mm: Optional[Tuple[float, float]] = None, target_size_mm: Optional[Tuple[float, float]] = None,
scale_mode: str = "proportional", scale_mode: str = "proportional",
margin_percent: float = 2.5, margin_percent: float = 2.5,
auto_embed: bool = True auto_embed: bool = True,
) -> Page: ) -> Page:
""" """
Create a new page from a template. Create a new page from a template.
@ -494,11 +471,7 @@ class TemplateManager:
page_size = target_size_mm page_size = target_size_mm
# Scale template elements with margins # Scale template elements with margins
elements = self.scale_template_elements( elements = self.scale_template_elements(
template.elements, template.elements, template.page_size_mm, target_size_mm, scale_mode, margin_percent
template.page_size_mm,
target_size_mm,
scale_mode,
margin_percent
) )
# Create new page layout # Create new page layout

View File

@ -3,8 +3,15 @@ Text editing dialog for pyPhotoAlbum
""" """
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QDialog,
QTextEdit, QLabel, QComboBox, QSpinBox, QColorDialog QVBoxLayout,
QHBoxLayout,
QPushButton,
QTextEdit,
QLabel,
QComboBox,
QSpinBox,
QColorDialog,
) )
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
from PyQt6.QtGui import QFont, QColor from PyQt6.QtGui import QFont, QColor
@ -12,58 +19,57 @@ from PyQt6.QtGui import QFont, QColor
class TextEditDialog(QDialog): class TextEditDialog(QDialog):
"""Dialog for editing text box content and properties""" """Dialog for editing text box content and properties"""
def __init__(self, text_element, parent=None): def __init__(self, text_element, parent=None):
super().__init__(parent) super().__init__(parent)
self.text_element = text_element self.text_element = text_element
self.setWindowTitle("Edit Text") self.setWindowTitle("Edit Text")
self.resize(500, 400) self.resize(500, 400)
# Create UI # Create UI
self._init_ui() self._init_ui()
# Load current values # Load current values
self._load_values() self._load_values()
def _init_ui(self): def _init_ui(self):
"""Initialize the user interface""" """Initialize the user interface"""
layout = QVBoxLayout() layout = QVBoxLayout()
# Text editor # Text editor
text_label = QLabel("Text:") text_label = QLabel("Text:")
self.text_edit = QTextEdit() self.text_edit = QTextEdit()
self.text_edit.setAcceptRichText(False) # Plain text only self.text_edit.setAcceptRichText(False) # Plain text only
layout.addWidget(text_label) layout.addWidget(text_label)
layout.addWidget(self.text_edit) layout.addWidget(self.text_edit)
# Font settings # Font settings
font_layout = QHBoxLayout() font_layout = QHBoxLayout()
# Font family # Font family
font_layout.addWidget(QLabel("Font:")) font_layout.addWidget(QLabel("Font:"))
self.font_combo = QComboBox() self.font_combo = QComboBox()
self.font_combo.addItems([ self.font_combo.addItems(
"Arial", "Times New Roman", "Courier New", ["Arial", "Times New Roman", "Courier New", "Helvetica", "Verdana", "Georgia", "Comic Sans MS"]
"Helvetica", "Verdana", "Georgia", "Comic Sans MS" )
])
font_layout.addWidget(self.font_combo) font_layout.addWidget(self.font_combo)
# Font size # Font size
font_layout.addWidget(QLabel("Size:")) font_layout.addWidget(QLabel("Size:"))
self.font_size_spin = QSpinBox() self.font_size_spin = QSpinBox()
self.font_size_spin.setRange(6, 72) self.font_size_spin.setRange(6, 72)
self.font_size_spin.setValue(12) self.font_size_spin.setValue(12)
font_layout.addWidget(self.font_size_spin) font_layout.addWidget(self.font_size_spin)
# Text color # Text color
self.color_button = QPushButton("Color") self.color_button = QPushButton("Color")
self.color_button.clicked.connect(self._choose_color) self.color_button.clicked.connect(self._choose_color)
self.current_color = QColor(0, 0, 0) # Default black self.current_color = QColor(0, 0, 0) # Default black
font_layout.addWidget(self.color_button) font_layout.addWidget(self.color_button)
font_layout.addStretch() font_layout.addStretch()
layout.addLayout(font_layout) layout.addLayout(font_layout)
# Alignment # Alignment
alignment_layout = QHBoxLayout() alignment_layout = QHBoxLayout()
alignment_layout.addWidget(QLabel("Alignment:")) alignment_layout.addWidget(QLabel("Alignment:"))
@ -72,84 +78,76 @@ class TextEditDialog(QDialog):
alignment_layout.addWidget(self.alignment_combo) alignment_layout.addWidget(self.alignment_combo)
alignment_layout.addStretch() alignment_layout.addStretch()
layout.addLayout(alignment_layout) layout.addLayout(alignment_layout)
# Buttons # Buttons
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
button_layout.addStretch() button_layout.addStretch()
cancel_button = QPushButton("Cancel") cancel_button = QPushButton("Cancel")
cancel_button.clicked.connect(self.reject) cancel_button.clicked.connect(self.reject)
button_layout.addWidget(cancel_button) button_layout.addWidget(cancel_button)
ok_button = QPushButton("OK") ok_button = QPushButton("OK")
ok_button.clicked.connect(self.accept) ok_button.clicked.connect(self.accept)
ok_button.setDefault(True) ok_button.setDefault(True)
button_layout.addWidget(ok_button) button_layout.addWidget(ok_button)
layout.addLayout(button_layout) layout.addLayout(button_layout)
self.setLayout(layout) self.setLayout(layout)
def _load_values(self): def _load_values(self):
"""Load current values from text element""" """Load current values from text element"""
# Load text content # Load text content
self.text_edit.setPlainText(self.text_element.text_content) self.text_edit.setPlainText(self.text_element.text_content)
# Load font settings # Load font settings
font_family = self.text_element.font_settings.get('family', 'Arial') font_family = self.text_element.font_settings.get("family", "Arial")
index = self.font_combo.findText(font_family) index = self.font_combo.findText(font_family)
if index >= 0: if index >= 0:
self.font_combo.setCurrentIndex(index) self.font_combo.setCurrentIndex(index)
font_size = self.text_element.font_settings.get('size', 12) font_size = self.text_element.font_settings.get("size", 12)
self.font_size_spin.setValue(int(font_size)) self.font_size_spin.setValue(int(font_size))
# Load color # Load color
color = self.text_element.font_settings.get('color', (0, 0, 0)) color = self.text_element.font_settings.get("color", (0, 0, 0))
if all(isinstance(c, int) and c > 1 for c in color): if all(isinstance(c, int) and c > 1 for c in color):
# Color in 0-255 range # Color in 0-255 range
self.current_color = QColor(*color) self.current_color = QColor(*color)
else: else:
# Color in 0-1 range # Color in 0-1 range
self.current_color = QColor( self.current_color = QColor(int(color[0] * 255), int(color[1] * 255), int(color[2] * 255))
int(color[0] * 255),
int(color[1] * 255),
int(color[2] * 255)
)
self._update_color_button() self._update_color_button()
# Load alignment # Load alignment
alignment = self.text_element.alignment alignment = self.text_element.alignment
index = self.alignment_combo.findText(alignment) index = self.alignment_combo.findText(alignment)
if index >= 0: if index >= 0:
self.alignment_combo.setCurrentIndex(index) self.alignment_combo.setCurrentIndex(index)
def _choose_color(self): def _choose_color(self):
"""Open color picker dialog""" """Open color picker dialog"""
color = QColorDialog.getColor(self.current_color, self, "Choose Text Color") color = QColorDialog.getColor(self.current_color, self, "Choose Text Color")
if color.isValid(): if color.isValid():
self.current_color = color self.current_color = color
self._update_color_button() self._update_color_button()
def _update_color_button(self): def _update_color_button(self):
"""Update color button appearance""" """Update color button appearance"""
self.color_button.setStyleSheet( self.color_button.setStyleSheet(
f"background-color: {self.current_color.name()}; " f"background-color: {self.current_color.name()}; "
f"color: {'white' if self.current_color.lightness() < 128 else 'black'};" f"color: {'white' if self.current_color.lightness() < 128 else 'black'};"
) )
def get_values(self): def get_values(self):
"""Get the edited values""" """Get the edited values"""
return { return {
'text_content': self.text_edit.toPlainText(), "text_content": self.text_edit.toPlainText(),
'font_settings': { "font_settings": {
'family': self.font_combo.currentText(), "family": self.font_combo.currentText(),
'size': self.font_size_spin.value(), "size": self.font_size_spin.value(),
'color': ( "color": (self.current_color.red(), self.current_color.green(), self.current_color.blue()),
self.current_color.red(),
self.current_color.green(),
self.current_color.blue()
)
}, },
'alignment': self.alignment_combo.currentText() "alignment": self.alignment_combo.currentText(),
} }

View File

@ -24,7 +24,7 @@ VERSION_HISTORY = {
"released": "2025-01-11", "released": "2025-01-11",
"breaking_changes": [ "breaking_changes": [
"Asset paths changed from absolute/full-project-relative to project-relative", "Asset paths changed from absolute/full-project-relative to project-relative",
"Added automatic path normalization for legacy projects" "Added automatic path normalization for legacy projects",
], ],
"compatible_with": ["1.0", "2.0"], # 2.0 can read 1.0 with migration "compatible_with": ["1.0", "2.0"], # 2.0 can read 1.0 with migration
}, },
@ -38,7 +38,7 @@ VERSION_HISTORY = {
"Added deletion tracking (deleted flag and deleted_at timestamp)", "Added deletion tracking (deleted flag and deleted_at timestamp)",
], ],
"compatible_with": ["1.0", "2.0", "3.0"], # 3.0 can read older versions with migration "compatible_with": ["1.0", "2.0", "3.0"], # 3.0 can read older versions with migration
} },
} }
@ -120,9 +120,11 @@ class DataMigration:
@classmethod @classmethod
def register_migration(cls, from_version: str, to_version: str): def register_migration(cls, from_version: str, to_version: str):
"""Decorator to register a migration function""" """Decorator to register a migration function"""
def decorator(func): def decorator(func):
cls._migrations[(from_version, to_version)] = func cls._migrations[(from_version, to_version)] = func
return func return func
return decorator return decorator
@classmethod @classmethod
@ -164,6 +166,7 @@ class DataMigration:
# Register migrations # Register migrations
@DataMigration.register_migration("1.0", "2.0") @DataMigration.register_migration("1.0", "2.0")
def migrate_1_0_to_2_0(data: Dict[str, Any]) -> Dict[str, Any]: def migrate_1_0_to_2_0(data: Dict[str, Any]) -> Dict[str, Any]:
""" """
@ -176,7 +179,7 @@ def migrate_1_0_to_2_0(data: Dict[str, Any]) -> Dict[str, Any]:
print("Migration 1.0 → 2.0: Asset paths will be normalized during load") print("Migration 1.0 → 2.0: Asset paths will be normalized during load")
# Update version in data # Update version in data
data['data_version'] = "2.0" data["data_version"] = "2.0"
# Note: Actual path normalization is handled in load_from_zip # Note: Actual path normalization is handled in load_from_zip
# This migration mainly updates the version number # This migration mainly updates the version number
@ -249,7 +252,7 @@ def migrate_2_0_to_3_0(data: Dict[str, Any]) -> Dict[str, Any]:
element_data["deleted_at"] = None element_data["deleted_at"] = None
# Update version # Update version
data['data_version'] = "3.0" data["data_version"] = "3.0"
print(f" Migrated {len(data.get('pages', []))} pages to v3.0") print(f" Migrated {len(data.get('pages', []))} pages to v3.0")
@ -287,7 +290,7 @@ def check_version_compatibility(file_version: str, file_path: str = "") -> tuple
error_msg += f"File version info:\n" error_msg += f"File version info:\n"
error_msg += f" Description: {file_info.get('description', 'Unknown')}\n" error_msg += f" Description: {file_info.get('description', 'Unknown')}\n"
error_msg += f" Released: {file_info.get('released', 'Unknown')}\n" error_msg += f" Released: {file_info.get('released', 'Unknown')}\n"
breaking_changes = file_info.get('breaking_changes', []) breaking_changes = file_info.get("breaking_changes", [])
if breaking_changes: if breaking_changes:
error_msg += f" Breaking changes:\n" error_msg += f" Breaking changes:\n"
for change in breaking_changes: for change in breaking_changes:
@ -312,7 +315,7 @@ def format_version_info() -> str:
info.append(f" Description: {version_info.get('description', 'Unknown')}") info.append(f" Description: {version_info.get('description', 'Unknown')}")
info.append(f" Released: {version_info.get('released', 'Unknown')}") info.append(f" Released: {version_info.get('released', 'Unknown')}")
breaking_changes = version_info.get('breaking_changes', []) breaking_changes = version_info.get("breaking_changes", [])
if breaking_changes: if breaking_changes:
info.append(f" Breaking changes:") info.append(f" Breaking changes:")
for change in breaking_changes: for change in breaking_changes:

View File

@ -15,9 +15,9 @@ from pyPhotoAlbum.project import Project, Page
@pytest.fixture @pytest.fixture
def temp_image_file(): def temp_image_file():
"""Create a temporary test image file""" """Create a temporary test image file"""
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f: with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
# Create a simple test image # Create a simple test image
img = Image.new('RGB', (100, 100), color='red') img = Image.new("RGB", (100, 100), color="red")
img.save(f.name) img.save(f.name)
yield f.name yield f.name
# Cleanup # Cleanup
@ -37,37 +37,19 @@ def temp_dir():
@pytest.fixture @pytest.fixture
def sample_image_data(temp_image_file): def sample_image_data(temp_image_file):
"""Create a sample ImageData instance""" """Create a sample ImageData instance"""
return ImageData( return ImageData(image_path=temp_image_file, x=10.0, y=20.0, width=100.0, height=150.0)
image_path=temp_image_file,
x=10.0,
y=20.0,
width=100.0,
height=150.0
)
@pytest.fixture @pytest.fixture
def sample_placeholder_data(): def sample_placeholder_data():
"""Create a sample PlaceholderData instance""" """Create a sample PlaceholderData instance"""
return PlaceholderData( return PlaceholderData(placeholder_type="image", x=50.0, y=60.0, width=200.0, height=150.0)
placeholder_type="image",
x=50.0,
y=60.0,
width=200.0,
height=150.0
)
@pytest.fixture @pytest.fixture
def sample_textbox_data(): def sample_textbox_data():
"""Create a sample TextBoxData instance""" """Create a sample TextBoxData instance"""
return TextBoxData( return TextBoxData(text_content="Sample Text", x=30.0, y=40.0, width=150.0, height=50.0)
text_content="Sample Text",
x=30.0,
y=40.0,
width=150.0,
height=50.0
)
@pytest.fixture @pytest.fixture
@ -119,10 +101,7 @@ def mock_main_window():
window.project = Project(name="Test Project") window.project = Project(name="Test Project")
# Add a test page # Add a test page
page = Page( page = Page(layout=PageLayout(width=210, height=297), page_number=1) # A4 size in mm
layout=PageLayout(width=210, height=297), # A4 size in mm
page_number=1
)
window.project.pages.append(page) window.project.pages.append(page)
window.project.working_dpi = 96 window.project.working_dpi = 96
window.project.page_size_mm = (210, 297) window.project.page_size_mm = (210, 297)
@ -139,39 +118,19 @@ def mock_main_window():
@pytest.fixture @pytest.fixture
def sample_image_element(): def sample_image_element():
"""Create a sample ImageData element for testing""" """Create a sample ImageData element for testing"""
return ImageData( return ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150, z_index=1)
image_path="test.jpg",
x=100,
y=100,
width=200,
height=150,
z_index=1
)
@pytest.fixture @pytest.fixture
def sample_placeholder_element(): def sample_placeholder_element():
"""Create a sample PlaceholderData element for testing""" """Create a sample PlaceholderData element for testing"""
return PlaceholderData( return PlaceholderData(x=50, y=50, width=100, height=100, z_index=0)
x=50,
y=50,
width=100,
height=100,
z_index=0
)
@pytest.fixture @pytest.fixture
def sample_textbox_element(): def sample_textbox_element():
"""Create a sample TextBoxData element for testing""" """Create a sample TextBoxData element for testing"""
return TextBoxData( return TextBoxData(x=10, y=10, width=180, height=50, text_content="Test Text", z_index=2)
x=10,
y=10,
width=180,
height=50,
text_content="Test Text",
z_index=2
)
@pytest.fixture @pytest.fixture
@ -189,19 +148,19 @@ def mock_page_renderer():
# Mock coordinate conversion methods # Mock coordinate conversion methods
def page_to_screen(x, y): def page_to_screen(x, y):
return (renderer.screen_x + x * renderer.zoom, return (renderer.screen_x + x * renderer.zoom, renderer.screen_y + y * renderer.zoom)
renderer.screen_y + y * renderer.zoom)
def screen_to_page(x, y): def screen_to_page(x, y):
return ((x - renderer.screen_x) / renderer.zoom, return ((x - renderer.screen_x) / renderer.zoom, (y - renderer.screen_y) / renderer.zoom)
(y - renderer.screen_y) / renderer.zoom)
def is_point_in_page(x, y): def is_point_in_page(x, y):
# Simple bounds check (assume 210mm x 297mm page at 96 DPI) # Simple bounds check (assume 210mm x 297mm page at 96 DPI)
page_width_px = 210 * 96 / 25.4 page_width_px = 210 * 96 / 25.4
page_height_px = 297 * 96 / 25.4 page_height_px = 297 * 96 / 25.4
return (renderer.screen_x <= x <= renderer.screen_x + page_width_px * renderer.zoom and return (
renderer.screen_y <= y <= renderer.screen_y + page_height_px * renderer.zoom) renderer.screen_x <= x <= renderer.screen_x + page_width_px * renderer.zoom
and renderer.screen_y <= y <= renderer.screen_y + page_height_px * renderer.zoom
)
renderer.page_to_screen = page_to_screen renderer.page_to_screen = page_to_screen
renderer.screen_to_page = screen_to_page renderer.screen_to_page = screen_to_page
@ -213,8 +172,8 @@ def mock_page_renderer():
@pytest.fixture @pytest.fixture
def create_mouse_event(): def create_mouse_event():
"""Factory fixture for creating QMouseEvent objects""" """Factory fixture for creating QMouseEvent objects"""
def _create_event(event_type, x, y, button=Qt.MouseButton.LeftButton,
modifiers=Qt.KeyboardModifier.NoModifier): def _create_event(event_type, x, y, button=Qt.MouseButton.LeftButton, modifiers=Qt.KeyboardModifier.NoModifier):
"""Create a QMouseEvent for testing """Create a QMouseEvent for testing
Args: Args:
@ -224,19 +183,15 @@ def create_mouse_event():
modifiers: Keyboard modifiers modifiers: Keyboard modifiers
""" """
pos = QPointF(x, y) pos = QPointF(x, y)
return QMouseEvent( return QMouseEvent(event_type, pos, button, button, modifiers)
event_type,
pos,
button,
button,
modifiers
)
return _create_event return _create_event
@pytest.fixture @pytest.fixture
def create_wheel_event(): def create_wheel_event():
"""Factory fixture for creating QWheelEvent objects""" """Factory fixture for creating QWheelEvent objects"""
def _create_event(x, y, delta_y=120, modifiers=Qt.KeyboardModifier.NoModifier): def _create_event(x, y, delta_y=120, modifiers=Qt.KeyboardModifier.NoModifier):
"""Create a QWheelEvent for testing """Create a QWheelEvent for testing
@ -257,38 +212,22 @@ def create_wheel_event():
Qt.MouseButton.NoButton, Qt.MouseButton.NoButton,
modifiers, modifiers,
Qt.ScrollPhase.NoScrollPhase, Qt.ScrollPhase.NoScrollPhase,
False False,
) )
return _create_event return _create_event
@pytest.fixture @pytest.fixture
def populated_page(): def populated_page():
"""Create a page with multiple elements for testing""" """Create a page with multiple elements for testing"""
page = Page( page = Page(layout=PageLayout(width=210, height=297), page_number=1)
layout=PageLayout(width=210, height=297),
page_number=1
)
# Add various elements # Add various elements
page.layout.add_element(ImageData( page.layout.add_element(ImageData(image_path="img1.jpg", x=10, y=10, width=100, height=75, z_index=0))
image_path="img1.jpg",
x=10, y=10,
width=100, height=75,
z_index=0
))
page.layout.add_element(PlaceholderData( page.layout.add_element(PlaceholderData(x=120, y=10, width=80, height=60, z_index=1))
x=120, y=10,
width=80, height=60,
z_index=1
))
page.layout.add_element(TextBoxData( page.layout.add_element(TextBoxData(x=10, y=100, width=190, height=40, text_content="Sample Text", z_index=2))
x=10, y=100,
width=190, height=40,
text_content="Sample Text",
z_index=2
))
return page return page

View File

@ -20,7 +20,7 @@ class TestAlignmentManager:
"""Test get_bounds with single element""" """Test get_bounds with single element"""
elem = ImageData(x=10, y=20, width=100, height=50) elem = ImageData(x=10, y=20, width=100, height=50)
bounds = AlignmentManager.get_bounds([elem]) bounds = AlignmentManager.get_bounds([elem])
# min_x, min_y, max_x, max_y # min_x, min_y, max_x, max_y
assert bounds == (10, 20, 110, 70) assert bounds == (10, 20, 110, 70)
@ -29,9 +29,9 @@ class TestAlignmentManager:
elem1 = ImageData(x=10, y=20, width=100, height=50) elem1 = ImageData(x=10, y=20, width=100, height=50)
elem2 = ImageData(x=50, y=10, width=80, height=60) elem2 = ImageData(x=50, y=10, width=80, height=60)
elem3 = ImageData(x=5, y=30, width=90, height=40) elem3 = ImageData(x=5, y=30, width=90, height=40)
bounds = AlignmentManager.get_bounds([elem1, elem2, elem3]) bounds = AlignmentManager.get_bounds([elem1, elem2, elem3])
# min_x = 5, min_y = 10, max_x = 130 (50+80), max_y = 70 (10+60 or 20+50) # min_x = 5, min_y = 10, max_x = 130 (50+80), max_y = 70 (10+60 or 20+50)
assert bounds[0] == 5 # min_x assert bounds[0] == 5 # min_x
assert bounds[1] == 10 # min_y assert bounds[1] == 10 # min_y
@ -55,14 +55,14 @@ class TestAlignmentManager:
elem1 = ImageData(x=50, y=20, width=100, height=50) elem1 = ImageData(x=50, y=20, width=100, height=50)
elem2 = ImageData(x=30, y=40, width=80, height=60) elem2 = ImageData(x=30, y=40, width=80, height=60)
elem3 = ImageData(x=70, y=60, width=90, height=40) elem3 = ImageData(x=70, y=60, width=90, height=40)
changes = AlignmentManager.align_left([elem1, elem2, elem3]) changes = AlignmentManager.align_left([elem1, elem2, elem3])
# All should align to x=30 (leftmost) # All should align to x=30 (leftmost)
assert elem1.position == (30, 20) assert elem1.position == (30, 20)
assert elem2.position == (30, 40) assert elem2.position == (30, 40)
assert elem3.position == (30, 60) assert elem3.position == (30, 60)
# Check undo information # Check undo information
assert len(changes) == 3 assert len(changes) == 3
assert changes[0] == (elem1, (50, 20)) assert changes[0] == (elem1, (50, 20))
@ -72,16 +72,16 @@ class TestAlignmentManager:
def test_align_right_multiple_elements(self): def test_align_right_multiple_elements(self):
"""Test align_right with multiple elements""" """Test align_right with multiple elements"""
elem1 = ImageData(x=50, y=20, width=100, height=50) # right edge at 150 elem1 = ImageData(x=50, y=20, width=100, height=50) # right edge at 150
elem2 = ImageData(x=30, y=40, width=80, height=60) # right edge at 110 elem2 = ImageData(x=30, y=40, width=80, height=60) # right edge at 110
elem3 = ImageData(x=70, y=60, width=90, height=40) # right edge at 160 elem3 = ImageData(x=70, y=60, width=90, height=40) # right edge at 160
changes = AlignmentManager.align_right([elem1, elem2, elem3]) changes = AlignmentManager.align_right([elem1, elem2, elem3])
# All right edges should align to x=160 (rightmost) # All right edges should align to x=160 (rightmost)
assert elem1.position[0] == 60 # 160 - 100 assert elem1.position[0] == 60 # 160 - 100
assert elem2.position[0] == 80 # 160 - 80 assert elem2.position[0] == 80 # 160 - 80
assert elem3.position[0] == 70 # 160 - 90 assert elem3.position[0] == 70 # 160 - 90
# Y positions should not change # Y positions should not change
assert elem1.position[1] == 20 assert elem1.position[1] == 20
assert elem2.position[1] == 40 assert elem2.position[1] == 40
@ -92,14 +92,14 @@ class TestAlignmentManager:
elem1 = ImageData(x=50, y=30, width=100, height=50) elem1 = ImageData(x=50, y=30, width=100, height=50)
elem2 = ImageData(x=30, y=20, width=80, height=60) elem2 = ImageData(x=30, y=20, width=80, height=60)
elem3 = ImageData(x=70, y=40, width=90, height=40) elem3 = ImageData(x=70, y=40, width=90, height=40)
changes = AlignmentManager.align_top([elem1, elem2, elem3]) changes = AlignmentManager.align_top([elem1, elem2, elem3])
# All should align to y=20 (topmost) # All should align to y=20 (topmost)
assert elem1.position[1] == 20 assert elem1.position[1] == 20
assert elem2.position[1] == 20 assert elem2.position[1] == 20
assert elem3.position[1] == 20 assert elem3.position[1] == 20
# X positions should not change # X positions should not change
assert elem1.position[0] == 50 assert elem1.position[0] == 50
assert elem2.position[0] == 30 assert elem2.position[0] == 30
@ -108,16 +108,16 @@ class TestAlignmentManager:
def test_align_bottom_multiple_elements(self): def test_align_bottom_multiple_elements(self):
"""Test align_bottom with multiple elements""" """Test align_bottom with multiple elements"""
elem1 = ImageData(x=50, y=30, width=100, height=50) # bottom at 80 elem1 = ImageData(x=50, y=30, width=100, height=50) # bottom at 80
elem2 = ImageData(x=30, y=20, width=80, height=60) # bottom at 80 elem2 = ImageData(x=30, y=20, width=80, height=60) # bottom at 80
elem3 = ImageData(x=70, y=40, width=90, height=50) # bottom at 90 elem3 = ImageData(x=70, y=40, width=90, height=50) # bottom at 90
changes = AlignmentManager.align_bottom([elem1, elem2, elem3]) changes = AlignmentManager.align_bottom([elem1, elem2, elem3])
# All bottom edges should align to y=90 (bottommost) # All bottom edges should align to y=90 (bottommost)
assert elem1.position[1] == 40 # 90 - 50 assert elem1.position[1] == 40 # 90 - 50
assert elem2.position[1] == 30 # 90 - 60 assert elem2.position[1] == 30 # 90 - 60
assert elem3.position[1] == 40 # 90 - 50 assert elem3.position[1] == 40 # 90 - 50
# X positions should not change # X positions should not change
assert elem1.position[0] == 50 assert elem1.position[0] == 50
assert elem2.position[0] == 30 assert elem2.position[0] == 30
@ -125,18 +125,18 @@ class TestAlignmentManager:
def test_align_horizontal_center_multiple_elements(self): def test_align_horizontal_center_multiple_elements(self):
"""Test align_horizontal_center with multiple elements""" """Test align_horizontal_center with multiple elements"""
elem1 = ImageData(x=50, y=20, width=100, height=50) # center at 100 elem1 = ImageData(x=50, y=20, width=100, height=50) # center at 100
elem2 = ImageData(x=30, y=40, width=80, height=60) # center at 70 elem2 = ImageData(x=30, y=40, width=80, height=60) # center at 70
elem3 = ImageData(x=70, y=60, width=60, height=40) # center at 100 elem3 = ImageData(x=70, y=60, width=60, height=40) # center at 100
changes = AlignmentManager.align_horizontal_center([elem1, elem2, elem3]) changes = AlignmentManager.align_horizontal_center([elem1, elem2, elem3])
# Average center = (100 + 70 + 100) / 3 = 90 # Average center = (100 + 70 + 100) / 3 = 90
# All elements should center at x=90 # All elements should center at x=90
assert abs(elem1.position[0] + elem1.size[0]/2 - 90) < 0.01 assert abs(elem1.position[0] + elem1.size[0] / 2 - 90) < 0.01
assert abs(elem2.position[0] + elem2.size[0]/2 - 90) < 0.01 assert abs(elem2.position[0] + elem2.size[0] / 2 - 90) < 0.01
assert abs(elem3.position[0] + elem3.size[0]/2 - 90) < 0.01 assert abs(elem3.position[0] + elem3.size[0] / 2 - 90) < 0.01
# Y positions should not change # Y positions should not change
assert elem1.position[1] == 20 assert elem1.position[1] == 20
assert elem2.position[1] == 40 assert elem2.position[1] == 40
@ -144,18 +144,18 @@ class TestAlignmentManager:
def test_align_vertical_center_multiple_elements(self): def test_align_vertical_center_multiple_elements(self):
"""Test align_vertical_center with multiple elements""" """Test align_vertical_center with multiple elements"""
elem1 = ImageData(x=50, y=20, width=100, height=50) # center at 45 elem1 = ImageData(x=50, y=20, width=100, height=50) # center at 45
elem2 = ImageData(x=30, y=40, width=80, height=60) # center at 70 elem2 = ImageData(x=30, y=40, width=80, height=60) # center at 70
elem3 = ImageData(x=70, y=30, width=60, height=40) # center at 50 elem3 = ImageData(x=70, y=30, width=60, height=40) # center at 50
changes = AlignmentManager.align_vertical_center([elem1, elem2, elem3]) changes = AlignmentManager.align_vertical_center([elem1, elem2, elem3])
# Average center = (45 + 70 + 50) / 3 = 55 # Average center = (45 + 70 + 50) / 3 = 55
# All elements should center at y=55 # All elements should center at y=55
assert abs(elem1.position[1] + elem1.size[1]/2 - 55) < 0.01 assert abs(elem1.position[1] + elem1.size[1] / 2 - 55) < 0.01
assert abs(elem2.position[1] + elem2.size[1]/2 - 55) < 0.01 assert abs(elem2.position[1] + elem2.size[1] / 2 - 55) < 0.01
assert abs(elem3.position[1] + elem3.size[1]/2 - 55) < 0.01 assert abs(elem3.position[1] + elem3.size[1] / 2 - 55) < 0.01
# X positions should not change # X positions should not change
assert elem1.position[0] == 50 assert elem1.position[0] == 50
assert elem2.position[0] == 30 assert elem2.position[0] == 30
@ -178,14 +178,14 @@ class TestAlignmentManager:
elem1 = ImageData(x=50, y=20, width=100, height=50) elem1 = ImageData(x=50, y=20, width=100, height=50)
elem2 = ImageData(x=30, y=40, width=80, height=60) elem2 = ImageData(x=30, y=40, width=80, height=60)
elem3 = ImageData(x=70, y=60, width=90, height=40) elem3 = ImageData(x=70, y=60, width=90, height=40)
changes = AlignmentManager.make_same_size([elem1, elem2, elem3]) changes = AlignmentManager.make_same_size([elem1, elem2, elem3])
# All should match elem1's size # All should match elem1's size
assert elem1.size == (100, 50) assert elem1.size == (100, 50)
assert elem2.size == (100, 50) assert elem2.size == (100, 50)
assert elem3.size == (100, 50) assert elem3.size == (100, 50)
# Check undo information (only elem2 and elem3 change) # Check undo information (only elem2 and elem3 change)
assert len(changes) == 2 assert len(changes) == 2
assert changes[0][0] == elem2 assert changes[0][0] == elem2
@ -198,14 +198,14 @@ class TestAlignmentManager:
elem1 = ImageData(x=50, y=20, width=100, height=50) elem1 = ImageData(x=50, y=20, width=100, height=50)
elem2 = ImageData(x=30, y=40, width=80, height=60) elem2 = ImageData(x=30, y=40, width=80, height=60)
elem3 = ImageData(x=70, y=60, width=90, height=40) elem3 = ImageData(x=70, y=60, width=90, height=40)
changes = AlignmentManager.make_same_width([elem1, elem2, elem3]) changes = AlignmentManager.make_same_width([elem1, elem2, elem3])
# All widths should match elem1 # All widths should match elem1
assert elem1.size[0] == 100 assert elem1.size[0] == 100
assert elem2.size[0] == 100 assert elem2.size[0] == 100
assert elem3.size[0] == 100 assert elem3.size[0] == 100
# Heights should not change # Heights should not change
assert elem1.size[1] == 50 assert elem1.size[1] == 50
assert elem2.size[1] == 60 assert elem2.size[1] == 60
@ -216,14 +216,14 @@ class TestAlignmentManager:
elem1 = ImageData(x=50, y=20, width=100, height=50) elem1 = ImageData(x=50, y=20, width=100, height=50)
elem2 = ImageData(x=30, y=40, width=80, height=60) elem2 = ImageData(x=30, y=40, width=80, height=60)
elem3 = ImageData(x=70, y=60, width=90, height=40) elem3 = ImageData(x=70, y=60, width=90, height=40)
changes = AlignmentManager.make_same_height([elem1, elem2, elem3]) changes = AlignmentManager.make_same_height([elem1, elem2, elem3])
# All heights should match elem1 # All heights should match elem1
assert elem1.size[1] == 50 assert elem1.size[1] == 50
assert elem2.size[1] == 50 assert elem2.size[1] == 50
assert elem3.size[1] == 50 assert elem3.size[1] == 50
# Widths should not change # Widths should not change
assert elem1.size[0] == 100 assert elem1.size[0] == 100
assert elem2.size[0] == 80 assert elem2.size[0] == 80
@ -233,7 +233,7 @@ class TestAlignmentManager:
"""Test distribute_horizontally with less than 3 elements""" """Test distribute_horizontally with less than 3 elements"""
elem1 = ImageData(x=50, y=20, width=100, height=50) elem1 = ImageData(x=50, y=20, width=100, height=50)
elem2 = ImageData(x=30, y=40, width=80, height=60) elem2 = ImageData(x=30, y=40, width=80, height=60)
changes = AlignmentManager.distribute_horizontally([elem1, elem2]) changes = AlignmentManager.distribute_horizontally([elem1, elem2])
assert changes == [] assert changes == []
@ -242,15 +242,15 @@ class TestAlignmentManager:
elem1 = ImageData(x=0, y=20, width=100, height=50) elem1 = ImageData(x=0, y=20, width=100, height=50)
elem2 = ImageData(x=50, y=40, width=80, height=60) elem2 = ImageData(x=50, y=40, width=80, height=60)
elem3 = ImageData(x=200, y=60, width=90, height=40) elem3 = ImageData(x=200, y=60, width=90, height=40)
changes = AlignmentManager.distribute_horizontally([elem1, elem2, elem3]) changes = AlignmentManager.distribute_horizontally([elem1, elem2, elem3])
# Elements should be distributed evenly by their left edges # Elements should be distributed evenly by their left edges
# min_x = 0, max_x = 200, span = 200 # min_x = 0, max_x = 200, span = 200
# spacing = 200 / (3-1) = 100 # spacing = 200 / (3-1) = 100
positions = [elem.position[0] for elem in [elem1, elem2, elem3]] positions = [elem.position[0] for elem in [elem1, elem2, elem3]]
sorted_positions = sorted(positions) sorted_positions = sorted(positions)
assert sorted_positions[0] == 0 assert sorted_positions[0] == 0
assert sorted_positions[1] == 100 assert sorted_positions[1] == 100
assert sorted_positions[2] == 200 assert sorted_positions[2] == 200
@ -260,15 +260,15 @@ class TestAlignmentManager:
elem1 = ImageData(x=20, y=0, width=100, height=50) elem1 = ImageData(x=20, y=0, width=100, height=50)
elem2 = ImageData(x=40, y=50, width=80, height=60) elem2 = ImageData(x=40, y=50, width=80, height=60)
elem3 = ImageData(x=60, y=300, width=90, height=40) elem3 = ImageData(x=60, y=300, width=90, height=40)
changes = AlignmentManager.distribute_vertically([elem1, elem2, elem3]) changes = AlignmentManager.distribute_vertically([elem1, elem2, elem3])
# Elements should be distributed evenly by their top edges # Elements should be distributed evenly by their top edges
# min_y = 0, max_y = 300, span = 300 # min_y = 0, max_y = 300, span = 300
# spacing = 300 / (3-1) = 150 # spacing = 300 / (3-1) = 150
positions = [elem.position[1] for elem in [elem1, elem2, elem3]] positions = [elem.position[1] for elem in [elem1, elem2, elem3]]
sorted_positions = sorted(positions) sorted_positions = sorted(positions)
assert sorted_positions[0] == 0 assert sorted_positions[0] == 0
assert sorted_positions[1] == 150 assert sorted_positions[1] == 150
assert sorted_positions[2] == 300 assert sorted_positions[2] == 300
@ -277,7 +277,7 @@ class TestAlignmentManager:
"""Test space_horizontally with less than 3 elements""" """Test space_horizontally with less than 3 elements"""
elem1 = ImageData(x=50, y=20, width=100, height=50) elem1 = ImageData(x=50, y=20, width=100, height=50)
elem2 = ImageData(x=200, y=40, width=80, height=60) elem2 = ImageData(x=200, y=40, width=80, height=60)
changes = AlignmentManager.space_horizontally([elem1, elem2]) changes = AlignmentManager.space_horizontally([elem1, elem2])
assert changes == [] assert changes == []
@ -286,17 +286,17 @@ class TestAlignmentManager:
elem1 = ImageData(x=0, y=20, width=100, height=50) elem1 = ImageData(x=0, y=20, width=100, height=50)
elem2 = ImageData(x=150, y=40, width=50, height=60) elem2 = ImageData(x=150, y=40, width=50, height=60)
elem3 = ImageData(x=250, y=60, width=100, height=40) elem3 = ImageData(x=250, y=60, width=100, height=40)
changes = AlignmentManager.space_horizontally([elem1, elem2, elem3]) changes = AlignmentManager.space_horizontally([elem1, elem2, elem3])
# Total width = 100 + 50 + 100 = 250 # Total width = 100 + 50 + 100 = 250
# Span = 0 to 350 (250 + 100 from elem3) # Span = 0 to 350 (250 + 100 from elem3)
# Available space = 350 - 0 - 250 = 100 # Available space = 350 - 0 - 250 = 100
# Spacing = 100 / (3-1) = 50 # Spacing = 100 / (3-1) = 50
# After sorting by x: elem1 at 0, elem2 after 100+50=150, elem3 after 150+50+50=250 # After sorting by x: elem1 at 0, elem2 after 100+50=150, elem3 after 150+50+50=250
sorted_elements = sorted([elem1, elem2, elem3], key=lambda e: e.position[0]) sorted_elements = sorted([elem1, elem2, elem3], key=lambda e: e.position[0])
assert sorted_elements[0].position[0] == 0 assert sorted_elements[0].position[0] == 0
assert sorted_elements[1].position[0] == 150 # 0 + 100 + 50 assert sorted_elements[1].position[0] == 150 # 0 + 100 + 50
assert sorted_elements[2].position[0] == 250 # 150 + 50 + 50 assert sorted_elements[2].position[0] == 250 # 150 + 50 + 50
@ -306,17 +306,17 @@ class TestAlignmentManager:
elem1 = ImageData(x=20, y=0, width=100, height=50) elem1 = ImageData(x=20, y=0, width=100, height=50)
elem2 = ImageData(x=40, y=100, width=80, height=30) elem2 = ImageData(x=40, y=100, width=80, height=30)
elem3 = ImageData(x=60, y=200, width=90, height=50) elem3 = ImageData(x=60, y=200, width=90, height=50)
changes = AlignmentManager.space_vertically([elem1, elem2, elem3]) changes = AlignmentManager.space_vertically([elem1, elem2, elem3])
# Total height = 50 + 30 + 50 = 130 # Total height = 50 + 30 + 50 = 130
# Span = 0 to 250 (200 + 50 from elem3) # Span = 0 to 250 (200 + 50 from elem3)
# Available space = 250 - 0 - 130 = 120 # Available space = 250 - 0 - 130 = 120
# Spacing = 120 / (3-1) = 60 # Spacing = 120 / (3-1) = 60
# After sorting by y: elem1 at 0, elem2 after 50+60=110, elem3 after 110+30+60=200 # After sorting by y: elem1 at 0, elem2 after 50+60=110, elem3 after 110+30+60=200
sorted_elements = sorted([elem1, elem2, elem3], key=lambda e: e.position[1]) sorted_elements = sorted([elem1, elem2, elem3], key=lambda e: e.position[1])
assert sorted_elements[0].position[1] == 0 assert sorted_elements[0].position[1] == 0
assert sorted_elements[1].position[1] == 110 # 0 + 50 + 60 assert sorted_elements[1].position[1] == 110 # 0 + 50 + 60
assert sorted_elements[2].position[1] == 200 # 110 + 30 + 60 assert sorted_elements[2].position[1] == 200 # 110 + 30 + 60
@ -326,10 +326,10 @@ class TestAlignmentManager:
elem1 = ImageData(x=50, y=20, width=100, height=50) elem1 = ImageData(x=50, y=20, width=100, height=50)
elem2 = PlaceholderData(placeholder_type="image", x=30, y=40, width=80, height=60) elem2 = PlaceholderData(placeholder_type="image", x=30, y=40, width=80, height=60)
elem3 = TextBoxData(text_content="Test", x=70, y=60, width=90, height=40) elem3 = TextBoxData(text_content="Test", x=70, y=60, width=90, height=40)
# Test align_left # Test align_left
changes = AlignmentManager.align_left([elem1, elem2, elem3]) changes = AlignmentManager.align_left([elem1, elem2, elem3])
assert elem1.position[0] == 30 assert elem1.position[0] == 30
assert elem2.position[0] == 30 assert elem2.position[0] == 30
assert elem3.position[0] == 30 assert elem3.position[0] == 30
@ -339,23 +339,23 @@ class TestAlignmentManager:
elem1 = ImageData(x=50, y=20, width=100, height=50) elem1 = ImageData(x=50, y=20, width=100, height=50)
elem2 = ImageData(x=30, y=40, width=80, height=60) elem2 = ImageData(x=30, y=40, width=80, height=60)
elem3 = ImageData(x=70, y=60, width=90, height=40) elem3 = ImageData(x=70, y=60, width=90, height=40)
# Test position changes # Test position changes
changes = AlignmentManager.align_left([elem1, elem2, elem3]) changes = AlignmentManager.align_left([elem1, elem2, elem3])
for change in changes: for change in changes:
assert len(change) == 2 # (element, old_position) assert len(change) == 2 # (element, old_position)
assert isinstance(change[0], ImageData) assert isinstance(change[0], ImageData)
assert isinstance(change[1], tuple) assert isinstance(change[1], tuple)
assert len(change[1]) == 2 # (x, y) assert len(change[1]) == 2 # (x, y)
# Test size changes # Test size changes
elem1 = ImageData(x=50, y=20, width=100, height=50) elem1 = ImageData(x=50, y=20, width=100, height=50)
elem2 = ImageData(x=30, y=40, width=80, height=60) elem2 = ImageData(x=30, y=40, width=80, height=60)
elem3 = ImageData(x=70, y=60, width=90, height=40) elem3 = ImageData(x=70, y=60, width=90, height=40)
changes = AlignmentManager.make_same_size([elem1, elem2, elem3]) changes = AlignmentManager.make_same_size([elem1, elem2, elem3])
for change in changes: for change in changes:
assert len(change) == 3 # (element, old_position, old_size) assert len(change) == 3 # (element, old_position, old_size)
assert isinstance(change[0], ImageData) assert isinstance(change[0], ImageData)
@ -368,15 +368,15 @@ class TestAlignmentManager:
"""Test that alignment operations only change intended properties""" """Test that alignment operations only change intended properties"""
elem1 = ImageData(x=50, y=20, width=100, height=50, rotation=45, z_index=5) elem1 = ImageData(x=50, y=20, width=100, height=50, rotation=45, z_index=5)
elem2 = ImageData(x=30, y=40, width=80, height=60, rotation=90, z_index=3) elem2 = ImageData(x=30, y=40, width=80, height=60, rotation=90, z_index=3)
AlignmentManager.align_left([elem1, elem2]) AlignmentManager.align_left([elem1, elem2])
# Rotation and z_index should not change # Rotation and z_index should not change
assert elem1.rotation == 45 assert elem1.rotation == 45
assert elem1.z_index == 5 assert elem1.z_index == 5
assert elem2.rotation == 90 assert elem2.rotation == 90
assert elem2.z_index == 3 assert elem2.z_index == 3
# Heights should not change # Heights should not change
assert elem1.size[1] == 50 assert elem1.size[1] == 50
assert elem2.size[1] == 60 assert elem2.size[1] == 60
@ -387,10 +387,10 @@ class TestAlignmentManager:
elem3 = ImageData(x=200, y=60, width=90, height=40) elem3 = ImageData(x=200, y=60, width=90, height=40)
elem1 = ImageData(x=0, y=20, width=100, height=50) elem1 = ImageData(x=0, y=20, width=100, height=50)
elem2 = ImageData(x=100, y=40, width=80, height=60) elem2 = ImageData(x=100, y=40, width=80, height=60)
# Pass in random order # Pass in random order
changes = AlignmentManager.distribute_horizontally([elem3, elem1, elem2]) changes = AlignmentManager.distribute_horizontally([elem3, elem1, elem2])
# Should still distribute correctly # Should still distribute correctly
positions = sorted([elem1.position[0], elem2.position[0], elem3.position[0]]) positions = sorted([elem1.position[0], elem2.position[0], elem3.position[0]])
assert positions[0] == 0 assert positions[0] == 0
@ -402,21 +402,21 @@ class TestAlignmentManager:
elem1 = ImageData(x=0, y=0, width=50, height=50) elem1 = ImageData(x=0, y=0, width=50, height=50)
elem2 = ImageData(x=100, y=0, width=100, height=50) elem2 = ImageData(x=100, y=0, width=100, height=50)
elem3 = ImageData(x=250, y=0, width=75, height=50) elem3 = ImageData(x=250, y=0, width=75, height=50)
changes = AlignmentManager.space_horizontally([elem1, elem2, elem3]) changes = AlignmentManager.space_horizontally([elem1, elem2, elem3])
# Calculate expected spacing # Calculate expected spacing
# Total width = 50 + 100 + 75 = 225 # Total width = 50 + 100 + 75 = 225
# rightmost edge = 250 + 75 = 325 # rightmost edge = 250 + 75 = 325
# Available space = 325 - 0 - 225 = 100 # Available space = 325 - 0 - 225 = 100
# Spacing = 100 / 2 = 50 # Spacing = 100 / 2 = 50
sorted_elements = sorted([elem1, elem2, elem3], key=lambda e: e.position[0]) sorted_elements = sorted([elem1, elem2, elem3], key=lambda e: e.position[0])
# Verify spacing between elements is equal # Verify spacing between elements is equal
gap1 = sorted_elements[1].position[0] - (sorted_elements[0].position[0] + sorted_elements[0].size[0]) gap1 = sorted_elements[1].position[0] - (sorted_elements[0].position[0] + sorted_elements[0].size[0])
gap2 = sorted_elements[2].position[0] - (sorted_elements[1].position[0] + sorted_elements[1].size[0]) gap2 = sorted_elements[2].position[0] - (sorted_elements[1].position[0] + sorted_elements[1].size[0])
assert abs(gap1 - 50) < 0.01 assert abs(gap1 - 50) < 0.01
assert abs(gap2 - 50) < 0.01 assert abs(gap2 - 50) < 0.01
@ -452,7 +452,7 @@ class TestAlignmentManager:
assert len(changes) == 1 assert len(changes) == 1
assert changes[0][0] == elem assert changes[0][0] == elem
assert changes[0][1] == (100, 80) # old position assert changes[0][1] == (100, 80) # old position
assert changes[0][2] == (20, 15) # old size assert changes[0][2] == (20, 15) # old size
def test_maximize_pattern_two_elements_horizontal(self): def test_maximize_pattern_two_elements_horizontal(self):
"""Test maximize_pattern with two elements side by side""" """Test maximize_pattern with two elements side by side"""
@ -469,11 +469,11 @@ class TestAlignmentManager:
# Elements should not overlap (min_gap = 2.0) # Elements should not overlap (min_gap = 2.0)
gap_x = max( gap_x = max(
elem2.position[0] - (elem1.position[0] + elem1.size[0]), elem2.position[0] - (elem1.position[0] + elem1.size[0]),
elem1.position[0] - (elem2.position[0] + elem2.size[0]) elem1.position[0] - (elem2.position[0] + elem2.size[0]),
) )
gap_y = max( gap_y = max(
elem2.position[1] - (elem1.position[1] + elem1.size[1]), elem2.position[1] - (elem1.position[1] + elem1.size[1]),
elem1.position[1] - (elem2.position[1] + elem2.size[1]) elem1.position[1] - (elem2.position[1] + elem2.size[1]),
) )
# Either horizontal or vertical gap should be >= min_gap # Either horizontal or vertical gap should be >= min_gap
@ -510,11 +510,11 @@ class TestAlignmentManager:
# Calculate gaps between rectangles # Calculate gaps between rectangles
gap_x = max( gap_x = max(
elem_b.position[0] - (elem_a.position[0] + elem_a.size[0]), elem_b.position[0] - (elem_a.position[0] + elem_a.size[0]),
elem_a.position[0] - (elem_b.position[0] + elem_b.size[0]) elem_a.position[0] - (elem_b.position[0] + elem_b.size[0]),
) )
gap_y = max( gap_y = max(
elem_b.position[1] - (elem_a.position[1] + elem_a.size[1]), elem_b.position[1] - (elem_a.position[1] + elem_a.size[1]),
elem_a.position[1] - (elem_b.position[1] + elem_b.size[1]) elem_a.position[1] - (elem_b.position[1] + elem_b.size[1]),
) )
# At least one gap should be >= min_gap # At least one gap should be >= min_gap
@ -564,11 +564,7 @@ class TestAlignmentManager:
elem4 = ImageData(x=140, y=90, width=10, height=10) elem4 = ImageData(x=140, y=90, width=10, height=10)
page_size = (160, 110) page_size = (160, 110)
changes = AlignmentManager.maximize_pattern( changes = AlignmentManager.maximize_pattern([elem1, elem2, elem3, elem4], page_size, min_gap=2.0)
[elem1, elem2, elem3, elem4],
page_size,
min_gap=2.0
)
# All elements should grow # All elements should grow
for elem in [elem1, elem2, elem3, elem4]: for elem in [elem1, elem2, elem3, elem4]:
@ -584,11 +580,11 @@ class TestAlignmentManager:
gap_x = max( gap_x = max(
elem_b.position[0] - (elem_a.position[0] + elem_a.size[0]), elem_b.position[0] - (elem_a.position[0] + elem_a.size[0]),
elem_a.position[0] - (elem_b.position[0] + elem_b.size[0]) elem_a.position[0] - (elem_b.position[0] + elem_b.size[0]),
) )
gap_y = max( gap_y = max(
elem_b.position[1] - (elem_a.position[1] + elem_a.size[1]), elem_b.position[1] - (elem_a.position[1] + elem_a.size[1]),
elem_a.position[1] - (elem_b.position[1] + elem_b.size[1]) elem_a.position[1] - (elem_b.position[1] + elem_b.size[1]),
) )
assert gap_x >= 2.0 or gap_y >= 2.0 assert gap_x >= 2.0 or gap_y >= 2.0

View File

@ -81,7 +81,7 @@ class TestGetSelectedElementsList:
class TestAlignLeft: class TestAlignLeft:
"""Test align_left method""" """Test align_left method"""
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager') @patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_align_left_success(self, mock_manager, qtbot): def test_align_left_success(self, mock_manager, qtbot):
"""Test aligning elements to the left""" """Test aligning elements to the left"""
window = TestAlignmentWindow() window = TestAlignmentWindow()
@ -93,10 +93,7 @@ class TestAlignLeft:
window.gl_widget.selected_elements = {element1, element2} window.gl_widget.selected_elements = {element1, element2}
# Mock AlignmentManager to return changes # Mock AlignmentManager to return changes
mock_manager.align_left.return_value = [ mock_manager.align_left.return_value = [(element1, (100, 0)), (element2, (200, 100))]
(element1, (100, 0)),
(element2, (200, 100))
]
window.align_left() window.align_left()
@ -106,7 +103,7 @@ class TestAlignLeft:
assert "aligned" in window._status_message.lower() assert "aligned" in window._status_message.lower()
assert "left" in window._status_message.lower() assert "left" in window._status_message.lower()
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager') @patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_align_left_no_changes(self, mock_manager, qtbot): def test_align_left_no_changes(self, mock_manager, qtbot):
"""Test align left when no changes needed""" """Test align left when no changes needed"""
window = TestAlignmentWindow() window = TestAlignmentWindow()
@ -143,7 +140,7 @@ class TestAlignLeft:
class TestAlignRight: class TestAlignRight:
"""Test align_right method""" """Test align_right method"""
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager') @patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_align_right_success(self, mock_manager, qtbot): def test_align_right_success(self, mock_manager, qtbot):
"""Test aligning elements to the right""" """Test aligning elements to the right"""
window = TestAlignmentWindow() window = TestAlignmentWindow()
@ -154,10 +151,7 @@ class TestAlignRight:
window.gl_widget.selected_elements = {element1, element2} window.gl_widget.selected_elements = {element1, element2}
mock_manager.align_right.return_value = [ mock_manager.align_right.return_value = [(element1, (100, 0)), (element2, (200, 100))]
(element1, (100, 0)),
(element2, (200, 100))
]
window.align_right() window.align_right()
@ -169,7 +163,7 @@ class TestAlignRight:
class TestAlignTop: class TestAlignTop:
"""Test align_top method""" """Test align_top method"""
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager') @patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_align_top_success(self, mock_manager, qtbot): def test_align_top_success(self, mock_manager, qtbot):
"""Test aligning elements to the top""" """Test aligning elements to the top"""
window = TestAlignmentWindow() window = TestAlignmentWindow()
@ -180,10 +174,7 @@ class TestAlignTop:
window.gl_widget.selected_elements = {element1, element2} window.gl_widget.selected_elements = {element1, element2}
mock_manager.align_top.return_value = [ mock_manager.align_top.return_value = [(element1, (0, 50)), (element2, (100, 100))]
(element1, (0, 50)),
(element2, (100, 100))
]
window.align_top() window.align_top()
@ -195,7 +186,7 @@ class TestAlignTop:
class TestAlignBottom: class TestAlignBottom:
"""Test align_bottom method""" """Test align_bottom method"""
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager') @patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_align_bottom_success(self, mock_manager, qtbot): def test_align_bottom_success(self, mock_manager, qtbot):
"""Test aligning elements to the bottom""" """Test aligning elements to the bottom"""
window = TestAlignmentWindow() window = TestAlignmentWindow()
@ -206,10 +197,7 @@ class TestAlignBottom:
window.gl_widget.selected_elements = {element1, element2} window.gl_widget.selected_elements = {element1, element2}
mock_manager.align_bottom.return_value = [ mock_manager.align_bottom.return_value = [(element1, (0, 50)), (element2, (100, 100))]
(element1, (0, 50)),
(element2, (100, 100))
]
window.align_bottom() window.align_bottom()
@ -221,7 +209,7 @@ class TestAlignBottom:
class TestAlignHorizontalCenter: class TestAlignHorizontalCenter:
"""Test align_horizontal_center method""" """Test align_horizontal_center method"""
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager') @patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_align_horizontal_center_success(self, mock_manager, qtbot): def test_align_horizontal_center_success(self, mock_manager, qtbot):
"""Test aligning elements to horizontal center""" """Test aligning elements to horizontal center"""
window = TestAlignmentWindow() window = TestAlignmentWindow()
@ -232,10 +220,7 @@ class TestAlignHorizontalCenter:
window.gl_widget.selected_elements = {element1, element2} window.gl_widget.selected_elements = {element1, element2}
mock_manager.align_horizontal_center.return_value = [ mock_manager.align_horizontal_center.return_value = [(element1, (0, 0)), (element2, (200, 100))]
(element1, (0, 0)),
(element2, (200, 100))
]
window.align_horizontal_center() window.align_horizontal_center()
@ -247,7 +232,7 @@ class TestAlignHorizontalCenter:
class TestAlignVerticalCenter: class TestAlignVerticalCenter:
"""Test align_vertical_center method""" """Test align_vertical_center method"""
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager') @patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_align_vertical_center_success(self, mock_manager, qtbot): def test_align_vertical_center_success(self, mock_manager, qtbot):
"""Test aligning elements to vertical center""" """Test aligning elements to vertical center"""
window = TestAlignmentWindow() window = TestAlignmentWindow()
@ -258,10 +243,7 @@ class TestAlignVerticalCenter:
window.gl_widget.selected_elements = {element1, element2} window.gl_widget.selected_elements = {element1, element2}
mock_manager.align_vertical_center.return_value = [ mock_manager.align_vertical_center.return_value = [(element1, (0, 0)), (element2, (100, 200))]
(element1, (0, 0)),
(element2, (100, 200))
]
window.align_vertical_center() window.align_vertical_center()
@ -273,7 +255,7 @@ class TestAlignVerticalCenter:
class TestAlignmentCommandPattern: class TestAlignmentCommandPattern:
"""Test alignment operations with command pattern for undo/redo""" """Test alignment operations with command pattern for undo/redo"""
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager') @patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_alignment_creates_command(self, mock_manager, qtbot): def test_alignment_creates_command(self, mock_manager, qtbot):
"""Test that alignment creates a command for undo""" """Test that alignment creates a command for undo"""
window = TestAlignmentWindow() window = TestAlignmentWindow()
@ -284,10 +266,7 @@ class TestAlignmentCommandPattern:
window.gl_widget.selected_elements = {element1, element2} window.gl_widget.selected_elements = {element1, element2}
mock_manager.align_left.return_value = [ mock_manager.align_left.return_value = [(element1, (100, 0)), (element2, (200, 100))]
(element1, (100, 0)),
(element2, (200, 100))
]
# Should have no commands initially # Should have no commands initially
assert not window.project.history.can_undo() assert not window.project.history.can_undo()
@ -297,7 +276,7 @@ class TestAlignmentCommandPattern:
# Should have created a command # Should have created a command
assert window.project.history.can_undo() assert window.project.history.can_undo()
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager') @patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_alignment_undo_redo(self, mock_manager, qtbot): def test_alignment_undo_redo(self, mock_manager, qtbot):
"""Test that alignment can be undone and redone""" """Test that alignment can be undone and redone"""
window = TestAlignmentWindow() window = TestAlignmentWindow()
@ -309,10 +288,7 @@ class TestAlignmentCommandPattern:
window.gl_widget.selected_elements = {element1, element2} window.gl_widget.selected_elements = {element1, element2}
# Mock alignment to return changes (command will handle actual moves) # Mock alignment to return changes (command will handle actual moves)
mock_manager.align_top.return_value = [ mock_manager.align_top.return_value = [(element1, (100, 0)), (element2, (200, 100))]
(element1, (100, 0)),
(element2, (200, 100))
]
# Execute alignment - command created # Execute alignment - command created
window.align_top() window.align_top()

View File

@ -28,8 +28,8 @@ class TestAssetDropWidget(AssetDropMixin, AssetPathMixin, PageNavigationMixin, V
def _get_project_folder(self): def _get_project_folder(self):
"""Override to access project via window mock""" """Override to access project via window mock"""
main_window = self.window() main_window = self.window()
if hasattr(main_window, 'project') and main_window.project: if hasattr(main_window, "project") and main_window.project:
return getattr(main_window.project, 'folder_path', None) return getattr(main_window.project, "folder_path", None)
return None return None
@ -43,7 +43,7 @@ class TestAssetDropInitialization:
# Should accept drops (set in GLWidget.__init__) # Should accept drops (set in GLWidget.__init__)
# This is a property of the widget, not the mixin # This is a property of the widget, not the mixin
assert hasattr(widget, 'acceptDrops') assert hasattr(widget, "acceptDrops")
class TestDragEnterEvent: class TestDragEnterEvent:
@ -141,7 +141,7 @@ class TestDragMoveEvent:
class TestDropEvent: class TestDropEvent:
"""Test dropEvent method""" """Test dropEvent method"""
@patch('pyPhotoAlbum.mixins.asset_drop.AddElementCommand') @patch("pyPhotoAlbum.mixins.asset_drop.AddElementCommand")
def test_drop_creates_image_element(self, mock_cmd_class, qtbot): def test_drop_creates_image_element(self, mock_cmd_class, qtbot):
"""Test dropping image file creates ImageData element""" """Test dropping image file creates ImageData element"""
widget = TestAssetDropWidget() widget = TestAssetDropWidget()
@ -239,7 +239,7 @@ class TestDropEvent:
# Create a real test image file # Create a real test image file
test_image = tmp_path / "test_image.jpg" test_image = tmp_path / "test_image.jpg"
test_image.write_bytes(b'\xFF\xD8\xFF\xE0' + b'\x00' * 100) # Minimal JPEG header test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100) # Minimal JPEG header
# Setup project with page containing placeholder # Setup project with page containing placeholder
mock_window = Mock() mock_window = Mock()
@ -248,6 +248,7 @@ class TestDropEvent:
page = Page(layout=PageLayout(width=210, height=297), page_number=1) page = Page(layout=PageLayout(width=210, height=297), page_number=1)
from pyPhotoAlbum.models import PlaceholderData from pyPhotoAlbum.models import PlaceholderData
placeholder = PlaceholderData(x=100, y=100, width=200, height=150) placeholder = PlaceholderData(x=100, y=100, width=200, height=150)
page.layout.elements.append(placeholder) page.layout.elements.append(placeholder)
@ -280,7 +281,7 @@ class TestDropEvent:
# Image path should now be in assets folder (imported) # Image path should now be in assets folder (imported)
assert page.layout.elements[0].image_path.startswith("assets/") assert page.layout.elements[0].image_path.startswith("assets/")
@patch('pyPhotoAlbum.mixins.asset_drop.AddElementCommand') @patch("pyPhotoAlbum.mixins.asset_drop.AddElementCommand")
def test_drop_multiple_files(self, mock_cmd_class, qtbot): def test_drop_multiple_files(self, mock_cmd_class, qtbot):
"""Test dropping first image from multiple files""" """Test dropping first image from multiple files"""
widget = TestAssetDropWidget() widget = TestAssetDropWidget()
@ -311,11 +312,13 @@ class TestDropEvent:
# Create drop event with multiple files (only first is used) # Create drop event with multiple files (only first is used)
mime_data = QMimeData() mime_data = QMimeData()
mime_data.setUrls([ mime_data.setUrls(
QUrl.fromLocalFile("/path/to/image1.jpg"), [
QUrl.fromLocalFile("/path/to/image2.png"), QUrl.fromLocalFile("/path/to/image1.jpg"),
QUrl.fromLocalFile("/path/to/image3.jpg") QUrl.fromLocalFile("/path/to/image2.png"),
]) QUrl.fromLocalFile("/path/to/image3.jpg"),
]
)
event = Mock() event = Mock()
event.mimeData = Mock(return_value=mime_data) event.mimeData = Mock(return_value=mime_data)
@ -364,7 +367,7 @@ class TestDropEvent:
# Create a real test image file # Create a real test image file
test_image = tmp_path / "new_image.jpg" test_image = tmp_path / "new_image.jpg"
test_image.write_bytes(b'\xFF\xD8\xFF\xE0' + b'\x00' * 100) test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
# Setup project with page containing existing ImageData # Setup project with page containing existing ImageData
mock_window = Mock() mock_window = Mock()
@ -372,10 +375,7 @@ class TestDropEvent:
mock_window.project.working_dpi = 96 mock_window.project.working_dpi = 96
page = Page(layout=PageLayout(width=210, height=297), page_number=1) page = Page(layout=PageLayout(width=210, height=297), page_number=1)
existing_image = ImageData( existing_image = ImageData(image_path="assets/old_image.jpg", x=100, y=100, width=200, height=150)
image_path="assets/old_image.jpg",
x=100, y=100, width=200, height=150
)
page.layout.elements.append(existing_image) page.layout.elements.append(existing_image)
mock_window.project.pages = [page] mock_window.project.pages = [page]
@ -407,24 +407,19 @@ class TestDropEvent:
widget.update = Mock() widget.update = Mock()
test_image = tmp_path / "test.jpg" test_image = tmp_path / "test.jpg"
test_image.write_bytes(b'\xFF\xD8\xFF\xE0' + b'\x00' * 100) test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
mock_window = Mock() mock_window = Mock()
mock_window.project = Project(name="Test") mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1) page = Page(layout=PageLayout(width=210, height=297), page_number=1)
existing_image = ImageData( existing_image = ImageData(image_path="assets/old.jpg", x=100, y=100, width=200, height=150)
image_path="assets/old.jpg",
x=100, y=100, width=200, height=150
)
page.layout.elements.append(existing_image) page.layout.elements.append(existing_image)
mock_window.project.pages = [page] mock_window.project.pages = [page]
# Mock asset manager to raise exception # Mock asset manager to raise exception
mock_window.project.asset_manager = Mock() mock_window.project.asset_manager = Mock()
mock_window.project.asset_manager.import_asset = Mock( mock_window.project.asset_manager.import_asset = Mock(side_effect=Exception("Import failed"))
side_effect=Exception("Import failed")
)
widget.window = Mock(return_value=mock_window) widget.window = Mock(return_value=mock_window)
widget._get_element_at = Mock(return_value=existing_image) widget._get_element_at = Mock(return_value=existing_image)
@ -454,7 +449,7 @@ class TestDropEvent:
# Create a corrupted/invalid image file # Create a corrupted/invalid image file
corrupted_image = tmp_path / "corrupted.jpg" corrupted_image = tmp_path / "corrupted.jpg"
corrupted_image.write_bytes(b'not a valid image') corrupted_image.write_bytes(b"not a valid image")
mock_window = Mock() mock_window = Mock()
mock_window.project = Project(name="Test") mock_window.project = Project(name="Test")
@ -488,7 +483,8 @@ class TestDropEvent:
# Should use default dimensions (200, 150) from _calculate_image_dimensions # Should use default dimensions (200, 150) from _calculate_image_dimensions
# Check that AddElementCommand was called with an ImageData # Check that AddElementCommand was called with an ImageData
from pyPhotoAlbum.commands import AddElementCommand from pyPhotoAlbum.commands import AddElementCommand
with patch('pyPhotoAlbum.mixins.asset_drop.AddElementCommand') as mock_cmd:
with patch("pyPhotoAlbum.mixins.asset_drop.AddElementCommand") as mock_cmd:
# Re-run to check the call # Re-run to check the call
widget.dropEvent(event) widget.dropEvent(event)
assert mock_cmd.called assert mock_cmd.called
@ -527,10 +523,7 @@ class TestExtractImagePathEdgeCases:
widget.update = Mock() widget.update = Mock()
mime_data = QMimeData() mime_data = QMimeData()
mime_data.setUrls([ mime_data.setUrls([QUrl.fromLocalFile("/path/to/document.pdf"), QUrl.fromLocalFile("/path/to/file.txt")])
QUrl.fromLocalFile("/path/to/document.pdf"),
QUrl.fromLocalFile("/path/to/file.txt")
])
event = Mock() event = Mock()
event.mimeData = Mock(return_value=mime_data) event.mimeData = Mock(return_value=mime_data)
@ -576,7 +569,7 @@ class TestPlaceholderReplacementEdgeCases:
widget.update = Mock() widget.update = Mock()
test_image = tmp_path / "test.jpg" test_image = tmp_path / "test.jpg"
test_image.write_bytes(b'\xFF\xD8\xFF\xE0' + b'\x00' * 100) test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
# Setup project WITHOUT pages # Setup project WITHOUT pages
mock_window = Mock() mock_window = Mock()
@ -585,6 +578,7 @@ class TestPlaceholderReplacementEdgeCases:
mock_window.project.pages = [] # Empty pages list mock_window.project.pages = [] # Empty pages list
from pyPhotoAlbum.models import PlaceholderData from pyPhotoAlbum.models import PlaceholderData
placeholder = PlaceholderData(x=100, y=100, width=200, height=150) placeholder = PlaceholderData(x=100, y=100, width=200, height=150)
mock_window.project.asset_manager = Mock() mock_window.project.asset_manager = Mock()

View File

@ -0,0 +1,183 @@
"""
Tests for asset_path mixin module
"""
import pytest
import os
from unittest.mock import Mock
class TestAssetPathMixin:
"""Tests for AssetPathMixin class"""
def test_resolve_asset_path_empty_path(self, tmp_path):
"""Test resolve_asset_path with empty path returns None"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock()
self.project.folder_path = str(tmp_path)
obj = TestClass()
assert obj.resolve_asset_path("") is None
assert obj.resolve_asset_path(None) is None
def test_resolve_asset_path_absolute_exists(self, tmp_path):
"""Test resolve_asset_path with existing absolute path"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
# Create a test file
test_file = tmp_path / "test_image.jpg"
test_file.write_text("test")
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock()
self.project.folder_path = str(tmp_path)
obj = TestClass()
result = obj.resolve_asset_path(str(test_file))
assert result == str(test_file)
def test_resolve_asset_path_absolute_not_exists(self, tmp_path):
"""Test resolve_asset_path with non-existing absolute path"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock()
self.project.folder_path = str(tmp_path)
obj = TestClass()
result = obj.resolve_asset_path("/nonexistent/path/image.jpg")
assert result is None
def test_resolve_asset_path_relative_exists(self, tmp_path):
"""Test resolve_asset_path with existing relative path"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
# Create assets folder and test file
assets_dir = tmp_path / "assets"
assets_dir.mkdir()
test_file = assets_dir / "photo.jpg"
test_file.write_text("test")
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock()
self.project.folder_path = str(tmp_path)
obj = TestClass()
result = obj.resolve_asset_path("assets/photo.jpg")
assert result == str(test_file)
def test_resolve_asset_path_relative_not_exists(self, tmp_path):
"""Test resolve_asset_path with non-existing relative path"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock()
self.project.folder_path = str(tmp_path)
obj = TestClass()
result = obj.resolve_asset_path("assets/nonexistent.jpg")
assert result is None
def test_resolve_asset_path_no_project_folder(self):
"""Test resolve_asset_path when project folder is not available"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = None
obj = TestClass()
result = obj.resolve_asset_path("assets/photo.jpg")
assert result is None
def test_get_asset_full_path_with_project(self, tmp_path):
"""Test get_asset_full_path returns correct path"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock()
self.project.folder_path = str(tmp_path)
obj = TestClass()
result = obj.get_asset_full_path("assets/photo.jpg")
expected = os.path.join(str(tmp_path), "assets/photo.jpg")
assert result == expected
def test_get_asset_full_path_no_project(self):
"""Test get_asset_full_path without project returns None"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = None
obj = TestClass()
result = obj.get_asset_full_path("assets/photo.jpg")
assert result is None
def test_get_asset_full_path_empty_path(self, tmp_path):
"""Test get_asset_full_path with empty path returns None"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock()
self.project.folder_path = str(tmp_path)
obj = TestClass()
assert obj.get_asset_full_path("") is None
assert obj.get_asset_full_path(None) is None
def test_get_project_folder_with_project(self, tmp_path):
"""Test _get_project_folder returns project folder"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock()
self.project.folder_path = str(tmp_path)
obj = TestClass()
result = obj._get_project_folder()
assert result == str(tmp_path)
def test_get_project_folder_no_project(self):
"""Test _get_project_folder without project returns None"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
pass
obj = TestClass()
result = obj._get_project_folder()
assert result is None
def test_get_project_folder_project_without_folder_path(self):
"""Test _get_project_folder with project missing folder_path"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock(spec=[]) # No folder_path attribute
obj = TestClass()
result = obj._get_project_folder()
assert result is None

View File

@ -0,0 +1,635 @@
"""
Tests for async_loading mixin module
"""
import pytest
from pathlib import Path
from unittest.mock import Mock, MagicMock, patch, PropertyMock
class TestAsyncLoadingMixinInit:
"""Tests for AsyncLoadingMixin initialization"""
def test_init_async_loading_creates_cache(self):
"""Test that _init_async_loading creates image cache"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
with (
patch("pyPhotoAlbum.mixins.async_loading.ImageCache") as mock_cache,
patch("pyPhotoAlbum.mixins.async_loading.AsyncImageLoader") as mock_loader,
patch("pyPhotoAlbum.mixins.async_loading.AsyncPDFGenerator") as mock_pdf,
):
mock_loader_instance = Mock()
mock_loader.return_value = mock_loader_instance
mock_pdf_instance = Mock()
mock_pdf.return_value = mock_pdf_instance
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj._init_async_loading()
mock_cache.assert_called_once_with(max_memory_mb=512)
assert hasattr(obj, "image_cache")
def test_init_async_loading_creates_image_loader(self):
"""Test that _init_async_loading creates async image loader"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
with (
patch("pyPhotoAlbum.mixins.async_loading.ImageCache") as mock_cache,
patch("pyPhotoAlbum.mixins.async_loading.AsyncImageLoader") as mock_loader,
patch("pyPhotoAlbum.mixins.async_loading.AsyncPDFGenerator") as mock_pdf,
):
mock_loader_instance = Mock()
mock_loader.return_value = mock_loader_instance
mock_pdf_instance = Mock()
mock_pdf.return_value = mock_pdf_instance
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj._init_async_loading()
mock_loader.assert_called_once()
assert hasattr(obj, "async_image_loader")
mock_loader_instance.start.assert_called_once()
def test_init_async_loading_creates_pdf_generator(self):
"""Test that _init_async_loading creates async PDF generator"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
with (
patch("pyPhotoAlbum.mixins.async_loading.ImageCache") as mock_cache,
patch("pyPhotoAlbum.mixins.async_loading.AsyncImageLoader") as mock_loader,
patch("pyPhotoAlbum.mixins.async_loading.AsyncPDFGenerator") as mock_pdf,
):
mock_loader_instance = Mock()
mock_loader.return_value = mock_loader_instance
mock_pdf_instance = Mock()
mock_pdf.return_value = mock_pdf_instance
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj._init_async_loading()
mock_pdf.assert_called_once()
assert hasattr(obj, "async_pdf_generator")
mock_pdf_instance.start.assert_called_once()
class TestAsyncLoadingMixinCleanup:
"""Tests for AsyncLoadingMixin cleanup"""
def test_cleanup_stops_image_loader(self):
"""Test that _cleanup_async_loading stops image loader"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
obj.async_pdf_generator = Mock()
obj.image_cache = Mock()
obj._cleanup_async_loading()
obj.async_image_loader.stop.assert_called_once()
def test_cleanup_stops_pdf_generator(self):
"""Test that _cleanup_async_loading stops PDF generator"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
obj.async_pdf_generator = Mock()
obj.image_cache = Mock()
obj._cleanup_async_loading()
obj.async_pdf_generator.stop.assert_called_once()
def test_cleanup_clears_cache(self):
"""Test that _cleanup_async_loading clears image cache"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
obj.async_pdf_generator = Mock()
obj.image_cache = Mock()
obj._cleanup_async_loading()
obj.image_cache.clear.assert_called_once()
def test_cleanup_handles_missing_components(self):
"""Test that _cleanup_async_loading handles missing components gracefully"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
# Don't set any async components
# Should not raise
obj._cleanup_async_loading()
class TestOnImageLoaded:
"""Tests for _on_image_loaded callback"""
def test_on_image_loaded_calls_element_callback(self):
"""Test that _on_image_loaded calls element's callback"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
def update(self):
pass
obj = TestClass()
mock_image = Mock()
mock_user_data = Mock()
mock_user_data._on_async_image_loaded = Mock()
obj._on_image_loaded(Path("/test/image.jpg"), mock_image, mock_user_data)
mock_user_data._on_async_image_loaded.assert_called_once_with(mock_image)
def test_on_image_loaded_triggers_update(self):
"""Test that _on_image_loaded triggers widget update"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
def __init__(self):
self.update_called = False
def update(self):
self.update_called = True
obj = TestClass()
obj._on_image_loaded(Path("/test/image.jpg"), Mock(), None)
assert obj.update_called
def test_on_image_loaded_handles_none_user_data(self):
"""Test that _on_image_loaded handles None user_data"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
def update(self):
pass
obj = TestClass()
# Should not raise
obj._on_image_loaded(Path("/test/image.jpg"), Mock(), None)
class TestOnImageLoadFailed:
"""Tests for _on_image_load_failed callback"""
def test_on_image_load_failed_calls_element_callback(self):
"""Test that _on_image_load_failed calls element's callback"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
mock_user_data = Mock()
mock_user_data._on_async_image_load_failed = Mock()
obj._on_image_load_failed(Path("/test/image.jpg"), "Error message", mock_user_data)
mock_user_data._on_async_image_load_failed.assert_called_once_with("Error message")
def test_on_image_load_failed_handles_none_user_data(self):
"""Test that _on_image_load_failed handles None user_data"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
# Should not raise
obj._on_image_load_failed(Path("/test/image.jpg"), "Error", None)
class TestOnPdfProgress:
"""Tests for _on_pdf_progress callback"""
def test_on_pdf_progress_updates_dialog(self):
"""Test that _on_pdf_progress updates progress dialog"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj._pdf_progress_dialog = Mock()
obj._on_pdf_progress(5, 10, "Processing page 5")
obj._pdf_progress_dialog.setValue.assert_called_once_with(5)
obj._pdf_progress_dialog.setLabelText.assert_called_once_with("Processing page 5")
def test_on_pdf_progress_handles_no_dialog(self):
"""Test that _on_pdf_progress handles missing dialog"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
# No _pdf_progress_dialog attribute
# Should not raise
obj._on_pdf_progress(5, 10, "Processing")
class TestOnPdfComplete:
"""Tests for _on_pdf_complete callback"""
def test_on_pdf_complete_closes_dialog(self):
"""Test that _on_pdf_complete closes progress dialog"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
def window(self):
return Mock(spec=[])
obj = TestClass()
mock_dialog = Mock()
obj._pdf_progress_dialog = mock_dialog
obj._on_pdf_complete(True, [])
mock_dialog.close.assert_called_once()
assert obj._pdf_progress_dialog is None
def test_on_pdf_complete_shows_success_status(self):
"""Test that _on_pdf_complete shows success status"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
mock_main_window = Mock()
class TestClass(AsyncLoadingMixin):
def window(self):
return mock_main_window
obj = TestClass()
obj._on_pdf_complete(True, [])
mock_main_window.show_status.assert_called_once()
call_args = mock_main_window.show_status.call_args[0]
assert "successfully" in call_args[0]
def test_on_pdf_complete_shows_warnings(self):
"""Test that _on_pdf_complete shows warning count"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
mock_main_window = Mock()
class TestClass(AsyncLoadingMixin):
def window(self):
return mock_main_window
obj = TestClass()
obj._on_pdf_complete(True, ["warning1", "warning2"])
mock_main_window.show_status.assert_called_once()
call_args = mock_main_window.show_status.call_args[0]
assert "2 warnings" in call_args[0]
def test_on_pdf_complete_shows_failure_status(self):
"""Test that _on_pdf_complete shows failure status"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
mock_main_window = Mock()
class TestClass(AsyncLoadingMixin):
def window(self):
return mock_main_window
obj = TestClass()
obj._on_pdf_complete(False, [])
mock_main_window.show_status.assert_called_once()
call_args = mock_main_window.show_status.call_args[0]
assert "failed" in call_args[0]
class TestOnPdfFailed:
"""Tests for _on_pdf_failed callback"""
def test_on_pdf_failed_closes_dialog(self):
"""Test that _on_pdf_failed closes progress dialog"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
def window(self):
return Mock(spec=[])
obj = TestClass()
mock_dialog = Mock()
obj._pdf_progress_dialog = mock_dialog
obj._on_pdf_failed("Error occurred")
mock_dialog.close.assert_called_once()
assert obj._pdf_progress_dialog is None
def test_on_pdf_failed_shows_error_status(self):
"""Test that _on_pdf_failed shows error status"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
mock_main_window = Mock()
class TestClass(AsyncLoadingMixin):
def window(self):
return mock_main_window
obj = TestClass()
obj._on_pdf_failed("Something went wrong")
mock_main_window.show_status.assert_called_once()
call_args = mock_main_window.show_status.call_args[0]
assert "failed" in call_args[0]
assert "Something went wrong" in call_args[0]
class TestRequestImageLoad:
"""Tests for request_image_load method"""
def test_request_image_load_no_loader(self):
"""Test request_image_load when loader not initialized"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
mock_image_data = Mock()
# Should not raise
obj.request_image_load(mock_image_data)
def test_request_image_load_empty_path(self):
"""Test request_image_load with empty image path"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
mock_image_data = Mock()
mock_image_data.image_path = ""
obj.request_image_load(mock_image_data)
obj.async_image_loader.request_load.assert_not_called()
def test_request_image_load_non_assets_path_skipped(self):
"""Test request_image_load skips paths outside assets folder"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
mock_image_data = Mock()
mock_image_data.image_path = "/absolute/path/image.jpg"
obj.request_image_load(mock_image_data)
obj.async_image_loader.request_load.assert_not_called()
def test_request_image_load_path_not_resolved(self):
"""Test request_image_load when path resolution fails"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
mock_image_data = Mock()
mock_image_data.image_path = "assets/missing.jpg"
mock_image_data.resolve_image_path.return_value = None
obj.request_image_load(mock_image_data)
obj.async_image_loader.request_load.assert_not_called()
def test_request_image_load_success(self, tmp_path):
"""Test successful request_image_load"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin, LoadPriority
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
# Create actual file
asset_path = tmp_path / "assets" / "photo.jpg"
asset_path.parent.mkdir(parents=True)
asset_path.write_text("test")
mock_image_data = Mock()
mock_image_data.image_path = "assets/photo.jpg"
mock_image_data.resolve_image_path.return_value = str(asset_path)
obj.request_image_load(mock_image_data, priority=LoadPriority.HIGH)
obj.async_image_loader.request_load.assert_called_once()
call_kwargs = obj.async_image_loader.request_load.call_args[1]
assert call_kwargs["priority"] == LoadPriority.HIGH
assert call_kwargs["user_data"] == mock_image_data
class TestExportPdfAsync:
"""Tests for export_pdf_async method"""
def test_export_pdf_async_no_generator(self):
"""Test export_pdf_async when generator not initialized"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
mock_project = Mock()
result = obj.export_pdf_async(mock_project, "/output.pdf")
assert result is False
def test_export_pdf_async_creates_progress_dialog(self, qtbot):
"""Test export_pdf_async creates progress dialog"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
from PyQt6.QtWidgets import QWidget
class TestWidget(QWidget, AsyncLoadingMixin):
pass
widget = TestWidget()
qtbot.addWidget(widget)
widget.async_pdf_generator = Mock()
widget.async_pdf_generator.export_pdf.return_value = True
mock_project = Mock()
mock_project.pages = [Mock(is_cover=False, is_double_spread=False)]
widget.export_pdf_async(mock_project, "/output.pdf")
assert hasattr(widget, "_pdf_progress_dialog")
assert widget._pdf_progress_dialog is not None
def test_export_pdf_async_calls_generator(self, qtbot):
"""Test export_pdf_async calls the PDF generator"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
from PyQt6.QtWidgets import QWidget
class TestWidget(QWidget, AsyncLoadingMixin):
pass
widget = TestWidget()
qtbot.addWidget(widget)
widget.async_pdf_generator = Mock()
widget.async_pdf_generator.export_pdf.return_value = True
mock_project = Mock()
mock_project.pages = []
result = widget.export_pdf_async(mock_project, "/output.pdf", export_dpi=150)
widget.async_pdf_generator.export_pdf.assert_called_once_with(mock_project, "/output.pdf", 150)
assert result is True
class TestOnPdfCancel:
"""Tests for _on_pdf_cancel callback"""
def test_on_pdf_cancel_cancels_export(self):
"""Test that _on_pdf_cancel cancels the export"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_pdf_generator = Mock()
obj._on_pdf_cancel()
obj.async_pdf_generator.cancel_export.assert_called_once()
def test_on_pdf_cancel_handles_no_generator(self):
"""Test that _on_pdf_cancel handles missing generator"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
# No async_pdf_generator
# Should not raise
obj._on_pdf_cancel()
class TestGetAsyncStats:
"""Tests for get_async_stats method"""
def test_get_async_stats_empty(self):
"""Test get_async_stats with no components initialized"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
stats = obj.get_async_stats()
assert stats == {}
def test_get_async_stats_with_loader(self):
"""Test get_async_stats includes loader stats"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
obj.async_image_loader.get_stats.return_value = {"loaded": 10}
stats = obj.get_async_stats()
assert "image_loader" in stats
assert stats["image_loader"]["loaded"] == 10
def test_get_async_stats_with_pdf_generator(self):
"""Test get_async_stats includes PDF generator stats"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_pdf_generator = Mock()
obj.async_pdf_generator.get_stats.return_value = {"exports": 5}
stats = obj.get_async_stats()
assert "pdf_generator" in stats
assert stats["pdf_generator"]["exports"] == 5
def test_get_async_stats_with_all_components(self):
"""Test get_async_stats includes all component stats"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
obj.async_image_loader.get_stats.return_value = {"loaded": 10}
obj.async_pdf_generator = Mock()
obj.async_pdf_generator.get_stats.return_value = {"exports": 5}
stats = obj.get_async_stats()
assert "image_loader" in stats
assert "pdf_generator" in stats

View File

@ -0,0 +1,511 @@
"""
Tests for AutosaveManager
"""
import pytest
import json
import tempfile
import shutil
from pathlib import Path
from datetime import datetime, timedelta
from unittest.mock import Mock, patch, MagicMock
from pyPhotoAlbum.autosave_manager import AutosaveManager
class TestAutosaveManagerInit:
"""Tests for AutosaveManager initialization"""
def test_init_creates_checkpoint_directory(self, tmp_path, monkeypatch):
"""Test that init creates the checkpoint directory"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
assert checkpoint_dir.exists()
def test_init_with_existing_directory(self, tmp_path, monkeypatch):
"""Test init when checkpoint directory already exists"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
assert checkpoint_dir.exists()
class TestGetCheckpointPath:
"""Tests for _get_checkpoint_path method"""
def test_get_checkpoint_path_basic(self, tmp_path, monkeypatch):
"""Test basic checkpoint path generation"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
path = manager._get_checkpoint_path("MyProject")
assert path.parent == checkpoint_dir
assert path.suffix == ".ppz"
assert "checkpoint_MyProject_" in path.name
def test_get_checkpoint_path_with_timestamp(self, tmp_path, monkeypatch):
"""Test checkpoint path with specific timestamp"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
timestamp = datetime(2024, 1, 15, 10, 30, 45)
path = manager._get_checkpoint_path("TestProject", timestamp)
assert "20240115_103045" in path.name
def test_get_checkpoint_path_sanitizes_name(self, tmp_path, monkeypatch):
"""Test that special characters in project name are sanitized"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
path = manager._get_checkpoint_path("My Project!@#$%")
# Should not contain special characters except - and _
name_without_ext = path.stem
for char in name_without_ext:
assert char.isalnum() or char in "-_", f"Invalid char: {char}"
class TestCreateCheckpoint:
"""Tests for create_checkpoint method"""
def test_create_checkpoint_success(self, tmp_path, monkeypatch):
"""Test successful checkpoint creation"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
# Mock save_to_zip - note the return value format
with patch("pyPhotoAlbum.autosave_manager.save_to_zip") as mock_save:
mock_save.return_value = (True, "Success")
mock_project = Mock()
mock_project.name = "TestProject"
mock_project.file_path = "/path/to/project.ppz"
success, message = manager.create_checkpoint(mock_project)
assert success is True
assert "Checkpoint created" in message
mock_save.assert_called_once()
def test_create_checkpoint_failure(self, tmp_path, monkeypatch):
"""Test checkpoint creation failure"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
with patch("pyPhotoAlbum.autosave_manager.save_to_zip") as mock_save:
mock_save.return_value = (False, "Disk full")
mock_project = Mock()
mock_project.name = "TestProject"
success, message = manager.create_checkpoint(mock_project)
assert success is False
assert "Checkpoint failed" in message
def test_create_checkpoint_exception(self, tmp_path, monkeypatch):
"""Test checkpoint creation with exception"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
with patch("pyPhotoAlbum.autosave_manager.save_to_zip") as mock_save:
mock_save.side_effect = Exception("IO Error")
mock_project = Mock()
mock_project.name = "TestProject"
success, message = manager.create_checkpoint(mock_project)
assert success is False
assert "Checkpoint error" in message
class TestSaveCheckpointMetadata:
"""Tests for _save_checkpoint_metadata method"""
def test_save_metadata(self, tmp_path, monkeypatch):
"""Test saving checkpoint metadata"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
mock_project = Mock()
mock_project.name = "TestProject"
mock_project.file_path = "/path/to/original.ppz"
checkpoint_path = checkpoint_dir / "checkpoint_TestProject_20240115_103045.ppz"
checkpoint_path.touch()
manager._save_checkpoint_metadata(mock_project, checkpoint_path)
metadata_path = checkpoint_path.with_suffix(".json")
assert metadata_path.exists()
with open(metadata_path, "r") as f:
metadata = json.load(f)
assert metadata["project_name"] == "TestProject"
assert metadata["original_path"] == "/path/to/original.ppz"
assert "timestamp" in metadata
class TestListCheckpoints:
"""Tests for list_checkpoints method"""
def test_list_checkpoints_empty(self, tmp_path, monkeypatch):
"""Test listing checkpoints when none exist"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
checkpoints = manager.list_checkpoints()
assert checkpoints == []
def test_list_checkpoints_with_files(self, tmp_path, monkeypatch):
"""Test listing checkpoints with existing files"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create some checkpoint files
cp1 = checkpoint_dir / "checkpoint_Project1_20240115_100000.ppz"
cp2 = checkpoint_dir / "checkpoint_Project2_20240115_110000.ppz"
cp1.touch()
cp2.touch()
# Create metadata for first checkpoint
metadata1 = {"project_name": "Project1", "timestamp": "2024-01-15T10:00:00"}
with open(cp1.with_suffix(".json"), "w") as f:
json.dump(metadata1, f)
manager = AutosaveManager()
checkpoints = manager.list_checkpoints()
assert len(checkpoints) == 2
def test_list_checkpoints_filter_by_project(self, tmp_path, monkeypatch):
"""Test listing checkpoints filtered by project name"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create checkpoint files with metadata
cp1 = checkpoint_dir / "checkpoint_Project1_20240115_100000.ppz"
cp2 = checkpoint_dir / "checkpoint_Project2_20240115_110000.ppz"
cp1.touch()
cp2.touch()
metadata1 = {"project_name": "Project1", "timestamp": "2024-01-15T10:00:00"}
metadata2 = {"project_name": "Project2", "timestamp": "2024-01-15T11:00:00"}
with open(cp1.with_suffix(".json"), "w") as f:
json.dump(metadata1, f)
with open(cp2.with_suffix(".json"), "w") as f:
json.dump(metadata2, f)
manager = AutosaveManager()
checkpoints = manager.list_checkpoints("Project1")
assert len(checkpoints) == 1
assert checkpoints[0][1]["project_name"] == "Project1"
def test_list_checkpoints_sorted_by_timestamp(self, tmp_path, monkeypatch):
"""Test that checkpoints are sorted by timestamp (newest first)"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create checkpoints with different timestamps
cp1 = checkpoint_dir / "checkpoint_Project_20240115_080000.ppz"
cp2 = checkpoint_dir / "checkpoint_Project_20240115_120000.ppz"
cp3 = checkpoint_dir / "checkpoint_Project_20240115_100000.ppz"
cp1.touch()
cp2.touch()
cp3.touch()
for cp, hour in [(cp1, "08"), (cp2, "12"), (cp3, "10")]:
metadata = {"project_name": "Project", "timestamp": f"2024-01-15T{hour}:00:00"}
with open(cp.with_suffix(".json"), "w") as f:
json.dump(metadata, f)
manager = AutosaveManager()
checkpoints = manager.list_checkpoints()
# Should be sorted newest first: 12:00, 10:00, 08:00
assert "12:00:00" in checkpoints[0][1]["timestamp"]
assert "10:00:00" in checkpoints[1][1]["timestamp"]
assert "08:00:00" in checkpoints[2][1]["timestamp"]
class TestLoadCheckpoint:
"""Tests for load_checkpoint method"""
def test_load_checkpoint_success(self, tmp_path, monkeypatch):
"""Test successful checkpoint loading"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
with patch("pyPhotoAlbum.autosave_manager.load_from_zip") as mock_load:
mock_project = Mock()
mock_load.return_value = mock_project
checkpoint_path = checkpoint_dir / "checkpoint_Test.ppz"
success, result = manager.load_checkpoint(checkpoint_path)
assert success is True
assert result == mock_project
def test_load_checkpoint_failure(self, tmp_path, monkeypatch):
"""Test checkpoint loading failure"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
with patch("pyPhotoAlbum.autosave_manager.load_from_zip") as mock_load:
mock_load.side_effect = Exception("Corrupt file")
checkpoint_path = checkpoint_dir / "checkpoint_Test.ppz"
success, result = manager.load_checkpoint(checkpoint_path)
assert success is False
assert "Failed to load checkpoint" in result
class TestDeleteCheckpoint:
"""Tests for delete_checkpoint method"""
def test_delete_checkpoint_success(self, tmp_path, monkeypatch):
"""Test successful checkpoint deletion"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create checkpoint and metadata files
cp = checkpoint_dir / "checkpoint_Test.ppz"
cp.touch()
metadata = cp.with_suffix(".json")
metadata.touch()
manager = AutosaveManager()
result = manager.delete_checkpoint(cp)
assert result is True
assert not cp.exists()
assert not metadata.exists()
def test_delete_checkpoint_nonexistent(self, tmp_path, monkeypatch):
"""Test deleting nonexistent checkpoint"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
cp = checkpoint_dir / "nonexistent.ppz"
result = manager.delete_checkpoint(cp)
assert result is True # Should succeed even if file doesn't exist
class TestDeleteAllCheckpoints:
"""Tests for delete_all_checkpoints method"""
def test_delete_all_checkpoints(self, tmp_path, monkeypatch):
"""Test deleting all checkpoints"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create multiple checkpoints
for i in range(3):
cp = checkpoint_dir / f"checkpoint_Project_{i}.ppz"
cp.touch()
metadata = {"project_name": "Project", "timestamp": f"2024-01-15T{i}:00:00"}
with open(cp.with_suffix(".json"), "w") as f:
json.dump(metadata, f)
manager = AutosaveManager()
manager.delete_all_checkpoints()
remaining = list(checkpoint_dir.glob("checkpoint_*.ppz"))
assert len(remaining) == 0
def test_delete_all_checkpoints_filtered(self, tmp_path, monkeypatch):
"""Test deleting all checkpoints for specific project"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create checkpoints for different projects
for name in ["ProjectA", "ProjectB", "ProjectA"]:
cp = checkpoint_dir / f"checkpoint_{name}_{datetime.now().strftime('%Y%m%d_%H%M%S%f')}.ppz"
cp.touch()
metadata = {"project_name": name, "timestamp": datetime.now().isoformat()}
with open(cp.with_suffix(".json"), "w") as f:
json.dump(metadata, f)
manager = AutosaveManager()
manager.delete_all_checkpoints("ProjectA")
# Only ProjectB should remain
remaining = list(checkpoint_dir.glob("checkpoint_*.ppz"))
assert len(remaining) == 1
assert "ProjectB" in remaining[0].name
class TestCleanupOldCheckpoints:
"""Tests for cleanup_old_checkpoints method"""
def test_cleanup_old_checkpoints_by_age(self, tmp_path, monkeypatch):
"""Test cleanup of old checkpoints by age"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create old and new checkpoints
old_time = datetime.now() - timedelta(hours=48)
new_time = datetime.now() - timedelta(hours=1)
old_cp = checkpoint_dir / "checkpoint_Project_old.ppz"
new_cp = checkpoint_dir / "checkpoint_Project_new.ppz"
old_cp.touch()
new_cp.touch()
old_metadata = {"project_name": "Project", "timestamp": old_time.isoformat()}
new_metadata = {"project_name": "Project", "timestamp": new_time.isoformat()}
with open(old_cp.with_suffix(".json"), "w") as f:
json.dump(old_metadata, f)
with open(new_cp.with_suffix(".json"), "w") as f:
json.dump(new_metadata, f)
manager = AutosaveManager()
manager.cleanup_old_checkpoints(max_age_hours=24)
# Only new checkpoint should remain
remaining = list(checkpoint_dir.glob("checkpoint_*.ppz"))
assert len(remaining) == 1
assert "new" in remaining[0].name
def test_cleanup_old_checkpoints_by_count(self, tmp_path, monkeypatch):
"""Test cleanup of checkpoints by count"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create many recent checkpoints
for i in range(5):
timestamp = datetime.now() - timedelta(hours=i)
cp = checkpoint_dir / f"checkpoint_Project_{i:02d}.ppz"
cp.touch()
metadata = {"project_name": "Project", "timestamp": timestamp.isoformat()}
with open(cp.with_suffix(".json"), "w") as f:
json.dump(metadata, f)
manager = AutosaveManager()
manager.cleanup_old_checkpoints(max_age_hours=24 * 7, max_count=3)
# Should only keep 3 most recent
remaining = list(checkpoint_dir.glob("checkpoint_*.ppz"))
assert len(remaining) == 3
class TestHasCheckpoints:
"""Tests for has_checkpoints method"""
def test_has_checkpoints_true(self, tmp_path, monkeypatch):
"""Test has_checkpoints returns True when checkpoints exist"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
cp = checkpoint_dir / "checkpoint_Test.ppz"
cp.touch()
manager = AutosaveManager()
assert manager.has_checkpoints() is True
def test_has_checkpoints_false(self, tmp_path, monkeypatch):
"""Test has_checkpoints returns False when no checkpoints"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
assert manager.has_checkpoints() is False
class TestGetLatestCheckpoint:
"""Tests for get_latest_checkpoint method"""
def test_get_latest_checkpoint(self, tmp_path, monkeypatch):
"""Test getting the latest checkpoint"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create checkpoints with different timestamps
for hour in [8, 10, 12]:
cp = checkpoint_dir / f"checkpoint_Project_{hour:02d}.ppz"
cp.touch()
metadata = {"project_name": "Project", "timestamp": f"2024-01-15T{hour:02d}:00:00"}
with open(cp.with_suffix(".json"), "w") as f:
json.dump(metadata, f)
manager = AutosaveManager()
result = manager.get_latest_checkpoint()
assert result is not None
assert "12:00:00" in result[1]["timestamp"]
def test_get_latest_checkpoint_none(self, tmp_path, monkeypatch):
"""Test getting latest checkpoint when none exist"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
result = manager.get_latest_checkpoint()
assert result is None
def test_get_latest_checkpoint_filtered(self, tmp_path, monkeypatch):
"""Test getting latest checkpoint for specific project"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create checkpoints for different projects
for name, hour in [("ProjectA", 10), ("ProjectB", 12), ("ProjectA", 8)]:
cp = checkpoint_dir / f"checkpoint_{name}_{hour:02d}.ppz"
cp.touch()
metadata = {"project_name": name, "timestamp": f"2024-01-15T{hour:02d}:00:00"}
with open(cp.with_suffix(".json"), "w") as f:
json.dump(metadata, f)
manager = AutosaveManager()
result = manager.get_latest_checkpoint("ProjectA")
assert result is not None
assert result[1]["project_name"] == "ProjectA"
assert "10:00:00" in result[1]["timestamp"] # Latest for ProjectA

View File

@ -249,7 +249,7 @@ class TestDialogMethods:
qtbot.addWidget(window) qtbot.addWidget(window)
mock_critical = Mock() mock_critical = Mock()
monkeypatch.setattr(QMessageBox, 'critical', mock_critical) monkeypatch.setattr(QMessageBox, "critical", mock_critical)
window.show_error("Error Title", "Error message") window.show_error("Error Title", "Error message")
@ -260,7 +260,7 @@ class TestDialogMethods:
qtbot.addWidget(window) qtbot.addWidget(window)
mock_warning = Mock() mock_warning = Mock()
monkeypatch.setattr(QMessageBox, 'warning', mock_warning) monkeypatch.setattr(QMessageBox, "warning", mock_warning)
window.show_warning("Warning Title", "Warning message") window.show_warning("Warning Title", "Warning message")
@ -271,7 +271,7 @@ class TestDialogMethods:
qtbot.addWidget(window) qtbot.addWidget(window)
mock_info = Mock() mock_info = Mock()
monkeypatch.setattr(QMessageBox, 'information', mock_info) monkeypatch.setattr(QMessageBox, "information", mock_info)
window.show_info("Info Title", "Info message") window.show_info("Info Title", "Info message")
@ -309,7 +309,7 @@ class TestRequirePage:
window._gl_widget = Mock() window._gl_widget = Mock()
mock_warning = Mock() mock_warning = Mock()
monkeypatch.setattr(QMessageBox, 'warning', mock_warning) monkeypatch.setattr(QMessageBox, "warning", mock_warning)
result = window.require_page(show_warning=True) result = window.require_page(show_warning=True)
@ -366,7 +366,7 @@ class TestRequireSelection:
window._gl_widget = gl_widget window._gl_widget = gl_widget
mock_info = Mock() mock_info = Mock()
monkeypatch.setattr(QMessageBox, 'information', mock_info) monkeypatch.setattr(QMessageBox, "information", mock_info)
result = window.require_selection(min_count=1, show_warning=True) result = window.require_selection(min_count=1, show_warning=True)
@ -386,7 +386,7 @@ class TestRequireSelection:
window._gl_widget = gl_widget window._gl_widget = gl_widget
mock_info = Mock() mock_info = Mock()
monkeypatch.setattr(QMessageBox, 'information', mock_info) monkeypatch.setattr(QMessageBox, "information", mock_info)
result = window.require_selection(min_count=3, show_warning=True) result = window.require_selection(min_count=3, show_warning=True)

View File

@ -16,7 +16,7 @@ from pyPhotoAlbum.commands import (
ChangeZOrderCommand, ChangeZOrderCommand,
StateChangeCommand, StateChangeCommand,
CommandHistory, CommandHistory,
_normalize_asset_path _normalize_asset_path,
) )
from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
from pyPhotoAlbum.page_layout import PageLayout from pyPhotoAlbum.page_layout import PageLayout
@ -107,9 +107,9 @@ class TestAddElementCommand:
data = cmd.serialize() data = cmd.serialize()
assert data['type'] == 'add_element' assert data["type"] == "add_element"
assert 'element' in data assert "element" in data
assert data['executed'] is True assert data["executed"] is True
def test_add_element_with_asset_manager(self): def test_add_element_with_asset_manager(self):
"""Test add element with asset manager reference""" """Test add element with asset manager reference"""
@ -164,8 +164,8 @@ class TestDeleteElementCommand:
cmd = DeleteElementCommand(layout, element) cmd = DeleteElementCommand(layout, element)
data = cmd.serialize() data = cmd.serialize()
assert data['type'] == 'delete_element' assert data["type"] == "delete_element"
assert 'element' in data assert "element" in data
class TestMoveElementCommand: class TestMoveElementCommand:
@ -198,9 +198,9 @@ class TestMoveElementCommand:
cmd = MoveElementCommand(element, old_position=(100, 100), new_position=(200, 200)) cmd = MoveElementCommand(element, old_position=(100, 100), new_position=(200, 200))
data = cmd.serialize() data = cmd.serialize()
assert data['type'] == 'move_element' assert data["type"] == "move_element"
assert data['old_position'] == (100, 100) assert data["old_position"] == (100, 100)
assert data['new_position'] == (200, 200) assert data["new_position"] == (200, 200)
class TestResizeElementCommand: class TestResizeElementCommand:
@ -211,11 +211,7 @@ class TestResizeElementCommand:
element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150)
cmd = ResizeElementCommand( cmd = ResizeElementCommand(
element, element, old_position=(100, 100), old_size=(200, 150), new_position=(100, 100), new_size=(300, 225)
old_position=(100, 100),
old_size=(200, 150),
new_position=(100, 100),
new_size=(300, 225)
) )
cmd.execute() cmd.execute()
@ -226,11 +222,7 @@ class TestResizeElementCommand:
element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150)
cmd = ResizeElementCommand( cmd = ResizeElementCommand(
element, element, old_position=(100, 100), old_size=(200, 150), new_position=(100, 100), new_size=(300, 225)
old_position=(100, 100),
old_size=(200, 150),
new_position=(100, 100),
new_size=(300, 225)
) )
cmd.execute() cmd.execute()
@ -243,11 +235,7 @@ class TestResizeElementCommand:
element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150)
cmd = ResizeElementCommand( cmd = ResizeElementCommand(
element, element, old_position=(100, 100), old_size=(200, 150), new_position=(90, 90), new_size=(220, 165)
old_position=(100, 100),
old_size=(200, 150),
new_position=(90, 90),
new_size=(220, 165)
) )
cmd.execute() cmd.execute()
@ -298,9 +286,9 @@ class TestRotateElementCommand:
cmd = RotateElementCommand(element, old_rotation=0, new_rotation=45) cmd = RotateElementCommand(element, old_rotation=0, new_rotation=45)
data = cmd.serialize() data = cmd.serialize()
assert data['type'] == 'rotate_element' assert data["type"] == "rotate_element"
assert data['old_rotation'] == 0 assert data["old_rotation"] == 0
assert data['new_rotation'] == 45 assert data["new_rotation"] == 45
class TestAdjustImageCropCommand: class TestAdjustImageCropCommand:
@ -310,32 +298,25 @@ class TestAdjustImageCropCommand:
"""Test adjusting image crop""" """Test adjusting image crop"""
element = ImageData( element = ImageData(
image_path="/test.jpg", image_path="/test.jpg",
x=100, y=100, x=100,
width=200, height=150, y=100,
crop_info={'x': 0.0, 'y': 0.0, 'width': 1.0, 'height': 1.0} width=200,
height=150,
crop_info={"x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0},
) )
new_crop = {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8} new_crop = {"x": 0.1, "y": 0.1, "width": 0.8, "height": 0.8}
cmd = AdjustImageCropCommand( cmd = AdjustImageCropCommand(element, old_crop_info=element.crop_info.copy(), new_crop_info=new_crop)
element,
old_crop_info=element.crop_info.copy(),
new_crop_info=new_crop
)
cmd.execute() cmd.execute()
assert element.crop_info == new_crop assert element.crop_info == new_crop
def test_adjust_crop_undo(self): def test_adjust_crop_undo(self):
"""Test undoing crop adjustment""" """Test undoing crop adjustment"""
old_crop = {'x': 0.0, 'y': 0.0, 'width': 1.0, 'height': 1.0} old_crop = {"x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0}
element = ImageData( element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150, crop_info=old_crop.copy())
image_path="/test.jpg",
x=100, y=100,
width=200, height=150,
crop_info=old_crop.copy()
)
new_crop = {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8} new_crop = {"x": 0.1, "y": 0.1, "width": 0.8, "height": 0.8}
cmd = AdjustImageCropCommand(element, old_crop_info=old_crop, new_crop_info=new_crop) cmd = AdjustImageCropCommand(element, old_crop_info=old_crop, new_crop_info=new_crop)
cmd.execute() cmd.execute()
@ -399,10 +380,7 @@ class TestResizeElementsCommand:
element2.size = (300, 300) element2.size = (300, 300)
# Command expects list of (element, old_position, old_size) tuples # Command expects list of (element, old_position, old_size) tuples
changes = [ changes = [(element1, (100, 100), (100, 100)), (element2, (200, 200), (150, 150))]
(element1, (100, 100), (100, 100)),
(element2, (200, 200), (150, 150))
]
cmd = ResizeElementsCommand(changes) cmd = ResizeElementsCommand(changes)
cmd.execute() cmd.execute()
@ -421,10 +399,7 @@ class TestResizeElementsCommand:
element2.size = (300, 300) element2.size = (300, 300)
# Command expects list of (element, old_position, old_size) tuples # Command expects list of (element, old_position, old_size) tuples
changes = [ changes = [(element1, (100, 100), (100, 100)), (element2, (200, 200), (150, 150))]
(element1, (100, 100), (100, 100)),
(element2, (200, 200), (150, 150))
]
cmd = ResizeElementsCommand(changes) cmd = ResizeElementsCommand(changes)
cmd.execute() cmd.execute()
@ -476,81 +451,68 @@ class TestStateChangeCommand:
def test_state_change_undo(self): def test_state_change_undo(self):
"""Test undoing state change""" """Test undoing state change"""
element = TextBoxData( element = TextBoxData(text_content="Old Text", x=100, y=100, width=200, height=100)
text_content="Old Text",
x=100, y=100,
width=200, height=100
)
# Define restore function # Define restore function
def restore_state(state): def restore_state(state):
element.text_content = state['text_content'] element.text_content = state["text_content"]
old_state = {'text_content': 'Old Text'} old_state = {"text_content": "Old Text"}
new_state = {'text_content': 'New Text'} new_state = {"text_content": "New Text"}
# Apply new state first # Apply new state first
element.text_content = 'New Text' element.text_content = "New Text"
cmd = StateChangeCommand( cmd = StateChangeCommand(
description="Change text", description="Change text", restore_func=restore_state, before_state=old_state, after_state=new_state
restore_func=restore_state,
before_state=old_state,
after_state=new_state
) )
# Undo should restore old state # Undo should restore old state
cmd.undo() cmd.undo()
assert element.text_content == 'Old Text' assert element.text_content == "Old Text"
def test_state_change_redo(self): def test_state_change_redo(self):
"""Test redoing state change""" """Test redoing state change"""
element = TextBoxData( element = TextBoxData(text_content="Old Text", x=100, y=100, width=200, height=100)
text_content="Old Text",
x=100, y=100,
width=200, height=100
)
# Define restore function # Define restore function
def restore_state(state): def restore_state(state):
element.text_content = state['text_content'] element.text_content = state["text_content"]
old_state = {'text_content': 'Old Text'} old_state = {"text_content": "Old Text"}
new_state = {'text_content': 'New Text'} new_state = {"text_content": "New Text"}
# Apply new state first # Apply new state first
element.text_content = 'New Text' element.text_content = "New Text"
cmd = StateChangeCommand( cmd = StateChangeCommand(
description="Change text", description="Change text", restore_func=restore_state, before_state=old_state, after_state=new_state
restore_func=restore_state,
before_state=old_state,
after_state=new_state
) )
# Undo then redo # Undo then redo
cmd.undo() cmd.undo()
assert element.text_content == 'Old Text' assert element.text_content == "Old Text"
cmd.redo() cmd.redo()
assert element.text_content == 'New Text' assert element.text_content == "New Text"
def test_state_change_serialization(self): def test_state_change_serialization(self):
"""Test serializing state change command""" """Test serializing state change command"""
def restore_func(state): def restore_func(state):
pass pass
cmd = StateChangeCommand( cmd = StateChangeCommand(
description="Test operation", description="Test operation",
restore_func=restore_func, restore_func=restore_func,
before_state={'test': 'before'}, before_state={"test": "before"},
after_state={'test': 'after'} after_state={"test": "after"},
) )
data = cmd.serialize() data = cmd.serialize()
assert data['type'] == 'state_change' assert data["type"] == "state_change"
assert data['description'] == 'Test operation' assert data["description"] == "Test operation"
class TestCommandHistory: class TestCommandHistory:
@ -656,7 +618,7 @@ class TestCommandHistory:
layout = PageLayout(width=210, height=297) layout = PageLayout(width=210, height=297)
for i in range(5): for i in range(5):
element = ImageData(image_path=f"/test{i}.jpg", x=i*10, y=i*10, width=100, height=100) element = ImageData(image_path=f"/test{i}.jpg", x=i * 10, y=i * 10, width=100, height=100)
history.execute(AddElementCommand(layout, element)) history.execute(AddElementCommand(layout, element))
# Should only have 3 commands in history (max_history) # Should only have 3 commands in history (max_history)
@ -678,8 +640,8 @@ class TestCommandHistory:
# Serialize # Serialize
data = history.serialize() data = history.serialize()
assert len(data['undo_stack']) == 1 assert len(data["undo_stack"]) == 1
assert data['undo_stack'][0]['type'] == 'add_element' assert data["undo_stack"][0]["type"] == "add_element"
# Create mock project for deserialization # Create mock project for deserialization
mock_project = Mock() mock_project = Mock()
@ -734,7 +696,7 @@ class TestCommandHistory:
# Manually build serialized history data # Manually build serialized history data
data = { data = {
'undo_stack': [ "undo_stack": [
cmd1.serialize(), cmd1.serialize(),
cmd2.serialize(), cmd2.serialize(),
cmd3.serialize(), cmd3.serialize(),
@ -745,8 +707,8 @@ class TestCommandHistory:
cmd8.serialize(), cmd8.serialize(),
cmd9.serialize(), cmd9.serialize(),
], ],
'redo_stack': [], "redo_stack": [],
'max_history': 100 "max_history": 100,
} }
# Create mock project # Create mock project
@ -758,15 +720,15 @@ class TestCommandHistory:
new_history.deserialize(data, mock_project) new_history.deserialize(data, mock_project)
assert len(new_history.undo_stack) == 9 assert len(new_history.undo_stack) == 9
assert new_history.undo_stack[0].__class__.__name__ == 'AddElementCommand' assert new_history.undo_stack[0].__class__.__name__ == "AddElementCommand"
assert new_history.undo_stack[1].__class__.__name__ == 'DeleteElementCommand' assert new_history.undo_stack[1].__class__.__name__ == "DeleteElementCommand"
assert new_history.undo_stack[2].__class__.__name__ == 'MoveElementCommand' assert new_history.undo_stack[2].__class__.__name__ == "MoveElementCommand"
assert new_history.undo_stack[3].__class__.__name__ == 'ResizeElementCommand' assert new_history.undo_stack[3].__class__.__name__ == "ResizeElementCommand"
assert new_history.undo_stack[4].__class__.__name__ == 'RotateElementCommand' assert new_history.undo_stack[4].__class__.__name__ == "RotateElementCommand"
assert new_history.undo_stack[5].__class__.__name__ == 'AdjustImageCropCommand' assert new_history.undo_stack[5].__class__.__name__ == "AdjustImageCropCommand"
assert new_history.undo_stack[6].__class__.__name__ == 'AlignElementsCommand' assert new_history.undo_stack[6].__class__.__name__ == "AlignElementsCommand"
assert new_history.undo_stack[7].__class__.__name__ == 'ResizeElementsCommand' assert new_history.undo_stack[7].__class__.__name__ == "ResizeElementsCommand"
assert new_history.undo_stack[8].__class__.__name__ == 'ChangeZOrderCommand' assert new_history.undo_stack[8].__class__.__name__ == "ChangeZOrderCommand"
def test_history_deserialize_unknown_command_type(self): def test_history_deserialize_unknown_command_type(self):
"""Test deserializing unknown command type returns None and continues""" """Test deserializing unknown command type returns None and continues"""
@ -774,12 +736,12 @@ class TestCommandHistory:
mock_project = Mock() mock_project = Mock()
data = { data = {
'undo_stack': [ "undo_stack": [
{'type': 'unknown_command', 'data': 'test'}, {"type": "unknown_command", "data": "test"},
{'type': 'add_element', 'element': ImageData().serialize(), 'executed': True} {"type": "add_element", "element": ImageData().serialize(), "executed": True},
], ],
'redo_stack': [], "redo_stack": [],
'max_history': 100 "max_history": 100,
} }
# Should not raise exception, just skip unknown command # Should not raise exception, just skip unknown command
@ -787,7 +749,7 @@ class TestCommandHistory:
# Should only have the valid command # Should only have the valid command
assert len(history.undo_stack) == 1 assert len(history.undo_stack) == 1
assert history.undo_stack[0].__class__.__name__ == 'AddElementCommand' assert history.undo_stack[0].__class__.__name__ == "AddElementCommand"
def test_history_deserialize_malformed_command(self): def test_history_deserialize_malformed_command(self):
"""Test deserializing malformed command handles exception gracefully""" """Test deserializing malformed command handles exception gracefully"""
@ -795,13 +757,17 @@ class TestCommandHistory:
mock_project = Mock() mock_project = Mock()
data = { data = {
'undo_stack': [ "undo_stack": [
{'type': 'add_element'}, # Missing required 'element' field {"type": "add_element"}, # Missing required 'element' field
{'type': 'move_element', 'element': ImageData().serialize(), {
'old_position': (0, 0), 'new_position': (10, 10)} "type": "move_element",
"element": ImageData().serialize(),
"old_position": (0, 0),
"new_position": (10, 10),
},
], ],
'redo_stack': [], "redo_stack": [],
'max_history': 100 "max_history": 100,
} }
# Should not raise exception, just skip malformed command # Should not raise exception, just skip malformed command
@ -809,7 +775,7 @@ class TestCommandHistory:
# Should only have the valid command # Should only have the valid command
assert len(history.undo_stack) == 1 assert len(history.undo_stack) == 1
assert history.undo_stack[0].__class__.__name__ == 'MoveElementCommand' assert history.undo_stack[0].__class__.__name__ == "MoveElementCommand"
def test_history_serialize_deserialize_with_redo_stack(self): def test_history_serialize_deserialize_with_redo_stack(self):
"""Test serializing and deserializing with items in redo stack""" """Test serializing and deserializing with items in redo stack"""
@ -826,8 +792,8 @@ class TestCommandHistory:
# Serialize # Serialize
data = history.serialize() data = history.serialize()
assert len(data['undo_stack']) == 1 assert len(data["undo_stack"]) == 1
assert len(data['redo_stack']) == 1 assert len(data["redo_stack"]) == 1
# Deserialize # Deserialize
mock_project = Mock() mock_project = Mock()

View File

@ -56,7 +56,7 @@ class TestGetSelectedElementsList:
class TestDistributeHorizontally: class TestDistributeHorizontally:
"""Test distribute_horizontally method""" """Test distribute_horizontally method"""
@patch('pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager') @patch("pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager")
def test_distribute_horizontally_success(self, mock_manager, qtbot): def test_distribute_horizontally_success(self, mock_manager, qtbot):
window = TestDistributionWindow() window = TestDistributionWindow()
qtbot.addWidget(window) qtbot.addWidget(window)
@ -70,7 +70,7 @@ class TestDistributeHorizontally:
mock_manager.distribute_horizontally.return_value = [ mock_manager.distribute_horizontally.return_value = [
(element1, (0, 0)), (element1, (0, 0)),
(element2, (150, 0)), (element2, (150, 0)),
(element3, (500, 0)) (element3, (500, 0)),
] ]
window.distribute_horizontally() window.distribute_horizontally()
@ -98,7 +98,7 @@ class TestDistributeHorizontally:
class TestDistributeVertically: class TestDistributeVertically:
"""Test distribute_vertically method""" """Test distribute_vertically method"""
@patch('pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager') @patch("pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager")
def test_distribute_vertically_success(self, mock_manager, qtbot): def test_distribute_vertically_success(self, mock_manager, qtbot):
window = TestDistributionWindow() window = TestDistributionWindow()
qtbot.addWidget(window) qtbot.addWidget(window)
@ -112,7 +112,7 @@ class TestDistributeVertically:
mock_manager.distribute_vertically.return_value = [ mock_manager.distribute_vertically.return_value = [
(element1, (0, 0)), (element1, (0, 0)),
(element2, (0, 150)), (element2, (0, 150)),
(element3, (0, 500)) (element3, (0, 500)),
] ]
window.distribute_vertically() window.distribute_vertically()
@ -125,7 +125,7 @@ class TestDistributeVertically:
class TestSpaceHorizontally: class TestSpaceHorizontally:
"""Test space_horizontally method""" """Test space_horizontally method"""
@patch('pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager') @patch("pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager")
def test_space_horizontally_success(self, mock_manager, qtbot): def test_space_horizontally_success(self, mock_manager, qtbot):
window = TestDistributionWindow() window = TestDistributionWindow()
qtbot.addWidget(window) qtbot.addWidget(window)
@ -136,11 +136,7 @@ class TestSpaceHorizontally:
window.gl_widget.selected_elements = {element1, element2, element3} window.gl_widget.selected_elements = {element1, element2, element3}
mock_manager.space_horizontally.return_value = [ mock_manager.space_horizontally.return_value = [(element1, (0, 0)), (element2, (100, 0)), (element3, (200, 0))]
(element1, (0, 0)),
(element2, (100, 0)),
(element3, (200, 0))
]
window.space_horizontally() window.space_horizontally()
@ -152,7 +148,7 @@ class TestSpaceHorizontally:
class TestSpaceVertically: class TestSpaceVertically:
"""Test space_vertically method""" """Test space_vertically method"""
@patch('pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager') @patch("pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager")
def test_space_vertically_success(self, mock_manager, qtbot): def test_space_vertically_success(self, mock_manager, qtbot):
window = TestDistributionWindow() window = TestDistributionWindow()
qtbot.addWidget(window) qtbot.addWidget(window)
@ -163,11 +159,7 @@ class TestSpaceVertically:
window.gl_widget.selected_elements = {element1, element2, element3} window.gl_widget.selected_elements = {element1, element2, element3}
mock_manager.space_vertically.return_value = [ mock_manager.space_vertically.return_value = [(element1, (0, 0)), (element2, (0, 100)), (element3, (0, 200))]
(element1, (0, 0)),
(element2, (0, 100)),
(element3, (0, 200))
]
window.space_vertically() window.space_vertically()
@ -178,7 +170,7 @@ class TestSpaceVertically:
class TestDistributionCommandPattern: class TestDistributionCommandPattern:
"""Test distribution operations with command pattern""" """Test distribution operations with command pattern"""
@patch('pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager') @patch("pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager")
def test_distribution_creates_command(self, mock_manager, qtbot): def test_distribution_creates_command(self, mock_manager, qtbot):
window = TestDistributionWindow() window = TestDistributionWindow()
qtbot.addWidget(window) qtbot.addWidget(window)
@ -192,7 +184,7 @@ class TestDistributionCommandPattern:
mock_manager.distribute_horizontally.return_value = [ mock_manager.distribute_horizontally.return_value = [
(element1, (0, 0)), (element1, (0, 0)),
(element2, (100, 0)), (element2, (100, 0)),
(element3, (200, 0)) (element3, (200, 0)),
] ]
assert not window.project.history.can_undo() assert not window.project.history.can_undo()

View File

@ -31,7 +31,7 @@ class TestEditWindow(EditOperationsMixin, QMainWindow):
return len(self.gl_widget.selected_elements) >= min_count return len(self.gl_widget.selected_elements) >= min_count
def get_current_page(self): def get_current_page(self):
if hasattr(self, '_current_page'): if hasattr(self, "_current_page"):
return self._current_page return self._current_page
return None return None

View File

@ -15,6 +15,7 @@ from pyPhotoAlbum.page_layout import PageLayout
# Create test widget combining necessary mixins # Create test widget combining necessary mixins
class TestManipulationWidget(ElementManipulationMixin, ElementSelectionMixin, QOpenGLWidget): class TestManipulationWidget(ElementManipulationMixin, ElementSelectionMixin, QOpenGLWidget):
"""Test widget combining manipulation and selection mixins""" """Test widget combining manipulation and selection mixins"""
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._page_renderers = [] self._page_renderers = []
@ -36,11 +37,7 @@ class TestElementManipulationInitialization:
assert widget.rotation_mode is False assert widget.rotation_mode is False
assert widget.rotation_start_angle is None assert widget.rotation_start_angle is None
assert widget.rotation_snap_angle == 15 assert widget.rotation_snap_angle == 15
assert widget.snap_state == { assert widget.snap_state == {"is_snapped": False, "last_position": None, "last_size": None}
'is_snapped': False,
'last_position': None,
'last_size': None
}
def test_rotation_mode_is_mutable(self, qtbot): def test_rotation_mode_is_mutable(self, qtbot):
"""Test that rotation mode can be toggled""" """Test that rotation mode can be toggled"""
@ -72,7 +69,7 @@ class TestResizeElementNoSnap:
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150) elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
widget.selected_element = elem widget.selected_element = elem
widget.resize_handle = 'se' widget.resize_handle = "se"
widget.resize_start_pos = (100, 100) widget.resize_start_pos = (100, 100)
widget.resize_start_size = (200, 150) widget.resize_start_size = (200, 150)
@ -89,7 +86,7 @@ class TestResizeElementNoSnap:
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150) elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
widget.selected_element = elem widget.selected_element = elem
widget.resize_handle = 'nw' widget.resize_handle = "nw"
widget.resize_start_pos = (100, 100) widget.resize_start_pos = (100, 100)
widget.resize_start_size = (200, 150) widget.resize_start_size = (200, 150)
@ -106,7 +103,7 @@ class TestResizeElementNoSnap:
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150) elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
widget.selected_element = elem widget.selected_element = elem
widget.resize_handle = 'ne' widget.resize_handle = "ne"
widget.resize_start_pos = (100, 100) widget.resize_start_pos = (100, 100)
widget.resize_start_size = (200, 150) widget.resize_start_size = (200, 150)
@ -123,7 +120,7 @@ class TestResizeElementNoSnap:
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150) elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
widget.selected_element = elem widget.selected_element = elem
widget.resize_handle = 'sw' widget.resize_handle = "sw"
widget.resize_start_pos = (100, 100) widget.resize_start_pos = (100, 100)
widget.resize_start_size = (200, 150) widget.resize_start_size = (200, 150)
@ -140,7 +137,7 @@ class TestResizeElementNoSnap:
elem = ImageData(image_path="test.jpg", x=100, y=100, width=50, height=50) elem = ImageData(image_path="test.jpg", x=100, y=100, width=50, height=50)
widget.selected_element = elem widget.selected_element = elem
widget.resize_handle = 'se' widget.resize_handle = "se"
widget.resize_start_pos = (100, 100) widget.resize_start_pos = (100, 100)
widget.resize_start_size = (50, 50) widget.resize_start_size = (50, 50)
@ -157,7 +154,7 @@ class TestResizeElementNoSnap:
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150) elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
widget.selected_element = elem widget.selected_element = elem
widget.resize_handle = 'se' widget.resize_handle = "se"
# Don't set resize_start_pos or resize_start_size # Don't set resize_start_pos or resize_start_size
original_pos = elem.position original_pos = elem.position
@ -185,7 +182,7 @@ class TestResizeElementWithSnap:
elem._parent_page = page elem._parent_page = page
widget.selected_element = elem widget.selected_element = elem
widget.resize_handle = 'se' widget.resize_handle = "se"
widget.resize_start_pos = (100, 100) widget.resize_start_pos = (100, 100)
widget.resize_start_size = (200, 150) widget.resize_start_size = (200, 150)
@ -208,7 +205,7 @@ class TestResizeElementWithSnap:
params = call_args[0][0] params = call_args[0][0]
assert params.dx == 50 assert params.dx == 50
assert params.dy == 30 assert params.dy == 30
assert params.resize_handle == 'se' assert params.resize_handle == "se"
# Verify element was updated # Verify element was updated
assert elem.size == (250, 180) assert elem.size == (250, 180)
@ -220,7 +217,7 @@ class TestResizeElementWithSnap:
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150) elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
widget.selected_element = elem widget.selected_element = elem
widget.resize_handle = 'se' widget.resize_handle = "se"
widget.resize_start_pos = (100, 100) widget.resize_start_pos = (100, 100)
widget.resize_start_size = (200, 150) widget.resize_start_size = (200, 150)
@ -241,7 +238,7 @@ class TestResizeElementWithSnap:
elem._parent_page = page elem._parent_page = page
widget.selected_element = elem widget.selected_element = elem
widget.resize_handle = 'se' widget.resize_handle = "se"
widget.resize_start_pos = (100, 100) widget.resize_start_pos = (100, 100)
widget.resize_start_size = (50, 50) widget.resize_start_size = (50, 50)
@ -344,20 +341,20 @@ class TestManipulationStateManagement:
widget = TestManipulationWidget() widget = TestManipulationWidget()
qtbot.addWidget(widget) qtbot.addWidget(widget)
assert 'is_snapped' in widget.snap_state assert "is_snapped" in widget.snap_state
assert 'last_position' in widget.snap_state assert "last_position" in widget.snap_state
assert 'last_size' in widget.snap_state assert "last_size" in widget.snap_state
def test_resize_state_can_be_set(self, qtbot): def test_resize_state_can_be_set(self, qtbot):
"""Test resize state variables can be set""" """Test resize state variables can be set"""
widget = TestManipulationWidget() widget = TestManipulationWidget()
qtbot.addWidget(widget) qtbot.addWidget(widget)
widget.resize_handle = 'nw' widget.resize_handle = "nw"
widget.resize_start_pos = (10, 20) widget.resize_start_pos = (10, 20)
widget.resize_start_size = (100, 200) widget.resize_start_size = (100, 200)
assert widget.resize_handle == 'nw' assert widget.resize_handle == "nw"
assert widget.resize_start_pos == (10, 20) assert widget.resize_start_pos == (10, 20)
assert widget.resize_start_size == (100, 200) assert widget.resize_start_size == (100, 200)

View File

@ -258,6 +258,7 @@ class TestElementMaximizer:
def test_maximize_empty_elements(self): def test_maximize_empty_elements(self):
"""Test maximize with empty element list.""" """Test maximize with empty element list."""
from pyPhotoAlbum.alignment import AlignmentManager from pyPhotoAlbum.alignment import AlignmentManager
result = AlignmentManager.maximize_pattern([], (200.0, 200.0)) result = AlignmentManager.maximize_pattern([], (200.0, 200.0))
assert result == [] assert result == []

View File

@ -43,11 +43,11 @@ class TestElementWindow(ElementOperationsMixin, AssetPathMixin, QMainWindow):
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
return self._current_page is not None if hasattr(self, '_current_page') else False return self._current_page is not None if hasattr(self, "_current_page") else False
def get_current_page(self): def get_current_page(self):
"""Return mock current page""" """Return mock current page"""
if hasattr(self, '_current_page'): if hasattr(self, "_current_page"):
return self._current_page return self._current_page
return None return None
@ -71,8 +71,8 @@ class TestElementWindow(ElementOperationsMixin, AssetPathMixin, QMainWindow):
class TestAddImage: class TestAddImage:
"""Test add_image method""" """Test add_image method"""
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName') @patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName")
@patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions') @patch("pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions")
def test_add_image_success(self, mock_get_dims, mock_file_dialog, qtbot): def test_add_image_success(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test successfully adding an image""" """Test successfully adding an image"""
window = TestElementWindow() window = TestElementWindow()
@ -106,7 +106,7 @@ class TestAddImage:
assert window._update_view_called assert window._update_view_called
assert "added image" in window._status_message.lower() assert "added image" in window._status_message.lower()
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName') @patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName")
def test_add_image_cancelled(self, mock_file_dialog, qtbot): def test_add_image_cancelled(self, mock_file_dialog, qtbot):
"""Test cancelling image selection""" """Test cancelling image selection"""
window = TestElementWindow() window = TestElementWindow()
@ -139,8 +139,8 @@ class TestAddImage:
assert window._require_page_called assert window._require_page_called
assert not window._update_view_called assert not window._update_view_called
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName') @patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName")
@patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions') @patch("pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions")
def test_add_image_scales_large_image(self, mock_get_dims, mock_file_dialog, qtbot): def test_add_image_scales_large_image(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test that large images are scaled down""" """Test that large images are scaled down"""
window = TestElementWindow() window = TestElementWindow()
@ -164,8 +164,8 @@ class TestAddImage:
# Image should be added (scaled down by get_image_dimensions) # Image should be added (scaled down by get_image_dimensions)
assert window._update_view_called assert window._update_view_called
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName') @patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName")
@patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions') @patch("pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions")
def test_add_image_fallback_dimensions(self, mock_get_dims, mock_file_dialog, qtbot): def test_add_image_fallback_dimensions(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test fallback dimensions when get_image_dimensions returns None""" """Test fallback dimensions when get_image_dimensions returns None"""
window = TestElementWindow() window = TestElementWindow()
@ -294,8 +294,8 @@ class TestAddPlaceholder:
class TestElementOperationsIntegration: class TestElementOperationsIntegration:
"""Test integration between element operations""" """Test integration between element operations"""
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName') @patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName")
@patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions') @patch("pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions")
def test_add_multiple_elements(self, mock_get_dims, mock_file_dialog, qtbot): def test_add_multiple_elements(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test adding multiple different element types""" """Test adding multiple different element types"""
window = TestElementWindow() window = TestElementWindow()
@ -326,8 +326,8 @@ class TestElementOperationsIntegration:
# Should have added all three elements # Should have added all three elements
assert window._update_view_called assert window._update_view_called
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName') @patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName")
@patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions') @patch("pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions")
def test_add_image_with_undo(self, mock_get_dims, mock_file_dialog, qtbot): def test_add_image_with_undo(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test that adding image can be undone""" """Test that adding image can be undone"""
window = TestElementWindow() window = TestElementWindow()

View File

@ -23,19 +23,19 @@ def mock_page_renderer():
# Mock coordinate conversion methods # Mock coordinate conversion methods
def page_to_screen(x, y): def page_to_screen(x, y):
return (renderer.screen_x + x * renderer.zoom, return (renderer.screen_x + x * renderer.zoom, renderer.screen_y + y * renderer.zoom)
renderer.screen_y + y * renderer.zoom)
def screen_to_page(x, y): def screen_to_page(x, y):
return ((x - renderer.screen_x) / renderer.zoom, return ((x - renderer.screen_x) / renderer.zoom, (y - renderer.screen_y) / renderer.zoom)
(y - renderer.screen_y) / renderer.zoom)
def is_point_in_page(x, y): def is_point_in_page(x, y):
# Simple bounds check (assume 210mm x 297mm page at 96 DPI) # Simple bounds check (assume 210mm x 297mm page at 96 DPI)
page_width_px = 210 * 96 / 25.4 page_width_px = 210 * 96 / 25.4
page_height_px = 297 * 96 / 25.4 page_height_px = 297 * 96 / 25.4
return (renderer.screen_x <= x <= renderer.screen_x + page_width_px * renderer.zoom and return (
renderer.screen_y <= y <= renderer.screen_y + page_height_px * renderer.zoom) renderer.screen_x <= x <= renderer.screen_x + page_width_px * renderer.zoom
and renderer.screen_y <= y <= renderer.screen_y + page_height_px * renderer.zoom
)
renderer.page_to_screen = page_to_screen renderer.page_to_screen = page_to_screen
renderer.screen_to_page = screen_to_page renderer.screen_to_page = screen_to_page
@ -47,6 +47,7 @@ def mock_page_renderer():
# Create a minimal test widget class # Create a minimal test widget class
class TestSelectionWidget(ElementSelectionMixin, QOpenGLWidget): class TestSelectionWidget(ElementSelectionMixin, QOpenGLWidget):
"""Test widget combining ElementSelectionMixin with QOpenGLWidget""" """Test widget combining ElementSelectionMixin with QOpenGLWidget"""
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._page_renderers = [] self._page_renderers = []
@ -60,7 +61,7 @@ class TestElementSelectionInitialization:
widget = TestSelectionWidget() widget = TestSelectionWidget()
qtbot.addWidget(widget) qtbot.addWidget(widget)
assert hasattr(widget, 'selected_elements') assert hasattr(widget, "selected_elements")
assert isinstance(widget.selected_elements, set) assert isinstance(widget.selected_elements, set)
assert len(widget.selected_elements) == 0 assert len(widget.selected_elements) == 0
@ -172,8 +173,8 @@ class TestGetElementAt:
assert result is not None assert result is not None
assert result == elem assert result == elem
assert hasattr(result, '_page_renderer') assert hasattr(result, "_page_renderer")
assert hasattr(result, '_parent_page') assert hasattr(result, "_parent_page")
def test_get_element_at_finds_topmost_element(self, qtbot, mock_page_renderer): def test_get_element_at_finds_topmost_element(self, qtbot, mock_page_renderer):
"""Test _get_element_at returns topmost element when overlapping""" """Test _get_element_at returns topmost element when overlapping"""
@ -246,8 +247,8 @@ class TestGetElementAt:
# Screen coords: (50 + 200, 50 + 175) = (250, 225) # Screen coords: (50 + 200, 50 + 175) = (250, 225)
result = widget._get_element_at(250, 225) result = widget._get_element_at(250, 225)
assert result == elem assert result == elem
assert hasattr(result, '_page_renderer') assert hasattr(result, "_page_renderer")
assert hasattr(result, '_parent_page') assert hasattr(result, "_parent_page")
def test_get_element_at_rotated_element_outside(self, qtbot, mock_page_renderer): def test_get_element_at_rotated_element_outside(self, qtbot, mock_page_renderer):
"""Test _get_element_at correctly rejects clicks outside rotated element""" """Test _get_element_at correctly rejects clicks outside rotated element"""
@ -288,8 +289,8 @@ class TestGetElementAt:
# Should be able to select the element even though it's off the page # Should be able to select the element even though it's off the page
assert result is not None assert result is not None
assert result == elem assert result == elem
assert hasattr(result, '_page_renderer') assert hasattr(result, "_page_renderer")
assert hasattr(result, '_parent_page') assert hasattr(result, "_parent_page")
class TestGetResizeHandleAt: class TestGetResizeHandleAt:
@ -330,6 +331,7 @@ class TestGetResizeHandleAt:
# Mock window with project # Mock window with project
from pyPhotoAlbum.project import Project, Page from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout from pyPhotoAlbum.page_layout import PageLayout
mock_window = Mock() mock_window = Mock()
mock_window.project = Project(name="Test") mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1) page = Page(layout=PageLayout(width=210, height=297), page_number=1)
@ -351,6 +353,7 @@ class TestGetResizeHandleAt:
# Mock window with project # Mock window with project
from pyPhotoAlbum.project import Project, Page from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout from pyPhotoAlbum.page_layout import PageLayout
mock_window = Mock() mock_window = Mock()
mock_window.project = Project(name="Test") mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1) page = Page(layout=PageLayout(width=210, height=297), page_number=1)
@ -359,7 +362,7 @@ class TestGetResizeHandleAt:
# Click on NW handle (screen: 50 + 100 = 150, 50 + 100 = 150) # Click on NW handle (screen: 50 + 100 = 150, 50 + 100 = 150)
result = widget._get_resize_handle_at(150, 150) result = widget._get_resize_handle_at(150, 150)
assert result == 'nw' assert result == "nw"
def test_get_resize_handle_detects_all_corners(self, qtbot, mock_page_renderer): def test_get_resize_handle_detects_all_corners(self, qtbot, mock_page_renderer):
"""Test _get_resize_handle_at detects all four corners""" """Test _get_resize_handle_at detects all four corners"""
@ -373,6 +376,7 @@ class TestGetResizeHandleAt:
# Mock window # Mock window
from pyPhotoAlbum.project import Project, Page from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout from pyPhotoAlbum.page_layout import PageLayout
mock_window = Mock() mock_window = Mock()
mock_window.project = Project(name="Test") mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1) page = Page(layout=PageLayout(width=210, height=297), page_number=1)
@ -380,16 +384,16 @@ class TestGetResizeHandleAt:
widget.window = Mock(return_value=mock_window) widget.window = Mock(return_value=mock_window)
# NW corner (screen: 50 + 100 = 150, 50 + 100 = 150) # NW corner (screen: 50 + 100 = 150, 50 + 100 = 150)
assert widget._get_resize_handle_at(150, 150) == 'nw' assert widget._get_resize_handle_at(150, 150) == "nw"
# NE corner (screen: 50 + 300 = 350, 50 + 100 = 150) # NE corner (screen: 50 + 300 = 350, 50 + 100 = 150)
assert widget._get_resize_handle_at(350, 150) == 'ne' assert widget._get_resize_handle_at(350, 150) == "ne"
# SW corner (screen: 50 + 100 = 150, 50 + 250 = 300) # SW corner (screen: 50 + 100 = 150, 50 + 250 = 300)
assert widget._get_resize_handle_at(150, 300) == 'sw' assert widget._get_resize_handle_at(150, 300) == "sw"
# SE corner (screen: 50 + 300 = 350, 50 + 250 = 300) # SE corner (screen: 50 + 300 = 350, 50 + 250 = 300)
assert widget._get_resize_handle_at(350, 300) == 'se' assert widget._get_resize_handle_at(350, 300) == "se"
def test_get_resize_handle_returns_none_for_center(self, qtbot, mock_page_renderer): def test_get_resize_handle_returns_none_for_center(self, qtbot, mock_page_renderer):
"""Test _get_resize_handle_at returns None for element center""" """Test _get_resize_handle_at returns None for element center"""
@ -403,6 +407,7 @@ class TestGetResizeHandleAt:
# Mock window # Mock window
from pyPhotoAlbum.project import Project, Page from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout from pyPhotoAlbum.page_layout import PageLayout
mock_window = Mock() mock_window = Mock()
mock_window.project = Project(name="Test") mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1) page = Page(layout=PageLayout(width=210, height=297), page_number=1)
@ -426,6 +431,7 @@ class TestGetResizeHandleAt:
# Mock window # Mock window
from pyPhotoAlbum.project import Project, Page from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout from pyPhotoAlbum.page_layout import PageLayout
mock_window = Mock() mock_window = Mock()
mock_window.project = Project(name="Test") mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1) page = Page(layout=PageLayout(width=210, height=297), page_number=1)
@ -436,7 +442,7 @@ class TestGetResizeHandleAt:
# For rotated element, the handle positions are transformed # For rotated element, the handle positions are transformed
result = widget._get_resize_handle_at(150, 150) result = widget._get_resize_handle_at(150, 150)
# Should detect a handle (exact handle depends on rotation transform) # Should detect a handle (exact handle depends on rotation transform)
assert result is None or result in ['nw', 'ne', 'sw', 'se'] assert result is None or result in ["nw", "ne", "sw", "se"]
def test_get_resize_handle_rotated_90_degrees(self, qtbot, mock_page_renderer): def test_get_resize_handle_rotated_90_degrees(self, qtbot, mock_page_renderer):
"""Test _get_resize_handle_at handles 90-degree rotated elements""" """Test _get_resize_handle_at handles 90-degree rotated elements"""
@ -451,6 +457,7 @@ class TestGetResizeHandleAt:
# Mock window # Mock window
from pyPhotoAlbum.project import Project, Page from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout from pyPhotoAlbum.page_layout import PageLayout
mock_window = Mock() mock_window = Mock()
mock_window.project = Project(name="Test") mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1) page = Page(layout=PageLayout(width=210, height=297), page_number=1)
@ -460,7 +467,7 @@ class TestGetResizeHandleAt:
# Test clicking at various positions - rotation code should handle them # Test clicking at various positions - rotation code should handle them
# Just verify the method runs without crashing # Just verify the method runs without crashing
result = widget._get_resize_handle_at(200, 200) result = widget._get_resize_handle_at(200, 200)
assert result is None or result in ['nw', 'ne', 'sw', 'se'] assert result is None or result in ["nw", "ne", "sw", "se"]
class TestMultiSelect: class TestMultiSelect:

View File

@ -15,21 +15,18 @@ def test_embed_template_in_project():
"""Test embedding a template in a project""" """Test embedding a template in a project"""
# Create a project # Create a project
project = Project(name="Test Project") project = Project(name="Test Project")
# Create a template manager with the project # Create a template manager with the project
template_manager = TemplateManager(project=project) template_manager = TemplateManager(project=project)
# Create a simple template # Create a simple template
template = Template(name="Test Template", description="A test template") template = Template(name="Test Template", description="A test template")
placeholder = PlaceholderData( placeholder = PlaceholderData(placeholder_type="image", x=10, y=10, width=100, height=100)
placeholder_type="image",
x=10, y=10, width=100, height=100
)
template.add_element(placeholder) template.add_element(placeholder)
# Embed the template # Embed the template
template_manager.embed_template(template) template_manager.embed_template(template)
# Verify it's embedded # Verify it's embedded
assert "Test Template" in project.embedded_templates assert "Test Template" in project.embedded_templates
assert project.embedded_templates["Test Template"]["name"] == "Test Template" assert project.embedded_templates["Test Template"]["name"] == "Test Template"
@ -40,22 +37,19 @@ def test_load_embedded_template():
"""Test loading an embedded template""" """Test loading an embedded template"""
# Create a project # Create a project
project = Project(name="Test Project") project = Project(name="Test Project")
# Create a template manager with the project # Create a template manager with the project
template_manager = TemplateManager(project=project) template_manager = TemplateManager(project=project)
# Create and embed a template # Create and embed a template
template = Template(name="Test Template", description="A test template") template = Template(name="Test Template", description="A test template")
placeholder = PlaceholderData( placeholder = PlaceholderData(placeholder_type="image", x=10, y=10, width=100, height=100)
placeholder_type="image",
x=10, y=10, width=100, height=100
)
template.add_element(placeholder) template.add_element(placeholder)
template_manager.embed_template(template) template_manager.embed_template(template)
# Load the embedded template # Load the embedded template
loaded_template = template_manager.load_template("Test Template") loaded_template = template_manager.load_template("Test Template")
assert loaded_template.name == "Test Template" assert loaded_template.name == "Test Template"
assert loaded_template.description == "A test template" assert loaded_template.description == "A test template"
assert len(loaded_template.elements) == 1 assert len(loaded_template.elements) == 1
@ -65,18 +59,18 @@ def test_list_embedded_templates():
"""Test listing embedded templates alongside filesystem templates""" """Test listing embedded templates alongside filesystem templates"""
# Create a project # Create a project
project = Project(name="Test Project") project = Project(name="Test Project")
# Create a template manager with the project # Create a template manager with the project
template_manager = TemplateManager(project=project) template_manager = TemplateManager(project=project)
# Embed some templates # Embed some templates
for i in range(3): for i in range(3):
template = Template(name=f"Embedded_{i}") template = Template(name=f"Embedded_{i}")
template_manager.embed_template(template) template_manager.embed_template(template)
# List all templates # List all templates
templates = template_manager.list_templates() templates = template_manager.list_templates()
# Check embedded templates are listed with prefix # Check embedded templates are listed with prefix
embedded_templates = [t for t in templates if t.startswith("[Embedded]")] embedded_templates = [t for t in templates if t.startswith("[Embedded]")]
assert len(embedded_templates) == 3 assert len(embedded_templates) == 3
@ -89,14 +83,14 @@ def test_embedded_template_priority():
"""Test that embedded templates take priority over filesystem templates""" """Test that embedded templates take priority over filesystem templates"""
# Create a project # Create a project
project = Project(name="Test Project") project = Project(name="Test Project")
# Create a template manager with the project # Create a template manager with the project
template_manager = TemplateManager(project=project) template_manager = TemplateManager(project=project)
# Embed a template with a common name # Embed a template with a common name
embedded_template = Template(name="Common", description="Embedded version") embedded_template = Template(name="Common", description="Embedded version")
template_manager.embed_template(embedded_template) template_manager.embed_template(embedded_template)
# Load by name without prefix (should get embedded version) # Load by name without prefix (should get embedded version)
loaded = template_manager.load_template("Common") loaded = template_manager.load_template("Common")
assert loaded.description == "Embedded version" assert loaded.description == "Embedded version"
@ -106,22 +100,19 @@ def test_serialize_project_with_embedded_templates():
"""Test serializing a project with embedded templates""" """Test serializing a project with embedded templates"""
# Create a project # Create a project
project = Project(name="Test Project") project = Project(name="Test Project")
# Create a template manager with the project # Create a template manager with the project
template_manager = TemplateManager(project=project) template_manager = TemplateManager(project=project)
# Create and embed a template # Create and embed a template
template = Template(name="Test Template", description="A test template") template = Template(name="Test Template", description="A test template")
placeholder = PlaceholderData( placeholder = PlaceholderData(placeholder_type="image", x=10, y=10, width=100, height=100)
placeholder_type="image",
x=10, y=10, width=100, height=100
)
template.add_element(placeholder) template.add_element(placeholder)
template_manager.embed_template(template) template_manager.embed_template(template)
# Serialize the project # Serialize the project
serialized = project.serialize() serialized = project.serialize()
# Verify embedded templates are in serialization # Verify embedded templates are in serialization
assert "embedded_templates" in serialized assert "embedded_templates" in serialized
assert "Test Template" in serialized["embedded_templates"] assert "Test Template" in serialized["embedded_templates"]
@ -133,26 +124,23 @@ def test_deserialize_project_with_embedded_templates():
# Create a project with embedded template # Create a project with embedded template
project = Project(name="Test Project") project = Project(name="Test Project")
template_manager = TemplateManager(project=project) template_manager = TemplateManager(project=project)
template = Template(name="Test Template", description="A test template") template = Template(name="Test Template", description="A test template")
placeholder = PlaceholderData( placeholder = PlaceholderData(placeholder_type="image", x=10, y=10, width=100, height=100)
placeholder_type="image",
x=10, y=10, width=100, height=100
)
template.add_element(placeholder) template.add_element(placeholder)
template_manager.embed_template(template) template_manager.embed_template(template)
# Serialize the project # Serialize the project
serialized = project.serialize() serialized = project.serialize()
# Create a new project and deserialize # Create a new project and deserialize
new_project = Project(name="New Project") new_project = Project(name="New Project")
new_project.deserialize(serialized) new_project.deserialize(serialized)
# Verify embedded templates were restored # Verify embedded templates were restored
assert "Test Template" in new_project.embedded_templates assert "Test Template" in new_project.embedded_templates
assert new_project.embedded_templates["Test Template"]["name"] == "Test Template" assert new_project.embedded_templates["Test Template"]["name"] == "Test Template"
# Verify we can load the template from the new project # Verify we can load the template from the new project
new_template_manager = TemplateManager(project=new_project) new_template_manager = TemplateManager(project=new_project)
loaded_template = new_template_manager.load_template("Test Template") loaded_template = new_template_manager.load_template("Test Template")
@ -166,21 +154,18 @@ def test_auto_embed_on_apply():
project = Project(name="Test Project") project = Project(name="Test Project")
page = Page() page = Page()
project.add_page(page) project.add_page(page)
# Create a template manager with the project # Create a template manager with the project
template_manager = TemplateManager(project=project) template_manager = TemplateManager(project=project)
# Create a template (not embedded yet) # Create a template (not embedded yet)
template = Template(name="Auto Embed Test", description="Should auto-embed") template = Template(name="Auto Embed Test", description="Should auto-embed")
placeholder = PlaceholderData( placeholder = PlaceholderData(placeholder_type="image", x=10, y=10, width=100, height=100)
placeholder_type="image",
x=10, y=10, width=100, height=100
)
template.add_element(placeholder) template.add_element(placeholder)
# Apply template with auto_embed=True (default) # Apply template with auto_embed=True (default)
template_manager.apply_template_to_page(template, page) template_manager.apply_template_to_page(template, page)
# Verify template was auto-embedded # Verify template was auto-embedded
assert "Auto Embed Test" in project.embedded_templates assert "Auto Embed Test" in project.embedded_templates
@ -189,21 +174,18 @@ def test_auto_embed_on_create_page():
"""Test that templates are automatically embedded when creating pages""" """Test that templates are automatically embedded when creating pages"""
# Create a project # Create a project
project = Project(name="Test Project") project = Project(name="Test Project")
# Create a template manager with the project # Create a template manager with the project
template_manager = TemplateManager(project=project) template_manager = TemplateManager(project=project)
# Create a template (not embedded yet) # Create a template (not embedded yet)
template = Template(name="Auto Embed Page Test", description="Should auto-embed") template = Template(name="Auto Embed Page Test", description="Should auto-embed")
placeholder = PlaceholderData( placeholder = PlaceholderData(placeholder_type="image", x=10, y=10, width=100, height=100)
placeholder_type="image",
x=10, y=10, width=100, height=100
)
template.add_element(placeholder) template.add_element(placeholder)
# Create page from template with auto_embed=True (default) # Create page from template with auto_embed=True (default)
page = template_manager.create_page_from_template(template, page_number=1) page = template_manager.create_page_from_template(template, page_number=1)
# Verify template was auto-embedded # Verify template was auto-embedded
assert "Auto Embed Page Test" in project.embedded_templates assert "Auto Embed Page Test" in project.embedded_templates
@ -212,19 +194,19 @@ def test_delete_embedded_template():
"""Test deleting an embedded template""" """Test deleting an embedded template"""
# Create a project # Create a project
project = Project(name="Test Project") project = Project(name="Test Project")
# Create a template manager with the project # Create a template manager with the project
template_manager = TemplateManager(project=project) template_manager = TemplateManager(project=project)
# Embed a template # Embed a template
template = Template(name="To Delete") template = Template(name="To Delete")
template_manager.embed_template(template) template_manager.embed_template(template)
assert "To Delete" in project.embedded_templates assert "To Delete" in project.embedded_templates
# Delete the embedded template # Delete the embedded template
template_manager.delete_template("[Embedded] To Delete") template_manager.delete_template("[Embedded] To Delete")
assert "To Delete" not in project.embedded_templates assert "To Delete" not in project.embedded_templates
@ -232,22 +214,19 @@ def test_embedded_template_with_text():
"""Test embedding template with text elements""" """Test embedding template with text elements"""
# Create a project # Create a project
project = Project(name="Test Project") project = Project(name="Test Project")
# Create a template manager with the project # Create a template manager with the project
template_manager = TemplateManager(project=project) template_manager = TemplateManager(project=project)
# Create a template with text # Create a template with text
template = Template(name="Text Template") template = Template(name="Text Template")
textbox = TextBoxData( textbox = TextBoxData(text_content="Sample Text", x=10, y=10, width=200, height=50)
text_content="Sample Text",
x=10, y=10, width=200, height=50
)
template.add_element(textbox) template.add_element(textbox)
# Embed and reload # Embed and reload
template_manager.embed_template(template) template_manager.embed_template(template)
loaded = template_manager.load_template("Text Template") loaded = template_manager.load_template("Text Template")
assert len(loaded.elements) == 1 assert len(loaded.elements) == 1
assert isinstance(loaded.elements[0], TextBoxData) assert isinstance(loaded.elements[0], TextBoxData)
assert loaded.elements[0].text_content == "Sample Text" assert loaded.elements[0].text_content == "Sample Text"
@ -258,46 +237,38 @@ def test_roundtrip_serialization():
# Create a project with pages and embedded template # Create a project with pages and embedded template
project = Project(name="Roundtrip Test") project = Project(name="Roundtrip Test")
template_manager = TemplateManager(project=project) template_manager = TemplateManager(project=project)
# Create a template # Create a template
template = Template(name="Roundtrip Template", page_size_mm=(200, 300)) template = Template(name="Roundtrip Template", page_size_mm=(200, 300))
placeholder1 = PlaceholderData( placeholder1 = PlaceholderData(placeholder_type="image", x=10, y=10, width=80, height=80)
placeholder_type="image", placeholder2 = PlaceholderData(placeholder_type="image", x=110, y=10, width=80, height=80)
x=10, y=10, width=80, height=80
)
placeholder2 = PlaceholderData(
placeholder_type="image",
x=110, y=10, width=80, height=80
)
template.add_element(placeholder1) template.add_element(placeholder1)
template.add_element(placeholder2) template.add_element(placeholder2)
# Create a page from this template # Create a page from this template
page = template_manager.create_page_from_template(template, page_number=1) page = template_manager.create_page_from_template(template, page_number=1)
project.add_page(page) project.add_page(page)
# Serialize # Serialize
serialized = project.serialize() serialized = project.serialize()
# Create new project and deserialize # Create new project and deserialize
new_project = Project(name="New Roundtrip") new_project = Project(name="New Roundtrip")
new_project.deserialize(serialized) new_project.deserialize(serialized)
# Verify embedded template # Verify embedded template
assert "Roundtrip Template" in new_project.embedded_templates assert "Roundtrip Template" in new_project.embedded_templates
# Verify we can use the template # Verify we can use the template
new_template_manager = TemplateManager(project=new_project) new_template_manager = TemplateManager(project=new_project)
loaded_template = new_template_manager.load_template("Roundtrip Template") loaded_template = new_template_manager.load_template("Roundtrip Template")
assert loaded_template.name == "Roundtrip Template" assert loaded_template.name == "Roundtrip Template"
assert loaded_template.page_size_mm == (200, 300) assert loaded_template.page_size_mm == (200, 300)
assert len(loaded_template.elements) == 2 assert len(loaded_template.elements) == 2
# Create another page from the loaded template # Create another page from the loaded template
new_page = new_template_manager.create_page_from_template( new_page = new_template_manager.create_page_from_template(
loaded_template, loaded_template, page_number=2, auto_embed=False # Don't embed again
page_number=2,
auto_embed=False # Don't embed again
) )
assert len(new_page.layout.elements) == 2 assert len(new_page.layout.elements) == 2

View File

@ -21,13 +21,13 @@ class TestGLWidgetInitialization:
qtbot.addWidget(widget) qtbot.addWidget(widget)
# Verify mixin state is initialized # Verify mixin state is initialized
assert hasattr(widget, 'zoom_level') assert hasattr(widget, "zoom_level")
assert hasattr(widget, 'pan_offset') assert hasattr(widget, "pan_offset")
assert hasattr(widget, 'selected_elements') assert hasattr(widget, "selected_elements")
assert hasattr(widget, 'drag_start_pos') assert hasattr(widget, "drag_start_pos")
assert hasattr(widget, 'is_dragging') assert hasattr(widget, "is_dragging")
assert hasattr(widget, 'is_panning') assert hasattr(widget, "is_panning")
assert hasattr(widget, 'rotation_mode') assert hasattr(widget, "rotation_mode")
def test_gl_widget_accepts_drops(self, qtbot): def test_gl_widget_accepts_drops(self, qtbot):
"""Test GLWidget is configured to accept drops""" """Test GLWidget is configured to accept drops"""
@ -105,7 +105,7 @@ class TestGLWidgetMixinIntegration:
QPointF(75, 75), QPointF(75, 75),
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton,
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.NoModifier Qt.KeyboardModifier.NoModifier,
) )
widget.mousePressEvent(event) widget.mousePressEvent(event)
@ -125,7 +125,7 @@ class TestGLWidgetMixinIntegration:
# Begin operation (should be tracked for undo) # Begin operation (should be tracked for undo)
widget._begin_move(element) widget._begin_move(element)
assert widget._interaction_state.element is not None assert widget._interaction_state.element is not None
assert widget._interaction_state.interaction_type == 'move' assert widget._interaction_state.interaction_type == "move"
assert widget._interaction_state.position == (100, 100) assert widget._interaction_state.position == (100, 100)
# End operation # End operation
@ -152,11 +152,8 @@ class TestGLWidgetKeyEvents:
# Create key press event for Escape # Create key press event for Escape
from PyQt6.QtGui import QKeyEvent from PyQt6.QtGui import QKeyEvent
event = QKeyEvent(
QKeyEvent.Type.KeyPress, event = QKeyEvent(QKeyEvent.Type.KeyPress, Qt.Key.Key_Escape, Qt.KeyboardModifier.NoModifier)
Qt.Key.Key_Escape,
Qt.KeyboardModifier.NoModifier
)
widget.keyPressEvent(event) widget.keyPressEvent(event)
@ -186,11 +183,8 @@ class TestGLWidgetKeyEvents:
# Create key press event for Tab # Create key press event for Tab
from PyQt6.QtGui import QKeyEvent from PyQt6.QtGui import QKeyEvent
event = QKeyEvent(
QKeyEvent.Type.KeyPress, event = QKeyEvent(QKeyEvent.Type.KeyPress, Qt.Key.Key_Tab, Qt.KeyboardModifier.NoModifier)
Qt.Key.Key_Tab,
Qt.KeyboardModifier.NoModifier
)
widget.keyPressEvent(event) widget.keyPressEvent(event)
@ -220,11 +214,8 @@ class TestGLWidgetKeyEvents:
# Create key press event for Delete # Create key press event for Delete
from PyQt6.QtGui import QKeyEvent from PyQt6.QtGui import QKeyEvent
event = QKeyEvent(
QKeyEvent.Type.KeyPress, event = QKeyEvent(QKeyEvent.Type.KeyPress, Qt.Key.Key_Delete, Qt.KeyboardModifier.NoModifier)
Qt.Key.Key_Delete,
Qt.KeyboardModifier.NoModifier
)
widget.keyPressEvent(event) widget.keyPressEvent(event)
@ -257,7 +248,7 @@ class TestGLWidgetWithProject:
# Verify we can access project through widget # Verify we can access project through widget
main_window = widget.window() main_window = widget.window()
assert hasattr(main_window, 'project') assert hasattr(main_window, "project")
assert main_window.project.name == "Test Project" assert main_window.project.name == "Test Project"
assert len(main_window.project.pages) == 1 assert len(main_window.project.pages) == 1
assert len(main_window.project.pages[0].layout.elements) == 1 assert len(main_window.project.pages[0].layout.elements) == 1
@ -321,6 +312,7 @@ class TestGLWidgetOpenGL:
# Should have NoPartialUpdate set # Should have NoPartialUpdate set
from PyQt6.QtOpenGLWidgets import QOpenGLWidget from PyQt6.QtOpenGLWidgets import QOpenGLWidget
assert widget.updateBehavior() == QOpenGLWidget.UpdateBehavior.NoPartialUpdate assert widget.updateBehavior() == QOpenGLWidget.UpdateBehavior.NoPartialUpdate

View File

@ -14,6 +14,7 @@ from pyPhotoAlbum.models import ImageData, PlaceholderData
# Create test widget combining necessary mixins # Create test widget combining necessary mixins
class TestImagePanWidget(ImagePanMixin, ElementSelectionMixin, ViewportMixin, QOpenGLWidget): class TestImagePanWidget(ImagePanMixin, ElementSelectionMixin, ViewportMixin, QOpenGLWidget):
"""Test widget combining image pan, selection, and viewport mixins""" """Test widget combining image pan, selection, and viewport mixins"""
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.drag_start_pos = None self.drag_start_pos = None

View File

@ -8,7 +8,7 @@ from pyPhotoAlbum.mixins.interaction_command_builders import (
MoveCommandBuilder, MoveCommandBuilder,
ResizeCommandBuilder, ResizeCommandBuilder,
RotateCommandBuilder, RotateCommandBuilder,
ImagePanCommandBuilder ImagePanCommandBuilder,
) )
from pyPhotoAlbum.mixins.interaction_validators import InteractionChangeDetector from pyPhotoAlbum.mixins.interaction_validators import InteractionChangeDetector
@ -22,7 +22,7 @@ class TestMoveCommandBuilder:
element = Mock() element = Mock()
element.position = (10.0, 10.0) element.position = (10.0, 10.0)
start_state = {'position': (0.0, 0.0)} start_state = {"position": (0.0, 0.0)}
assert builder.can_build(element, start_state) assert builder.can_build(element, start_state)
@ -32,7 +32,7 @@ class TestMoveCommandBuilder:
element = Mock() element = Mock()
element.position = (0.05, 0.05) element.position = (0.05, 0.05)
start_state = {'position': (0.0, 0.0)} start_state = {"position": (0.0, 0.0)}
assert not builder.can_build(element, start_state) assert not builder.can_build(element, start_state)
@ -52,7 +52,7 @@ class TestMoveCommandBuilder:
element = Mock() element = Mock()
element.position = (10.0, 10.0) element.position = (10.0, 10.0)
start_state = {'position': (0.0, 0.0)} start_state = {"position": (0.0, 0.0)}
command = builder.build(element, start_state) command = builder.build(element, start_state)
@ -65,7 +65,7 @@ class TestMoveCommandBuilder:
element = Mock() element = Mock()
element.position = (0.05, 0.05) element.position = (0.05, 0.05)
start_state = {'position': (0.0, 0.0)} start_state = {"position": (0.0, 0.0)}
command = builder.build(element, start_state) command = builder.build(element, start_state)
@ -82,10 +82,7 @@ class TestResizeCommandBuilder:
element.position = (0.0, 0.0) element.position = (0.0, 0.0)
element.size = (200.0, 200.0) element.size = (200.0, 200.0)
start_state = { start_state = {"position": (0.0, 0.0), "size": (100.0, 100.0)}
'position': (0.0, 0.0),
'size': (100.0, 100.0)
}
assert builder.can_build(element, start_state) assert builder.can_build(element, start_state)
@ -96,10 +93,7 @@ class TestResizeCommandBuilder:
element.position = (10.0, 10.0) element.position = (10.0, 10.0)
element.size = (100.0, 100.0) element.size = (100.0, 100.0)
start_state = { start_state = {"position": (0.0, 0.0), "size": (100.0, 100.0)}
'position': (0.0, 0.0),
'size': (100.0, 100.0)
}
assert builder.can_build(element, start_state) assert builder.can_build(element, start_state)
@ -110,10 +104,7 @@ class TestResizeCommandBuilder:
element.position = (10.0, 10.0) element.position = (10.0, 10.0)
element.size = (200.0, 200.0) element.size = (200.0, 200.0)
start_state = { start_state = {"position": (0.0, 0.0), "size": (100.0, 100.0)}
'position': (0.0, 0.0),
'size': (100.0, 100.0)
}
assert builder.can_build(element, start_state) assert builder.can_build(element, start_state)
@ -124,10 +115,7 @@ class TestResizeCommandBuilder:
element.position = (0.0, 0.0) element.position = (0.0, 0.0)
element.size = (100.0, 100.0) element.size = (100.0, 100.0)
start_state = { start_state = {"position": (0.0, 0.0), "size": (100.0, 100.0)}
'position': (0.0, 0.0),
'size': (100.0, 100.0)
}
assert not builder.can_build(element, start_state) assert not builder.can_build(element, start_state)
@ -138,10 +126,7 @@ class TestResizeCommandBuilder:
element.position = (10.0, 10.0) element.position = (10.0, 10.0)
element.size = (200.0, 200.0) element.size = (200.0, 200.0)
start_state = { start_state = {"position": (0.0, 0.0), "size": (100.0, 100.0)}
'position': (0.0, 0.0),
'size': (100.0, 100.0)
}
command = builder.build(element, start_state) command = builder.build(element, start_state)
@ -158,7 +143,7 @@ class TestRotateCommandBuilder:
element = Mock() element = Mock()
element.rotation = 45.0 element.rotation = 45.0
start_state = {'rotation': 0.0} start_state = {"rotation": 0.0}
assert builder.can_build(element, start_state) assert builder.can_build(element, start_state)
@ -168,7 +153,7 @@ class TestRotateCommandBuilder:
element = Mock() element = Mock()
element.rotation = 0.05 element.rotation = 0.05
start_state = {'rotation': 0.0} start_state = {"rotation": 0.0}
assert not builder.can_build(element, start_state) assert not builder.can_build(element, start_state)
@ -178,7 +163,7 @@ class TestRotateCommandBuilder:
element = Mock() element = Mock()
element.rotation = 45.0 element.rotation = 45.0
start_state = {'rotation': 0.0} start_state = {"rotation": 0.0}
command = builder.build(element, start_state) command = builder.build(element, start_state)
@ -197,7 +182,7 @@ class TestImagePanCommandBuilder:
element = Mock(spec=ImageData) element = Mock(spec=ImageData)
element.crop_info = (0.1, 0.1, 0.9, 0.9) element.crop_info = (0.1, 0.1, 0.9, 0.9)
start_state = {'crop_info': (0.0, 0.0, 1.0, 1.0)} start_state = {"crop_info": (0.0, 0.0, 1.0, 1.0)}
assert builder.can_build(element, start_state) assert builder.can_build(element, start_state)
@ -207,7 +192,7 @@ class TestImagePanCommandBuilder:
element = Mock() element = Mock()
element.crop_info = (0.1, 0.1, 0.9, 0.9) element.crop_info = (0.1, 0.1, 0.9, 0.9)
start_state = {'crop_info': (0.0, 0.0, 1.0, 1.0)} start_state = {"crop_info": (0.0, 0.0, 1.0, 1.0)}
assert not builder.can_build(element, start_state) assert not builder.can_build(element, start_state)
@ -219,7 +204,7 @@ class TestImagePanCommandBuilder:
element = Mock(spec=ImageData) element = Mock(spec=ImageData)
element.crop_info = (0.0001, 0.0001, 1.0, 1.0) element.crop_info = (0.0001, 0.0001, 1.0, 1.0)
start_state = {'crop_info': (0.0, 0.0, 1.0, 1.0)} start_state = {"crop_info": (0.0, 0.0, 1.0, 1.0)}
assert not builder.can_build(element, start_state) assert not builder.can_build(element, start_state)
@ -231,7 +216,7 @@ class TestImagePanCommandBuilder:
element = Mock(spec=ImageData) element = Mock(spec=ImageData)
element.crop_info = (0.1, 0.1, 0.9, 0.9) element.crop_info = (0.1, 0.1, 0.9, 0.9)
start_state = {'crop_info': (0.0, 0.0, 1.0, 1.0)} start_state = {"crop_info": (0.0, 0.0, 1.0, 1.0)}
command = builder.build(element, start_state) command = builder.build(element, start_state)
@ -250,7 +235,7 @@ class TestCommandBuilderIntegration:
element = Mock() element = Mock()
element.position = (5.0, 5.0) element.position = (5.0, 5.0)
start_state = {'position': (0.0, 0.0)} start_state = {"position": (0.0, 0.0)}
# With high threshold, this should not build # With high threshold, this should not build
assert not builder.can_build(element, start_state) assert not builder.can_build(element, start_state)
@ -261,7 +246,7 @@ class TestCommandBuilderIntegration:
element = Mock() element = Mock()
element.position = (10.0, 10.0) element.position = (10.0, 10.0)
start_state = {'position': (0.0, 0.0)} start_state = {"position": (0.0, 0.0)}
builder.build(element, start_state) builder.build(element, start_state)

View File

@ -4,10 +4,7 @@ Unit tests for interaction command factory.
import pytest import pytest
from unittest.mock import Mock from unittest.mock import Mock
from pyPhotoAlbum.mixins.interaction_command_factory import ( from pyPhotoAlbum.mixins.interaction_command_factory import InteractionCommandFactory, InteractionState
InteractionCommandFactory,
InteractionState
)
from pyPhotoAlbum.mixins.interaction_command_builders import CommandBuilder from pyPhotoAlbum.mixins.interaction_command_builders import CommandBuilder
@ -18,71 +15,49 @@ class TestInteractionState:
"""Test that InteractionState initializes correctly.""" """Test that InteractionState initializes correctly."""
element = Mock() element = Mock()
state = InteractionState( state = InteractionState(
element=element, element=element, interaction_type="move", position=(0.0, 0.0), size=(100.0, 100.0), rotation=0.0
interaction_type='move',
position=(0.0, 0.0),
size=(100.0, 100.0),
rotation=0.0
) )
assert state.element == element assert state.element == element
assert state.interaction_type == 'move' assert state.interaction_type == "move"
assert state.position == (0.0, 0.0) assert state.position == (0.0, 0.0)
assert state.size == (100.0, 100.0) assert state.size == (100.0, 100.0)
assert state.rotation == 0.0 assert state.rotation == 0.0
def test_to_dict(self): def test_to_dict(self):
"""Test that to_dict returns correct dictionary.""" """Test that to_dict returns correct dictionary."""
state = InteractionState( state = InteractionState(position=(0.0, 0.0), size=(100.0, 100.0))
position=(0.0, 0.0),
size=(100.0, 100.0)
)
result = state.to_dict() result = state.to_dict()
assert result == { assert result == {"position": (0.0, 0.0), "size": (100.0, 100.0)}
'position': (0.0, 0.0),
'size': (100.0, 100.0)
}
def test_to_dict_excludes_none(self): def test_to_dict_excludes_none(self):
"""Test that to_dict excludes None values.""" """Test that to_dict excludes None values."""
state = InteractionState( state = InteractionState(position=(0.0, 0.0), size=None)
position=(0.0, 0.0),
size=None
)
result = state.to_dict() result = state.to_dict()
assert 'position' in result assert "position" in result
assert 'size' not in result assert "size" not in result
def test_is_valid_with_required_fields(self): def test_is_valid_with_required_fields(self):
"""Test that is_valid returns True when required fields are present.""" """Test that is_valid returns True when required fields are present."""
element = Mock() element = Mock()
state = InteractionState( state = InteractionState(element=element, interaction_type="move")
element=element,
interaction_type='move'
)
assert state.is_valid() assert state.is_valid()
def test_is_valid_without_element(self): def test_is_valid_without_element(self):
"""Test that is_valid returns False without element.""" """Test that is_valid returns False without element."""
state = InteractionState( state = InteractionState(element=None, interaction_type="move")
element=None,
interaction_type='move'
)
assert not state.is_valid() assert not state.is_valid()
def test_is_valid_without_interaction_type(self): def test_is_valid_without_interaction_type(self):
"""Test that is_valid returns False without interaction_type.""" """Test that is_valid returns False without interaction_type."""
element = Mock() element = Mock()
state = InteractionState( state = InteractionState(element=element, interaction_type=None)
element=element,
interaction_type=None
)
assert not state.is_valid() assert not state.is_valid()
@ -90,11 +65,7 @@ class TestInteractionState:
"""Test that clear resets all fields.""" """Test that clear resets all fields."""
element = Mock() element = Mock()
state = InteractionState( state = InteractionState(
element=element, element=element, interaction_type="move", position=(0.0, 0.0), size=(100.0, 100.0), rotation=0.0
interaction_type='move',
position=(0.0, 0.0),
size=(100.0, 100.0),
rotation=0.0
) )
state.clear() state.clear()
@ -113,19 +84,19 @@ class TestInteractionCommandFactory:
"""Test that factory initializes with default builders.""" """Test that factory initializes with default builders."""
factory = InteractionCommandFactory() factory = InteractionCommandFactory()
assert factory.has_builder('move') assert factory.has_builder("move")
assert factory.has_builder('resize') assert factory.has_builder("resize")
assert factory.has_builder('rotate') assert factory.has_builder("rotate")
assert factory.has_builder('image_pan') assert factory.has_builder("image_pan")
def test_register_builder(self): def test_register_builder(self):
"""Test registering a custom builder.""" """Test registering a custom builder."""
factory = InteractionCommandFactory() factory = InteractionCommandFactory()
custom_builder = Mock(spec=CommandBuilder) custom_builder = Mock(spec=CommandBuilder)
factory.register_builder('custom', custom_builder) factory.register_builder("custom", custom_builder)
assert factory.has_builder('custom') assert factory.has_builder("custom")
def test_get_supported_types(self): def test_get_supported_types(self):
"""Test getting list of supported types.""" """Test getting list of supported types."""
@ -133,10 +104,10 @@ class TestInteractionCommandFactory:
types = factory.get_supported_types() types = factory.get_supported_types()
assert 'move' in types assert "move" in types
assert 'resize' in types assert "resize" in types
assert 'rotate' in types assert "rotate" in types
assert 'image_pan' in types assert "image_pan" in types
def test_create_command_move(self): def test_create_command_move(self):
"""Test creating a move command.""" """Test creating a move command."""
@ -144,9 +115,9 @@ class TestInteractionCommandFactory:
element = Mock() element = Mock()
element.position = (10.0, 10.0) element.position = (10.0, 10.0)
start_state = {'position': (0.0, 0.0)} start_state = {"position": (0.0, 0.0)}
command = factory.create_command('move', element, start_state) command = factory.create_command("move", element, start_state)
assert command is not None assert command is not None
@ -157,12 +128,9 @@ class TestInteractionCommandFactory:
element.position = (10.0, 10.0) element.position = (10.0, 10.0)
element.size = (200.0, 200.0) element.size = (200.0, 200.0)
start_state = { start_state = {"position": (0.0, 0.0), "size": (100.0, 100.0)}
'position': (0.0, 0.0),
'size': (100.0, 100.0)
}
command = factory.create_command('resize', element, start_state) command = factory.create_command("resize", element, start_state)
assert command is not None assert command is not None
@ -172,9 +140,9 @@ class TestInteractionCommandFactory:
element = Mock() element = Mock()
element.rotation = 45.0 element.rotation = 45.0
start_state = {'rotation': 0.0} start_state = {"rotation": 0.0}
command = factory.create_command('rotate', element, start_state) command = factory.create_command("rotate", element, start_state)
assert command is not None assert command is not None
@ -183,7 +151,7 @@ class TestInteractionCommandFactory:
factory = InteractionCommandFactory() factory = InteractionCommandFactory()
element = Mock() element = Mock()
command = factory.create_command('unknown', element, {}) command = factory.create_command("unknown", element, {})
assert command is None assert command is None
captured = capsys.readouterr() captured = capsys.readouterr()
@ -195,9 +163,9 @@ class TestInteractionCommandFactory:
element = Mock() element = Mock()
element.position = (0.05, 0.05) element.position = (0.05, 0.05)
start_state = {'position': (0.0, 0.0)} start_state = {"position": (0.0, 0.0)}
command = factory.create_command('move', element, start_state) command = factory.create_command("move", element, start_state)
assert command is None assert command is None
@ -211,12 +179,12 @@ class TestInteractionCommandFactory:
custom_builder.can_build.return_value = True custom_builder.can_build.return_value = True
custom_builder.build.return_value = mock_command custom_builder.build.return_value = mock_command
factory.register_builder('custom', custom_builder) factory.register_builder("custom", custom_builder)
element = Mock() element = Mock()
start_state = {'position': (0.0, 0.0)} start_state = {"position": (0.0, 0.0)}
command = factory.create_command('custom', element, start_state) command = factory.create_command("custom", element, start_state)
assert command == mock_command assert command == mock_command
custom_builder.can_build.assert_called_once() custom_builder.can_build.assert_called_once()
@ -232,17 +200,9 @@ class TestInteractionStateIntegration:
element = Mock() element = Mock()
element.position = (10.0, 10.0) element.position = (10.0, 10.0)
state = InteractionState( state = InteractionState(element=element, interaction_type="move", position=(0.0, 0.0))
element=element,
interaction_type='move',
position=(0.0, 0.0)
)
command = factory.create_command( command = factory.create_command(state.interaction_type, state.element, state.to_dict())
state.interaction_type,
state.element,
state.to_dict()
)
assert command is not None assert command is not None
@ -254,7 +214,7 @@ class TestInteractionStateIntegration:
# Begin interaction # Begin interaction
state = InteractionState() state = InteractionState()
state.element = element state.element = element
state.interaction_type = 'move' state.interaction_type = "move"
state.position = element.position state.position = element.position
assert state.is_valid() assert state.is_valid()
@ -264,11 +224,7 @@ class TestInteractionStateIntegration:
# Create command # Create command
factory = InteractionCommandFactory() factory = InteractionCommandFactory()
command = factory.create_command( command = factory.create_command(state.interaction_type, state.element, state.to_dict())
state.interaction_type,
state.element,
state.to_dict()
)
assert command is not None assert command is not None

View File

@ -29,8 +29,8 @@ class TestUndoableInteractionInitialization:
qtbot.addWidget(widget) qtbot.addWidget(widget)
# Should have initialized tracking state object # Should have initialized tracking state object
assert hasattr(widget, '_interaction_state') assert hasattr(widget, "_interaction_state")
assert hasattr(widget, '_command_factory') assert hasattr(widget, "_command_factory")
# State should be clear initially # State should be clear initially
assert widget._interaction_state.element is None assert widget._interaction_state.element is None
@ -53,7 +53,7 @@ class TestBeginMove:
widget._begin_move(element) widget._begin_move(element)
assert widget._interaction_state.element is element assert widget._interaction_state.element is element
assert widget._interaction_state.interaction_type == 'move' assert widget._interaction_state.interaction_type == "move"
assert widget._interaction_state.position == (100, 100) assert widget._interaction_state.position == (100, 100)
assert widget._interaction_state.size is None assert widget._interaction_state.size is None
assert widget._interaction_state.rotation is None assert widget._interaction_state.rotation is None
@ -87,7 +87,7 @@ class TestBeginResize:
widget._begin_resize(element) widget._begin_resize(element)
assert widget._interaction_state.element is element assert widget._interaction_state.element is element
assert widget._interaction_state.interaction_type == 'resize' assert widget._interaction_state.interaction_type == "resize"
assert widget._interaction_state.position == (100, 100) assert widget._interaction_state.position == (100, 100)
assert widget._interaction_state.size == (200, 150) assert widget._interaction_state.size == (200, 150)
assert widget._interaction_state.rotation is None assert widget._interaction_state.rotation is None
@ -107,7 +107,7 @@ class TestBeginRotate:
widget._begin_rotate(element) widget._begin_rotate(element)
assert widget._interaction_state.element is element assert widget._interaction_state.element is element
assert widget._interaction_state.interaction_type == 'rotate' assert widget._interaction_state.interaction_type == "rotate"
assert widget._interaction_state.position is None assert widget._interaction_state.position is None
assert widget._interaction_state.size is None assert widget._interaction_state.size is None
assert widget._interaction_state.rotation == 45.0 assert widget._interaction_state.rotation == 45.0
@ -121,17 +121,12 @@ class TestBeginImagePan:
widget = TestUndoableWidget() widget = TestUndoableWidget()
qtbot.addWidget(widget) qtbot.addWidget(widget)
element = ImageData( element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150, crop_info=(0.1, 0.2, 0.8, 0.7))
image_path="/test.jpg",
x=100, y=100,
width=200, height=150,
crop_info=(0.1, 0.2, 0.8, 0.7)
)
widget._begin_image_pan(element) widget._begin_image_pan(element)
assert widget._interaction_state.element is element assert widget._interaction_state.element is element
assert widget._interaction_state.interaction_type == 'image_pan' assert widget._interaction_state.interaction_type == "image_pan"
assert widget._interaction_state.crop_info == (0.1, 0.2, 0.8, 0.7) assert widget._interaction_state.crop_info == (0.1, 0.2, 0.8, 0.7)
def test_begin_image_pan_ignores_non_image(self, qtbot): def test_begin_image_pan_ignores_non_image(self, qtbot):
@ -151,7 +146,7 @@ class TestBeginImagePan:
class TestEndInteraction: class TestEndInteraction:
"""Test _end_interaction method""" """Test _end_interaction method"""
@patch('pyPhotoAlbum.commands.MoveElementCommand') @patch("pyPhotoAlbum.commands.MoveElementCommand")
def test_end_interaction_creates_move_command(self, mock_cmd_class, qtbot): def test_end_interaction_creates_move_command(self, mock_cmd_class, qtbot):
"""Test that ending move interaction creates MoveElementCommand""" """Test that ending move interaction creates MoveElementCommand"""
widget = TestUndoableWidget() widget = TestUndoableWidget()
@ -177,7 +172,7 @@ class TestEndInteraction:
mock_cmd_class.assert_called_once_with(element, (100, 100), (150, 160)) mock_cmd_class.assert_called_once_with(element, (100, 100), (150, 160))
assert mock_window.project.history.execute.called assert mock_window.project.history.execute.called
@patch('pyPhotoAlbum.commands.ResizeElementCommand') @patch("pyPhotoAlbum.commands.ResizeElementCommand")
def test_end_interaction_creates_resize_command(self, mock_cmd_class, qtbot): def test_end_interaction_creates_resize_command(self, mock_cmd_class, qtbot):
"""Test that ending resize interaction creates ResizeElementCommand""" """Test that ending resize interaction creates ResizeElementCommand"""
widget = TestUndoableWidget() widget = TestUndoableWidget()
@ -204,12 +199,12 @@ class TestEndInteraction:
element, element,
(100, 100), # old position (100, 100), # old position
(200, 150), # old size (200, 150), # old size
(90, 90), # new position (90, 90), # new position
(250, 200) # new size (250, 200), # new size
) )
assert mock_window.project.history.execute.called assert mock_window.project.history.execute.called
@patch('pyPhotoAlbum.commands.RotateElementCommand') @patch("pyPhotoAlbum.commands.RotateElementCommand")
def test_end_interaction_creates_rotate_command(self, mock_cmd_class, qtbot): def test_end_interaction_creates_rotate_command(self, mock_cmd_class, qtbot):
"""Test that ending rotate interaction creates RotateElementCommand""" """Test that ending rotate interaction creates RotateElementCommand"""
widget = TestUndoableWidget() widget = TestUndoableWidget()
@ -235,7 +230,7 @@ class TestEndInteraction:
mock_cmd_class.assert_called_once_with(element, 0, 90) mock_cmd_class.assert_called_once_with(element, 0, 90)
assert mock_window.project.history.execute.called assert mock_window.project.history.execute.called
@patch('pyPhotoAlbum.commands.AdjustImageCropCommand') @patch("pyPhotoAlbum.commands.AdjustImageCropCommand")
def test_end_interaction_creates_crop_command(self, mock_cmd_class, qtbot): def test_end_interaction_creates_crop_command(self, mock_cmd_class, qtbot):
"""Test that ending image pan interaction creates AdjustImageCropCommand""" """Test that ending image pan interaction creates AdjustImageCropCommand"""
widget = TestUndoableWidget() widget = TestUndoableWidget()
@ -248,9 +243,11 @@ class TestEndInteraction:
element = ImageData( element = ImageData(
image_path="/test.jpg", image_path="/test.jpg",
x=100, y=100, x=100,
width=200, height=150, y=100,
crop_info=(0.0, 0.0, 1.0, 1.0) # Tuple format used in code width=200,
height=150,
crop_info=(0.0, 0.0, 1.0, 1.0), # Tuple format used in code
) )
widget._begin_image_pan(element) widget._begin_image_pan(element)
@ -391,12 +388,7 @@ class TestClearInteractionState:
widget = TestUndoableWidget() widget = TestUndoableWidget()
qtbot.addWidget(widget) qtbot.addWidget(widget)
element = ImageData( element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150, crop_info=(0.0, 0.0, 1.0, 1.0))
image_path="/test.jpg",
x=100, y=100,
width=200, height=150,
crop_info=(0.0, 0.0, 1.0, 1.0)
)
widget._begin_image_pan(element) widget._begin_image_pan(element)
# After begin_image_pan, crop_info should be stored # After begin_image_pan, crop_info should be stored
@ -452,10 +444,10 @@ class TestInteractionEdgeCases:
widget._begin_rotate(element) widget._begin_rotate(element)
# Should have rotate state (last call wins) # Should have rotate state (last call wins)
assert widget._interaction_state.interaction_type == 'rotate' assert widget._interaction_state.interaction_type == "rotate"
assert widget._interaction_state.rotation == 0 assert widget._interaction_state.rotation == 0
@patch('pyPhotoAlbum.commands.ResizeElementCommand') @patch("pyPhotoAlbum.commands.ResizeElementCommand")
def test_resize_with_only_size_change(self, mock_cmd_class, qtbot): def test_resize_with_only_size_change(self, mock_cmd_class, qtbot):
"""Test resize command when only size changes (position same)""" """Test resize command when only size changes (position same)"""
widget = TestUndoableWidget() widget = TestUndoableWidget()
@ -479,7 +471,7 @@ class TestInteractionEdgeCases:
assert mock_cmd_class.called assert mock_cmd_class.called
assert mock_window.project.history.execute.called assert mock_window.project.history.execute.called
@patch('pyPhotoAlbum.commands.ResizeElementCommand') @patch("pyPhotoAlbum.commands.ResizeElementCommand")
def test_resize_with_only_position_change(self, mock_cmd_class, qtbot): def test_resize_with_only_position_change(self, mock_cmd_class, qtbot):
"""Test resize command when only position changes (size same)""" """Test resize command when only position changes (size same)"""
widget = TestUndoableWidget() widget = TestUndoableWidget()

View File

@ -31,8 +31,8 @@ class TestUndoableInteractionMixinRefactored:
"""Test that mixin initializes correctly.""" """Test that mixin initializes correctly."""
widget = MockWidget() widget = MockWidget()
assert hasattr(widget, '_command_factory') assert hasattr(widget, "_command_factory")
assert hasattr(widget, '_interaction_state') assert hasattr(widget, "_interaction_state")
def test_begin_move(self): def test_begin_move(self):
"""Test beginning a move interaction.""" """Test beginning a move interaction."""
@ -43,7 +43,7 @@ class TestUndoableInteractionMixinRefactored:
widget._begin_move(element) widget._begin_move(element)
assert widget._interaction_state.element == element assert widget._interaction_state.element == element
assert widget._interaction_state.interaction_type == 'move' assert widget._interaction_state.interaction_type == "move"
assert widget._interaction_state.position == (0.0, 0.0) assert widget._interaction_state.position == (0.0, 0.0)
def test_begin_resize(self): def test_begin_resize(self):
@ -56,7 +56,7 @@ class TestUndoableInteractionMixinRefactored:
widget._begin_resize(element) widget._begin_resize(element)
assert widget._interaction_state.element == element assert widget._interaction_state.element == element
assert widget._interaction_state.interaction_type == 'resize' assert widget._interaction_state.interaction_type == "resize"
assert widget._interaction_state.position == (0.0, 0.0) assert widget._interaction_state.position == (0.0, 0.0)
assert widget._interaction_state.size == (100.0, 100.0) assert widget._interaction_state.size == (100.0, 100.0)
@ -69,7 +69,7 @@ class TestUndoableInteractionMixinRefactored:
widget._begin_rotate(element) widget._begin_rotate(element)
assert widget._interaction_state.element == element assert widget._interaction_state.element == element
assert widget._interaction_state.interaction_type == 'rotate' assert widget._interaction_state.interaction_type == "rotate"
assert widget._interaction_state.rotation == 0.0 assert widget._interaction_state.rotation == 0.0
def test_begin_image_pan(self): def test_begin_image_pan(self):
@ -83,7 +83,7 @@ class TestUndoableInteractionMixinRefactored:
widget._begin_image_pan(element) widget._begin_image_pan(element)
assert widget._interaction_state.element == element assert widget._interaction_state.element == element
assert widget._interaction_state.interaction_type == 'image_pan' assert widget._interaction_state.interaction_type == "image_pan"
assert widget._interaction_state.crop_info == (0.0, 0.0, 1.0, 1.0) assert widget._interaction_state.crop_info == (0.0, 0.0, 1.0, 1.0)
def test_begin_image_pan_non_image_element(self): def test_begin_image_pan_non_image_element(self):
@ -211,7 +211,7 @@ class TestUndoableInteractionMixinRefactored:
"""Test that ending interaction without project is safe.""" """Test that ending interaction without project is safe."""
widget = MockWidget() widget = MockWidget()
# Remove the project attribute entirely # Remove the project attribute entirely
delattr(widget._mock_window, 'project') delattr(widget._mock_window, "project")
element = Mock(spec=BaseLayoutElement) element = Mock(spec=BaseLayoutElement)
element.position = (0.0, 0.0) element.position = (0.0, 0.0)

View File

@ -3,10 +3,7 @@ Unit tests for interaction validators and change detection.
""" """
import pytest import pytest
from pyPhotoAlbum.mixins.interaction_validators import ( from pyPhotoAlbum.mixins.interaction_validators import ChangeValidator, InteractionChangeDetector
ChangeValidator,
InteractionChangeDetector
)
class TestChangeValidator: class TestChangeValidator:
@ -104,10 +101,10 @@ class TestInteractionChangeDetector:
change = detector.detect_position_change(old_pos, new_pos) change = detector.detect_position_change(old_pos, new_pos)
assert change is not None assert change is not None
assert change['old_position'] == old_pos assert change["old_position"] == old_pos
assert change['new_position'] == new_pos assert change["new_position"] == new_pos
assert change['delta_x'] == 5.0 assert change["delta_x"] == 5.0
assert change['delta_y'] == 3.0 assert change["delta_y"] == 3.0
def test_detect_position_change_insignificant(self): def test_detect_position_change_insignificant(self):
"""Test that insignificant position changes return None.""" """Test that insignificant position changes return None."""
@ -128,10 +125,10 @@ class TestInteractionChangeDetector:
change = detector.detect_size_change(old_size, new_size) change = detector.detect_size_change(old_size, new_size)
assert change is not None assert change is not None
assert change['old_size'] == old_size assert change["old_size"] == old_size
assert change['new_size'] == new_size assert change["new_size"] == new_size
assert change['delta_width'] == 50.0 assert change["delta_width"] == 50.0
assert change['delta_height'] == 20.0 assert change["delta_height"] == 20.0
def test_detect_rotation_change_significant(self): def test_detect_rotation_change_significant(self):
"""Test detecting significant rotation changes.""" """Test detecting significant rotation changes."""
@ -142,9 +139,9 @@ class TestInteractionChangeDetector:
change = detector.detect_rotation_change(old_rotation, new_rotation) change = detector.detect_rotation_change(old_rotation, new_rotation)
assert change is not None assert change is not None
assert change['old_rotation'] == old_rotation assert change["old_rotation"] == old_rotation
assert change['new_rotation'] == new_rotation assert change["new_rotation"] == new_rotation
assert change['delta_angle'] == 45.0 assert change["delta_angle"] == 45.0
def test_detect_crop_change_significant(self): def test_detect_crop_change_significant(self):
"""Test detecting significant crop changes.""" """Test detecting significant crop changes."""
@ -155,13 +152,13 @@ class TestInteractionChangeDetector:
change = detector.detect_crop_change(old_crop, new_crop) change = detector.detect_crop_change(old_crop, new_crop)
assert change is not None assert change is not None
assert change['old_crop'] == old_crop assert change["old_crop"] == old_crop
assert change['new_crop'] == new_crop assert change["new_crop"] == new_crop
# Use approximate comparison for floating point # Use approximate comparison for floating point
assert abs(change['delta'][0] - 0.1) < 0.001 assert abs(change["delta"][0] - 0.1) < 0.001
assert abs(change['delta'][1] - 0.1) < 0.001 assert abs(change["delta"][1] - 0.1) < 0.001
assert abs(change['delta'][2] - (-0.1)) < 0.001 assert abs(change["delta"][2] - (-0.1)) < 0.001
assert abs(change['delta'][3] - (-0.1)) < 0.001 assert abs(change["delta"][3] - (-0.1)) < 0.001
def test_custom_threshold(self): def test_custom_threshold(self):
"""Test using custom threshold values.""" """Test using custom threshold values."""

View File

@ -26,10 +26,7 @@ def create_base_project():
# Add a page with text # Add a page with text
page = Page(page_number=1) page = Page(page_number=1)
text = TextBoxData( text = TextBoxData(text_content="Original Text", x=10, y=10, width=100, height=50)
text_content="Original Text",
x=10, y=10, width=100, height=50
)
page.layout.add_element(text) page.layout.add_element(text)
project.add_page(page) project.add_page(page)
@ -145,7 +142,7 @@ def test_different_project_concatenation():
print("\n3. Concatenating projects...") print("\n3. Concatenating projects...")
merged_data = concatenate_projects(data_a, data_b) merged_data = concatenate_projects(data_a, data_b)
assert len(merged_data['pages']) == 2, "Should have 2 pages" assert len(merged_data["pages"]) == 2, "Should have 2 pages"
print(f" ✓ Concatenated project has {len(merged_data['pages'])} pages") print(f" ✓ Concatenated project has {len(merged_data['pages'])} pages")
print(f" ✓ Combined name: {merged_data['name']}") print(f" ✓ Combined name: {merged_data['name']}")
@ -252,6 +249,7 @@ def run_all_tests():
except Exception as e: except Exception as e:
print(f"\n❌ Test '{name}' FAILED with exception: {e}") print(f"\n❌ Test '{name}' FAILED with exception: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
results.append((name, False)) results.append((name, False))
@ -293,20 +291,20 @@ def test_merge_helper_add_missing_pages():
data_b = project_b.serialize() data_b = project_b.serialize()
# Make them same project # Make them same project
data_b['project_id'] = data_a['project_id'] data_b["project_id"] = data_a["project_id"]
merge_manager = MergeManager() merge_manager = MergeManager()
merge_manager.detect_conflicts(data_a, data_b) merge_manager.detect_conflicts(data_a, data_b)
# Test _add_missing_pages # Test _add_missing_pages
merged_data = data_a.copy() merged_data = data_a.copy()
merged_data['pages'] = list(data_a['pages']) merged_data["pages"] = list(data_a["pages"])
initial_page_count = len(merged_data['pages']) initial_page_count = len(merged_data["pages"])
merge_manager._add_missing_pages(merged_data, data_b) merge_manager._add_missing_pages(merged_data, data_b)
# Should have added only page_b2 since page_b1 has same UUID as page_a1 # Should have added only page_b2 since page_b1 has same UUID as page_a1
assert len(merged_data['pages']) == initial_page_count + 1 assert len(merged_data["pages"]) == initial_page_count + 1
print(f" ✓ Added missing page: {len(merged_data['pages'])} total pages") print(f" ✓ Added missing page: {len(merged_data['pages'])} total pages")
print(f"\n{'=' * 60}") print(f"\n{'=' * 60}")
@ -332,7 +330,7 @@ def test_merge_helper_is_element_in_conflict():
element_uuid="elem-456", element_uuid="elem-456",
our_version={}, our_version={},
their_version={}, their_version={},
description="Test conflict" description="Test conflict",
) )
merge_manager.conflicts.append(conflict) merge_manager.conflicts.append(conflict)
@ -365,50 +363,24 @@ def test_merge_helper_merge_by_timestamp():
older = (now - timedelta(hours=1)).isoformat() older = (now - timedelta(hours=1)).isoformat()
newer = (now + timedelta(hours=1)).isoformat() newer = (now + timedelta(hours=1)).isoformat()
our_page = { our_page = {"layout": {"elements": [{"uuid": "elem-1", "text_content": "Older version", "last_modified": older}]}}
'layout': {
'elements': [
{
'uuid': 'elem-1',
'text_content': 'Older version',
'last_modified': older
}
]
}
}
our_elem = our_page['layout']['elements'][0] our_elem = our_page["layout"]["elements"][0]
their_elem = { their_elem = {"uuid": "elem-1", "text_content": "Newer version", "last_modified": newer}
'uuid': 'elem-1',
'text_content': 'Newer version',
'last_modified': newer
}
# Test: their version is newer, should replace # Test: their version is newer, should replace
merge_manager._merge_by_timestamp(our_page, 'elem-1', their_elem, our_elem) merge_manager._merge_by_timestamp(our_page, "elem-1", their_elem, our_elem)
assert our_page['layout']['elements'][0]['text_content'] == 'Newer version' assert our_page["layout"]["elements"][0]["text_content"] == "Newer version"
print(f" ✓ Correctly replaced with newer version") print(f" ✓ Correctly replaced with newer version")
# Test: our version is newer, should not replace # Test: our version is newer, should not replace
our_page['layout']['elements'][0] = { our_page["layout"]["elements"][0] = {"uuid": "elem-2", "text_content": "Our newer version", "last_modified": newer}
'uuid': 'elem-2', their_elem_older = {"uuid": "elem-2", "text_content": "Their older version", "last_modified": older}
'text_content': 'Our newer version',
'last_modified': newer
}
their_elem_older = {
'uuid': 'elem-2',
'text_content': 'Their older version',
'last_modified': older
}
merge_manager._merge_by_timestamp( merge_manager._merge_by_timestamp(our_page, "elem-2", their_elem_older, our_page["layout"]["elements"][0])
our_page, 'elem-2',
their_elem_older,
our_page['layout']['elements'][0]
)
assert our_page['layout']['elements'][0]['text_content'] == 'Our newer version' assert our_page["layout"]["elements"][0]["text_content"] == "Our newer version"
print(f" ✓ Correctly kept our newer version") print(f" ✓ Correctly kept our newer version")
print(f"\n{'=' * 60}") print(f"\n{'=' * 60}")
@ -430,73 +402,49 @@ def test_merge_helper_merge_element():
# Setup: page with one element # Setup: page with one element
our_page = { our_page = {
'uuid': 'page-1', "uuid": "page-1",
'layout': { "layout": {"elements": [{"uuid": "elem-existing", "text_content": "Existing", "last_modified": now}]},
'elements': [
{
'uuid': 'elem-existing',
'text_content': 'Existing',
'last_modified': now
}
]
}
} }
our_elements = { our_elements = {"elem-existing": our_page["layout"]["elements"][0]}
'elem-existing': our_page['layout']['elements'][0]
}
# Test 1: Adding new element # Test 1: Adding new element
their_new_elem = { their_new_elem = {"uuid": "elem-new", "text_content": "New element", "last_modified": now}
'uuid': 'elem-new',
'text_content': 'New element',
'last_modified': now
}
merge_manager._merge_element( merge_manager._merge_element(
our_page=our_page, our_page=our_page, page_uuid="page-1", their_elem=their_new_elem, our_elements=our_elements
page_uuid='page-1',
their_elem=their_new_elem,
our_elements=our_elements
) )
assert len(our_page['layout']['elements']) == 2 assert len(our_page["layout"]["elements"]) == 2
assert our_page['layout']['elements'][1]['uuid'] == 'elem-new' assert our_page["layout"]["elements"][1]["uuid"] == "elem-new"
print(f" ✓ Correctly added new element") print(f" ✓ Correctly added new element")
# Test 2: Element in conflict should be skipped # Test 2: Element in conflict should be skipped
from pyPhotoAlbum.merge_manager import ConflictInfo, ConflictType from pyPhotoAlbum.merge_manager import ConflictInfo, ConflictType
conflict_elem = { conflict_elem = {"uuid": "elem-conflict", "text_content": "Conflict element", "last_modified": now}
'uuid': 'elem-conflict',
'text_content': 'Conflict element',
'last_modified': now
}
conflict = ConflictInfo( conflict = ConflictInfo(
conflict_type=ConflictType.ELEMENT_MODIFIED_BOTH, conflict_type=ConflictType.ELEMENT_MODIFIED_BOTH,
page_uuid='page-1', page_uuid="page-1",
element_uuid='elem-conflict', element_uuid="elem-conflict",
our_version={}, our_version={},
their_version={}, their_version={},
description="Test" description="Test",
) )
merge_manager.conflicts.append(conflict) merge_manager.conflicts.append(conflict)
our_elements['elem-conflict'] = {'uuid': 'elem-conflict', 'text_content': 'Ours'} our_elements["elem-conflict"] = {"uuid": "elem-conflict", "text_content": "Ours"}
our_page['layout']['elements'].append(our_elements['elem-conflict']) our_page["layout"]["elements"].append(our_elements["elem-conflict"])
initial_count = len(our_page['layout']['elements']) initial_count = len(our_page["layout"]["elements"])
merge_manager._merge_element( merge_manager._merge_element(
our_page=our_page, our_page=our_page, page_uuid="page-1", their_elem=conflict_elem, our_elements=our_elements
page_uuid='page-1',
their_elem=conflict_elem,
our_elements=our_elements
) )
# Should not change anything since it's in conflict # Should not change anything since it's in conflict
assert len(our_page['layout']['elements']) == initial_count assert len(our_page["layout"]["elements"]) == initial_count
print(f" ✓ Correctly skipped conflicting element") print(f" ✓ Correctly skipped conflicting element")
print(f"\n{'=' * 60}") print(f"\n{'=' * 60}")

View File

@ -49,29 +49,16 @@ def create_v2_project_json():
"rotation": 0, "rotation": 0,
"z_index": 0, "z_index": 0,
"text_content": "Hello v2.0", "text_content": "Hello v2.0",
"font_settings": { "font_settings": {"family": "Arial", "size": 12, "color": [0, 0, 0]},
"family": "Arial", "alignment": "left",
"size": 12,
"color": [0, 0, 0]
},
"alignment": "left"
} }
], ],
"snapping_system": { "snapping_system": {"snap_threshold_mm": 5.0, "grid_size_mm": 10.0},
"snap_threshold_mm": 5.0, },
"grid_size_mm": 10.0
}
}
} }
], ],
"history": { "history": {"undo_stack": [], "redo_stack": [], "max_history": 100},
"undo_stack": [], "asset_manager": {"reference_counts": {}},
"redo_stack": [],
"max_history": 100
},
"asset_manager": {
"reference_counts": {}
}
} }
@ -88,9 +75,9 @@ def test_migration():
print(f"\n1. Creating v2.0 project file: {v2_file}") print(f"\n1. Creating v2.0 project file: {v2_file}")
v2_data = create_v2_project_json() v2_data = create_v2_project_json()
with zipfile.ZipFile(v2_file, 'w', zipfile.ZIP_DEFLATED) as zipf: with zipfile.ZipFile(v2_file, "w", zipfile.ZIP_DEFLATED) as zipf:
project_json = json.dumps(v2_data, indent=2) project_json = json.dumps(v2_data, indent=2)
zipf.writestr('project.json', project_json) zipf.writestr("project.json", project_json)
print(f" ✓ Created v2.0 project with {len(v2_data['pages'])} page(s)") print(f" ✓ Created v2.0 project with {len(v2_data['pages'])} page(s)")
print(f" ✓ Version: {v2_data['data_version']}") print(f" ✓ Version: {v2_data['data_version']}")
@ -107,9 +94,9 @@ def test_migration():
print(f"\n3. Verifying migration to v3.0...") print(f"\n3. Verifying migration to v3.0...")
# Check project-level fields # Check project-level fields
assert hasattr(project, 'project_id'), "Missing project_id" assert hasattr(project, "project_id"), "Missing project_id"
assert hasattr(project, 'created'), "Missing created timestamp" assert hasattr(project, "created"), "Missing created timestamp"
assert hasattr(project, 'last_modified'), "Missing last_modified timestamp" assert hasattr(project, "last_modified"), "Missing last_modified timestamp"
print(f" ✓ Project has project_id: {project.project_id}") print(f" ✓ Project has project_id: {project.project_id}")
print(f" ✓ Project has created: {project.created}") print(f" ✓ Project has created: {project.created}")
print(f" ✓ Project has last_modified: {project.last_modified}") print(f" ✓ Project has last_modified: {project.last_modified}")
@ -117,20 +104,20 @@ def test_migration():
# Check page-level fields # Check page-level fields
assert len(project.pages) > 0, "No pages in project" assert len(project.pages) > 0, "No pages in project"
page = project.pages[0] page = project.pages[0]
assert hasattr(page, 'uuid'), "Page missing uuid" assert hasattr(page, "uuid"), "Page missing uuid"
assert hasattr(page, 'created'), "Page missing created" assert hasattr(page, "created"), "Page missing created"
assert hasattr(page, 'last_modified'), "Page missing last_modified" assert hasattr(page, "last_modified"), "Page missing last_modified"
assert hasattr(page, 'deleted'), "Page missing deleted flag" assert hasattr(page, "deleted"), "Page missing deleted flag"
print(f" ✓ Page 1 has uuid: {page.uuid}") print(f" ✓ Page 1 has uuid: {page.uuid}")
print(f" ✓ Page 1 has timestamps and deletion tracking") print(f" ✓ Page 1 has timestamps and deletion tracking")
# Check element-level fields # Check element-level fields
assert len(page.layout.elements) > 0, "No elements in page" assert len(page.layout.elements) > 0, "No elements in page"
element = page.layout.elements[0] element = page.layout.elements[0]
assert hasattr(element, 'uuid'), "Element missing uuid" assert hasattr(element, "uuid"), "Element missing uuid"
assert hasattr(element, 'created'), "Element missing created" assert hasattr(element, "created"), "Element missing created"
assert hasattr(element, 'last_modified'), "Element missing last_modified" assert hasattr(element, "last_modified"), "Element missing last_modified"
assert hasattr(element, 'deleted'), "Element missing deleted flag" assert hasattr(element, "deleted"), "Element missing deleted flag"
print(f" ✓ Element has uuid: {element.uuid}") print(f" ✓ Element has uuid: {element.uuid}")
print(f" ✓ Element has timestamps and deletion tracking") print(f" ✓ Element has timestamps and deletion tracking")
@ -142,14 +129,14 @@ def test_migration():
print(f" ✓ Saved to: {v3_file}") print(f" ✓ Saved to: {v3_file}")
# Verify v3.0 file structure # Verify v3.0 file structure
with zipfile.ZipFile(v3_file, 'r') as zipf: with zipfile.ZipFile(v3_file, "r") as zipf:
project_json = zipf.read('project.json').decode('utf-8') project_json = zipf.read("project.json").decode("utf-8")
v3_data = json.loads(project_json) v3_data = json.loads(project_json)
assert v3_data.get('data_version') == "3.0", "Wrong version" assert v3_data.get("data_version") == "3.0", "Wrong version"
assert 'project_id' in v3_data, "Missing project_id in saved file" assert "project_id" in v3_data, "Missing project_id in saved file"
assert 'created' in v3_data, "Missing created in saved file" assert "created" in v3_data, "Missing created in saved file"
assert 'uuid' in v3_data['pages'][0], "Missing page uuid in saved file" assert "uuid" in v3_data["pages"][0], "Missing page uuid in saved file"
print(f" ✓ Saved file version: {v3_data.get('data_version')}") print(f" ✓ Saved file version: {v3_data.get('data_version')}")
print(f" ✓ All v3.0 fields present in saved file") print(f" ✓ All v3.0 fields present in saved file")
@ -164,6 +151,7 @@ def test_migration():
print(f"❌ Migration test FAILED: {e}") print(f"❌ Migration test FAILED: {e}")
print(f"{'=' * 60}\n") print(f"{'=' * 60}\n")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
return False return False

View File

@ -30,15 +30,7 @@ class TestImageData:
def test_initialization_with_parameters(self, temp_image_file): def test_initialization_with_parameters(self, temp_image_file):
"""Test ImageData initialization with custom parameters""" """Test ImageData initialization with custom parameters"""
img = ImageData( img = ImageData(image_path=temp_image_file, x=10.0, y=20.0, width=200.0, height=150.0, rotation=45.0, z_index=5)
image_path=temp_image_file,
x=10.0,
y=20.0,
width=200.0,
height=150.0,
rotation=45.0,
z_index=5
)
assert img.image_path == temp_image_file assert img.image_path == temp_image_file
assert img.position == (10.0, 20.0) assert img.position == (10.0, 20.0)
assert img.size == (200.0, 150.0) assert img.size == (200.0, 150.0)
@ -53,15 +45,7 @@ class TestImageData:
def test_serialization(self, temp_image_file): def test_serialization(self, temp_image_file):
"""Test ImageData serialization to dictionary""" """Test ImageData serialization to dictionary"""
img = ImageData( img = ImageData(image_path=temp_image_file, x=15.0, y=25.0, width=180.0, height=120.0, rotation=30.0, z_index=3)
image_path=temp_image_file,
x=15.0,
y=25.0,
width=180.0,
height=120.0,
rotation=30.0,
z_index=3
)
data = img.serialize() data = img.serialize()
assert data["type"] == "image" assert data["type"] == "image"
@ -81,7 +65,7 @@ class TestImageData:
"rotation": 90.0, "rotation": 90.0,
"z_index": 7, "z_index": 7,
"image_path": "new_image.jpg", "image_path": "new_image.jpg",
"crop_info": (0.2, 0.3, 0.7, 0.8) "crop_info": (0.2, 0.3, 0.7, 0.8),
} }
img.deserialize(data) img.deserialize(data)
@ -118,7 +102,7 @@ class TestImageData:
height=200.0, height=200.0,
rotation=0, # Visual rotation should be 0 for images rotation=0, # Visual rotation should be 0 for images
z_index=2, z_index=2,
crop_info=(0.1, 0.1, 0.9, 0.9) crop_info=(0.1, 0.1, 0.9, 0.9),
) )
original.pil_rotation_90 = 1 # Set PIL rotation to 90 degrees original.pil_rotation_90 = 1 # Set PIL rotation to 90 degrees
@ -170,7 +154,7 @@ class TestPlaceholderData:
width=150.0, width=150.0,
height=100.0, height=100.0,
rotation=10.0, rotation=10.0,
z_index=4 z_index=4,
) )
assert placeholder.placeholder_type == "text" assert placeholder.placeholder_type == "text"
assert placeholder.default_content == "Sample" assert placeholder.default_content == "Sample"
@ -189,7 +173,7 @@ class TestPlaceholderData:
width=200.0, width=200.0,
height=150.0, height=150.0,
rotation=20.0, rotation=20.0,
z_index=2 z_index=2,
) )
data = placeholder.serialize() data = placeholder.serialize()
@ -210,7 +194,7 @@ class TestPlaceholderData:
"rotation": 45.0, "rotation": 45.0,
"z_index": 6, "z_index": 6,
"placeholder_type": "text", "placeholder_type": "text",
"default_content": "Default Text" "default_content": "Default Text",
} }
placeholder.deserialize(data) placeholder.deserialize(data)
@ -243,7 +227,7 @@ class TestPlaceholderData:
width=300.0, width=300.0,
height=250.0, height=250.0,
rotation=60.0, rotation=60.0,
z_index=8 z_index=8,
) )
data = original.serialize() data = original.serialize()
restored = PlaceholderData() restored = PlaceholderData()
@ -283,7 +267,7 @@ class TestTextBoxData:
width=180.0, width=180.0,
height=60.0, height=60.0,
rotation=5.0, rotation=5.0,
z_index=3 z_index=3,
) )
assert textbox.text_content == "Hello World" assert textbox.text_content == "Hello World"
assert textbox.font_settings == font_settings assert textbox.font_settings == font_settings
@ -305,7 +289,7 @@ class TestTextBoxData:
width=220.0, width=220.0,
height=80.0, height=80.0,
rotation=15.0, rotation=15.0,
z_index=5 z_index=5,
) )
data = textbox.serialize() data = textbox.serialize()
@ -329,7 +313,7 @@ class TestTextBoxData:
"z_index": 7, "z_index": 7,
"text_content": "Deserialized Text", "text_content": "Deserialized Text",
"font_settings": font_settings, "font_settings": font_settings,
"alignment": "justify" "alignment": "justify",
} }
textbox.deserialize(data) textbox.deserialize(data)
@ -366,7 +350,7 @@ class TestTextBoxData:
width=320.0, width=320.0,
height=120.0, height=120.0,
rotation=25.0, rotation=25.0,
z_index=9 z_index=9,
) )
data = original.serialize() data = original.serialize()
restored = TextBoxData() restored = TextBoxData()

View File

@ -28,7 +28,7 @@ class TestMouseInteractionWidget(
ElementSelectionMixin, ElementSelectionMixin,
ViewportMixin, ViewportMixin,
UndoableInteractionMixin, UndoableInteractionMixin,
QOpenGLWidget QOpenGLWidget,
): ):
"""Test widget combining mouse interaction with other required mixins""" """Test widget combining mouse interaction with other required mixins"""
@ -49,9 +49,9 @@ class TestMouseInteractionInitialization:
qtbot.addWidget(widget) qtbot.addWidget(widget)
# Should have initialized state # Should have initialized state
assert hasattr(widget, 'drag_start_pos') assert hasattr(widget, "drag_start_pos")
assert hasattr(widget, 'is_dragging') assert hasattr(widget, "is_dragging")
assert hasattr(widget, 'is_panning') assert hasattr(widget, "is_panning")
assert widget.drag_start_pos is None assert widget.drag_start_pos is None
assert widget.is_dragging is False assert widget.is_dragging is False
assert widget.is_panning is False assert widget.is_panning is False
@ -123,8 +123,11 @@ class TestMousePressEvent:
# Create image element with crop info # Create image element with crop info
element = ImageData( element = ImageData(
image_path="/test.jpg", image_path="/test.jpg",
x=50, y=50, width=100, height=100, x=50,
crop_info=(0.0, 0.0, 1.0, 1.0) # crop_info is a tuple (x, y, width, height) y=50,
width=100,
height=100,
crop_info=(0.0, 0.0, 1.0, 1.0), # crop_info is a tuple (x, y, width, height)
) )
event = Mock() event = Mock()
@ -205,7 +208,7 @@ class TestMouseMoveEvent:
event.buttons = Mock(return_value=Qt.MouseButton.NoButton) event.buttons = Mock(return_value=Qt.MouseButton.NoButton)
# Mock resize handle detection # Mock resize handle detection
widget._get_resize_handle_at = Mock(return_value='bottom-right') widget._get_resize_handle_at = Mock(return_value="bottom-right")
widget._get_element_at = Mock(return_value=element) widget._get_element_at = Mock(return_value=element)
widget.mouseMoveEvent(event) widget.mouseMoveEvent(event)
@ -295,9 +298,11 @@ class TestMouseMoveEvent:
# Create image element with crop info # Create image element with crop info
element = ImageData( element = ImageData(
image_path="/test.jpg", image_path="/test.jpg",
x=100, y=100, x=100,
width=100, height=100, y=100,
crop_info=(0.0, 0.0, 1.0, 1.0) # crop_info is a tuple (x, y, width, height) width=100,
height=100,
crop_info=(0.0, 0.0, 1.0, 1.0), # crop_info is a tuple (x, y, width, height)
) )
widget.selected_elements.add(element) widget.selected_elements.add(element)
@ -377,9 +382,11 @@ class TestMouseDoubleClickEvent:
# Create text element with correct constructor # Create text element with correct constructor
text_element = TextBoxData( text_element = TextBoxData(
text_content="Test", text_content="Test",
x=100, y=100, x=100,
width=100, height=50, y=100,
font_settings={"family": "Arial", "size": 12, "color": (0, 0, 0)} width=100,
height=50,
font_settings={"family": "Arial", "size": 12, "color": (0, 0, 0)},
) )
# Mock _edit_text_element method # Mock _edit_text_element method
@ -394,7 +401,7 @@ class TestMouseDoubleClickEvent:
QPointF(125, 125), QPointF(125, 125),
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton,
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.NoModifier Qt.KeyboardModifier.NoModifier,
) )
widget.mouseDoubleClickEvent(event) widget.mouseDoubleClickEvent(event)
@ -419,7 +426,7 @@ class TestMouseDoubleClickEvent:
QPointF(125, 125), QPointF(125, 125),
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton,
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.NoModifier Qt.KeyboardModifier.NoModifier,
) )
widget.mouseDoubleClickEvent(event) widget.mouseDoubleClickEvent(event)

View File

@ -16,6 +16,7 @@ from pyPhotoAlbum.page_layout import PageLayout
# Create a minimal test widget class that doesn't require full GLWidget initialization # Create a minimal test widget class that doesn't require full GLWidget initialization
class MultiSelectTestWidget(ElementSelectionMixin, RenderingMixin, QOpenGLWidget): class MultiSelectTestWidget(ElementSelectionMixin, RenderingMixin, QOpenGLWidget):
"""Widget combining necessary mixins for multiselect testing""" """Widget combining necessary mixins for multiselect testing"""
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._page_renderers = [] self._page_renderers = []
@ -69,7 +70,7 @@ def test_multiselect_visual_feedback(qtbot):
print("\nTest 1: Single selection") print("\nTest 1: Single selection")
widget.selected_elements = {element1} widget.selected_elements = {element1}
with patch.object(widget, '_draw_selection_handles') as mock_draw: with patch.object(widget, "_draw_selection_handles") as mock_draw:
# Simulate paintGL call (only the relevant part) # Simulate paintGL call (only the relevant part)
for selected_elem in widget.selected_elements: for selected_elem in widget.selected_elements:
widget._draw_selection_handles(selected_elem) widget._draw_selection_handles(selected_elem)
@ -82,7 +83,7 @@ def test_multiselect_visual_feedback(qtbot):
print("\nTest 2: Multiple selection (2 elements)") print("\nTest 2: Multiple selection (2 elements)")
widget.selected_elements = {element1, element2} widget.selected_elements = {element1, element2}
with patch.object(widget, '_draw_selection_handles') as mock_draw: with patch.object(widget, "_draw_selection_handles") as mock_draw:
for selected_elem in widget.selected_elements: for selected_elem in widget.selected_elements:
widget._draw_selection_handles(selected_elem) widget._draw_selection_handles(selected_elem)
@ -95,7 +96,7 @@ def test_multiselect_visual_feedback(qtbot):
print("\nTest 3: Multiple selection (3 elements)") print("\nTest 3: Multiple selection (3 elements)")
widget.selected_elements = {element1, element2, element3} widget.selected_elements = {element1, element2, element3}
with patch.object(widget, '_draw_selection_handles') as mock_draw: with patch.object(widget, "_draw_selection_handles") as mock_draw:
for selected_elem in widget.selected_elements: for selected_elem in widget.selected_elements:
widget._draw_selection_handles(selected_elem) widget._draw_selection_handles(selected_elem)
@ -108,7 +109,7 @@ def test_multiselect_visual_feedback(qtbot):
print("\nTest 4: No selection") print("\nTest 4: No selection")
widget.selected_elements = set() widget.selected_elements = set()
with patch.object(widget, '_draw_selection_handles') as mock_draw: with patch.object(widget, "_draw_selection_handles") as mock_draw:
for selected_elem in widget.selected_elements: for selected_elem in widget.selected_elements:
widget._draw_selection_handles(selected_elem) widget._draw_selection_handles(selected_elem)
@ -120,15 +121,17 @@ def test_multiselect_visual_feedback(qtbot):
widget.selected_elements = {element2} widget.selected_elements = {element2}
# Mock OpenGL functions # Mock OpenGL functions
with patch('pyPhotoAlbum.gl_widget.glColor3f'), \ with (
patch('pyPhotoAlbum.gl_widget.glLineWidth'), \ patch("pyPhotoAlbum.gl_widget.glColor3f"),
patch('pyPhotoAlbum.gl_widget.glBegin'), \ patch("pyPhotoAlbum.gl_widget.glLineWidth"),
patch('pyPhotoAlbum.gl_widget.glEnd'), \ patch("pyPhotoAlbum.gl_widget.glBegin"),
patch('pyPhotoAlbum.gl_widget.glVertex2f'), \ patch("pyPhotoAlbum.gl_widget.glEnd"),
patch('pyPhotoAlbum.gl_widget.glPushMatrix'), \ patch("pyPhotoAlbum.gl_widget.glVertex2f"),
patch('pyPhotoAlbum.gl_widget.glPopMatrix'), \ patch("pyPhotoAlbum.gl_widget.glPushMatrix"),
patch('pyPhotoAlbum.gl_widget.glTranslatef'), \ patch("pyPhotoAlbum.gl_widget.glPopMatrix"),
patch('pyPhotoAlbum.gl_widget.glRotatef'): patch("pyPhotoAlbum.gl_widget.glTranslatef"),
patch("pyPhotoAlbum.gl_widget.glRotatef"),
):
# Call the actual method # Call the actual method
widget._draw_selection_handles(element2) widget._draw_selection_handles(element2)

View File

@ -174,7 +174,7 @@ class TestPageLayoutDeserialization:
"elements": [], "elements": [],
"grid_layout": None, "grid_layout": None,
"snapping_system": {}, "snapping_system": {},
"show_snap_lines": False "show_snap_lines": False,
} }
layout.deserialize(data) layout.deserialize(data)
@ -198,7 +198,7 @@ class TestPageLayoutDeserialization:
"size": (100, 100), "size": (100, 100),
"rotation": 0, "rotation": 0,
"z_index": 0, "z_index": 0,
"crop_info": (0, 0, 1, 1) "crop_info": (0, 0, 1, 1),
}, },
{ {
"type": "placeholder", "type": "placeholder",
@ -207,7 +207,7 @@ class TestPageLayoutDeserialization:
"position": (30, 40), "position": (30, 40),
"size": (80, 80), "size": (80, 80),
"rotation": 0, "rotation": 0,
"z_index": 1 "z_index": 1,
}, },
{ {
"type": "textbox", "type": "textbox",
@ -217,9 +217,9 @@ class TestPageLayoutDeserialization:
"position": (50, 60), "position": (50, 60),
"size": (120, 40), "size": (120, 40),
"rotation": 0, "rotation": 0,
"z_index": 2 "z_index": 2,
} },
] ],
} }
layout.deserialize(data) layout.deserialize(data)
@ -242,7 +242,7 @@ class TestPageLayoutDeserialization:
"size": (100, 100), "size": (100, 100),
"rotation": 0, "rotation": 0,
"z_index": 5, # Higher z_index "z_index": 5, # Higher z_index
"crop_info": (0, 0, 1, 1) "crop_info": (0, 0, 1, 1),
}, },
{ {
"type": "placeholder", "type": "placeholder",
@ -251,9 +251,9 @@ class TestPageLayoutDeserialization:
"position": (30, 40), "position": (30, 40),
"size": (80, 80), "size": (80, 80),
"rotation": 0, "rotation": 0,
"z_index": 1 # Lower z_index - should be first "z_index": 1, # Lower z_index - should be first
} },
] ],
} }
layout.deserialize(data) layout.deserialize(data)
@ -269,12 +269,7 @@ class TestPageLayoutDeserialization:
data = { data = {
"size": (210, 297), "size": (210, 297),
"elements": [], "elements": [],
"grid_layout": { "grid_layout": {"rows": 2, "columns": 3, "spacing": 12.5, "merged_cells": [(0, 0), (1, 1)]},
"rows": 2,
"columns": 3,
"spacing": 12.5,
"merged_cells": [(0, 0), (1, 1)]
}
} }
layout.deserialize(data) layout.deserialize(data)
@ -291,10 +286,7 @@ class TestPageLayoutDeserialization:
data = { data = {
"size": (210, 297), "size": (210, 297),
"elements": [ "elements": [
{ {"type": "unknown_type", "position": (10, 20)},
"type": "unknown_type",
"position": (10, 20)
},
{ {
"type": "image", "type": "image",
"image_path": "test.jpg", "image_path": "test.jpg",
@ -302,9 +294,9 @@ class TestPageLayoutDeserialization:
"size": (100, 100), "size": (100, 100),
"rotation": 0, "rotation": 0,
"z_index": 0, "z_index": 0,
"crop_info": (0, 0, 1, 1) "crop_info": (0, 0, 1, 1),
} },
] ],
} }
layout.deserialize(data) layout.deserialize(data)
@ -430,12 +422,7 @@ class TestGridLayoutClass:
def test_grid_deserialization(self): def test_grid_deserialization(self):
"""Test GridLayout deserialization""" """Test GridLayout deserialization"""
grid = GridLayout() grid = GridLayout()
data = { data = {"rows": 4, "columns": 5, "spacing": 8.5, "merged_cells": [(1, 2), (3, 3)]}
"rows": 4,
"columns": 5,
"spacing": 8.5,
"merged_cells": [(1, 2), (3, 3)]
}
grid.deserialize(data) grid.deserialize(data)

View File

@ -15,6 +15,7 @@ from pyPhotoAlbum.models import GhostPageData
# Create test widget combining necessary mixins # Create test widget combining necessary mixins
class TestPageNavWidget(PageNavigationMixin, ViewportMixin, QOpenGLWidget): class TestPageNavWidget(PageNavigationMixin, ViewportMixin, QOpenGLWidget):
"""Test widget combining page navigation and viewport mixins""" """Test widget combining page navigation and viewport mixins"""
pass pass
@ -149,9 +150,7 @@ class TestGetPagePositions:
mock_window.project.pages = [page] mock_window.project.pages = [page]
# Mock calculate_page_layout_with_ghosts # Mock calculate_page_layout_with_ghosts
mock_window.project.calculate_page_layout_with_ghosts = Mock(return_value=[ mock_window.project.calculate_page_layout_with_ghosts = Mock(return_value=[("page", page, 0)])
('page', page, 0)
])
widget.window = Mock(return_value=mock_window) widget.window = Mock(return_value=mock_window)
@ -159,7 +158,7 @@ class TestGetPagePositions:
# Should have one page entry # Should have one page entry
assert len(result) >= 1 assert len(result) >= 1
assert result[0][0] == 'page' assert result[0][0] == "page"
assert result[0][1] is page assert result[0][1] is page
def test_get_page_positions_includes_ghosts(self, qtbot): def test_get_page_positions_includes_ghosts(self, qtbot):
@ -177,10 +176,9 @@ class TestGetPagePositions:
mock_window.project.pages = [page] mock_window.project.pages = [page]
# Mock with ghost page # Mock with ghost page
mock_window.project.calculate_page_layout_with_ghosts = Mock(return_value=[ mock_window.project.calculate_page_layout_with_ghosts = Mock(
('page', page, 0), return_value=[("page", page, 0), ("ghost", None, 1)]
('ghost', None, 1) )
])
widget.window = Mock(return_value=mock_window) widget.window = Mock(return_value=mock_window)
@ -189,8 +187,8 @@ class TestGetPagePositions:
# Should have page + ghost # Should have page + ghost
assert len(result) >= 2 assert len(result) >= 2
page_types = [r[0] for r in result] page_types = [r[0] for r in result]
assert 'page' in page_types assert "page" in page_types
assert 'ghost' in page_types assert "ghost" in page_types
class TestCheckGhostPageClick: class TestCheckGhostPageClick:
@ -217,7 +215,7 @@ class TestCheckGhostPageClick:
result = widget._check_ghost_page_click(100, 100) result = widget._check_ghost_page_click(100, 100)
assert result is False assert result is False
@patch('pyPhotoAlbum.page_renderer.PageRenderer') @patch("pyPhotoAlbum.page_renderer.PageRenderer")
def test_check_ghost_page_click_on_ghost(self, mock_page_renderer_class, qtbot): def test_check_ghost_page_click_on_ghost(self, mock_page_renderer_class, qtbot):
"""Test clicking on ghost page creates new page""" """Test clicking on ghost page creates new page"""
widget = TestPageNavWidget() widget = TestPageNavWidget()
@ -238,9 +236,7 @@ class TestCheckGhostPageClick:
# Mock _get_page_positions to return a ghost # Mock _get_page_positions to return a ghost
ghost = GhostPageData(page_size=(210, 297)) ghost = GhostPageData(page_size=(210, 297))
widget._get_page_positions = Mock(return_value=[ widget._get_page_positions = Mock(return_value=[("ghost", ghost, 100)])
('ghost', ghost, 100)
])
# Mock PageRenderer to say click is in page # Mock PageRenderer to say click is in page
mock_renderer_instance = Mock() mock_renderer_instance = Mock()
@ -257,7 +253,7 @@ class TestCheckGhostPageClick:
assert len(mock_window.project.pages) == 1 assert len(mock_window.project.pages) == 1
assert widget.update.called assert widget.update.called
@patch('pyPhotoAlbum.page_renderer.PageRenderer') @patch("pyPhotoAlbum.page_renderer.PageRenderer")
def test_check_ghost_page_click_outside_ghost(self, mock_page_renderer_class, qtbot): def test_check_ghost_page_click_outside_ghost(self, mock_page_renderer_class, qtbot):
"""Test clicking outside ghost page returns False""" """Test clicking outside ghost page returns False"""
widget = TestPageNavWidget() widget = TestPageNavWidget()
@ -273,9 +269,7 @@ class TestCheckGhostPageClick:
mock_window.project.pages = [] mock_window.project.pages = []
ghost = GhostPageData(page_size=(210, 297)) ghost = GhostPageData(page_size=(210, 297))
widget._get_page_positions = Mock(return_value=[ widget._get_page_positions = Mock(return_value=[("ghost", ghost, 100)])
('ghost', ghost, 100)
])
# Mock renderer to say click is NOT in page # Mock renderer to say click is NOT in page
mock_renderer_instance = Mock() mock_renderer_instance = Mock()

View File

@ -98,11 +98,7 @@ class TestGetMostVisiblePageIndex:
renderer3 = Mock() renderer3 = Mock()
renderer3.screen_y = 800 renderer3.screen_y = 800
window.gl_widget._page_renderers = [ window.gl_widget._page_renderers = [(renderer1, page1), (renderer2, page2), (renderer3, page3)]
(renderer1, page1),
(renderer2, page2),
(renderer3, page3)
]
result = window._get_most_visible_page_index() result = window._get_most_visible_page_index()
# Page 2 (index 1) should be closest to viewport center # Page 2 (index 1) should be closest to viewport center
@ -122,10 +118,7 @@ class TestGetMostVisiblePageIndex:
renderer_orphan = Mock() renderer_orphan = Mock()
renderer_orphan.screen_y = 50 # Closer to center renderer_orphan.screen_y = 50 # Closer to center
window.gl_widget._page_renderers = [ window.gl_widget._page_renderers = [(renderer1, page1), (renderer_orphan, orphan_page)] # Not in project.pages
(renderer1, page1),
(renderer_orphan, orphan_page) # Not in project.pages
]
window.gl_widget.current_page_index = 0 window.gl_widget.current_page_index = 0
result = window._get_most_visible_page_index() result = window._get_most_visible_page_index()
@ -222,11 +215,7 @@ class TestToggleDoubleSpread:
renderer3 = Mock() renderer3 = Mock()
renderer3.screen_y = 800 renderer3.screen_y = 800
window.gl_widget._page_renderers = [ window.gl_widget._page_renderers = [(renderer1, page1), (renderer2, page2), (renderer3, page3)]
(renderer1, page1),
(renderer2, page2),
(renderer3, page3)
]
window.toggle_double_spread() window.toggle_double_spread()
@ -340,11 +329,7 @@ class TestAddPage:
renderer3 = Mock() renderer3 = Mock()
renderer3.screen_y = 800 renderer3.screen_y = 800
window.gl_widget._page_renderers = [ window.gl_widget._page_renderers = [(renderer1, page1), (renderer2, page2), (renderer3, page3)]
(renderer1, page1),
(renderer2, page2),
(renderer3, page3)
]
window.add_page() window.add_page()
@ -387,11 +372,7 @@ class TestAddPage:
renderer3 = Mock() renderer3 = Mock()
renderer3.screen_y = 1500 renderer3.screen_y = 1500
window.gl_widget._page_renderers = [ window.gl_widget._page_renderers = [(renderer1, page1), (renderer2, page2), (renderer3, page3)]
(renderer1, page1),
(renderer2, page2),
(renderer3, page3)
]
window.add_page() window.add_page()
@ -458,11 +439,7 @@ class TestRemovePage:
renderer3 = Mock() renderer3 = Mock()
renderer3.screen_y = -300 # Page 3 is most visible renderer3.screen_y = -300 # Page 3 is most visible
window.gl_widget._page_renderers = [ window.gl_widget._page_renderers = [(renderer1, page1), (renderer2, page2), (renderer3, page3)]
(renderer1, page1),
(renderer2, page2),
(renderer3, page3)
]
window.remove_page() window.remove_page()

View File

@ -8,164 +8,124 @@ from pyPhotoAlbum.page_renderer import PageRenderer
class TestPageRendererCoordinates: class TestPageRendererCoordinates:
"""Test coordinate transformation methods""" """Test coordinate transformation methods"""
def test_page_to_screen_no_zoom_no_pan(self): def test_page_to_screen_no_zoom_no_pan(self):
"""Test page_to_screen conversion with zoom=1.0 and no pan""" """Test page_to_screen conversion with zoom=1.0 and no pan"""
renderer = PageRenderer( renderer = PageRenderer(
page_width_mm=210.0, page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.0
) )
# Element at page origin should map to screen_x, screen_y # Element at page origin should map to screen_x, screen_y
screen_x, screen_y = renderer.page_to_screen(0, 0) screen_x, screen_y = renderer.page_to_screen(0, 0)
assert screen_x == 100.0 assert screen_x == 100.0
assert screen_y == 200.0 assert screen_y == 200.0
# Element at (50, 75) should be offset by that amount # Element at (50, 75) should be offset by that amount
screen_x, screen_y = renderer.page_to_screen(50, 75) screen_x, screen_y = renderer.page_to_screen(50, 75)
assert screen_x == 150.0 assert screen_x == 150.0
assert screen_y == 275.0 assert screen_y == 275.0
def test_page_to_screen_with_zoom(self): def test_page_to_screen_with_zoom(self):
"""Test page_to_screen conversion with zoom applied""" """Test page_to_screen conversion with zoom applied"""
renderer = PageRenderer( renderer = PageRenderer(
page_width_mm=210.0, page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=2.0
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=2.0
) )
# With zoom=2.0, distances should be doubled # With zoom=2.0, distances should be doubled
screen_x, screen_y = renderer.page_to_screen(50, 75) screen_x, screen_y = renderer.page_to_screen(50, 75)
assert screen_x == 200.0 # 100 + 50*2 assert screen_x == 200.0 # 100 + 50*2
assert screen_y == 350.0 # 200 + 75*2 assert screen_y == 350.0 # 200 + 75*2
def test_page_to_screen_with_fractional_zoom(self): def test_page_to_screen_with_fractional_zoom(self):
"""Test page_to_screen conversion with fractional zoom""" """Test page_to_screen conversion with fractional zoom"""
renderer = PageRenderer( renderer = PageRenderer(
page_width_mm=210.0, page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=0.5
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=0.5
) )
# With zoom=0.5, distances should be halved # With zoom=0.5, distances should be halved
screen_x, screen_y = renderer.page_to_screen(100, 150) screen_x, screen_y = renderer.page_to_screen(100, 150)
assert screen_x == 150.0 # 100 + 100*0.5 assert screen_x == 150.0 # 100 + 100*0.5
assert screen_y == 275.0 # 200 + 150*0.5 assert screen_y == 275.0 # 200 + 150*0.5
def test_screen_to_page_no_zoom_no_pan(self): def test_screen_to_page_no_zoom_no_pan(self):
"""Test screen_to_page conversion with zoom=1.0 and no pan""" """Test screen_to_page conversion with zoom=1.0 and no pan"""
renderer = PageRenderer( renderer = PageRenderer(
page_width_mm=210.0, page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.0
) )
# Screen position at screen_x, screen_y should map to page origin # Screen position at screen_x, screen_y should map to page origin
page_x, page_y = renderer.screen_to_page(100.0, 200.0) page_x, page_y = renderer.screen_to_page(100.0, 200.0)
assert page_x == 0.0 assert page_x == 0.0
assert page_y == 0.0 assert page_y == 0.0
# Screen position offset should map to same offset in page coords # Screen position offset should map to same offset in page coords
page_x, page_y = renderer.screen_to_page(150.0, 275.0) page_x, page_y = renderer.screen_to_page(150.0, 275.0)
assert page_x == 50.0 assert page_x == 50.0
assert page_y == 75.0 assert page_y == 75.0
def test_screen_to_page_with_zoom(self): def test_screen_to_page_with_zoom(self):
"""Test screen_to_page conversion with zoom applied""" """Test screen_to_page conversion with zoom applied"""
renderer = PageRenderer( renderer = PageRenderer(
page_width_mm=210.0, page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=2.0
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=2.0
) )
# With zoom=2.0, screen distances should be divided by 2 to get page coords # With zoom=2.0, screen distances should be divided by 2 to get page coords
page_x, page_y = renderer.screen_to_page(200.0, 350.0) page_x, page_y = renderer.screen_to_page(200.0, 350.0)
assert page_x == 50.0 # (200-100)/2 assert page_x == 50.0 # (200-100)/2
assert page_y == 75.0 # (350-200)/2 assert page_y == 75.0 # (350-200)/2
def test_roundtrip_conversion_no_zoom(self): def test_roundtrip_conversion_no_zoom(self):
"""Test that page->screen->page conversion is accurate with no zoom""" """Test that page->screen->page conversion is accurate with no zoom"""
renderer = PageRenderer( renderer = PageRenderer(
page_width_mm=210.0, page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.0
) )
# Start with page coordinates # Start with page coordinates
orig_page_x, orig_page_y = 123.45, 678.90 orig_page_x, orig_page_y = 123.45, 678.90
# Convert to screen and back # Convert to screen and back
screen_x, screen_y = renderer.page_to_screen(orig_page_x, orig_page_y) screen_x, screen_y = renderer.page_to_screen(orig_page_x, orig_page_y)
page_x, page_y = renderer.screen_to_page(screen_x, screen_y) page_x, page_y = renderer.screen_to_page(screen_x, screen_y)
# Should get back the original values # Should get back the original values
assert abs(page_x - orig_page_x) < 0.001 assert abs(page_x - orig_page_x) < 0.001
assert abs(page_y - orig_page_y) < 0.001 assert abs(page_y - orig_page_y) < 0.001
def test_roundtrip_conversion_with_zoom(self): def test_roundtrip_conversion_with_zoom(self):
"""Test that page->screen->page conversion is accurate with zoom""" """Test that page->screen->page conversion is accurate with zoom"""
renderer = PageRenderer( renderer = PageRenderer(
page_width_mm=210.0, page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.5
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.5
) )
# Start with page coordinates # Start with page coordinates
orig_page_x, orig_page_y = 123.45, 678.90 orig_page_x, orig_page_y = 123.45, 678.90
# Convert to screen and back # Convert to screen and back
screen_x, screen_y = renderer.page_to_screen(orig_page_x, orig_page_y) screen_x, screen_y = renderer.page_to_screen(orig_page_x, orig_page_y)
page_x, page_y = renderer.screen_to_page(screen_x, screen_y) page_x, page_y = renderer.screen_to_page(screen_x, screen_y)
# Should get back the original values (with floating point tolerance) # Should get back the original values (with floating point tolerance)
assert abs(page_x - orig_page_x) < 0.001 assert abs(page_x - orig_page_x) < 0.001
assert abs(page_y - orig_page_y) < 0.001 assert abs(page_y - orig_page_y) < 0.001
def test_roundtrip_conversion_extreme_zoom(self): def test_roundtrip_conversion_extreme_zoom(self):
"""Test coordinate conversion with extreme zoom levels""" """Test coordinate conversion with extreme zoom levels"""
for zoom in [0.1, 0.5, 1.0, 2.0, 5.0]: for zoom in [0.1, 0.5, 1.0, 2.0, 5.0]:
renderer = PageRenderer( renderer = PageRenderer(
page_width_mm=210.0, page_width_mm=210.0, page_height_mm=297.0, screen_x=50.0, screen_y=100.0, dpi=96, zoom=zoom
page_height_mm=297.0,
screen_x=50.0,
screen_y=100.0,
dpi=96,
zoom=zoom
) )
orig_page_x, orig_page_y = 250.0, 400.0 orig_page_x, orig_page_y = 250.0, 400.0
screen_x, screen_y = renderer.page_to_screen(orig_page_x, orig_page_y) screen_x, screen_y = renderer.page_to_screen(orig_page_x, orig_page_y)
page_x, page_y = renderer.screen_to_page(screen_x, screen_y) page_x, page_y = renderer.screen_to_page(screen_x, screen_y)
assert abs(page_x - orig_page_x) < 0.001 assert abs(page_x - orig_page_x) < 0.001
assert abs(page_y - orig_page_y) < 0.001 assert abs(page_y - orig_page_y) < 0.001
class TestPageRendererBounds: class TestPageRendererBounds:
"""Test page bounds and point detection""" """Test page bounds and point detection"""
def test_is_point_in_page_inside(self): def test_is_point_in_page_inside(self):
"""Test is_point_in_page for points inside the page""" """Test is_point_in_page for points inside the page"""
renderer = PageRenderer( renderer = PageRenderer(
@ -174,108 +134,85 @@ class TestPageRendererBounds:
screen_x=100.0, screen_x=100.0,
screen_y=200.0, screen_y=200.0,
dpi=96, dpi=96,
zoom=1.0 zoom=1.0,
) )
# Calculate page dimensions in pixels # Calculate page dimensions in pixels
page_width_px = 210.0 * 96 / 25.4 # ~794 pixels page_width_px = 210.0 * 96 / 25.4 # ~794 pixels
page_height_px = 297.0 * 96 / 25.4 # ~1123 pixels page_height_px = 297.0 * 96 / 25.4 # ~1123 pixels
# Point in center should be inside # Point in center should be inside
center_x = 100.0 + page_width_px / 2 center_x = 100.0 + page_width_px / 2
center_y = 200.0 + page_height_px / 2 center_y = 200.0 + page_height_px / 2
assert renderer.is_point_in_page(center_x, center_y) assert renderer.is_point_in_page(center_x, center_y)
# Point at origin should be inside # Point at origin should be inside
assert renderer.is_point_in_page(100.0, 200.0) assert renderer.is_point_in_page(100.0, 200.0)
# Point at bottom-right corner should be inside # Point at bottom-right corner should be inside
assert renderer.is_point_in_page( assert renderer.is_point_in_page(100.0 + page_width_px, 200.0 + page_height_px)
100.0 + page_width_px,
200.0 + page_height_px
)
def test_is_point_in_page_outside(self): def test_is_point_in_page_outside(self):
"""Test is_point_in_page for points outside the page""" """Test is_point_in_page for points outside the page"""
renderer = PageRenderer( renderer = PageRenderer(
page_width_mm=210.0, page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.0
) )
# Point before page start # Point before page start
assert not renderer.is_point_in_page(50.0, 150.0) assert not renderer.is_point_in_page(50.0, 150.0)
# Point way beyond page # Point way beyond page
assert not renderer.is_point_in_page(2000.0, 2000.0) assert not renderer.is_point_in_page(2000.0, 2000.0)
# Point to the left of page # Point to the left of page
assert not renderer.is_point_in_page(50.0, 500.0) assert not renderer.is_point_in_page(50.0, 500.0)
# Point above page # Point above page
assert not renderer.is_point_in_page(500.0, 150.0) assert not renderer.is_point_in_page(500.0, 150.0)
def test_is_point_in_page_with_zoom(self): def test_is_point_in_page_with_zoom(self):
"""Test is_point_in_page with different zoom levels""" """Test is_point_in_page with different zoom levels"""
for zoom in [0.5, 1.0, 2.0]: for zoom in [0.5, 1.0, 2.0]:
renderer = PageRenderer( renderer = PageRenderer(
page_width_mm=210.0, page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=zoom
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=zoom
) )
# Center of page should always be inside regardless of zoom # Center of page should always be inside regardless of zoom
page_width_px = 210.0 * 96 / 25.4 page_width_px = 210.0 * 96 / 25.4
page_height_px = 297.0 * 96 / 25.4 page_height_px = 297.0 * 96 / 25.4
center_x = 100.0 + (page_width_px * zoom) / 2 center_x = 100.0 + (page_width_px * zoom) / 2
center_y = 200.0 + (page_height_px * zoom) / 2 center_y = 200.0 + (page_height_px * zoom) / 2
assert renderer.is_point_in_page(center_x, center_y) assert renderer.is_point_in_page(center_x, center_y)
def test_get_page_bounds_screen(self): def test_get_page_bounds_screen(self):
"""Test get_page_bounds_screen returns correct screen coordinates""" """Test get_page_bounds_screen returns correct screen coordinates"""
renderer = PageRenderer( renderer = PageRenderer(
page_width_mm=210.0, page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.5
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.5
) )
x, y, w, h = renderer.get_page_bounds_screen() x, y, w, h = renderer.get_page_bounds_screen()
assert x == 100.0 assert x == 100.0
assert y == 200.0 assert y == 200.0
# Width and height should be scaled by zoom # Width and height should be scaled by zoom
page_width_px = 210.0 * 96 / 25.4 page_width_px = 210.0 * 96 / 25.4
page_height_px = 297.0 * 96 / 25.4 page_height_px = 297.0 * 96 / 25.4
assert abs(w - page_width_px * 1.5) < 0.1 assert abs(w - page_width_px * 1.5) < 0.1
assert abs(h - page_height_px * 1.5) < 0.1 assert abs(h - page_height_px * 1.5) < 0.1
def test_get_page_bounds_page(self): def test_get_page_bounds_page(self):
"""Test get_page_bounds_page returns correct page-local coordinates""" """Test get_page_bounds_page returns correct page-local coordinates"""
renderer = PageRenderer( renderer = PageRenderer(
page_width_mm=210.0, page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.5
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.5
) )
x, y, w, h = renderer.get_page_bounds_page() x, y, w, h = renderer.get_page_bounds_page()
# Origin should be at 0,0 in page-local coordinates # Origin should be at 0,0 in page-local coordinates
assert x == 0.0 assert x == 0.0
assert y == 0.0 assert y == 0.0
# Width and height should NOT be affected by zoom (page-local coords) # Width and height should NOT be affected by zoom (page-local coords)
page_width_px = 210.0 * 96 / 25.4 page_width_px = 210.0 * 96 / 25.4
page_height_px = 297.0 * 96 / 25.4 page_height_px = 297.0 * 96 / 25.4
@ -285,22 +222,17 @@ class TestPageRendererBounds:
class TestPageRendererSubPages: class TestPageRendererSubPages:
"""Test sub-page detection for facing pages""" """Test sub-page detection for facing pages"""
def test_get_sub_page_at_single_page(self): def test_get_sub_page_at_single_page(self):
"""Test that get_sub_page_at returns None for single pages""" """Test that get_sub_page_at returns None for single pages"""
renderer = PageRenderer( renderer = PageRenderer(
page_width_mm=210.0, page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.0
) )
# For non-facing pages, should return None # For non-facing pages, should return None
result = renderer.get_sub_page_at(500.0, is_facing_page=False) result = renderer.get_sub_page_at(500.0, is_facing_page=False)
assert result is None assert result is None
def test_get_sub_page_at_facing_page_left(self): def test_get_sub_page_at_facing_page_left(self):
"""Test get_sub_page_at for left side of facing page""" """Test get_sub_page_at for left side of facing page"""
renderer = PageRenderer( renderer = PageRenderer(
@ -309,17 +241,17 @@ class TestPageRendererSubPages:
screen_x=100.0, screen_x=100.0,
screen_y=200.0, screen_y=200.0,
dpi=96, dpi=96,
zoom=1.0 zoom=1.0,
) )
# Calculate center line # Calculate center line
page_width_px = 420.0 * 96 / 25.4 page_width_px = 420.0 * 96 / 25.4
center_x = 100.0 + page_width_px / 2 center_x = 100.0 + page_width_px / 2
# Point before center should be 'left' # Point before center should be 'left'
result = renderer.get_sub_page_at(center_x - 10, is_facing_page=True) result = renderer.get_sub_page_at(center_x - 10, is_facing_page=True)
assert result == 'left' assert result == "left"
def test_get_sub_page_at_facing_page_right(self): def test_get_sub_page_at_facing_page_right(self):
"""Test get_sub_page_at for right side of facing page""" """Test get_sub_page_at for right side of facing page"""
renderer = PageRenderer( renderer = PageRenderer(
@ -328,21 +260,21 @@ class TestPageRendererSubPages:
screen_x=100.0, screen_x=100.0,
screen_y=200.0, screen_y=200.0,
dpi=96, dpi=96,
zoom=1.0 zoom=1.0,
) )
# Calculate center line # Calculate center line
page_width_px = 420.0 * 96 / 25.4 page_width_px = 420.0 * 96 / 25.4
center_x = 100.0 + page_width_px / 2 center_x = 100.0 + page_width_px / 2
# Point after center should be 'right' # Point after center should be 'right'
result = renderer.get_sub_page_at(center_x + 10, is_facing_page=True) result = renderer.get_sub_page_at(center_x + 10, is_facing_page=True)
assert result == 'right' assert result == "right"
class TestPageRendererDimensions: class TestPageRendererDimensions:
"""Test page dimension calculations""" """Test page dimension calculations"""
def test_page_dimensions_calculated_correctly(self): def test_page_dimensions_calculated_correctly(self):
"""Test that page dimensions are calculated correctly from mm to pixels""" """Test that page dimensions are calculated correctly from mm to pixels"""
renderer = PageRenderer( renderer = PageRenderer(
@ -351,113 +283,86 @@ class TestPageRendererDimensions:
screen_x=0.0, screen_x=0.0,
screen_y=0.0, screen_y=0.0,
dpi=96, dpi=96,
zoom=1.0 zoom=1.0,
) )
# A4 at 96 DPI # A4 at 96 DPI
expected_width = 210.0 * 96 / 25.4 # ~794 pixels expected_width = 210.0 * 96 / 25.4 # ~794 pixels
expected_height = 297.0 * 96 / 25.4 # ~1123 pixels expected_height = 297.0 * 96 / 25.4 # ~1123 pixels
assert abs(renderer.page_width_px - expected_width) < 0.1 assert abs(renderer.page_width_px - expected_width) < 0.1
assert abs(renderer.page_height_px - expected_height) < 0.1 assert abs(renderer.page_height_px - expected_height) < 0.1
def test_screen_dimensions_with_zoom(self): def test_screen_dimensions_with_zoom(self):
"""Test that screen dimensions account for zoom""" """Test that screen dimensions account for zoom"""
renderer = PageRenderer( renderer = PageRenderer(page_width_mm=210.0, page_height_mm=297.0, screen_x=0.0, screen_y=0.0, dpi=96, zoom=2.0)
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=0.0,
screen_y=0.0,
dpi=96,
zoom=2.0
)
# Screen dimensions should be doubled due to zoom # Screen dimensions should be doubled due to zoom
expected_width = (210.0 * 96 / 25.4) * 2.0 expected_width = (210.0 * 96 / 25.4) * 2.0
expected_height = (297.0 * 96 / 25.4) * 2.0 expected_height = (297.0 * 96 / 25.4) * 2.0
assert abs(renderer.screen_width - expected_width) < 0.1 assert abs(renderer.screen_width - expected_width) < 0.1
assert abs(renderer.screen_height - expected_height) < 0.1 assert abs(renderer.screen_height - expected_height) < 0.1
def test_different_dpi_values(self): def test_different_dpi_values(self):
"""Test page dimensions with different DPI values""" """Test page dimensions with different DPI values"""
dpi_values = [72, 96, 150, 300] dpi_values = [72, 96, 150, 300]
for dpi in dpi_values: for dpi in dpi_values:
renderer = PageRenderer( renderer = PageRenderer(
page_width_mm=210.0, page_width_mm=210.0, page_height_mm=297.0, screen_x=0.0, screen_y=0.0, dpi=dpi, zoom=1.0
page_height_mm=297.0,
screen_x=0.0,
screen_y=0.0,
dpi=dpi,
zoom=1.0
) )
expected_width = 210.0 * dpi / 25.4 expected_width = 210.0 * dpi / 25.4
expected_height = 297.0 * dpi / 25.4 expected_height = 297.0 * dpi / 25.4
assert abs(renderer.page_width_px - expected_width) < 0.1 assert abs(renderer.page_width_px - expected_width) < 0.1
assert abs(renderer.page_height_px - expected_height) < 0.1 assert abs(renderer.page_height_px - expected_height) < 0.1
class TestPageRendererEdgeCases: class TestPageRendererEdgeCases:
"""Test edge cases and boundary conditions""" """Test edge cases and boundary conditions"""
def test_zero_coordinates(self): def test_zero_coordinates(self):
"""Test handling of zero coordinates""" """Test handling of zero coordinates"""
renderer = PageRenderer( renderer = PageRenderer(
page_width_mm=210.0, page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.0
) )
screen_x, screen_y = renderer.page_to_screen(0, 0) screen_x, screen_y = renderer.page_to_screen(0, 0)
assert screen_x == 100.0 assert screen_x == 100.0
assert screen_y == 200.0 assert screen_y == 200.0
page_x, page_y = renderer.screen_to_page(100.0, 200.0) page_x, page_y = renderer.screen_to_page(100.0, 200.0)
assert page_x == 0.0 assert page_x == 0.0
assert page_y == 0.0 assert page_y == 0.0
def test_negative_page_coordinates(self): def test_negative_page_coordinates(self):
"""Test handling of negative page coordinates""" """Test handling of negative page coordinates"""
renderer = PageRenderer( renderer = PageRenderer(
page_width_mm=210.0, page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.0
) )
# Negative page coordinates should still convert correctly # Negative page coordinates should still convert correctly
screen_x, screen_y = renderer.page_to_screen(-50, -75) screen_x, screen_y = renderer.page_to_screen(-50, -75)
assert screen_x == 50.0 assert screen_x == 50.0
assert screen_y == 125.0 assert screen_y == 125.0
# And back again # And back again
page_x, page_y = renderer.screen_to_page(50.0, 125.0) page_x, page_y = renderer.screen_to_page(50.0, 125.0)
assert page_x == -50.0 assert page_x == -50.0
assert page_y == -75.0 assert page_y == -75.0
def test_very_large_coordinates(self): def test_very_large_coordinates(self):
"""Test handling of very large coordinates""" """Test handling of very large coordinates"""
renderer = PageRenderer( renderer = PageRenderer(
page_width_mm=210.0, page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.0
) )
large_x, large_y = 10000.0, 20000.0 large_x, large_y = 10000.0, 20000.0
screen_x, screen_y = renderer.page_to_screen(large_x, large_y) screen_x, screen_y = renderer.page_to_screen(large_x, large_y)
page_x, page_y = renderer.screen_to_page(screen_x, screen_y) page_x, page_y = renderer.screen_to_page(screen_x, screen_y)
assert abs(page_x - large_x) < 0.001 assert abs(page_x - large_x) < 0.001
assert abs(page_y - large_y) < 0.001 assert abs(page_y - large_y) < 0.001

View File

@ -190,16 +190,16 @@ class TestPageSetupDialog:
values = dialog.get_values() values = dialog.get_values()
# Check all values returned # Check all values returned
assert values['selected_index'] == 0 assert values["selected_index"] == 0
assert values['selected_page'] == page assert values["selected_page"] == page
assert values['is_cover'] is True assert values["is_cover"] is True
assert values['paper_thickness_mm'] == 0.15 assert values["paper_thickness_mm"] == 0.15
assert values['cover_bleed_mm'] == 5.0 assert values["cover_bleed_mm"] == 5.0
assert values['width_mm'] == 200 assert values["width_mm"] == 200
assert values['height_mm'] == 280 assert values["height_mm"] == 280
assert values['working_dpi'] == 150 assert values["working_dpi"] == 150
assert values['export_dpi'] == 600 assert values["export_dpi"] == 600
assert values['set_as_default'] is True assert values["set_as_default"] is True
def test_dialog_page_change_updates_values(self, qtbot): def test_dialog_page_change_updates_values(self, qtbot):
"""Test changing selected page updates displayed values""" """Test changing selected page updates displayed values"""
@ -240,14 +240,14 @@ class TestDialogMixin:
# Create mock dialog with get_values as a proper method # Create mock dialog with get_values as a proper method
mock_dialog = MagicMock(spec=QDialog) mock_dialog = MagicMock(spec=QDialog)
mock_dialog.exec = Mock(return_value=QDialog.DialogCode.Accepted) mock_dialog.exec = Mock(return_value=QDialog.DialogCode.Accepted)
mock_dialog.get_values = Mock(return_value={'test': 'value'}) mock_dialog.get_values = Mock(return_value={"test": "value"})
# Mock dialog class # Mock dialog class
mock_dialog_class = Mock(return_value=mock_dialog) mock_dialog_class = Mock(return_value=mock_dialog)
result = window.create_dialog(mock_dialog_class) result = window.create_dialog(mock_dialog_class)
assert result == {'test': 'value'} assert result == {"test": "value"}
mock_dialog.exec.assert_called_once() mock_dialog.exec.assert_called_once()
def test_dialog_mixin_create_dialog_rejected(self, qtbot): def test_dialog_mixin_create_dialog_rejected(self, qtbot):
@ -283,7 +283,7 @@ class TestDialogMixin:
# Create mock dialog with get_values as a proper method # Create mock dialog with get_values as a proper method
mock_dialog = MagicMock(spec=QDialog) mock_dialog = MagicMock(spec=QDialog)
mock_dialog.exec = Mock(return_value=QDialog.DialogCode.Accepted) mock_dialog.exec = Mock(return_value=QDialog.DialogCode.Accepted)
mock_dialog.get_values = Mock(return_value={'test': 'value'}) mock_dialog.get_values = Mock(return_value={"test": "value"})
# Mock dialog class # Mock dialog class
mock_dialog_class = Mock(return_value=mock_dialog) mock_dialog_class = Mock(return_value=mock_dialog)
@ -294,7 +294,7 @@ class TestDialogMixin:
result = window.show_dialog(mock_dialog_class, on_accept=callback) result = window.show_dialog(mock_dialog_class, on_accept=callback)
assert result is True assert result is True
callback.assert_called_once_with({'test': 'value'}) callback.assert_called_once_with({"test": "value"})
class TestDialogActionDecorator: class TestDialogActionDecorator:
@ -362,7 +362,7 @@ class TestDialogMixinEdgeCases:
mock_dialog = MagicMock(spec=QDialog) mock_dialog = MagicMock(spec=QDialog)
mock_dialog.exec = Mock(return_value=QDialog.DialogCode.Accepted) mock_dialog.exec = Mock(return_value=QDialog.DialogCode.Accepted)
mock_dialog.get_values = Mock(return_value={'data': 'test'}) mock_dialog.get_values = Mock(return_value={"data": "test"})
mock_dialog_class = Mock(return_value=mock_dialog) mock_dialog_class = Mock(return_value=mock_dialog)
@ -370,7 +370,7 @@ class TestDialogMixinEdgeCases:
# Verify setWindowTitle was called # Verify setWindowTitle was called
mock_dialog.setWindowTitle.assert_called_once_with("Custom Title") mock_dialog.setWindowTitle.assert_called_once_with("Custom Title")
assert result == {'data': 'test'} assert result == {"data": "test"}
def test_show_dialog_rejected(self, qtbot): def test_show_dialog_rejected(self, qtbot):
"""Test show_dialog when user rejects dialog""" """Test show_dialog when user rejects dialog"""
@ -543,16 +543,16 @@ class TestPageSetupIntegration:
# Create mock values that would come from dialog # Create mock values that would come from dialog
values = { values = {
'selected_index': 0, "selected_index": 0,
'selected_page': window.project.pages[0], "selected_page": window.project.pages[0],
'is_cover': False, "is_cover": False,
'paper_thickness_mm': 0.15, "paper_thickness_mm": 0.15,
'cover_bleed_mm': 5.0, "cover_bleed_mm": 5.0,
'width_mm': 200, "width_mm": 200,
'height_mm': 280, "height_mm": 280,
'working_dpi': 150, "working_dpi": 150,
'export_dpi': 600, "export_dpi": 600,
'set_as_default': True "set_as_default": True,
} }
# Access the unwrapped function to test business logic directly # Access the unwrapped function to test business logic directly
@ -564,7 +564,7 @@ class TestPageSetupIntegration:
original_func = window.page_setup original_func = window.page_setup
# Decorators return wrappers, but we can call them with values directly # Decorators return wrappers, but we can call them with values directly
# by accessing the innermost wrapped function # by accessing the innermost wrapped function
while hasattr(original_func, '__wrapped__'): while hasattr(original_func, "__wrapped__"):
original_func = original_func.__wrapped__ original_func = original_func.__wrapped__
# If no __wrapped__, the decorator system is different # If no __wrapped__, the decorator system is different
@ -575,7 +575,7 @@ class TestPageSetupIntegration:
# Get the undecorated method from the class # Get the undecorated method from the class
undecorated_page_setup = page_ops.PageOperationsMixin.page_setup undecorated_page_setup = page_ops.PageOperationsMixin.page_setup
# Find the innermost function # Find the innermost function
while hasattr(undecorated_page_setup, '__wrapped__'): while hasattr(undecorated_page_setup, "__wrapped__"):
undecorated_page_setup = undecorated_page_setup.__wrapped__ undecorated_page_setup = undecorated_page_setup.__wrapped__
# Call the business logic directly # Call the business logic directly
@ -635,22 +635,23 @@ class TestPageSetupIntegration:
# Test designating first page as cover # Test designating first page as cover
values = { values = {
'selected_index': 0, "selected_index": 0,
'selected_page': window.project.pages[0], "selected_page": window.project.pages[0],
'is_cover': True, # Designate as cover "is_cover": True, # Designate as cover
'paper_thickness_mm': 0.1, "paper_thickness_mm": 0.1,
'cover_bleed_mm': 3.0, "cover_bleed_mm": 3.0,
'width_mm': 210, "width_mm": 210,
'height_mm': 297, "height_mm": 297,
'working_dpi': 96, "working_dpi": 96,
'export_dpi': 300, "export_dpi": 300,
'set_as_default': False "set_as_default": False,
} }
# Get the undecorated method # Get the undecorated method
from pyPhotoAlbum.mixins.operations import page_ops from pyPhotoAlbum.mixins.operations import page_ops
undecorated_page_setup = page_ops.PageOperationsMixin.page_setup undecorated_page_setup = page_ops.PageOperationsMixin.page_setup
while hasattr(undecorated_page_setup, '__wrapped__'): while hasattr(undecorated_page_setup, "__wrapped__"):
undecorated_page_setup = undecorated_page_setup.__wrapped__ undecorated_page_setup = undecorated_page_setup.__wrapped__
# Mock update_cover_dimensions # Mock update_cover_dimensions
@ -706,21 +707,22 @@ class TestPageSetupIntegration:
# Test changing double spread page size # Test changing double spread page size
values = { values = {
'selected_index': 0, "selected_index": 0,
'selected_page': window.project.pages[0], "selected_page": window.project.pages[0],
'is_cover': False, "is_cover": False,
'paper_thickness_mm': 0.1, "paper_thickness_mm": 0.1,
'cover_bleed_mm': 3.0, "cover_bleed_mm": 3.0,
'width_mm': 200, # New base width "width_mm": 200, # New base width
'height_mm': 280, # New height "height_mm": 280, # New height
'working_dpi': 96, "working_dpi": 96,
'export_dpi': 300, "export_dpi": 300,
'set_as_default': False "set_as_default": False,
} }
from pyPhotoAlbum.mixins.operations import page_ops from pyPhotoAlbum.mixins.operations import page_ops
undecorated_page_setup = page_ops.PageOperationsMixin.page_setup undecorated_page_setup = page_ops.PageOperationsMixin.page_setup
while hasattr(undecorated_page_setup, '__wrapped__'): while hasattr(undecorated_page_setup, "__wrapped__"):
undecorated_page_setup = undecorated_page_setup.__wrapped__ undecorated_page_setup = undecorated_page_setup.__wrapped__
undecorated_page_setup(window, values) undecorated_page_setup(window, values)

View File

@ -26,7 +26,7 @@ class TestPageSetupDialogWithMocks:
# We can verify the class signature and that it would accept these params # We can verify the class signature and that it would accept these params
# This is a structural test rather than a full initialization test # This is a structural test rather than a full initialization test
assert hasattr(PageSetupDialog, '__init__') assert hasattr(PageSetupDialog, "__init__")
# The actual widget creation tests are in test_page_setup_dialog.py # The actual widget creation tests are in test_page_setup_dialog.py
# using qtbot which handles Qt properly # using qtbot which handles Qt properly
@ -44,7 +44,7 @@ class TestPageSetupDialogWithMocks:
project.pages = [page1, page2] project.pages = [page1, page2]
# Mock the dialog instance # Mock the dialog instance
with patch.object(PageSetupDialog, '__init__', lambda self, *args, **kwargs: None): with patch.object(PageSetupDialog, "__init__", lambda self, *args, **kwargs: None):
dialog = PageSetupDialog(None, None, 0) dialog = PageSetupDialog(None, None, 0)
# Manually set required attributes # Manually set required attributes
@ -88,7 +88,7 @@ class TestPageSetupDialogWithMocks:
page = Page(layout=PageLayout(width=210, height=297), page_number=1) page = Page(layout=PageLayout(width=210, height=297), page_number=1)
project.pages = [page] project.pages = [page]
with patch.object(PageSetupDialog, '__init__', lambda self, *args, **kwargs: None): with patch.object(PageSetupDialog, "__init__", lambda self, *args, **kwargs: None):
dialog = PageSetupDialog(None, None, 0) dialog = PageSetupDialog(None, None, 0)
dialog.project = project dialog.project = project
dialog._cover_group = Mock() dialog._cover_group = Mock()
@ -112,11 +112,11 @@ class TestPageSetupDialogWithMocks:
# Create 3 content pages (not covers) # Create 3 content pages (not covers)
for i in range(3): for i in range(3):
page = Page(layout=PageLayout(width=210, height=297), page_number=i+1) page = Page(layout=PageLayout(width=210, height=297), page_number=i + 1)
page.is_cover = False page.is_cover = False
project.pages.append(page) project.pages.append(page)
with patch.object(PageSetupDialog, '__init__', lambda self, *args, **kwargs: None): with patch.object(PageSetupDialog, "__init__", lambda self, *args, **kwargs: None):
dialog = PageSetupDialog(None, None, 0) dialog = PageSetupDialog(None, None, 0)
dialog.project = project dialog.project = project
dialog.cover_checkbox = Mock() dialog.cover_checkbox = Mock()
@ -158,7 +158,7 @@ class TestPageSetupDialogWithMocks:
page = Page(layout=PageLayout(width=210, height=297), page_number=1) page = Page(layout=PageLayout(width=210, height=297), page_number=1)
project.pages = [page] project.pages = [page]
with patch.object(PageSetupDialog, '__init__', lambda self, *args, **kwargs: None): with patch.object(PageSetupDialog, "__init__", lambda self, *args, **kwargs: None):
dialog = PageSetupDialog(None, None, 0) dialog = PageSetupDialog(None, None, 0)
dialog.project = project dialog.project = project
@ -194,16 +194,16 @@ class TestPageSetupDialogWithMocks:
values = dialog.get_values() values = dialog.get_values()
# Verify all values were extracted # Verify all values were extracted
assert values['selected_index'] == 0 assert values["selected_index"] == 0
assert values['selected_page'] == page assert values["selected_page"] == page
assert values['is_cover'] is True assert values["is_cover"] is True
assert values['paper_thickness_mm'] == 0.15 assert values["paper_thickness_mm"] == 0.15
assert values['cover_bleed_mm'] == 5.0 assert values["cover_bleed_mm"] == 5.0
assert values['width_mm'] == 200.0 assert values["width_mm"] == 200.0
assert values['height_mm'] == 280.0 assert values["height_mm"] == 280.0
assert values['working_dpi'] == 150 assert values["working_dpi"] == 150
assert values['export_dpi'] == 600 assert values["export_dpi"] == 600
assert values['set_as_default'] is True assert values["set_as_default"] is True
def test_cover_page_width_display(self): def test_cover_page_width_display(self):
"""Test cover page shows full width, not base width""" """Test cover page shows full width, not base width"""
@ -217,7 +217,7 @@ class TestPageSetupDialogWithMocks:
page.is_cover = True page.is_cover = True
project.pages = [page] project.pages = [page]
with patch.object(PageSetupDialog, '__init__', lambda self, *args, **kwargs: None): with patch.object(PageSetupDialog, "__init__", lambda self, *args, **kwargs: None):
dialog = PageSetupDialog(None, None, 0) dialog = PageSetupDialog(None, None, 0)
dialog.project = project dialog.project = project
dialog._cover_group = Mock() dialog._cover_group = Mock()
@ -259,7 +259,7 @@ class TestDialogMixinMocked:
# Mock dialog class # Mock dialog class
mock_dialog_instance = Mock() mock_dialog_instance = Mock()
mock_dialog_instance.exec.return_value = 1 # Accepted mock_dialog_instance.exec.return_value = 1 # Accepted
mock_dialog_instance.get_values.return_value = {'key': 'value'} mock_dialog_instance.get_values.return_value = {"key": "value"}
mock_dialog_class = Mock(return_value=mock_dialog_instance) mock_dialog_class = Mock(return_value=mock_dialog_instance)
@ -279,7 +279,7 @@ class TestDialogMixinMocked:
mock_dialog_instance.get_values.assert_called_once() mock_dialog_instance.get_values.assert_called_once()
# Verify result # Verify result
assert result == {'key': 'value'} assert result == {"key": "value"}
def test_show_dialog_with_callback_flow(self): def test_show_dialog_with_callback_flow(self):
"""Test show_dialog method with callback""" """Test show_dialog method with callback"""
@ -293,7 +293,7 @@ class TestDialogMixinMocked:
# Mock dialog # Mock dialog
mock_dialog_instance = Mock() mock_dialog_instance = Mock()
mock_dialog_instance.exec.return_value = 1 # Accepted mock_dialog_instance.exec.return_value = 1 # Accepted
mock_dialog_instance.get_values.return_value = {'data': 'test'} mock_dialog_instance.get_values.return_value = {"data": "test"}
mock_dialog_class = Mock(return_value=mock_dialog_instance) mock_dialog_class = Mock(return_value=mock_dialog_instance)
@ -304,7 +304,7 @@ class TestDialogMixinMocked:
result = window.show_dialog(mock_dialog_class, on_accept=callback, param="value") result = window.show_dialog(mock_dialog_class, on_accept=callback, param="value")
# Verify callback was called with dialog values # Verify callback was called with dialog values
callback.assert_called_once_with({'data': 'test'}) callback.assert_called_once_with({"data": "test"})
# Verify result # Verify result
assert result is True assert result is True
@ -346,7 +346,7 @@ class TestDialogActionDecoratorMocked:
# Mock dialog instance # Mock dialog instance
mock_dialog = Mock() mock_dialog = Mock()
mock_dialog.exec.return_value = QDialog.DialogCode.Accepted # Accepted mock_dialog.exec.return_value = QDialog.DialogCode.Accepted # Accepted
mock_dialog.get_values.return_value = {'test': 'data'} mock_dialog.get_values.return_value = {"test": "data"}
# Mock dialog class # Mock dialog class
mock_dialog_cls = Mock(return_value=mock_dialog) mock_dialog_cls = Mock(return_value=mock_dialog)
@ -354,7 +354,7 @@ class TestDialogActionDecoratorMocked:
# Create decorated function # Create decorated function
@dialog_action(dialog_class=mock_dialog_cls, requires_pages=True) @dialog_action(dialog_class=mock_dialog_cls, requires_pages=True)
def test_function(self, values): def test_function(self, values):
return values['test'] return values["test"]
# Mock instance with required attributes # Mock instance with required attributes
instance = Mock() instance = Mock()
@ -375,7 +375,7 @@ class TestDialogActionDecoratorMocked:
mock_dialog.get_values.assert_called_once() mock_dialog.get_values.assert_called_once()
# Verify original function received values # Verify original function received values
assert result == 'data' assert result == "data"
def test_decorator_returns_early_when_no_pages(self): def test_decorator_returns_early_when_no_pages(self):
"""Test decorator returns early when pages required but not present""" """Test decorator returns early when pages required but not present"""
@ -407,7 +407,7 @@ class TestDialogActionDecoratorMocked:
mock_dialog = Mock() mock_dialog = Mock()
mock_dialog.exec.return_value = 1 mock_dialog.exec.return_value = 1
mock_dialog.get_values.return_value = {'key': 'val'} mock_dialog.get_values.return_value = {"key": "val"}
mock_dialog_cls = Mock(return_value=mock_dialog) mock_dialog_cls = Mock(return_value=mock_dialog)
@ -427,8 +427,8 @@ class TestDialogActionDecoratorMocked:
mock_dialog_cls.assert_called_once() mock_dialog_cls.assert_called_once()
# Verify result # Verify result
assert result == {'key': 'val'} assert result == {"key": "val"}
if __name__ == '__main__': if __name__ == "__main__":
pytest.main([__file__, '-v']) pytest.main([__file__, "-v"])

View File

@ -15,27 +15,27 @@ def test_pdf_exporter_basic():
# Create a simple project # Create a simple project
project = Project("Test Project") project = Project("Test Project")
project.page_size_mm = (210, 297) # A4 project.page_size_mm = (210, 297) # A4
# Add a single page # Add a single page
page = Page(page_number=1, is_double_spread=False) page = Page(page_number=1, is_double_spread=False)
project.add_page(page) project.add_page(page)
# Export to temporary file # Export to temporary file
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp: with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
tmp_path = tmp.name tmp_path = tmp.name
try: try:
exporter = PDFExporter(project) exporter = PDFExporter(project)
success, warnings = exporter.export(tmp_path) success, warnings = exporter.export(tmp_path)
assert success, f"Export failed: {warnings}" assert success, f"Export failed: {warnings}"
assert os.path.exists(tmp_path), "PDF file was not created" assert os.path.exists(tmp_path), "PDF file was not created"
assert os.path.getsize(tmp_path) > 0, "PDF file is empty" assert os.path.getsize(tmp_path) > 0, "PDF file is empty"
print(f"✓ Basic PDF export successful: {tmp_path}") print(f"✓ Basic PDF export successful: {tmp_path}")
if warnings: if warnings:
print(f" Warnings: {warnings}") print(f" Warnings: {warnings}")
finally: finally:
if os.path.exists(tmp_path): if os.path.exists(tmp_path):
os.remove(tmp_path) os.remove(tmp_path)
@ -45,26 +45,26 @@ def test_pdf_exporter_double_spread():
"""Test PDF export with double-page spread""" """Test PDF export with double-page spread"""
project = Project("Test Spread Project") project = Project("Test Spread Project")
project.page_size_mm = (210, 297) # A4 project.page_size_mm = (210, 297) # A4
# Add a double-page spread # Add a double-page spread
spread_page = Page(page_number=1, is_double_spread=True) spread_page = Page(page_number=1, is_double_spread=True)
project.add_page(spread_page) project.add_page(spread_page)
# Export to temporary file # Export to temporary file
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp: with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
tmp_path = tmp.name tmp_path = tmp.name
try: try:
exporter = PDFExporter(project) exporter = PDFExporter(project)
success, warnings = exporter.export(tmp_path) success, warnings = exporter.export(tmp_path)
assert success, f"Export failed: {warnings}" assert success, f"Export failed: {warnings}"
assert os.path.exists(tmp_path), "PDF file was not created" assert os.path.exists(tmp_path), "PDF file was not created"
print(f"✓ Double-spread PDF export successful: {tmp_path}") print(f"✓ Double-spread PDF export successful: {tmp_path}")
if warnings: if warnings:
print(f" Warnings: {warnings}") print(f" Warnings: {warnings}")
finally: finally:
if os.path.exists(tmp_path): if os.path.exists(tmp_path):
os.remove(tmp_path) os.remove(tmp_path)
@ -83,14 +83,17 @@ def test_pdf_exporter_with_text():
text_content="Hello, World!", text_content="Hello, World!",
font_settings={"family": "Helvetica", "size": 24, "color": (0, 0, 0)}, font_settings={"family": "Helvetica", "size": 24, "color": (0, 0, 0)},
alignment="center", alignment="center",
x=50, y=50, width=100, height=30 x=50,
y=50,
width=100,
height=30,
) )
page.layout.add_element(text_box) page.layout.add_element(text_box)
project.add_page(page) project.add_page(page)
# Export to temporary file # Export to temporary file
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp: with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
tmp_path = tmp.name tmp_path = tmp.name
try: try:
@ -143,7 +146,7 @@ def test_pdf_text_position_and_size():
x=text_box_x_px, x=text_box_x_px,
y=text_box_y_px, y=text_box_y_px,
width=text_box_width_px, width=text_box_width_px,
height=text_box_height_px height=text_box_height_px,
) )
page.layout.add_element(text_box) page.layout.add_element(text_box)
project.add_page(page) project.add_page(page)
@ -168,7 +171,7 @@ def test_pdf_text_position_and_size():
expected_font_size_pt = font_size_px * 25.4 / dpi * MM_TO_POINTS expected_font_size_pt = font_size_px * 25.4 / dpi * MM_TO_POINTS
# Export to temporary file # Export to temporary file
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp: with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
tmp_path = tmp.name tmp_path = tmp.name
try: try:
@ -186,16 +189,20 @@ def test_pdf_text_position_and_size():
# Get the first character's position and font size # Get the first character's position and font size
first_char = chars[0] first_char = chars[0]
text_x = first_char['x0'] text_x = first_char["x0"]
text_y_baseline = first_char['y0'] # This is the baseline y position text_y_baseline = first_char["y0"] # This is the baseline y position
actual_font_size = first_char['size'] actual_font_size = first_char["size"]
print(f"\nText Position Analysis:") print(f"\nText Position Analysis:")
print(f" Text box (in pixels at {dpi} DPI): x={text_box_x_px}, y={text_box_y_px}, " print(
f"w={text_box_width_px}, h={text_box_height_px}") f" Text box (in pixels at {dpi} DPI): x={text_box_x_px}, y={text_box_y_px}, "
print(f" Text box (in PDF points): x={text_box_x_pt:.1f}, " f"w={text_box_width_px}, h={text_box_height_px}"
f"y_bottom={text_box_y_pt_bottom:.1f}, y_top={text_box_y_pt_top:.1f}, " )
f"height={text_box_height_pt:.1f}") print(
f" Text box (in PDF points): x={text_box_x_pt:.1f}, "
f"y_bottom={text_box_y_pt_bottom:.1f}, y_top={text_box_y_pt_top:.1f}, "
f"height={text_box_height_pt:.1f}"
)
print(f" Font size (pixels): {font_size_px}") print(f" Font size (pixels): {font_size_px}")
print(f" Expected font size (points): {expected_font_size_pt:.1f}") print(f" Expected font size (points): {expected_font_size_pt:.1f}")
print(f" Actual font size (points): {actual_font_size:.1f}") print(f" Actual font size (points): {actual_font_size:.1f}")
@ -213,8 +220,7 @@ def test_pdf_text_position_and_size():
# Verify text X position is near the left edge of the text box # Verify text X position is near the left edge of the text box
x_diff = abs(text_x - text_box_x_pt) x_diff = abs(text_x - text_box_x_pt)
assert x_diff < 5.0, ( assert x_diff < 5.0, (
f"Text X position mismatch: expected ~{text_box_x_pt:.1f}, " f"Text X position mismatch: expected ~{text_box_x_pt:.1f}, " f"got {text_x:.1f} (diff: {x_diff:.1f}pt)"
f"got {text_x:.1f} (diff: {x_diff:.1f}pt)"
) )
# Verify text Y baseline is INSIDE the text box (not above it) # Verify text Y baseline is INSIDE the text box (not above it)
@ -280,7 +286,7 @@ def test_pdf_text_wrapping():
x=text_box_x_px, x=text_box_x_px,
y=text_box_y_px, y=text_box_y_px,
width=text_box_width_px, width=text_box_width_px,
height=text_box_height_px height=text_box_height_px,
) )
page.layout.add_element(text_box) page.layout.add_element(text_box)
project.add_page(page) project.add_page(page)
@ -296,7 +302,7 @@ def test_pdf_text_wrapping():
text_box_right_pt = text_box_x_pt + text_box_width_pt text_box_right_pt = text_box_x_pt + text_box_width_pt
# Export to temporary file # Export to temporary file
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp: with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
tmp_path = tmp.name tmp_path = tmp.name
try: try:
@ -313,7 +319,7 @@ def test_pdf_text_wrapping():
assert len(chars) > 0, "No text found in PDF" assert len(chars) > 0, "No text found in PDF"
# Get all unique Y positions (lines) # Get all unique Y positions (lines)
y_positions = sorted(set(round(c['top'], 1) for c in chars)) y_positions = sorted(set(round(c["top"], 1) for c in chars))
print(f"\nText Wrapping Analysis:") print(f"\nText Wrapping Analysis:")
print(f" Text box width: {text_box_width_pt:.1f}pt") print(f" Text box width: {text_box_width_pt:.1f}pt")
@ -322,21 +328,19 @@ def test_pdf_text_wrapping():
print(f" Line Y positions: {y_positions[:5]}...") # Show first 5 print(f" Line Y positions: {y_positions[:5]}...") # Show first 5
# Verify text wrapped to multiple lines # Verify text wrapped to multiple lines
assert len(y_positions) > 1, ( assert len(y_positions) > 1, f"Text should wrap to multiple lines but only found {len(y_positions)} line(s)"
f"Text should wrap to multiple lines but only found {len(y_positions)} line(s)"
)
# Verify all characters are within box width (with small tolerance) # Verify all characters are within box width (with small tolerance)
tolerance = 5.0 # Small tolerance for rounding tolerance = 5.0 # Small tolerance for rounding
for char in chars: for char in chars:
char_x = char['x0'] char_x = char["x0"]
char_right = char['x1'] char_right = char["x1"]
assert char_x >= text_box_x_pt - tolerance, ( assert (
f"Character '{char['text']}' at x={char_x:.1f} is left of box start {text_box_x_pt:.1f}" char_x >= text_box_x_pt - tolerance
) ), f"Character '{char['text']}' at x={char_x:.1f} is left of box start {text_box_x_pt:.1f}"
assert char_right <= text_box_right_pt + tolerance, ( assert (
f"Character '{char['text']}' ends at x={char_right:.1f} which exceeds box right {text_box_right_pt:.1f}" char_right <= text_box_right_pt + tolerance
) ), f"Character '{char['text']}' ends at x={char_right:.1f} which exceeds box right {text_box_right_pt:.1f}"
print(f" All characters within box width: ✓") print(f" All characters within box width: ✓")
print(f"\n✓ Text wrapping test passed!") print(f"\n✓ Text wrapping test passed!")
@ -354,28 +358,28 @@ def test_pdf_exporter_facing_pages_alignment():
# Add single page (page 1) # Add single page (page 1)
page1 = Page(page_number=1, is_double_spread=False) page1 = Page(page_number=1, is_double_spread=False)
project.add_page(page1) project.add_page(page1)
# Add double spread (should start on page 2, which requires blank insert) # Add double spread (should start on page 2, which requires blank insert)
# Since page 1 is odd, a blank page should be inserted, making the spread pages 2-3 # Since page 1 is odd, a blank page should be inserted, making the spread pages 2-3
spread = Page(page_number=2, is_double_spread=True) spread = Page(page_number=2, is_double_spread=True)
project.add_page(spread) project.add_page(spread)
# Export to temporary file # Export to temporary file
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp: with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
tmp_path = tmp.name tmp_path = tmp.name
try: try:
exporter = PDFExporter(project) exporter = PDFExporter(project)
success, warnings = exporter.export(tmp_path) success, warnings = exporter.export(tmp_path)
assert success, f"Export failed: {warnings}" assert success, f"Export failed: {warnings}"
assert os.path.exists(tmp_path), "PDF file was not created" assert os.path.exists(tmp_path), "PDF file was not created"
print(f"✓ Facing pages alignment successful: {tmp_path}") print(f"✓ Facing pages alignment successful: {tmp_path}")
print(f" Expected: Page 1 (single), blank page, Pages 2-3 (spread)") print(f" Expected: Page 1 (single), blank page, Pages 2-3 (spread)")
if warnings: if warnings:
print(f" Warnings: {warnings}") print(f" Warnings: {warnings}")
finally: finally:
if os.path.exists(tmp_path): if os.path.exists(tmp_path):
os.remove(tmp_path) os.remove(tmp_path)
@ -385,34 +389,31 @@ def test_pdf_exporter_missing_image():
"""Test PDF export with missing image (should warn but not fail)""" """Test PDF export with missing image (should warn but not fail)"""
project = Project("Test Missing Image") project = Project("Test Missing Image")
project.page_size_mm = (210, 297) project.page_size_mm = (210, 297)
# Create page with image that doesn't exist # Create page with image that doesn't exist
page = Page(page_number=1, is_double_spread=False) page = Page(page_number=1, is_double_spread=False)
# Add image with non-existent path # Add image with non-existent path
image = ImageData( image = ImageData(image_path="/nonexistent/path/to/image.jpg", x=50, y=50, width=100, height=100)
image_path="/nonexistent/path/to/image.jpg",
x=50, y=50, width=100, height=100
)
page.layout.add_element(image) page.layout.add_element(image)
project.add_page(page) project.add_page(page)
# Export to temporary file # Export to temporary file
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp: with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
tmp_path = tmp.name tmp_path = tmp.name
try: try:
exporter = PDFExporter(project) exporter = PDFExporter(project)
success, warnings = exporter.export(tmp_path) success, warnings = exporter.export(tmp_path)
assert success, "Export should succeed even with missing images" assert success, "Export should succeed even with missing images"
assert len(warnings) > 0, "Should have warnings for missing image" assert len(warnings) > 0, "Should have warnings for missing image"
assert "not found" in warnings[0].lower(), "Warning should mention missing image" assert "not found" in warnings[0].lower(), "Warning should mention missing image"
print(f"✓ Missing image handling successful: {tmp_path}") print(f"✓ Missing image handling successful: {tmp_path}")
print(f" Warnings: {warnings}") print(f" Warnings: {warnings}")
finally: finally:
if os.path.exists(tmp_path): if os.path.exists(tmp_path):
os.remove(tmp_path) os.remove(tmp_path)
@ -422,65 +423,59 @@ def test_pdf_exporter_spanning_image():
"""Test PDF export with image spanning across center line of double spread""" """Test PDF export with image spanning across center line of double spread"""
import tempfile import tempfile
from PIL import Image as PILImage from PIL import Image as PILImage
project = Project("Test Spanning Image") project = Project("Test Spanning Image")
project.page_size_mm = (210, 297) # A4 project.page_size_mm = (210, 297) # A4
project.working_dpi = 96 # Standard DPI project.working_dpi = 96 # Standard DPI
# Create a test image (solid color for easy verification) # Create a test image (solid color for easy verification)
test_img = PILImage.new('RGB', (400, 200), color='red') test_img = PILImage.new("RGB", (400, 200), color="red")
# Save test image to temporary file # Save test image to temporary file
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp: with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp:
img_path = img_tmp.name img_path = img_tmp.name
test_img.save(img_path) test_img.save(img_path)
try: try:
# Create a double-page spread # Create a double-page spread
spread_page = Page(page_number=1, is_double_spread=True) spread_page = Page(page_number=1, is_double_spread=True)
# Calculate center position in pixels (for a 210mm page width at 96 DPI) # Calculate center position in pixels (for a 210mm page width at 96 DPI)
# Spread width is 2 * 210mm = 420mm # Spread width is 2 * 210mm = 420mm
spread_width_px = 420 * 96 / 25.4 # ~1587 pixels spread_width_px = 420 * 96 / 25.4 # ~1587 pixels
center_px = spread_width_px / 2 # ~794 pixels center_px = spread_width_px / 2 # ~794 pixels
# Add an image that spans across the center # Add an image that spans across the center
# Position it so it overlaps the center line # Position it so it overlaps the center line
image_width_px = 400 image_width_px = 400
image_x_px = center_px - 200 # Start 200px before center, end 200px after image_x_px = center_px - 200 # Start 200px before center, end 200px after
spanning_image = ImageData( spanning_image = ImageData(image_path=img_path, x=image_x_px, y=100, width=image_width_px, height=200)
image_path=img_path,
x=image_x_px,
y=100,
width=image_width_px,
height=200
)
spread_page.layout.add_element(spanning_image) spread_page.layout.add_element(spanning_image)
project.add_page(spread_page) project.add_page(spread_page)
# Export to temporary PDF # Export to temporary PDF
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp: with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp:
pdf_path = pdf_tmp.name pdf_path = pdf_tmp.name
try: try:
exporter = PDFExporter(project) exporter = PDFExporter(project)
success, warnings = exporter.export(pdf_path) success, warnings = exporter.export(pdf_path)
assert success, f"Export failed: {warnings}" assert success, f"Export failed: {warnings}"
assert os.path.exists(pdf_path), "PDF file was not created" assert os.path.exists(pdf_path), "PDF file was not created"
print(f"✓ Spanning image export successful: {pdf_path}") print(f"✓ Spanning image export successful: {pdf_path}")
print(f" Image spans from {image_x_px:.1f}px to {image_x_px + image_width_px:.1f}px") print(f" Image spans from {image_x_px:.1f}px to {image_x_px + image_width_px:.1f}px")
print(f" Center line at {center_px:.1f}px") print(f" Center line at {center_px:.1f}px")
if warnings: if warnings:
print(f" Warnings: {warnings}") print(f" Warnings: {warnings}")
finally: finally:
if os.path.exists(pdf_path): if os.path.exists(pdf_path):
os.remove(pdf_path) os.remove(pdf_path)
finally: finally:
if os.path.exists(img_path): if os.path.exists(img_path):
os.remove(img_path) os.remove(img_path)
@ -490,71 +485,61 @@ def test_pdf_exporter_multiple_spanning_elements():
"""Test PDF export with multiple images spanning the center line""" """Test PDF export with multiple images spanning the center line"""
import tempfile import tempfile
from PIL import Image as PILImage from PIL import Image as PILImage
project = Project("Test Multiple Spanning") project = Project("Test Multiple Spanning")
project.page_size_mm = (210, 297) # A4 project.page_size_mm = (210, 297) # A4
project.working_dpi = 96 project.working_dpi = 96
# Create test images # Create test images
test_img1 = PILImage.new('RGB', (300, 150), color='blue') test_img1 = PILImage.new("RGB", (300, 150), color="blue")
test_img2 = PILImage.new('RGB', (250, 200), color='green') test_img2 = PILImage.new("RGB", (250, 200), color="green")
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp1: with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp1:
img_path1 = img_tmp1.name img_path1 = img_tmp1.name
test_img1.save(img_path1) test_img1.save(img_path1)
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp2: with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp2:
img_path2 = img_tmp2.name img_path2 = img_tmp2.name
test_img2.save(img_path2) test_img2.save(img_path2)
try: try:
spread_page = Page(page_number=1, is_double_spread=True) spread_page = Page(page_number=1, is_double_spread=True)
# Calculate positions # Calculate positions
spread_width_px = 420 * 96 / 25.4 spread_width_px = 420 * 96 / 25.4
center_px = spread_width_px / 2 center_px = spread_width_px / 2
# First spanning image # First spanning image
image1 = ImageData( image1 = ImageData(
image_path=img_path1, image_path=img_path1, x=center_px - 150, y=50, width=300, height=150 # Centered on split line
x=center_px - 150, # Centered on split line
y=50,
width=300,
height=150
) )
# Second spanning image (different position) # Second spanning image (different position)
image2 = ImageData( image2 = ImageData(image_path=img_path2, x=center_px - 100, y=250, width=250, height=200)
image_path=img_path2,
x=center_px - 100,
y=250,
width=250,
height=200
)
spread_page.layout.add_element(image1) spread_page.layout.add_element(image1)
spread_page.layout.add_element(image2) spread_page.layout.add_element(image2)
project.add_page(spread_page) project.add_page(spread_page)
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp: with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp:
pdf_path = pdf_tmp.name pdf_path = pdf_tmp.name
try: try:
exporter = PDFExporter(project) exporter = PDFExporter(project)
success, warnings = exporter.export(pdf_path) success, warnings = exporter.export(pdf_path)
assert success, f"Export failed: {warnings}" assert success, f"Export failed: {warnings}"
assert os.path.exists(pdf_path), "PDF file was not created" assert os.path.exists(pdf_path), "PDF file was not created"
print(f"✓ Multiple spanning images export successful: {pdf_path}") print(f"✓ Multiple spanning images export successful: {pdf_path}")
if warnings: if warnings:
print(f" Warnings: {warnings}") print(f" Warnings: {warnings}")
finally: finally:
if os.path.exists(pdf_path): if os.path.exists(pdf_path):
os.remove(pdf_path) os.remove(pdf_path)
finally: finally:
if os.path.exists(img_path1): if os.path.exists(img_path1):
os.remove(img_path1) os.remove(img_path1)
@ -566,52 +551,46 @@ def test_pdf_exporter_edge_case_barely_spanning():
"""Test image that barely crosses the threshold""" """Test image that barely crosses the threshold"""
import tempfile import tempfile
from PIL import Image as PILImage from PIL import Image as PILImage
project = Project("Test Edge Case") project = Project("Test Edge Case")
project.page_size_mm = (210, 297) project.page_size_mm = (210, 297)
project.working_dpi = 96 project.working_dpi = 96
test_img = PILImage.new('RGB', (100, 100), color='yellow') test_img = PILImage.new("RGB", (100, 100), color="yellow")
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp: with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp:
img_path = img_tmp.name img_path = img_tmp.name
test_img.save(img_path) test_img.save(img_path)
try: try:
spread_page = Page(page_number=1, is_double_spread=True) spread_page = Page(page_number=1, is_double_spread=True)
spread_width_px = 420 * 96 / 25.4 spread_width_px = 420 * 96 / 25.4
center_px = spread_width_px / 2 center_px = spread_width_px / 2
# Image that just barely crosses the center line # Image that just barely crosses the center line
image = ImageData( image = ImageData(image_path=img_path, x=center_px - 5, y=100, width=100, height=100) # Just 5px overlap
image_path=img_path,
x=center_px - 5, # Just 5px overlap
y=100,
width=100,
height=100
)
spread_page.layout.add_element(image) spread_page.layout.add_element(image)
project.add_page(spread_page) project.add_page(spread_page)
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp: with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp:
pdf_path = pdf_tmp.name pdf_path = pdf_tmp.name
try: try:
exporter = PDFExporter(project) exporter = PDFExporter(project)
success, warnings = exporter.export(pdf_path) success, warnings = exporter.export(pdf_path)
assert success, f"Export failed: {warnings}" assert success, f"Export failed: {warnings}"
print(f"✓ Edge case (barely spanning) export successful: {pdf_path}") print(f"✓ Edge case (barely spanning) export successful: {pdf_path}")
if warnings: if warnings:
print(f" Warnings: {warnings}") print(f" Warnings: {warnings}")
finally: finally:
if os.path.exists(pdf_path): if os.path.exists(pdf_path):
os.remove(pdf_path) os.remove(pdf_path)
finally: finally:
if os.path.exists(img_path): if os.path.exists(img_path):
os.remove(img_path) os.remove(img_path)
@ -622,12 +601,12 @@ def test_pdf_exporter_text_spanning():
project = Project("Test Spanning Text") project = Project("Test Spanning Text")
project.page_size_mm = (210, 297) project.page_size_mm = (210, 297)
project.working_dpi = 96 project.working_dpi = 96
spread_page = Page(page_number=1, is_double_spread=True) spread_page = Page(page_number=1, is_double_spread=True)
spread_width_px = 420 * 96 / 25.4 spread_width_px = 420 * 96 / 25.4
center_px = spread_width_px / 2 center_px = spread_width_px / 2
# Text box spanning the center # Text box spanning the center
text_box = TextBoxData( text_box = TextBoxData(
text_content="Spanning Text", text_content="Spanning Text",
@ -636,25 +615,25 @@ def test_pdf_exporter_text_spanning():
x=center_px - 100, x=center_px - 100,
y=100, y=100,
width=200, width=200,
height=50 height=50,
) )
spread_page.layout.add_element(text_box) spread_page.layout.add_element(text_box)
project.add_page(spread_page) project.add_page(spread_page)
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp: with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp:
pdf_path = pdf_tmp.name pdf_path = pdf_tmp.name
try: try:
exporter = PDFExporter(project) exporter = PDFExporter(project)
success, warnings = exporter.export(pdf_path) success, warnings = exporter.export(pdf_path)
assert success, f"Export failed: {warnings}" assert success, f"Export failed: {warnings}"
print(f"✓ Spanning text box export successful: {pdf_path}") print(f"✓ Spanning text box export successful: {pdf_path}")
if warnings: if warnings:
print(f" Warnings: {warnings}") print(f" Warnings: {warnings}")
finally: finally:
if os.path.exists(pdf_path): if os.path.exists(pdf_path):
os.remove(pdf_path) os.remove(pdf_path)
@ -664,76 +643,72 @@ def test_pdf_exporter_spanning_image_aspect_ratio():
"""Test that spanning images maintain correct aspect ratio and can be recombined""" """Test that spanning images maintain correct aspect ratio and can be recombined"""
import tempfile import tempfile
from PIL import Image as PILImage, ImageDraw from PIL import Image as PILImage, ImageDraw
project = Project("Test Aspect Ratio") project = Project("Test Aspect Ratio")
project.page_size_mm = (210, 297) # A4 project.page_size_mm = (210, 297) # A4
project.working_dpi = 96 project.working_dpi = 96
# Create a distinctive test image: red left half, blue right half, with a vertical line in center # Create a distinctive test image: red left half, blue right half, with a vertical line in center
test_width, test_height = 800, 400 test_width, test_height = 800, 400
test_img = PILImage.new('RGB', (test_width, test_height)) test_img = PILImage.new("RGB", (test_width, test_height))
draw = ImageDraw.Draw(test_img) draw = ImageDraw.Draw(test_img)
# Fill left half red # Fill left half red
draw.rectangle([0, 0, test_width // 2, test_height], fill=(255, 0, 0)) draw.rectangle([0, 0, test_width // 2, test_height], fill=(255, 0, 0))
# Fill right half blue # Fill right half blue
draw.rectangle([test_width // 2, 0, test_width, test_height], fill=(0, 0, 255)) draw.rectangle([test_width // 2, 0, test_width, test_height], fill=(0, 0, 255))
# Draw a black vertical line in the middle # Draw a black vertical line in the middle
draw.line([test_width // 2, 0, test_width // 2, test_height], fill=(0, 0, 0), width=5) draw.line([test_width // 2, 0, test_width // 2, test_height], fill=(0, 0, 0), width=5)
# Draw horizontal reference lines for visual verification # Draw horizontal reference lines for visual verification
for y in range(0, test_height, 50): for y in range(0, test_height, 50):
draw.line([0, y, test_width, y], fill=(255, 255, 255), width=2) draw.line([0, y, test_width, y], fill=(255, 255, 255), width=2)
# Save test image to temporary file # Save test image to temporary file
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp: with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp:
img_path = img_tmp.name img_path = img_tmp.name
test_img.save(img_path) test_img.save(img_path)
try: try:
# Create a double-page spread # Create a double-page spread
spread_page = Page(page_number=1, is_double_spread=True) spread_page = Page(page_number=1, is_double_spread=True)
# Calculate positions # Calculate positions
spread_width_px = 420 * 96 / 25.4 # ~1587 pixels spread_width_px = 420 * 96 / 25.4 # ~1587 pixels
center_px = spread_width_px / 2 # ~794 pixels center_px = spread_width_px / 2 # ~794 pixels
# Create an image element that spans the center with a specific aspect ratio # Create an image element that spans the center with a specific aspect ratio
# Make it 600px wide and 300px tall (2:1 aspect ratio) # Make it 600px wide and 300px tall (2:1 aspect ratio)
image_width_px = 600 image_width_px = 600
image_height_px = 300 image_height_px = 300
image_x_px = center_px - 300 # Centered on the split line image_x_px = center_px - 300 # Centered on the split line
spanning_image = ImageData( spanning_image = ImageData(
image_path=img_path, image_path=img_path, x=image_x_px, y=100, width=image_width_px, height=image_height_px
x=image_x_px,
y=100,
width=image_width_px,
height=image_height_px
) )
spread_page.layout.add_element(spanning_image) spread_page.layout.add_element(spanning_image)
project.add_page(spread_page) project.add_page(spread_page)
# Export to temporary PDF # Export to temporary PDF
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp: with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp:
pdf_path = pdf_tmp.name pdf_path = pdf_tmp.name
try: try:
exporter = PDFExporter(project) exporter = PDFExporter(project)
success, warnings = exporter.export(pdf_path) success, warnings = exporter.export(pdf_path)
assert success, f"Export failed: {warnings}" assert success, f"Export failed: {warnings}"
assert os.path.exists(pdf_path), "PDF file was not created" assert os.path.exists(pdf_path), "PDF file was not created"
# Verify the PDF was created and has expected properties # Verify the PDF was created and has expected properties
# We can't easily extract and verify pixel-perfect image reconstruction without # We can't easily extract and verify pixel-perfect image reconstruction without
# additional dependencies, but we can verify the export succeeded # additional dependencies, but we can verify the export succeeded
file_size = os.path.getsize(pdf_path) file_size = os.path.getsize(pdf_path)
assert file_size > 1000, "PDF file seems too small" assert file_size > 1000, "PDF file seems too small"
print(f"✓ Spanning image aspect ratio test successful: {pdf_path}") print(f"✓ Spanning image aspect ratio test successful: {pdf_path}")
print(f" Original image: {test_width}x{test_height} (aspect {test_width/test_height:.2f}:1)") print(f" Original image: {test_width}x{test_height} (aspect {test_width/test_height:.2f}:1)")
print(f" Element size: {image_width_px}x{image_height_px} (aspect {image_width_px/image_height_px:.2f}:1)") print(f" Element size: {image_width_px}x{image_height_px} (aspect {image_width_px/image_height_px:.2f}:1)")
@ -741,14 +716,14 @@ def test_pdf_exporter_spanning_image_aspect_ratio():
print(f" Left portion: {center_px - image_x_px:.1f}px wide") print(f" Left portion: {center_px - image_x_px:.1f}px wide")
print(f" Right portion: {image_width_px - (center_px - image_x_px):.1f}px wide") print(f" Right portion: {image_width_px - (center_px - image_x_px):.1f}px wide")
print(f" PDF size: {file_size} bytes") print(f" PDF size: {file_size} bytes")
if warnings: if warnings:
print(f" Warnings: {warnings}") print(f" Warnings: {warnings}")
finally: finally:
if os.path.exists(pdf_path): if os.path.exists(pdf_path):
os.remove(pdf_path) os.remove(pdf_path)
finally: finally:
if os.path.exists(img_path): if os.path.exists(img_path):
os.remove(img_path) os.remove(img_path)
@ -758,74 +733,76 @@ def test_pdf_exporter_varying_aspect_ratios():
"""Test spanning images with various aspect ratios""" """Test spanning images with various aspect ratios"""
import tempfile import tempfile
from PIL import Image as PILImage, ImageDraw from PIL import Image as PILImage, ImageDraw
project = Project("Test Varying Aspects") project = Project("Test Varying Aspects")
project.page_size_mm = (210, 297) project.page_size_mm = (210, 297)
project.working_dpi = 96 project.working_dpi = 96
# Test different aspect ratios # Test different aspect ratios
test_configs = [ test_configs = [
("Square", 400, 400), # 1:1 ("Square", 400, 400), # 1:1
("Landscape", 800, 400), # 2:1 ("Landscape", 800, 400), # 2:1
("Portrait", 400, 800), # 1:2 ("Portrait", 400, 800), # 1:2
("Wide", 1200, 400), # 3:1 ("Wide", 1200, 400), # 3:1
] ]
spread_width_px = 420 * 96 / 25.4 spread_width_px = 420 * 96 / 25.4
center_px = spread_width_px / 2 center_px = spread_width_px / 2
for idx, (name, img_w, img_h) in enumerate(test_configs): for idx, (name, img_w, img_h) in enumerate(test_configs):
# Create test image # Create test image
test_img = PILImage.new('RGB', (img_w, img_h)) test_img = PILImage.new("RGB", (img_w, img_h))
draw = ImageDraw.Draw(test_img) draw = ImageDraw.Draw(test_img)
# Different colors for each test # Different colors for each test
colors = [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 0)] colors = [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 0)]
draw.rectangle([0, 0, img_w // 2, img_h], fill=colors[idx]) draw.rectangle([0, 0, img_w // 2, img_h], fill=colors[idx])
draw.rectangle([img_w // 2, 0, img_w, img_h], fill=(255-colors[idx][0], 255-colors[idx][1], 255-colors[idx][2])) draw.rectangle(
[img_w // 2, 0, img_w, img_h], fill=(255 - colors[idx][0], 255 - colors[idx][1], 255 - colors[idx][2])
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp: )
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp:
img_path = img_tmp.name img_path = img_tmp.name
test_img.save(img_path) test_img.save(img_path)
try: try:
spread_page = Page(page_number=idx + 1, is_double_spread=True) spread_page = Page(page_number=idx + 1, is_double_spread=True)
# Position spanning element # Position spanning element
element_width_px = 500 element_width_px = 500
element_height_px = int(500 * img_h / img_w) # Maintain aspect ratio element_height_px = int(500 * img_h / img_w) # Maintain aspect ratio
spanning_image = ImageData( spanning_image = ImageData(
image_path=img_path, image_path=img_path,
x=center_px - 250, x=center_px - 250,
y=100 + idx * 200, y=100 + idx * 200,
width=element_width_px, width=element_width_px,
height=element_height_px height=element_height_px,
) )
spread_page.layout.add_element(spanning_image) spread_page.layout.add_element(spanning_image)
project.add_page(spread_page) project.add_page(spread_page)
finally: finally:
if os.path.exists(img_path): if os.path.exists(img_path):
os.remove(img_path) os.remove(img_path)
# Export all pages # Export all pages
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp: with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp:
pdf_path = pdf_tmp.name pdf_path = pdf_tmp.name
try: try:
exporter = PDFExporter(project) exporter = PDFExporter(project)
success, warnings = exporter.export(pdf_path) success, warnings = exporter.export(pdf_path)
assert success, f"Export failed: {warnings}" assert success, f"Export failed: {warnings}"
assert os.path.exists(pdf_path), "PDF file was not created" assert os.path.exists(pdf_path), "PDF file was not created"
print(f"✓ Varying aspect ratios test successful: {pdf_path}") print(f"✓ Varying aspect ratios test successful: {pdf_path}")
print(f" Tested {len(test_configs)} different aspect ratios") print(f" Tested {len(test_configs)} different aspect ratios")
if warnings: if warnings:
print(f" Warnings: {warnings}") print(f" Warnings: {warnings}")
finally: finally:
if os.path.exists(pdf_path): if os.path.exists(pdf_path):
os.remove(pdf_path) os.remove(pdf_path)
@ -842,7 +819,7 @@ def test_pdf_exporter_rotated_image():
# Create a distinctive test image that shows rotation clearly # Create a distinctive test image that shows rotation clearly
# Make it wider than tall (400x200) so we can verify rotation # Make it wider than tall (400x200) so we can verify rotation
test_img = PILImage.new('RGB', (400, 200), color='white') test_img = PILImage.new("RGB", (400, 200), color="white")
draw = ImageDraw.Draw(test_img) draw = ImageDraw.Draw(test_img)
# Draw a pattern that shows orientation # Draw a pattern that shows orientation
@ -855,7 +832,7 @@ def test_pdf_exporter_rotated_image():
# Yellow vertical stripe on right # Yellow vertical stripe on right
draw.rectangle([350, 0, 400, 200], fill=(255, 255, 0)) draw.rectangle([350, 0, 400, 200], fill=(255, 255, 0))
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp: with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp:
img_path = img_tmp.name img_path = img_tmp.name
test_img.save(img_path) test_img.save(img_path)
@ -865,11 +842,7 @@ def test_pdf_exporter_rotated_image():
# Add image with 90-degree PIL rotation # Add image with 90-degree PIL rotation
image = ImageData( image = ImageData(
image_path=img_path, image_path=img_path, x=50, y=50, width=200, height=400 # These dimensions are for the rotated version
x=50,
y=50,
width=200, # These dimensions are for the rotated version
height=400
) )
image.pil_rotation_90 = 1 # 90 degree rotation image.pil_rotation_90 = 1 # 90 degree rotation
image.image_dimensions = (400, 200) # Original dimensions before rotation image.image_dimensions = (400, 200) # Original dimensions before rotation
@ -878,7 +851,7 @@ def test_pdf_exporter_rotated_image():
project.add_page(page) project.add_page(page)
# Export to PDF # Export to PDF
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp: with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp:
pdf_path = pdf_tmp.name pdf_path = pdf_tmp.name
try: try:
@ -908,84 +881,85 @@ def test_pdf_exporter_image_downsampling():
"""Test that export DPI controls image downsampling and reduces file size""" """Test that export DPI controls image downsampling and reduces file size"""
import tempfile import tempfile
from PIL import Image as PILImage from PIL import Image as PILImage
project = Project("Test Downsampling") project = Project("Test Downsampling")
project.page_size_mm = (210, 297) # A4 project.page_size_mm = (210, 297) # A4
project.working_dpi = 96 project.working_dpi = 96
# Create a large test image (4000x3000 - typical high-res camera) # Create a large test image (4000x3000 - typical high-res camera)
large_img = PILImage.new('RGB', (4000, 3000)) large_img = PILImage.new("RGB", (4000, 3000))
# Add some pattern so it doesn't compress too much # Add some pattern so it doesn't compress too much
import random import random
pixels = large_img.load() pixels = large_img.load()
for i in range(0, 4000, 10): for i in range(0, 4000, 10):
for j in range(0, 3000, 10): for j in range(0, 3000, 10):
pixels[i, j] = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)) pixels[i, j] = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp: with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp:
img_path = img_tmp.name img_path = img_tmp.name
large_img.save(img_path) large_img.save(img_path)
try: try:
# Create a page with the large image # Create a page with the large image
page = Page(page_number=1, is_double_spread=False) page = Page(page_number=1, is_double_spread=False)
# Add image at reasonable size (100mm x 75mm) # Add image at reasonable size (100mm x 75mm)
image = ImageData( image = ImageData(
image_path=img_path, image_path=img_path,
x=50, x=50,
y=50, y=50,
width=int(100 * 96 / 25.4), # ~378 px width=int(100 * 96 / 25.4), # ~378 px
height=int(75 * 96 / 25.4) # ~283 px height=int(75 * 96 / 25.4), # ~283 px
) )
page.layout.add_element(image) page.layout.add_element(image)
project.add_page(page) project.add_page(page)
# Export with high DPI (300 - print quality) # Export with high DPI (300 - print quality)
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp1: with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp1:
pdf_path_300dpi = pdf_tmp1.name pdf_path_300dpi = pdf_tmp1.name
# Export with low DPI (150 - screen quality) # Export with low DPI (150 - screen quality)
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp2: with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp2:
pdf_path_150dpi = pdf_tmp2.name pdf_path_150dpi = pdf_tmp2.name
try: try:
# Export at 300 DPI # Export at 300 DPI
exporter_300 = PDFExporter(project, export_dpi=300) exporter_300 = PDFExporter(project, export_dpi=300)
success1, warnings1 = exporter_300.export(pdf_path_300dpi) success1, warnings1 = exporter_300.export(pdf_path_300dpi)
assert success1, f"300 DPI export failed: {warnings1}" assert success1, f"300 DPI export failed: {warnings1}"
# Export at 150 DPI # Export at 150 DPI
exporter_150 = PDFExporter(project, export_dpi=150) exporter_150 = PDFExporter(project, export_dpi=150)
success2, warnings2 = exporter_150.export(pdf_path_150dpi) success2, warnings2 = exporter_150.export(pdf_path_150dpi)
assert success2, f"150 DPI export failed: {warnings2}" assert success2, f"150 DPI export failed: {warnings2}"
# Check file sizes # Check file sizes
size_300dpi = os.path.getsize(pdf_path_300dpi) size_300dpi = os.path.getsize(pdf_path_300dpi)
size_150dpi = os.path.getsize(pdf_path_150dpi) size_150dpi = os.path.getsize(pdf_path_150dpi)
print(f"✓ Image downsampling test successful:") print(f"✓ Image downsampling test successful:")
print(f" Original image: 4000x3000 pixels") print(f" Original image: 4000x3000 pixels")
print(f" Element size: 100mm x 75mm") print(f" Element size: 100mm x 75mm")
print(f" PDF at 300 DPI: {size_300dpi:,} bytes") print(f" PDF at 300 DPI: {size_300dpi:,} bytes")
print(f" PDF at 150 DPI: {size_150dpi:,} bytes") print(f" PDF at 150 DPI: {size_150dpi:,} bytes")
print(f" Size reduction: {(1 - size_150dpi/size_300dpi)*100:.1f}%") print(f" Size reduction: {(1 - size_150dpi/size_300dpi)*100:.1f}%")
# 150 DPI should be smaller than 300 DPI # 150 DPI should be smaller than 300 DPI
assert size_150dpi < size_300dpi, \ assert (
f"150 DPI file ({size_150dpi}) should be smaller than 300 DPI file ({size_300dpi})" size_150dpi < size_300dpi
), f"150 DPI file ({size_150dpi}) should be smaller than 300 DPI file ({size_300dpi})"
# 150 DPI should be significantly smaller (at least 50% reduction) # 150 DPI should be significantly smaller (at least 50% reduction)
reduction_ratio = size_150dpi / size_300dpi reduction_ratio = size_150dpi / size_300dpi
assert reduction_ratio < 0.7, \ assert reduction_ratio < 0.7, f"150 DPI should be at least 30% smaller, got {(1-reduction_ratio)*100:.1f}%"
f"150 DPI should be at least 30% smaller, got {(1-reduction_ratio)*100:.1f}%"
finally: finally:
if os.path.exists(pdf_path_300dpi): if os.path.exists(pdf_path_300dpi):
os.remove(pdf_path_300dpi) os.remove(pdf_path_300dpi)
if os.path.exists(pdf_path_150dpi): if os.path.exists(pdf_path_150dpi):
os.remove(pdf_path_150dpi) os.remove(pdf_path_150dpi)
finally: finally:
if os.path.exists(img_path): if os.path.exists(img_path):
os.remove(img_path) os.remove(img_path)

View File

@ -15,7 +15,7 @@ class TestPage:
"""Test Page initialization with default values""" """Test Page initialization with default values"""
layout = PageLayout() layout = PageLayout()
page = Page(layout=layout, page_number=1) page = Page(layout=layout, page_number=1)
assert page.layout is layout assert page.layout is layout
assert page.page_number == 1 assert page.page_number == 1
@ -23,7 +23,7 @@ class TestPage:
"""Test Page initialization with custom parameters""" """Test Page initialization with custom parameters"""
layout = PageLayout() layout = PageLayout()
page = Page(layout=layout, page_number=5) page = Page(layout=layout, page_number=5)
assert page.layout is layout assert page.layout is layout
assert page.page_number == 5 assert page.page_number == 5
@ -32,7 +32,7 @@ class TestPage:
layout = PageLayout() layout = PageLayout()
page = Page(layout=layout, page_number=1) page = Page(layout=layout, page_number=1)
page.page_number = 10 page.page_number = 10
assert page.page_number == 10 assert page.page_number == 10
@ -42,7 +42,7 @@ class TestProject:
def test_initialization_default(self): def test_initialization_default(self):
"""Test Project initialization with default values""" """Test Project initialization with default values"""
project = Project() project = Project()
assert project.name == "Untitled Project" assert project.name == "Untitled Project"
assert len(project.pages) == 0 assert len(project.pages) == 0
assert project.working_dpi == 300 assert project.working_dpi == 300
@ -51,7 +51,7 @@ class TestProject:
def test_initialization_with_name(self): def test_initialization_with_name(self):
"""Test Project initialization with custom name""" """Test Project initialization with custom name"""
project = Project(name="My Album") project = Project(name="My Album")
assert project.name == "My Album" assert project.name == "My Album"
def test_add_page(self): def test_add_page(self):
@ -59,24 +59,24 @@ class TestProject:
project = Project() project = Project()
layout = PageLayout() layout = PageLayout()
page = Page(layout=layout, page_number=1) page = Page(layout=layout, page_number=1)
project.add_page(page) project.add_page(page)
assert len(project.pages) == 1 assert len(project.pages) == 1
assert project.pages[0] is page assert project.pages[0] is page
def test_add_multiple_pages(self): def test_add_multiple_pages(self):
"""Test adding multiple pages to the project""" """Test adding multiple pages to the project"""
project = Project() project = Project()
page1 = Page(layout=PageLayout(), page_number=1) page1 = Page(layout=PageLayout(), page_number=1)
page2 = Page(layout=PageLayout(), page_number=2) page2 = Page(layout=PageLayout(), page_number=2)
page3 = Page(layout=PageLayout(), page_number=3) page3 = Page(layout=PageLayout(), page_number=3)
project.add_page(page1) project.add_page(page1)
project.add_page(page2) project.add_page(page2)
project.add_page(page3) project.add_page(page3)
assert len(project.pages) == 3 assert len(project.pages) == 3
assert project.pages[0] is page1 assert project.pages[0] is page1
assert project.pages[1] is page2 assert project.pages[1] is page2
@ -85,15 +85,15 @@ class TestProject:
def test_remove_page(self): def test_remove_page(self):
"""Test removing a page from the project""" """Test removing a page from the project"""
project = Project() project = Project()
page1 = Page(layout=PageLayout(), page_number=1) page1 = Page(layout=PageLayout(), page_number=1)
page2 = Page(layout=PageLayout(), page_number=2) page2 = Page(layout=PageLayout(), page_number=2)
project.add_page(page1) project.add_page(page1)
project.add_page(page2) project.add_page(page2)
project.remove_page(page1) project.remove_page(page1)
assert len(project.pages) == 1 assert len(project.pages) == 1
assert project.pages[0] is page2 assert project.pages[0] is page2
@ -103,7 +103,7 @@ class TestProject:
page1 = Page(layout=PageLayout(), page_number=1) page1 = Page(layout=PageLayout(), page_number=1)
page2 = Page(layout=PageLayout(), page_number=2) page2 = Page(layout=PageLayout(), page_number=2)
project.add_page(page1) project.add_page(page1)
# Try to remove a page that was never added # Try to remove a page that was never added
with pytest.raises(ValueError): with pytest.raises(ValueError):
project.remove_page(page2) project.remove_page(page2)
@ -112,51 +112,51 @@ class TestProject:
"""Test modifying working DPI""" """Test modifying working DPI"""
project = Project() project = Project()
project.working_dpi = 300 project.working_dpi = 300
assert project.working_dpi == 300 assert project.working_dpi == 300
def test_page_size_modification(self): def test_page_size_modification(self):
"""Test modifying page size""" """Test modifying page size"""
project = Project() project = Project()
project.page_size_mm = (300, 400) project.page_size_mm = (300, 400)
assert project.page_size_mm == (300, 400) assert project.page_size_mm == (300, 400)
def test_project_name_modification(self): def test_project_name_modification(self):
"""Test modifying project name""" """Test modifying project name"""
project = Project(name="Initial Name") project = Project(name="Initial Name")
project.name = "New Name" project.name = "New Name"
assert project.name == "New Name" assert project.name == "New Name"
def test_asset_manager_exists(self): def test_asset_manager_exists(self):
"""Test that project has an asset manager""" """Test that project has an asset manager"""
project = Project() project = Project()
assert hasattr(project, 'asset_manager') assert hasattr(project, "asset_manager")
assert project.asset_manager is not None assert project.asset_manager is not None
def test_history_exists(self): def test_history_exists(self):
"""Test that project has a history manager""" """Test that project has a history manager"""
project = Project() project = Project()
assert hasattr(project, 'history') assert hasattr(project, "history")
assert project.history is not None assert project.history is not None
def test_pages_list_is_mutable(self): def test_pages_list_is_mutable(self):
"""Test that pages list can be directly modified""" """Test that pages list can be directly modified"""
project = Project() project = Project()
page = Page(layout=PageLayout(), page_number=1) page = Page(layout=PageLayout(), page_number=1)
project.pages.append(page) project.pages.append(page)
assert len(project.pages) == 1 assert len(project.pages) == 1
assert project.pages[0] is page assert project.pages[0] is page
def test_empty_project_has_no_pages(self): def test_empty_project_has_no_pages(self):
"""Test that a new project has no pages""" """Test that a new project has no pages"""
project = Project() project = Project()
assert len(project.pages) == 0 assert len(project.pages) == 0
assert project.pages == [] assert project.pages == []
@ -167,23 +167,17 @@ class TestProjectWithPages:
def test_project_with_populated_pages(self, sample_image_data): def test_project_with_populated_pages(self, sample_image_data):
"""Test project with pages containing elements""" """Test project with pages containing elements"""
project = Project(name="Photo Album") project = Project(name="Photo Album")
# Create pages with elements # Create pages with elements
for i in range(3): for i in range(3):
layout = PageLayout() layout = PageLayout()
img = ImageData( img = ImageData(image_path=f"image_{i}.jpg", x=10 + i * 10, y=20 + i * 10, width=100, height=100)
image_path=f"image_{i}.jpg",
x=10 + i*10,
y=20 + i*10,
width=100,
height=100
)
layout.add_element(img) layout.add_element(img)
page = Page(layout=layout, page_number=i+1) page = Page(layout=layout, page_number=i + 1)
project.add_page(page) project.add_page(page)
assert len(project.pages) == 3 assert len(project.pages) == 3
# Check each page has elements # Check each page has elements
for i, page in enumerate(project.pages): for i, page in enumerate(project.pages):
assert len(page.layout.elements) == 1 assert len(page.layout.elements) == 1
@ -192,18 +186,18 @@ class TestProjectWithPages:
def test_reorder_pages(self): def test_reorder_pages(self):
"""Test reordering pages in project""" """Test reordering pages in project"""
project = Project() project = Project()
page1 = Page(layout=PageLayout(), page_number=1) page1 = Page(layout=PageLayout(), page_number=1)
page2 = Page(layout=PageLayout(), page_number=2) page2 = Page(layout=PageLayout(), page_number=2)
page3 = Page(layout=PageLayout(), page_number=3) page3 = Page(layout=PageLayout(), page_number=3)
project.add_page(page1) project.add_page(page1)
project.add_page(page2) project.add_page(page2)
project.add_page(page3) project.add_page(page3)
# Swap page 1 and page 3 # Swap page 1 and page 3
project.pages[0], project.pages[2] = project.pages[2], project.pages[0] project.pages[0], project.pages[2] = project.pages[2], project.pages[0]
assert project.pages[0] is page3 assert project.pages[0] is page3
assert project.pages[1] is page2 assert project.pages[1] is page2
assert project.pages[2] is page1 assert project.pages[2] is page1
@ -211,43 +205,43 @@ class TestProjectWithPages:
def test_clear_all_pages(self): def test_clear_all_pages(self):
"""Test clearing all pages from project""" """Test clearing all pages from project"""
project = Project() project = Project()
for i in range(5): for i in range(5):
page = Page(layout=PageLayout(), page_number=i+1) page = Page(layout=PageLayout(), page_number=i + 1)
project.add_page(page) project.add_page(page)
# Clear all pages # Clear all pages
project.pages.clear() project.pages.clear()
assert len(project.pages) == 0 assert len(project.pages) == 0
def test_get_page_by_index(self): def test_get_page_by_index(self):
"""Test accessing pages by index""" """Test accessing pages by index"""
project = Project() project = Project()
page1 = Page(layout=PageLayout(), page_number=1) page1 = Page(layout=PageLayout(), page_number=1)
page2 = Page(layout=PageLayout(), page_number=2) page2 = Page(layout=PageLayout(), page_number=2)
project.add_page(page1) project.add_page(page1)
project.add_page(page2) project.add_page(page2)
assert project.pages[0] is page1 assert project.pages[0] is page1
assert project.pages[1] is page2 assert project.pages[1] is page2
def test_insert_page_at_position(self): def test_insert_page_at_position(self):
"""Test inserting a page at a specific position""" """Test inserting a page at a specific position"""
project = Project() project = Project()
page1 = Page(layout=PageLayout(), page_number=1) page1 = Page(layout=PageLayout(), page_number=1)
page2 = Page(layout=PageLayout(), page_number=2) page2 = Page(layout=PageLayout(), page_number=2)
page_new = Page(layout=PageLayout(), page_number=99) page_new = Page(layout=PageLayout(), page_number=99)
project.add_page(page1) project.add_page(page1)
project.add_page(page2) project.add_page(page2)
# Insert new page in the middle # Insert new page in the middle
project.pages.insert(1, page_new) project.pages.insert(1, page_new)
assert len(project.pages) == 3 assert len(project.pages) == 3
assert project.pages[0] is page1 assert project.pages[0] is page1
assert project.pages[1] is page_new assert project.pages[1] is page_new

View File

@ -39,9 +39,9 @@ def sample_project(temp_dir):
def sample_image(temp_dir): def sample_image(temp_dir):
"""Create a sample image file for testing""" """Create a sample image file for testing"""
from PIL import Image from PIL import Image
# Create a simple test image # Create a simple test image
img = Image.new('RGB', (100, 100), color='red') img = Image.new("RGB", (100, 100), color="red")
image_path = os.path.join(temp_dir, "test_image.jpg") image_path = os.path.join(temp_dir, "test_image.jpg")
img.save(image_path) img.save(image_path)
return image_path return image_path
@ -49,42 +49,41 @@ def sample_image(temp_dir):
class TestBasicSerialization: class TestBasicSerialization:
"""Tests for basic save/load functionality""" """Tests for basic save/load functionality"""
def test_save_empty_project(self, sample_project, temp_dir): def test_save_empty_project(self, sample_project, temp_dir):
"""Test saving an empty project to ZIP""" """Test saving an empty project to ZIP"""
zip_path = os.path.join(temp_dir, "empty_project.ppz") zip_path = os.path.join(temp_dir, "empty_project.ppz")
success, error = save_to_zip(sample_project, zip_path) success, error = save_to_zip(sample_project, zip_path)
assert success is True assert success is True
assert error is None assert error is None
assert os.path.exists(zip_path) assert os.path.exists(zip_path)
assert zip_path.endswith('.ppz') assert zip_path.endswith(".ppz")
def test_save_adds_ppz_extension(self, sample_project, temp_dir): def test_save_adds_ppz_extension(self, sample_project, temp_dir):
"""Test that .ppz extension is added automatically""" """Test that .ppz extension is added automatically"""
zip_path = os.path.join(temp_dir, "project") zip_path = os.path.join(temp_dir, "project")
success, error = save_to_zip(sample_project, zip_path) success, error = save_to_zip(sample_project, zip_path)
assert success is True assert success is True
expected_path = zip_path + '.ppz' expected_path = zip_path + ".ppz"
assert os.path.exists(expected_path) assert os.path.exists(expected_path)
def test_load_empty_project(self, sample_project, temp_dir): def test_load_empty_project(self, sample_project, temp_dir):
"""Test loading an empty project from ZIP""" """Test loading an empty project from ZIP"""
zip_path = os.path.join(temp_dir, "empty_project.ppz") zip_path = os.path.join(temp_dir, "empty_project.ppz")
save_to_zip(sample_project, zip_path) save_to_zip(sample_project, zip_path)
loaded_project = load_from_zip(zip_path) loaded_project = load_from_zip(zip_path)
assert loaded_project is not None assert loaded_project is not None
assert loaded_project.name == "Test Project" assert loaded_project.name == "Test Project"
assert loaded_project.page_size_mm == (210, 297) assert loaded_project.page_size_mm == (210, 297)
assert loaded_project.working_dpi == 300 assert loaded_project.working_dpi == 300
assert len(loaded_project.pages) == 0 assert len(loaded_project.pages) == 0
def test_load_nonexistent_file(self, temp_dir): def test_load_nonexistent_file(self, temp_dir):
"""Test loading from a non-existent file""" """Test loading from a non-existent file"""
zip_path = os.path.join(temp_dir, "nonexistent.ppz") zip_path = os.path.join(temp_dir, "nonexistent.ppz")
@ -95,34 +94,34 @@ class TestBasicSerialization:
except Exception as error: except Exception as error:
assert error is not None assert error is not None
assert "not found" in str(error).lower() assert "not found" in str(error).lower()
def test_save_project_with_pages(self, sample_project, temp_dir): def test_save_project_with_pages(self, sample_project, temp_dir):
"""Test saving a project with multiple pages""" """Test saving a project with multiple pages"""
# Add pages # Add pages
for i in range(3): for i in range(3):
layout = PageLayout() layout = PageLayout()
page = Page(layout=layout, page_number=i+1) page = Page(layout=layout, page_number=i + 1)
sample_project.add_page(page) sample_project.add_page(page)
zip_path = os.path.join(temp_dir, "project_with_pages.ppz") zip_path = os.path.join(temp_dir, "project_with_pages.ppz")
success, error = save_to_zip(sample_project, zip_path) success, error = save_to_zip(sample_project, zip_path)
assert success is True assert success is True
assert os.path.exists(zip_path) assert os.path.exists(zip_path)
def test_load_project_with_pages(self, sample_project, temp_dir): def test_load_project_with_pages(self, sample_project, temp_dir):
"""Test loading a project with multiple pages""" """Test loading a project with multiple pages"""
# Add pages # Add pages
for i in range(3): for i in range(3):
layout = PageLayout() layout = PageLayout()
page = Page(layout=layout, page_number=i+1) page = Page(layout=layout, page_number=i + 1)
sample_project.add_page(page) sample_project.add_page(page)
# Save and load # Save and load
zip_path = os.path.join(temp_dir, "project_with_pages.ppz") zip_path = os.path.join(temp_dir, "project_with_pages.ppz")
save_to_zip(sample_project, zip_path) save_to_zip(sample_project, zip_path)
loaded_project = load_from_zip(zip_path) loaded_project = load_from_zip(zip_path)
assert loaded_project is not None assert loaded_project is not None
assert len(loaded_project.pages) == 3 assert len(loaded_project.pages) == 3
assert loaded_project.pages[0].page_number == 1 assert loaded_project.pages[0].page_number == 1
@ -131,139 +130,139 @@ class TestBasicSerialization:
class TestZipStructure: class TestZipStructure:
"""Tests for ZIP file structure and content""" """Tests for ZIP file structure and content"""
def test_zip_contains_project_json(self, sample_project, temp_dir): def test_zip_contains_project_json(self, sample_project, temp_dir):
"""Test that ZIP contains project.json""" """Test that ZIP contains project.json"""
zip_path = os.path.join(temp_dir, "test.ppz") zip_path = os.path.join(temp_dir, "test.ppz")
save_to_zip(sample_project, zip_path) save_to_zip(sample_project, zip_path)
with zipfile.ZipFile(zip_path, 'r') as zipf: with zipfile.ZipFile(zip_path, "r") as zipf:
assert 'project.json' in zipf.namelist() assert "project.json" in zipf.namelist()
def test_project_json_is_valid(self, sample_project, temp_dir): def test_project_json_is_valid(self, sample_project, temp_dir):
"""Test that project.json contains valid JSON""" """Test that project.json contains valid JSON"""
zip_path = os.path.join(temp_dir, "test.ppz") zip_path = os.path.join(temp_dir, "test.ppz")
save_to_zip(sample_project, zip_path) save_to_zip(sample_project, zip_path)
with zipfile.ZipFile(zip_path, 'r') as zipf: with zipfile.ZipFile(zip_path, "r") as zipf:
project_json = zipf.read('project.json').decode('utf-8') project_json = zipf.read("project.json").decode("utf-8")
data = json.loads(project_json) data = json.loads(project_json)
assert 'name' in data assert "name" in data
assert 'serialization_version' in data assert "serialization_version" in data
assert data['name'] == "Test Project" assert data["name"] == "Test Project"
def test_version_in_serialized_data(self, sample_project, temp_dir): def test_version_in_serialized_data(self, sample_project, temp_dir):
"""Test that version information is included""" """Test that version information is included"""
zip_path = os.path.join(temp_dir, "test.ppz") zip_path = os.path.join(temp_dir, "test.ppz")
save_to_zip(sample_project, zip_path) save_to_zip(sample_project, zip_path)
with zipfile.ZipFile(zip_path, 'r') as zipf: with zipfile.ZipFile(zip_path, "r") as zipf:
project_json = zipf.read('project.json').decode('utf-8') project_json = zipf.read("project.json").decode("utf-8")
data = json.loads(project_json) data = json.loads(project_json)
assert 'serialization_version' in data assert "serialization_version" in data
assert data['serialization_version'] == "3.0" assert data["serialization_version"] == "3.0"
class TestAssetManagement: class TestAssetManagement:
"""Tests for asset bundling and management""" """Tests for asset bundling and management"""
def test_save_project_with_image(self, sample_project, sample_image, temp_dir): def test_save_project_with_image(self, sample_project, sample_image, temp_dir):
"""Test saving a project with an image""" """Test saving a project with an image"""
# Import image to project # Import image to project
imported_path = sample_project.asset_manager.import_asset(sample_image) imported_path = sample_project.asset_manager.import_asset(sample_image)
# Create page with image # Create page with image
layout = PageLayout() layout = PageLayout()
img_data = ImageData(image_path=imported_path, x=10, y=10, width=100, height=100) img_data = ImageData(image_path=imported_path, x=10, y=10, width=100, height=100)
layout.add_element(img_data) layout.add_element(img_data)
page = Page(layout=layout, page_number=1) page = Page(layout=layout, page_number=1)
sample_project.add_page(page) sample_project.add_page(page)
# Save # Save
zip_path = os.path.join(temp_dir, "project_with_image.ppz") zip_path = os.path.join(temp_dir, "project_with_image.ppz")
success, error = save_to_zip(sample_project, zip_path) success, error = save_to_zip(sample_project, zip_path)
assert success is True assert success is True
assert os.path.exists(zip_path) assert os.path.exists(zip_path)
def test_assets_folder_in_zip(self, sample_project, sample_image, temp_dir): def test_assets_folder_in_zip(self, sample_project, sample_image, temp_dir):
"""Test that assets folder is included in ZIP""" """Test that assets folder is included in ZIP"""
# Import image # Import image
imported_path = sample_project.asset_manager.import_asset(sample_image) imported_path = sample_project.asset_manager.import_asset(sample_image)
# Create page with image # Create page with image
layout = PageLayout() layout = PageLayout()
img_data = ImageData(image_path=imported_path, x=10, y=10, width=100, height=100) img_data = ImageData(image_path=imported_path, x=10, y=10, width=100, height=100)
layout.add_element(img_data) layout.add_element(img_data)
page = Page(layout=layout, page_number=1) page = Page(layout=layout, page_number=1)
sample_project.add_page(page) sample_project.add_page(page)
# Save # Save
zip_path = os.path.join(temp_dir, "project_with_image.ppz") zip_path = os.path.join(temp_dir, "project_with_image.ppz")
save_to_zip(sample_project, zip_path) save_to_zip(sample_project, zip_path)
# Check ZIP contents # Check ZIP contents
with zipfile.ZipFile(zip_path, 'r') as zipf: with zipfile.ZipFile(zip_path, "r") as zipf:
names = zipf.namelist() names = zipf.namelist()
# Should contain assets folder # Should contain assets folder
asset_files = [n for n in names if n.startswith('assets/')] asset_files = [n for n in names if n.startswith("assets/")]
assert len(asset_files) > 0 assert len(asset_files) > 0
def test_load_project_with_image(self, sample_project, sample_image, temp_dir): def test_load_project_with_image(self, sample_project, sample_image, temp_dir):
"""Test loading a project with images""" """Test loading a project with images"""
# Import image # Import image
imported_path = sample_project.asset_manager.import_asset(sample_image) imported_path = sample_project.asset_manager.import_asset(sample_image)
# Create page with image # Create page with image
layout = PageLayout() layout = PageLayout()
img_data = ImageData(image_path=imported_path, x=10, y=10, width=100, height=100) img_data = ImageData(image_path=imported_path, x=10, y=10, width=100, height=100)
layout.add_element(img_data) layout.add_element(img_data)
page = Page(layout=layout, page_number=1) page = Page(layout=layout, page_number=1)
sample_project.add_page(page) sample_project.add_page(page)
# Save and load # Save and load
zip_path = os.path.join(temp_dir, "project_with_image.ppz") zip_path = os.path.join(temp_dir, "project_with_image.ppz")
save_to_zip(sample_project, zip_path) save_to_zip(sample_project, zip_path)
loaded_project = load_from_zip(zip_path) loaded_project = load_from_zip(zip_path)
assert loaded_project is not None assert loaded_project is not None
assert len(loaded_project.pages) == 1 assert len(loaded_project.pages) == 1
assert len(loaded_project.pages[0].layout.elements) == 1 assert len(loaded_project.pages[0].layout.elements) == 1
# Verify image element # Verify image element
img_element = loaded_project.pages[0].layout.elements[0] img_element = loaded_project.pages[0].layout.elements[0]
assert isinstance(img_element, ImageData) assert isinstance(img_element, ImageData)
assert img_element.image_path != "" assert img_element.image_path != ""
def test_asset_reference_counts_preserved(self, sample_project, sample_image, temp_dir): def test_asset_reference_counts_preserved(self, sample_project, sample_image, temp_dir):
"""Test that asset reference counts are preserved""" """Test that asset reference counts are preserved"""
# Import image # Import image
imported_path = sample_project.asset_manager.import_asset(sample_image) imported_path = sample_project.asset_manager.import_asset(sample_image)
# Use image twice # Use image twice
layout1 = PageLayout() layout1 = PageLayout()
img1 = ImageData(image_path=imported_path, x=10, y=10, width=100, height=100) img1 = ImageData(image_path=imported_path, x=10, y=10, width=100, height=100)
layout1.add_element(img1) layout1.add_element(img1)
page1 = Page(layout=layout1, page_number=1) page1 = Page(layout=layout1, page_number=1)
sample_project.add_page(page1) sample_project.add_page(page1)
layout2 = PageLayout() layout2 = PageLayout()
img2 = ImageData(image_path=imported_path, x=20, y=20, width=100, height=100) img2 = ImageData(image_path=imported_path, x=20, y=20, width=100, height=100)
layout2.add_element(img2) layout2.add_element(img2)
page2 = Page(layout=layout2, page_number=2) page2 = Page(layout=layout2, page_number=2)
sample_project.add_page(page2) sample_project.add_page(page2)
# Get relative path for reference count check # Get relative path for reference count check
rel_path = os.path.relpath(imported_path, sample_project.folder_path) rel_path = os.path.relpath(imported_path, sample_project.folder_path)
original_ref_count = sample_project.asset_manager.get_reference_count(rel_path) original_ref_count = sample_project.asset_manager.get_reference_count(rel_path)
# Save and load # Save and load
zip_path = os.path.join(temp_dir, "project_refs.ppz") zip_path = os.path.join(temp_dir, "project_refs.ppz")
save_to_zip(sample_project, zip_path) save_to_zip(sample_project, zip_path)
loaded_project = load_from_zip(zip_path) loaded_project = load_from_zip(zip_path)
assert loaded_project is not None assert loaded_project is not None
# Reference counts should be preserved # Reference counts should be preserved
# Note: The actual reference counting behavior depends on deserialize implementation # Note: The actual reference counting behavior depends on deserialize implementation
@ -271,7 +270,7 @@ class TestAssetManagement:
class TestPortability: class TestPortability:
"""Tests for project portability across different locations""" """Tests for project portability across different locations"""
def test_load_to_different_directory(self, sample_project, sample_image, temp_dir): def test_load_to_different_directory(self, sample_project, sample_image, temp_dir):
"""Test loading project to a different directory""" """Test loading project to a different directory"""
# Import image and create page # Import image and create page
@ -281,23 +280,23 @@ class TestPortability:
layout.add_element(img_data) layout.add_element(img_data)
page = Page(layout=layout, page_number=1) page = Page(layout=layout, page_number=1)
sample_project.add_page(page) sample_project.add_page(page)
# Save # Save
zip_path = os.path.join(temp_dir, "portable_project.ppz") zip_path = os.path.join(temp_dir, "portable_project.ppz")
save_to_zip(sample_project, zip_path) save_to_zip(sample_project, zip_path)
# Load to a different location # Load to a different location
new_location = os.path.join(temp_dir, "different_location") new_location = os.path.join(temp_dir, "different_location")
loaded_project = load_from_zip(zip_path, extract_to=new_location) loaded_project = load_from_zip(zip_path, extract_to=new_location)
assert loaded_project is not None assert loaded_project is not None
assert loaded_project.folder_path == new_location assert loaded_project.folder_path == new_location
assert os.path.exists(new_location) assert os.path.exists(new_location)
# Verify assets were extracted # Verify assets were extracted
assets_folder = os.path.join(new_location, "assets") assets_folder = os.path.join(new_location, "assets")
assert os.path.exists(assets_folder) assert os.path.exists(assets_folder)
def test_relative_paths_work_after_move(self, sample_project, sample_image, temp_dir): def test_relative_paths_work_after_move(self, sample_project, sample_image, temp_dir):
"""Test that relative paths still work after loading to different location""" """Test that relative paths still work after loading to different location"""
# Import image # Import image
@ -307,19 +306,19 @@ class TestPortability:
layout.add_element(img_data) layout.add_element(img_data)
page = Page(layout=layout, page_number=1) page = Page(layout=layout, page_number=1)
sample_project.add_page(page) sample_project.add_page(page)
# Save # Save
zip_path = os.path.join(temp_dir, "portable_project.ppz") zip_path = os.path.join(temp_dir, "portable_project.ppz")
save_to_zip(sample_project, zip_path) save_to_zip(sample_project, zip_path)
# Load to different location # Load to different location
new_location = os.path.join(temp_dir, "new_location") new_location = os.path.join(temp_dir, "new_location")
loaded_project = load_from_zip(zip_path, extract_to=new_location) loaded_project = load_from_zip(zip_path, extract_to=new_location)
# Verify image path is accessible from new location # Verify image path is accessible from new location
img_element = loaded_project.pages[0].layout.elements[0] img_element = loaded_project.pages[0].layout.elements[0]
image_path = img_element.image_path image_path = img_element.image_path
# Image path should exist # Image path should exist
# Note: May be absolute or relative depending on implementation # Note: May be absolute or relative depending on implementation
if not os.path.isabs(image_path): if not os.path.isabs(image_path):
@ -331,77 +330,73 @@ class TestPortability:
class TestProjectInfo: class TestProjectInfo:
"""Tests for get_project_info utility function""" """Tests for get_project_info utility function"""
def test_get_project_info(self, sample_project, temp_dir): def test_get_project_info(self, sample_project, temp_dir):
"""Test getting project info without loading""" """Test getting project info without loading"""
# Add some pages # Add some pages
for i in range(5): for i in range(5):
layout = PageLayout() layout = PageLayout()
page = Page(layout=layout, page_number=i+1) page = Page(layout=layout, page_number=i + 1)
sample_project.add_page(page) sample_project.add_page(page)
# Save # Save
zip_path = os.path.join(temp_dir, "info_test.ppz") zip_path = os.path.join(temp_dir, "info_test.ppz")
save_to_zip(sample_project, zip_path) save_to_zip(sample_project, zip_path)
# Get info # Get info
info = get_project_info(zip_path) info = get_project_info(zip_path)
assert info is not None assert info is not None
assert info['name'] == "Test Project" assert info["name"] == "Test Project"
assert info['page_count'] == 5 assert info["page_count"] == 5
assert info['version'] == "3.0" assert info["version"] == "3.0"
assert info['working_dpi'] == 300 assert info["working_dpi"] == 300
def test_get_info_invalid_zip(self, temp_dir): def test_get_info_invalid_zip(self, temp_dir):
"""Test getting info from invalid ZIP""" """Test getting info from invalid ZIP"""
zip_path = os.path.join(temp_dir, "invalid.ppz") zip_path = os.path.join(temp_dir, "invalid.ppz")
info = get_project_info(zip_path) info = get_project_info(zip_path)
assert info is None assert info is None
class TestEdgeCases: class TestEdgeCases:
"""Tests for edge cases and error handling""" """Tests for edge cases and error handling"""
def test_save_to_invalid_path(self, sample_project): def test_save_to_invalid_path(self, sample_project):
"""Test saving to an invalid path""" """Test saving to an invalid path"""
invalid_path = "/nonexistent/directory/project.ppz" invalid_path = "/nonexistent/directory/project.ppz"
success, error = save_to_zip(sample_project, invalid_path) success, error = save_to_zip(sample_project, invalid_path)
assert success is False assert success is False
assert error is not None assert error is not None
def test_load_corrupted_zip(self, temp_dir): def test_load_corrupted_zip(self, temp_dir):
"""Test loading a corrupted ZIP file""" """Test loading a corrupted ZIP file"""
# Create a fake corrupted file # Create a fake corrupted file
corrupted_path = os.path.join(temp_dir, "corrupted.ppz") corrupted_path = os.path.join(temp_dir, "corrupted.ppz")
with open(corrupted_path, 'w') as f: with open(corrupted_path, "w") as f:
f.write("This is not a ZIP file") f.write("This is not a ZIP file")
try: try:
loaded_project = load_from_zip(corrupted_path) loaded_project = load_from_zip(corrupted_path)
assert False, "Should have raised an exception" assert False, "Should have raised an exception"
except Exception as error: except Exception as error:
assert error is not None assert error is not None
def test_load_zip_without_project_json(self, temp_dir): def test_load_zip_without_project_json(self, temp_dir):
"""Test loading a ZIP without project.json""" """Test loading a ZIP without project.json"""
zip_path = os.path.join(temp_dir, "no_json.ppz") zip_path = os.path.join(temp_dir, "no_json.ppz")
# Create ZIP without project.json # Create ZIP without project.json
with zipfile.ZipFile(zip_path, 'w') as zipf: with zipfile.ZipFile(zip_path, "w") as zipf:
zipf.writestr('dummy.txt', 'dummy content') zipf.writestr("dummy.txt", "dummy content")
try: try:
loaded_project = load_from_zip(zip_path) loaded_project = load_from_zip(zip_path)
@ -409,27 +404,24 @@ class TestEdgeCases:
except Exception as error: except Exception as error:
assert error is not None assert error is not None
assert "project.json not found" in str(error) assert "project.json not found" in str(error)
def test_project_with_text_elements(self, sample_project, temp_dir): def test_project_with_text_elements(self, sample_project, temp_dir):
"""Test saving/loading project with text elements""" """Test saving/loading project with text elements"""
# Create page with text # Create page with text
layout = PageLayout() layout = PageLayout()
text = TextBoxData( text = TextBoxData(text_content="Hello World", x=10, y=10, width=200, height=50)
text_content="Hello World",
x=10, y=10, width=200, height=50
)
layout.add_element(text) layout.add_element(text)
page = Page(layout=layout, page_number=1) page = Page(layout=layout, page_number=1)
sample_project.add_page(page) sample_project.add_page(page)
# Save and load # Save and load
zip_path = os.path.join(temp_dir, "with_text.ppz") zip_path = os.path.join(temp_dir, "with_text.ppz")
save_to_zip(sample_project, zip_path) save_to_zip(sample_project, zip_path)
loaded_project = load_from_zip(zip_path) loaded_project = load_from_zip(zip_path)
assert loaded_project is not None assert loaded_project is not None
assert len(loaded_project.pages) == 1 assert len(loaded_project.pages) == 1
text_element = loaded_project.pages[0].layout.elements[0] text_element = loaded_project.pages[0].layout.elements[0]
assert isinstance(text_element, TextBoxData) assert isinstance(text_element, TextBoxData)
assert text_element.text_content == "Hello World" assert text_element.text_content == "Hello World"

View File

@ -0,0 +1,432 @@
"""
Comprehensive tests for project_serializer module
"""
import pytest
import os
import json
import zipfile
import tempfile
import shutil
from pathlib import Path
from unittest.mock import Mock, patch, MagicMock
from pyPhotoAlbum.project_serializer import (
save_to_zip,
load_from_zip,
get_project_info,
_normalize_asset_paths,
_import_external_images,
SERIALIZATION_VERSION,
)
from pyPhotoAlbum.project import Project
from pyPhotoAlbum.models import ImageData
class TestSaveToZip:
"""Tests for save_to_zip function"""
def test_save_to_zip_basic(self, tmp_path):
"""Test basic project saving to zip"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="TestProject", folder_path=str(project_folder))
zip_path = str(tmp_path / "test_project.ppz")
success, error = save_to_zip(project, zip_path)
assert success is True
assert error is None
assert os.path.exists(zip_path)
def test_save_to_zip_adds_extension(self, tmp_path):
"""Test that .ppz extension is added if missing"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="TestProject", folder_path=str(project_folder))
zip_path = str(tmp_path / "test_project") # No extension
success, error = save_to_zip(project, zip_path)
assert success is True
assert os.path.exists(zip_path + ".ppz")
def test_save_to_zip_includes_project_json(self, tmp_path):
"""Test that saved zip contains project.json"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="TestProject", folder_path=str(project_folder))
zip_path = str(tmp_path / "test_project.ppz")
save_to_zip(project, zip_path)
with zipfile.ZipFile(zip_path, "r") as zipf:
assert "project.json" in zipf.namelist()
project_data = json.loads(zipf.read("project.json"))
assert project_data["name"] == "TestProject"
assert "data_version" in project_data
def test_save_to_zip_includes_assets(self, tmp_path):
"""Test that saved zip includes asset files"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
# Create a dummy asset file
asset_file = assets_folder / "image.jpg"
asset_file.write_bytes(b"fake image data")
project = Project(name="TestProject", folder_path=str(project_folder))
zip_path = str(tmp_path / "test_project.ppz")
save_to_zip(project, zip_path)
with zipfile.ZipFile(zip_path, "r") as zipf:
assert "assets/image.jpg" in zipf.namelist()
def test_save_to_zip_handles_error(self, tmp_path):
"""Test error handling during save"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="TestProject", folder_path=str(project_folder))
# Try to save to an invalid path
zip_path = "/nonexistent/directory/test.ppz"
success, error = save_to_zip(project, zip_path)
assert success is False
assert error is not None
assert "Error saving" in error
class TestLoadFromZip:
"""Tests for load_from_zip function"""
def test_load_from_zip_basic(self, tmp_path):
"""Test basic project loading from zip"""
# First create a valid project zip
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="LoadTest", folder_path=str(project_folder))
zip_path = str(tmp_path / "test_project.ppz")
save_to_zip(project, zip_path)
# Now load it
extract_to = str(tmp_path / "extracted")
loaded_project = load_from_zip(zip_path, extract_to)
assert loaded_project.name == "LoadTest"
assert loaded_project.folder_path == extract_to
def test_load_from_zip_creates_temp_dir(self, tmp_path):
"""Test that loading creates a temp directory when none specified"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="TempTest", folder_path=str(project_folder))
zip_path = str(tmp_path / "test_project.ppz")
save_to_zip(project, zip_path)
# Load without specifying extraction directory
loaded_project = load_from_zip(zip_path)
assert loaded_project.name == "TempTest"
assert loaded_project.folder_path is not None
assert os.path.exists(loaded_project.folder_path)
# Should have a _temp_dir attribute
assert hasattr(loaded_project, "_temp_dir")
def test_load_from_zip_file_not_found(self, tmp_path):
"""Test loading from nonexistent file"""
with pytest.raises(FileNotFoundError):
load_from_zip(str(tmp_path / "nonexistent.ppz"))
def test_load_from_zip_invalid_zip(self, tmp_path):
"""Test loading from invalid zip file"""
invalid_file = tmp_path / "invalid.ppz"
invalid_file.write_text("not a zip file")
with pytest.raises(Exception):
load_from_zip(str(invalid_file))
def test_load_from_zip_missing_project_json(self, tmp_path):
"""Test loading from zip without project.json"""
zip_path = tmp_path / "no_project.ppz"
# Create zip without project.json
with zipfile.ZipFile(str(zip_path), "w") as zipf:
zipf.writestr("other_file.txt", "some content")
with pytest.raises(ValueError) as exc_info:
load_from_zip(str(zip_path))
assert "project.json not found" in str(exc_info.value)
class TestGetProjectInfo:
"""Tests for get_project_info function"""
def test_get_project_info_basic(self, tmp_path):
"""Test getting project info from zip"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="InfoTest", folder_path=str(project_folder))
zip_path = str(tmp_path / "test_project.ppz")
save_to_zip(project, zip_path)
info = get_project_info(zip_path)
assert info is not None
assert info["name"] == "InfoTest"
assert "version" in info
assert "page_count" in info
assert "page_size_mm" in info
assert "working_dpi" in info
def test_get_project_info_invalid_file(self, tmp_path):
"""Test getting info from invalid file"""
invalid_file = tmp_path / "invalid.ppz"
invalid_file.write_text("not a zip")
info = get_project_info(str(invalid_file))
assert info is None
def test_get_project_info_nonexistent_file(self, tmp_path):
"""Test getting info from nonexistent file"""
info = get_project_info(str(tmp_path / "nonexistent.ppz"))
assert info is None
class TestNormalizeAssetPaths:
"""Tests for _normalize_asset_paths function"""
def test_normalize_relative_path_unchanged(self, tmp_path):
"""Test that simple relative paths are unchanged"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="Test", folder_path=str(project_folder))
# Add a page with an image that has a simple relative path
from pyPhotoAlbum.page_layout import PageLayout
page_mock = Mock()
layout = PageLayout(width=210, height=297)
img = ImageData(image_path="assets/image.jpg")
layout.add_element(img)
page_mock.layout = layout
project.pages = [page_mock]
_normalize_asset_paths(project, str(project_folder))
# Path should be unchanged
assert img.image_path == "assets/image.jpg"
def test_normalize_absolute_path(self, tmp_path):
"""Test that absolute paths are normalized"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="Test", folder_path=str(project_folder))
from pyPhotoAlbum.page_layout import PageLayout
page_mock = Mock()
layout = PageLayout(width=210, height=297)
# Use a path that contains /assets/ pattern
abs_path = str(project_folder / "assets" / "image.jpg")
img = ImageData(image_path=abs_path)
layout.add_element(img)
page_mock.layout = layout
project.pages = [page_mock]
_normalize_asset_paths(project, str(project_folder))
# Path should be normalized to relative
assert img.image_path == "assets/image.jpg"
def test_normalize_legacy_path(self, tmp_path):
"""Test normalizing legacy project path format"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="Test", folder_path=str(project_folder))
from pyPhotoAlbum.page_layout import PageLayout
page_mock = Mock()
layout = PageLayout(width=210, height=297)
# Legacy path format
img = ImageData(image_path="./projects/old_project/assets/image.jpg")
layout.add_element(img)
page_mock.layout = layout
project.pages = [page_mock]
_normalize_asset_paths(project, str(project_folder))
# Should extract just the assets/filename part
assert img.image_path == "assets/image.jpg"
class TestImportExternalImages:
"""Tests for _import_external_images function"""
def test_import_external_images_no_external(self, tmp_path):
"""Test with no external images"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="Test", folder_path=str(project_folder))
from pyPhotoAlbum.page_layout import PageLayout
page_mock = Mock()
layout = PageLayout(width=210, height=297)
img = ImageData(image_path="assets/existing.jpg")
layout.add_element(img)
page_mock.layout = layout
project.pages = [page_mock]
# Should not raise and not change path
_import_external_images(project)
assert img.image_path == "assets/existing.jpg"
class TestRoundTrip:
"""Test save and load roundtrip"""
def test_roundtrip_basic(self, tmp_path):
"""Test saving and loading a project"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
original = Project(name="RoundTrip", folder_path=str(project_folder))
original.working_dpi = 150
zip_path = str(tmp_path / "roundtrip.ppz")
success, _ = save_to_zip(original, zip_path)
assert success
extract_to = str(tmp_path / "extracted")
loaded = load_from_zip(zip_path, extract_to)
assert loaded.name == original.name
assert loaded.working_dpi == original.working_dpi
def test_roundtrip_with_pages(self, tmp_path):
"""Test roundtrip with pages"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
original = Project(name="WithPages", folder_path=str(project_folder))
# Project starts with 1 page, add more using create_page
from pyPhotoAlbum.project import Page
from pyPhotoAlbum.page_layout import PageLayout
page2 = Page(PageLayout(width=210, height=297))
page3 = Page(PageLayout(width=210, height=297))
original.add_page(page2)
original.add_page(page3)
zip_path = str(tmp_path / "pages.ppz")
save_to_zip(original, zip_path)
extract_to = str(tmp_path / "extracted")
loaded = load_from_zip(zip_path, extract_to)
# Pages are preserved (Project might not start with a default page)
assert len(loaded.pages) >= 2
def test_roundtrip_with_elements(self, tmp_path, temp_image_file):
"""Test roundtrip with elements on page"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
# Copy temp image to assets
shutil.copy(temp_image_file, assets_folder / "test.jpg")
original = Project(name="WithElements", folder_path=str(project_folder))
# Add element to first page (project starts with at least 1 page)
img = ImageData(image_path="assets/test.jpg", x=50, y=50, width=100, height=100)
# Check if there's a default page, add one if needed
if not original.pages:
from pyPhotoAlbum.project import Page
from pyPhotoAlbum.page_layout import PageLayout
original.add_page(Page(PageLayout(width=210, height=297)))
original.pages[0].layout.add_element(img)
zip_path = str(tmp_path / "elements.ppz")
save_to_zip(original, zip_path)
extract_to = str(tmp_path / "extracted")
loaded = load_from_zip(zip_path, extract_to)
assert len(loaded.pages) >= 1
assert len(loaded.pages[0].layout.elements) >= 1
loaded_elem = loaded.pages[0].layout.elements[0]
assert loaded_elem.position == (50.0, 50.0)
assert loaded_elem.size == (100.0, 100.0)
class TestVersionCompatibility:
"""Tests for version handling"""
def test_version_included_in_save(self, tmp_path):
"""Test that version is included when saving"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="Version", folder_path=str(project_folder))
zip_path = str(tmp_path / "version.ppz")
save_to_zip(project, zip_path)
with zipfile.ZipFile(zip_path, "r") as zipf:
data = json.loads(zipf.read("project.json"))
# Should have both legacy and new version fields
assert "serialization_version" in data
assert "data_version" in data

View File

@ -0,0 +1,634 @@
"""
Tests for ribbon_builder module
"""
import pytest
from io import StringIO
from unittest.mock import Mock, patch
from pyPhotoAlbum.ribbon_builder import (
build_ribbon_config,
get_keyboard_shortcuts,
validate_ribbon_config,
print_ribbon_summary,
)
class TestBuildRibbonConfig:
"""Tests for build_ribbon_config function"""
def test_empty_class(self):
"""Test with a class that has no ribbon actions"""
class EmptyClass:
pass
config = build_ribbon_config(EmptyClass)
assert config == {}
def test_single_action(self):
"""Test with a class that has one ribbon action"""
class SingleAction:
def my_action(self):
pass
my_action._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "My Action",
"action": "my_action",
"tooltip": "Does something",
}
config = build_ribbon_config(SingleAction)
assert "Home" in config
assert len(config["Home"]["groups"]) == 1
assert config["Home"]["groups"][0]["name"] == "File"
assert len(config["Home"]["groups"][0]["actions"]) == 1
assert config["Home"]["groups"][0]["actions"][0]["label"] == "My Action"
def test_multiple_actions_same_group(self):
"""Test with multiple actions in the same group"""
class MultiAction:
def action1(self):
pass
action1._ribbon_action = {
"tab": "Home",
"group": "Edit",
"label": "Action 1",
"action": "action1",
"tooltip": "First action",
}
def action2(self):
pass
action2._ribbon_action = {
"tab": "Home",
"group": "Edit",
"label": "Action 2",
"action": "action2",
"tooltip": "Second action",
}
config = build_ribbon_config(MultiAction)
assert "Home" in config
assert len(config["Home"]["groups"]) == 1
assert config["Home"]["groups"][0]["name"] == "Edit"
assert len(config["Home"]["groups"][0]["actions"]) == 2
def test_multiple_groups(self):
"""Test with actions in different groups"""
class MultiGroup:
def action1(self):
pass
action1._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "File Action",
"action": "action1",
"tooltip": "File stuff",
}
def action2(self):
pass
action2._ribbon_action = {
"tab": "Home",
"group": "Edit",
"label": "Edit Action",
"action": "action2",
"tooltip": "Edit stuff",
}
config = build_ribbon_config(MultiGroup)
assert "Home" in config
assert len(config["Home"]["groups"]) == 2
group_names = [g["name"] for g in config["Home"]["groups"]]
assert "File" in group_names
assert "Edit" in group_names
def test_multiple_tabs(self):
"""Test with actions in different tabs"""
class MultiTab:
def action1(self):
pass
action1._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "Home Action",
"action": "action1",
"tooltip": "Home stuff",
}
def action2(self):
pass
action2._ribbon_action = {
"tab": "View",
"group": "Zoom",
"label": "View Action",
"action": "action2",
"tooltip": "View stuff",
}
config = build_ribbon_config(MultiTab)
assert "Home" in config
assert "View" in config
def test_tab_ordering(self):
"""Test that tabs are ordered correctly"""
class OrderedTabs:
def action1(self):
pass
action1._ribbon_action = {
"tab": "Export",
"group": "Export",
"label": "Export",
"action": "action1",
"tooltip": "Export",
}
def action2(self):
pass
action2._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "Home",
"action": "action2",
"tooltip": "Home",
}
def action3(self):
pass
action3._ribbon_action = {
"tab": "View",
"group": "Zoom",
"label": "View",
"action": "action3",
"tooltip": "View",
}
config = build_ribbon_config(OrderedTabs)
tab_names = list(config.keys())
# Home should come before View, View before Export
assert tab_names.index("Home") < tab_names.index("View")
assert tab_names.index("View") < tab_names.index("Export")
def test_action_with_optional_fields(self):
"""Test action with optional icon and shortcut"""
class WithOptional:
def action(self):
pass
action._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "Save",
"action": "save",
"tooltip": "Save project",
"icon": "save.png",
"shortcut": "Ctrl+S",
}
config = build_ribbon_config(WithOptional)
action = config["Home"]["groups"][0]["actions"][0]
assert action["icon"] == "save.png"
assert action["shortcut"] == "Ctrl+S"
def test_action_without_optional_fields(self):
"""Test action without optional icon and shortcut"""
class WithoutOptional:
def action(self):
pass
action._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "Action",
"action": "action",
"tooltip": "Does stuff",
}
config = build_ribbon_config(WithoutOptional)
action = config["Home"]["groups"][0]["actions"][0]
assert action.get("icon") is None
assert action.get("shortcut") is None
def test_custom_tab_not_in_order(self):
"""Test custom tab not in predefined order"""
class CustomTab:
def action(self):
pass
action._ribbon_action = {
"tab": "CustomTab",
"group": "CustomGroup",
"label": "Custom",
"action": "action",
"tooltip": "Custom action",
}
config = build_ribbon_config(CustomTab)
assert "CustomTab" in config
def test_inherited_actions(self):
"""Test that actions from parent classes are included"""
class BaseClass:
def base_action(self):
pass
base_action._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "Base Action",
"action": "base_action",
"tooltip": "From base",
}
class DerivedClass(BaseClass):
def derived_action(self):
pass
derived_action._ribbon_action = {
"tab": "Home",
"group": "Edit",
"label": "Derived Action",
"action": "derived_action",
"tooltip": "From derived",
}
config = build_ribbon_config(DerivedClass)
# Should have both actions
all_actions = []
for group in config["Home"]["groups"]:
all_actions.extend(group["actions"])
action_names = [a["action"] for a in all_actions]
assert "base_action" in action_names
assert "derived_action" in action_names
class TestGetKeyboardShortcuts:
"""Tests for get_keyboard_shortcuts function"""
def test_empty_class(self):
"""Test with a class that has no shortcuts"""
class NoShortcuts:
pass
shortcuts = get_keyboard_shortcuts(NoShortcuts)
assert shortcuts == {}
def test_single_shortcut(self):
"""Test with a single shortcut"""
class SingleShortcut:
def save(self):
pass
save._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "Save",
"action": "save",
"tooltip": "Save",
"shortcut": "Ctrl+S",
}
shortcuts = get_keyboard_shortcuts(SingleShortcut)
assert "Ctrl+S" in shortcuts
assert shortcuts["Ctrl+S"] == "save"
def test_multiple_shortcuts(self):
"""Test with multiple shortcuts"""
class MultiShortcut:
def save(self):
pass
save._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "Save",
"action": "save",
"tooltip": "Save",
"shortcut": "Ctrl+S",
}
def undo(self):
pass
undo._ribbon_action = {
"tab": "Home",
"group": "Edit",
"label": "Undo",
"action": "undo",
"tooltip": "Undo",
"shortcut": "Ctrl+Z",
}
shortcuts = get_keyboard_shortcuts(MultiShortcut)
assert len(shortcuts) == 2
assert shortcuts["Ctrl+S"] == "save"
assert shortcuts["Ctrl+Z"] == "undo"
def test_action_without_shortcut_ignored(self):
"""Test that actions without shortcuts are not included"""
class MixedShortcuts:
def with_shortcut(self):
pass
with_shortcut._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "With",
"action": "with_shortcut",
"tooltip": "Has shortcut",
"shortcut": "Ctrl+W",
}
def without_shortcut(self):
pass
without_shortcut._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "Without",
"action": "without_shortcut",
"tooltip": "No shortcut",
}
shortcuts = get_keyboard_shortcuts(MixedShortcuts)
assert len(shortcuts) == 1
assert "Ctrl+W" in shortcuts
class TestValidateRibbonConfig:
"""Tests for validate_ribbon_config function"""
def test_valid_config(self):
"""Test with a valid configuration"""
config = {
"Home": {
"groups": [
{
"name": "File",
"actions": [
{
"label": "Save",
"action": "save",
"tooltip": "Save project",
}
],
}
]
}
}
errors = validate_ribbon_config(config)
assert errors == []
def test_empty_config(self):
"""Test with empty config"""
errors = validate_ribbon_config({})
assert errors == []
def test_config_not_dict(self):
"""Test with non-dict config"""
errors = validate_ribbon_config("not a dict")
assert len(errors) == 1
assert "must be a dictionary" in errors[0]
def test_tab_data_not_dict(self):
"""Test with tab data that is not a dict"""
config = {"Home": "not a dict"}
errors = validate_ribbon_config(config)
assert len(errors) == 1
assert "Tab 'Home' data must be a dictionary" in errors[0]
def test_missing_groups_key(self):
"""Test with missing 'groups' key"""
config = {"Home": {"other_key": []}}
errors = validate_ribbon_config(config)
assert len(errors) == 1
assert "missing 'groups' key" in errors[0]
def test_groups_not_list(self):
"""Test with groups that is not a list"""
config = {"Home": {"groups": "not a list"}}
errors = validate_ribbon_config(config)
assert len(errors) == 1
assert "groups must be a list" in errors[0]
def test_group_not_dict(self):
"""Test with group that is not a dict"""
config = {"Home": {"groups": ["not a dict"]}}
errors = validate_ribbon_config(config)
assert len(errors) == 1
assert "group 0 must be a dictionary" in errors[0]
def test_group_missing_name(self):
"""Test with group missing name"""
config = {"Home": {"groups": [{"actions": []}]}}
errors = validate_ribbon_config(config)
assert any("missing 'name'" in e for e in errors)
def test_group_missing_actions(self):
"""Test with group missing actions"""
config = {"Home": {"groups": [{"name": "File"}]}}
errors = validate_ribbon_config(config)
assert any("missing 'actions'" in e for e in errors)
def test_actions_not_list(self):
"""Test with actions that is not a list"""
config = {"Home": {"groups": [{"name": "File", "actions": "not a list"}]}}
errors = validate_ribbon_config(config)
assert any("actions must be a list" in e for e in errors)
def test_action_not_dict(self):
"""Test with action that is not a dict"""
config = {"Home": {"groups": [{"name": "File", "actions": ["not a dict"]}]}}
errors = validate_ribbon_config(config)
assert any("action 0 must be a dictionary" in e for e in errors)
def test_action_missing_required_keys(self):
"""Test with action missing required keys"""
config = {
"Home": {
"groups": [
{
"name": "File",
"actions": [
{
"label": "Save"
# missing 'action' and 'tooltip'
}
],
}
]
}
}
errors = validate_ribbon_config(config)
assert any("missing 'action'" in e for e in errors)
assert any("missing 'tooltip'" in e for e in errors)
def test_multiple_errors(self):
"""Test that multiple errors are collected"""
config = {
"Tab1": {"groups": [{"name": "Group1", "actions": [{"label": "A"}]}]}, # missing action and tooltip
"Tab2": {"groups": "not a list"},
}
errors = validate_ribbon_config(config)
assert len(errors) >= 3 # At least: missing action, missing tooltip, groups not list
class TestPrintRibbonSummary:
"""Tests for print_ribbon_summary function"""
def test_print_empty_config(self):
"""Test printing empty config"""
config = {}
with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
print_ribbon_summary(config)
output = mock_stdout.getvalue()
assert "Total Tabs: 0" in output
assert "Total Groups: 0" in output
assert "Total Actions: 0" in output
def test_print_single_tab(self):
"""Test printing single tab config"""
config = {
"Home": {
"groups": [
{
"name": "File",
"actions": [
{
"label": "Save",
"action": "save",
"tooltip": "Save",
}
],
}
]
}
}
with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
print_ribbon_summary(config)
output = mock_stdout.getvalue()
assert "Total Tabs: 1" in output
assert "Total Groups: 1" in output
assert "Total Actions: 1" in output
assert "Home" in output
assert "File" in output
assert "Save" in output
def test_print_with_shortcuts(self):
"""Test printing actions with shortcuts"""
config = {
"Home": {
"groups": [
{
"name": "File",
"actions": [
{
"label": "Save",
"action": "save",
"tooltip": "Save",
"shortcut": "Ctrl+S",
}
],
}
]
}
}
with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
print_ribbon_summary(config)
output = mock_stdout.getvalue()
assert "(Ctrl+S)" in output
def test_print_multiple_tabs_and_groups(self):
"""Test printing config with multiple tabs and groups"""
config = {
"Home": {
"groups": [
{
"name": "File",
"actions": [
{"label": "New", "action": "new", "tooltip": "New"},
{"label": "Open", "action": "open", "tooltip": "Open"},
],
},
{
"name": "Edit",
"actions": [
{"label": "Undo", "action": "undo", "tooltip": "Undo"},
],
},
]
},
"View": {
"groups": [
{
"name": "Zoom",
"actions": [
{"label": "Zoom In", "action": "zoom_in", "tooltip": "Zoom In"},
],
}
]
},
}
with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
print_ribbon_summary(config)
output = mock_stdout.getvalue()
assert "Total Tabs: 2" in output
assert "Total Groups: 3" in output
assert "Total Actions: 4" in output

402
tests/test_ribbon_widget.py Normal file
View File

@ -0,0 +1,402 @@
"""
Tests for ribbon_widget module
"""
import pytest
from unittest.mock import Mock, MagicMock, patch
class TestRibbonWidgetInit:
"""Tests for RibbonWidget initialization"""
def test_init_with_custom_config(self, qtbot):
"""Test initialization with custom ribbon config"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
mock_main_window = Mock()
config = {
"File": {
"groups": [
{"name": "Project", "actions": [{"label": "New", "action": "new_project", "tooltip": "Create new"}]}
]
}
}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
assert widget.main_window == mock_main_window
assert widget.ribbon_config == config
assert widget.buttons_per_row == 4 # default
def test_init_with_custom_buttons_per_row(self, qtbot):
"""Test initialization with custom buttons_per_row"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
mock_main_window = Mock()
config = {"Test": {"groups": []}}
widget = RibbonWidget(mock_main_window, ribbon_config=config, buttons_per_row=6)
qtbot.addWidget(widget)
assert widget.buttons_per_row == 6
def test_init_creates_tab_widget(self, qtbot):
"""Test that initialization creates a tab widget"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
mock_main_window = Mock()
config = {"Tab1": {"groups": []}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
assert widget.tab_widget is not None
assert widget.tab_widget.count() == 1
class TestBuildRibbon:
"""Tests for _build_ribbon method"""
def test_build_ribbon_creates_tabs(self, qtbot):
"""Test that _build_ribbon creates tabs from config"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
mock_main_window = Mock()
config = {"File": {"groups": []}, "Edit": {"groups": []}, "View": {"groups": []}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
assert widget.tab_widget.count() == 3
# Tab names should be present
tab_names = [widget.tab_widget.tabText(i) for i in range(widget.tab_widget.count())]
assert "File" in tab_names
assert "Edit" in tab_names
assert "View" in tab_names
def test_build_ribbon_empty_config(self, qtbot):
"""Test _build_ribbon with empty config"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
mock_main_window = Mock()
config = {}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
assert widget.tab_widget.count() == 0
class TestCreateTab:
"""Tests for _create_tab method"""
def test_create_tab_with_groups(self, qtbot):
"""Test tab creation with groups"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
mock_main_window = Mock()
config = {"Test": {"groups": [{"name": "Group1", "actions": []}, {"name": "Group2", "actions": []}]}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
# Get the tab widget content
tab_content = widget.tab_widget.widget(0)
assert tab_content is not None
def test_create_tab_empty_groups(self, qtbot):
"""Test tab creation with no groups"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
mock_main_window = Mock()
config = {"Test": {"groups": []}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
tab_content = widget.tab_widget.widget(0)
assert tab_content is not None
class TestCreateGroup:
"""Tests for _create_group method"""
def test_create_group_with_actions(self, qtbot):
"""Test group creation with action buttons"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
from PyQt6.QtWidgets import QPushButton
mock_main_window = Mock()
config = {
"Test": {
"groups": [
{
"name": "Actions",
"actions": [
{"label": "Action1", "action": "do_action1"},
{"label": "Action2", "action": "do_action2"},
],
}
]
}
}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
tab_content = widget.tab_widget.widget(0)
# Find buttons in the tab
buttons = tab_content.findChildren(QPushButton)
assert len(buttons) == 2
button_labels = [btn.text() for btn in buttons]
assert "Action1" in button_labels
assert "Action2" in button_labels
def test_create_group_respects_buttons_per_row(self, qtbot):
"""Test that group respects buttons_per_row from config"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
from PyQt6.QtWidgets import QPushButton
mock_main_window = Mock()
config = {
"Test": {
"groups": [
{
"name": "Grid",
"buttons_per_row": 2,
"actions": [
{"label": "A", "action": "a"},
{"label": "B", "action": "b"},
{"label": "C", "action": "c"},
{"label": "D", "action": "d"},
],
}
]
}
}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
tab_content = widget.tab_widget.widget(0)
buttons = tab_content.findChildren(QPushButton)
assert len(buttons) == 4
class TestCreateActionButton:
"""Tests for _create_action_button method"""
def test_button_has_correct_label(self, qtbot):
"""Test that button has correct label"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
from PyQt6.QtWidgets import QPushButton
mock_main_window = Mock()
config = {"Test": {"groups": [{"name": "Test", "actions": [{"label": "My Button", "action": "my_action"}]}]}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
buttons = widget.tab_widget.widget(0).findChildren(QPushButton)
assert len(buttons) == 1
assert buttons[0].text() == "My Button"
def test_button_has_tooltip(self, qtbot):
"""Test that button has correct tooltip"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
from PyQt6.QtWidgets import QPushButton
mock_main_window = Mock()
config = {
"Test": {
"groups": [
{"name": "Test", "actions": [{"label": "Button", "action": "action", "tooltip": "My tooltip"}]}
]
}
}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
buttons = widget.tab_widget.widget(0).findChildren(QPushButton)
assert buttons[0].toolTip() == "My tooltip"
def test_button_without_tooltip(self, qtbot):
"""Test button without tooltip configured"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
from PyQt6.QtWidgets import QPushButton
mock_main_window = Mock()
config = {"Test": {"groups": [{"name": "Test", "actions": [{"label": "Button", "action": "action"}]}]}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
buttons = widget.tab_widget.widget(0).findChildren(QPushButton)
assert buttons[0].toolTip() == ""
def test_button_minimum_size(self, qtbot):
"""Test that button has minimum size set"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
from PyQt6.QtWidgets import QPushButton
mock_main_window = Mock()
config = {"Test": {"groups": [{"name": "Test", "actions": [{"label": "Button", "action": "action"}]}]}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
buttons = widget.tab_widget.widget(0).findChildren(QPushButton)
assert buttons[0].minimumWidth() == 60
assert buttons[0].minimumHeight() == 40
class TestExecuteAction:
"""Tests for _execute_action method"""
def test_execute_action_calls_main_window_method(self, qtbot):
"""Test that _execute_action calls the method on main_window"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
mock_main_window = Mock()
mock_main_window.my_action = Mock()
config = {"Test": {"groups": []}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
widget._execute_action("my_action")
mock_main_window.my_action.assert_called_once()
def test_execute_action_missing_method_prints_warning(self, qtbot, capsys):
"""Test that _execute_action prints warning for missing method"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
mock_main_window = Mock(spec=[]) # No methods
config = {"Test": {"groups": []}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
widget._execute_action("nonexistent_action")
captured = capsys.readouterr()
assert "Warning" in captured.out
assert "nonexistent_action" in captured.out
def test_execute_action_non_callable_not_called(self, qtbot):
"""Test that non-callable attributes are not called"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
mock_main_window = Mock()
mock_main_window.not_a_method = "just a string"
config = {"Test": {"groups": []}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
# Should not raise
widget._execute_action("not_a_method")
def test_button_click_executes_action(self, qtbot):
"""Test that clicking a button executes the action"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
from PyQt6.QtWidgets import QPushButton
mock_main_window = Mock()
mock_main_window.do_something = Mock()
config = {"Test": {"groups": [{"name": "Test", "actions": [{"label": "Do It", "action": "do_something"}]}]}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
# Find the button and click it
buttons = widget.tab_widget.widget(0).findChildren(QPushButton)
assert len(buttons) == 1
qtbot.mouseClick(buttons[0], Qt.MouseButton.LeftButton)
mock_main_window.do_something.assert_called_once()
class TestGroupLabel:
"""Tests for group label creation"""
def test_group_has_label(self, qtbot):
"""Test that group has a label"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
from PyQt6.QtWidgets import QLabel
mock_main_window = Mock()
config = {"Test": {"groups": [{"name": "My Group", "actions": []}]}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
tab_content = widget.tab_widget.widget(0)
labels = tab_content.findChildren(QLabel)
# Should have at least one label with the group name
label_texts = [lbl.text() for lbl in labels]
assert "My Group" in label_texts
class TestRibbonLayoutIntegration:
"""Integration tests for ribbon layout"""
def test_full_ribbon_structure(self, qtbot):
"""Test complete ribbon structure with multiple tabs and groups"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
from PyQt6.QtWidgets import QPushButton
mock_main_window = Mock()
config = {
"File": {
"groups": [
{
"name": "Project",
"actions": [
{"label": "New", "action": "new_project"},
{"label": "Open", "action": "open_project"},
{"label": "Save", "action": "save_project"},
],
},
{"name": "Export", "actions": [{"label": "Export PDF", "action": "export_pdf"}]},
]
},
"Edit": {
"groups": [
{
"name": "Clipboard",
"actions": [{"label": "Copy", "action": "copy"}, {"label": "Paste", "action": "paste"}],
}
]
},
}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
# Check tabs
assert widget.tab_widget.count() == 2
# Check File tab has 4 buttons
file_tab = widget.tab_widget.widget(0)
file_buttons = file_tab.findChildren(QPushButton)
assert len(file_buttons) == 4
# Check Edit tab has 2 buttons
edit_tab = widget.tab_widget.widget(1)
edit_buttons = edit_tab.findChildren(QPushButton)
assert len(edit_buttons) == 2
# Import Qt for click simulation
from PyQt6.QtCore import Qt

View File

@ -19,7 +19,7 @@ class TestRotationSerialization:
def sample_image(self): def sample_image(self):
"""Create a sample test image""" """Create a sample test image"""
# Create a 400x200 test image (wider than tall) # Create a 400x200 test image (wider than tall)
img = Image.new('RGBA', (400, 200), color=(255, 0, 0, 255)) img = Image.new("RGBA", (400, 200), color=(255, 0, 0, 255))
return img return img
def test_serialize_rotation_metadata(self): def test_serialize_rotation_metadata(self):
@ -47,7 +47,7 @@ class TestRotationSerialization:
"image_path": "test.jpg", "image_path": "test.jpg",
"crop_info": (0, 0, 1, 1), "crop_info": (0, 0, 1, 1),
"pil_rotation_90": 1, "pil_rotation_90": 1,
"image_dimensions": (400, 200) "image_dimensions": (400, 200),
} }
img_data = ImageData() img_data = ImageData()
@ -71,8 +71,10 @@ class TestRotationSerialization:
img_data._on_async_image_loaded(sample_image) img_data._on_async_image_loaded(sample_image)
# Verify dimensions are updated to rotated dimensions # Verify dimensions are updated to rotated dimensions
assert img_data.image_dimensions == (200, 400), \ assert img_data.image_dimensions == (
f"Expected rotated dimensions (200, 400), got {img_data.image_dimensions}" 200,
400,
), f"Expected rotated dimensions (200, 400), got {img_data.image_dimensions}"
assert img_data._img_width == 200 assert img_data._img_width == 200
assert img_data._img_height == 400 assert img_data._img_height == 400
@ -143,7 +145,7 @@ class TestRotationSerialization:
"image_path": "test.jpg", "image_path": "test.jpg",
"crop_info": (0, 0, 1, 1), "crop_info": (0, 0, 1, 1),
"pil_rotation_90": 0, # Not set in old format "pil_rotation_90": 0, # Not set in old format
"image_dimensions": (400, 200) "image_dimensions": (400, 200),
} }
img_data = ImageData() img_data = ImageData()
@ -182,7 +184,6 @@ class TestRotationSerialization:
img2._on_async_image_loaded(sample_image) img2._on_async_image_loaded(sample_image)
# Verify dimensions are STILL correct after reload # Verify dimensions are STILL correct after reload
assert img2.image_dimensions == (200, 400), \ assert img2.image_dimensions == (200, 400), "Dimensions should remain correct after reload"
"Dimensions should remain correct after reload"
assert img2._img_width == 200 assert img2._img_width == 200
assert img2._img_height == 400 assert img2._img_height == 400

View File

@ -30,7 +30,7 @@ class TestSizeWindow(SizeOperationsMixin, QMainWindow):
return len(self.gl_widget.selected_elements) >= min_count return len(self.gl_widget.selected_elements) >= min_count
def get_current_page(self): def get_current_page(self):
if hasattr(self, '_current_page'): if hasattr(self, "_current_page"):
return self._current_page return self._current_page
return None return None
@ -47,7 +47,7 @@ class TestSizeWindow(SizeOperationsMixin, QMainWindow):
class TestMakeSameSize: class TestMakeSameSize:
"""Test make_same_size method""" """Test make_same_size method"""
@patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager') @patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager")
def test_make_same_size_success(self, mock_manager, qtbot): def test_make_same_size_success(self, mock_manager, qtbot):
window = TestSizeWindow() window = TestSizeWindow()
qtbot.addWidget(window) qtbot.addWidget(window)
@ -57,10 +57,7 @@ class TestMakeSameSize:
window.gl_widget.selected_elements = {element1, element2} window.gl_widget.selected_elements = {element1, element2}
mock_manager.make_same_size.return_value = [ mock_manager.make_same_size.return_value = [(element1, (0, 0), (100, 100)), (element2, (150, 0), (200, 150))]
(element1, (0, 0), (100, 100)),
(element2, (150, 0), (200, 150))
]
window.make_same_size() window.make_same_size()
@ -84,7 +81,7 @@ class TestMakeSameSize:
class TestMakeSameWidth: class TestMakeSameWidth:
"""Test make_same_width method""" """Test make_same_width method"""
@patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager') @patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager")
def test_make_same_width_success(self, mock_manager, qtbot): def test_make_same_width_success(self, mock_manager, qtbot):
window = TestSizeWindow() window = TestSizeWindow()
qtbot.addWidget(window) qtbot.addWidget(window)
@ -94,10 +91,7 @@ class TestMakeSameWidth:
window.gl_widget.selected_elements = {element1, element2} window.gl_widget.selected_elements = {element1, element2}
mock_manager.make_same_width.return_value = [ mock_manager.make_same_width.return_value = [(element1, (0, 0), (100, 100)), (element2, (150, 0), (200, 150))]
(element1, (0, 0), (100, 100)),
(element2, (150, 0), (200, 150))
]
window.make_same_width() window.make_same_width()
@ -109,7 +103,7 @@ class TestMakeSameWidth:
class TestMakeSameHeight: class TestMakeSameHeight:
"""Test make_same_height method""" """Test make_same_height method"""
@patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager') @patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager")
def test_make_same_height_success(self, mock_manager, qtbot): def test_make_same_height_success(self, mock_manager, qtbot):
window = TestSizeWindow() window = TestSizeWindow()
qtbot.addWidget(window) qtbot.addWidget(window)
@ -119,10 +113,7 @@ class TestMakeSameHeight:
window.gl_widget.selected_elements = {element1, element2} window.gl_widget.selected_elements = {element1, element2}
mock_manager.make_same_height.return_value = [ mock_manager.make_same_height.return_value = [(element1, (0, 0), (100, 100)), (element2, (150, 0), (200, 150))]
(element1, (0, 0), (100, 100)),
(element2, (150, 0), (200, 150))
]
window.make_same_height() window.make_same_height()
@ -134,7 +125,7 @@ class TestMakeSameHeight:
class TestFitToWidth: class TestFitToWidth:
"""Test fit_to_width method""" """Test fit_to_width method"""
@patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager') @patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager")
def test_fit_to_width_success(self, mock_manager, qtbot): def test_fit_to_width_success(self, mock_manager, qtbot):
window = TestSizeWindow() window = TestSizeWindow()
qtbot.addWidget(window) qtbot.addWidget(window)
@ -174,7 +165,7 @@ class TestFitToWidth:
class TestFitToHeight: class TestFitToHeight:
"""Test fit_to_height method""" """Test fit_to_height method"""
@patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager') @patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager")
def test_fit_to_height_success(self, mock_manager, qtbot): def test_fit_to_height_success(self, mock_manager, qtbot):
window = TestSizeWindow() window = TestSizeWindow()
qtbot.addWidget(window) qtbot.addWidget(window)
@ -199,7 +190,7 @@ class TestFitToHeight:
class TestFitToPage: class TestFitToPage:
"""Test fit_to_page method""" """Test fit_to_page method"""
@patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager') @patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager")
def test_fit_to_page_success(self, mock_manager, qtbot): def test_fit_to_page_success(self, mock_manager, qtbot):
window = TestSizeWindow() window = TestSizeWindow()
qtbot.addWidget(window) qtbot.addWidget(window)
@ -238,7 +229,7 @@ class TestFitToPage:
class TestSizeCommandPattern: class TestSizeCommandPattern:
"""Test size operations with command pattern""" """Test size operations with command pattern"""
@patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager') @patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager")
def test_size_operation_creates_command(self, mock_manager, qtbot): def test_size_operation_creates_command(self, mock_manager, qtbot):
window = TestSizeWindow() window = TestSizeWindow()
qtbot.addWidget(window) qtbot.addWidget(window)
@ -248,10 +239,7 @@ class TestSizeCommandPattern:
window.gl_widget.selected_elements = {element1, element2} window.gl_widget.selected_elements = {element1, element2}
mock_manager.make_same_size.return_value = [ mock_manager.make_same_size.return_value = [(element1, (0, 0), (100, 100)), (element2, (150, 0), (200, 150))]
(element1, (0, 0), (100, 100)),
(element2, (150, 0), (200, 150))
]
assert not window.project.history.can_undo() assert not window.project.history.can_undo()
@ -259,7 +247,7 @@ class TestSizeCommandPattern:
assert window.project.history.can_undo() assert window.project.history.can_undo()
@patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager') @patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager")
def test_fit_operation_creates_command(self, mock_manager, qtbot): def test_fit_operation_creates_command(self, mock_manager, qtbot):
window = TestSizeWindow() window = TestSizeWindow()
qtbot.addWidget(window) qtbot.addWidget(window)
@ -284,7 +272,7 @@ class TestSizeCommandPattern:
class TestExpandImage: class TestExpandImage:
"""Test expand_image method""" """Test expand_image method"""
@patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager') @patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager")
def test_expand_image_success(self, mock_manager, qtbot): def test_expand_image_success(self, mock_manager, qtbot):
window = TestSizeWindow() window = TestSizeWindow()
qtbot.addWidget(window) qtbot.addWidget(window)

Some files were not shown because too many files have changed in this diff Show More