diff --git a/pyPhotoAlbum/alignment.py b/pyPhotoAlbum/alignment.py
index 9e1307d..1abc5a7 100644
--- a/pyPhotoAlbum/alignment.py
+++ b/pyPhotoAlbum/alignment.py
@@ -65,14 +65,10 @@ class ElementMaximizer:
# Calculate distances between rectangles
horizontal_gap = max(
- other_x - (x + w), # Other is to the right
- x - (other_x + other_w) # Other is to the left
+ other_x - (x + w), x - (other_x + other_w) # Other is to the right # Other is to the left
)
- vertical_gap = max(
- other_y - (y + h), # Other is below
- y - (other_y + other_h) # Other is above
- )
+ vertical_gap = max(other_y - (y + h), y - (other_y + other_h)) # Other is below # Other is above
# If rectangles overlap or are too close in both dimensions
if horizontal_gap < self.min_gap and vertical_gap < self.min_gap:
@@ -80,8 +76,14 @@ class ElementMaximizer:
return False
- def find_max_scale(self, elem_idx: int, current_scale: float, max_search_scale: float = 3.0,
- tolerance: float = 0.001, max_iterations: int = 20) -> float:
+ def find_max_scale(
+ 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.
@@ -171,8 +173,10 @@ class ElementMaximizer:
ow, oh = other.size
# Check if rectangles overlap (with min_gap consideration)
- if (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):
+ if (
+ 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 False
@@ -233,7 +237,9 @@ class ElementMaximizer:
self.center_element_horizontally(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.
@@ -365,7 +371,9 @@ class AlignmentManager:
return changes
@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.
@@ -413,7 +421,9 @@ class AlignmentManager:
return changes
@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.
@@ -435,7 +445,9 @@ class AlignmentManager:
return changes
@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.
@@ -457,7 +469,9 @@ class AlignmentManager:
return changes
@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.
@@ -479,7 +493,9 @@ class AlignmentManager:
return changes
@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.
@@ -613,7 +629,9 @@ class AlignmentManager:
return changes
@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.
@@ -638,7 +656,9 @@ class AlignmentManager:
return (element, old_pos, old_size)
@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.
@@ -663,7 +683,9 @@ class AlignmentManager:
return (element, old_pos, old_size)
@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.
@@ -702,7 +724,7 @@ class AlignmentManager:
page_size: Tuple[float, float],
min_gap: float = 2.0,
max_iterations: int = 100,
- growth_rate: float = 0.05
+ growth_rate: float = 0.05,
) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
"""
Maximize element sizes using a crystal growth algorithm.
@@ -729,7 +751,7 @@ class AlignmentManager:
element: BaseLayoutElement,
page_size: Tuple[float, float],
other_elements: List[BaseLayoutElement],
- min_gap: float = 10.0
+ min_gap: float = 10.0,
) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
"""
Expand a single element until it is min_gap away from page edges or other elements.
diff --git a/pyPhotoAlbum/asset_heal_dialog.py b/pyPhotoAlbum/asset_heal_dialog.py
index cdeb506..40f79de 100644
--- a/pyPhotoAlbum/asset_heal_dialog.py
+++ b/pyPhotoAlbum/asset_heal_dialog.py
@@ -6,9 +6,16 @@ import os
import shutil
from typing import List, Dict, Set
from PyQt6.QtWidgets import (
- QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
- QListWidget, QListWidgetItem, QFileDialog, QGroupBox,
- QMessageBox
+ QDialog,
+ QVBoxLayout,
+ QHBoxLayout,
+ QLabel,
+ QPushButton,
+ QListWidget,
+ QListWidgetItem,
+ QFileDialog,
+ QGroupBox,
+ QMessageBox,
)
from PyQt6.QtCore import Qt
@@ -124,10 +131,7 @@ class AssetHealDialog(QDialog):
def _add_search_path(self):
"""Add a search path"""
directory = QFileDialog.getExistingDirectory(
- self,
- "Select Search Path for Assets",
- "",
- QFileDialog.Option.ShowDirsOnly
+ self, "Select Search Path for Assets", "", QFileDialog.Option.ShowDirsOnly
)
if directory:
@@ -203,7 +207,7 @@ class AssetHealDialog(QDialog):
# Check if the found file needs to be imported
# (i.e., it's not already in the assets folder)
needs_import = True
- if not os.path.isabs(asset_path) and asset_path.startswith('assets/'):
+ if not os.path.isabs(asset_path) and asset_path.startswith("assets/"):
# It's already a relative assets path, just missing from disk
# Copy it to the correct location
dest_path = os.path.join(self.project.folder_path, asset_path)
diff --git a/pyPhotoAlbum/asset_manager.py b/pyPhotoAlbum/asset_manager.py
index 77687bd..b3e1868 100644
--- a/pyPhotoAlbum/asset_manager.py
+++ b/pyPhotoAlbum/asset_manager.py
@@ -10,48 +10,48 @@ from pathlib import Path
class AssetManager:
"""Manages project assets with automatic reference counting and cleanup"""
-
+
def __init__(self, project_folder: str):
"""
Initialize AssetManager.
-
+
Args:
project_folder: Root folder for the project
"""
self.project_folder = project_folder
self.assets_folder = os.path.join(project_folder, "assets")
self.reference_counts: Dict[str, int] = {} # {relative_path: count}
-
+
# Create assets folder if it doesn't exist
os.makedirs(self.assets_folder, exist_ok=True)
-
+
def import_asset(self, source_path: str) -> str:
"""
Import an asset into the project by copying it to the assets folder.
-
+
Args:
source_path: Path to the source file
-
+
Returns:
Relative path to the imported asset (e.g., "assets/photo_001.jpg")
"""
if not os.path.exists(source_path):
raise FileNotFoundError(f"Source file not found: {source_path}")
-
+
# Get filename and extension
filename = os.path.basename(source_path)
name, ext = os.path.splitext(filename)
-
+
# Find a unique filename if there's a collision
counter = 1
dest_filename = filename
dest_path = os.path.join(self.assets_folder, dest_filename)
-
+
while os.path.exists(dest_path):
dest_filename = f"{name}_{counter:03d}{ext}"
dest_path = os.path.join(self.assets_folder, dest_filename)
counter += 1
-
+
# Copy the file
shutil.copy2(source_path, dest_path)
@@ -65,17 +65,17 @@ class AssetManager:
# Return relative path for storage in elements
return relative_path
-
+
def acquire_reference(self, asset_path: str):
"""
Increment the reference count for an asset.
-
+
Args:
asset_path: Relative path to the asset
"""
if not asset_path:
return
-
+
if asset_path in self.reference_counts:
self.reference_counts[asset_path] += 1
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)")
else:
print(f"AssetManager: Warning - asset not found: {asset_path}")
-
+
def release_reference(self, asset_path: str):
"""
Decrement the reference count for an asset.
If count reaches zero, delete the asset file.
-
+
Args:
asset_path: Relative path to the asset
"""
if not asset_path:
return
-
+
if asset_path not in self.reference_counts:
print(f"AssetManager: Warning - attempting to release unknown asset: {asset_path}")
return
-
+
self.reference_counts[asset_path] -= 1
print(f"AssetManager: Released reference to {asset_path} (refs={self.reference_counts[asset_path]})")
-
+
if self.reference_counts[asset_path] <= 0:
# No more references - safe to delete
full_path = os.path.join(self.project_folder, asset_path)
@@ -116,37 +116,35 @@ class AssetManager:
del self.reference_counts[asset_path]
except Exception as e:
print(f"AssetManager: Error deleting asset {asset_path}: {e}")
-
+
def get_absolute_path(self, relative_path: str) -> str:
"""
Convert a relative asset path to an absolute path.
-
+
Args:
relative_path: Relative path from project folder
-
+
Returns:
Absolute path to the asset
"""
return os.path.join(self.project_folder, relative_path)
-
+
def get_reference_count(self, asset_path: str) -> int:
"""
Get the current reference count for an asset.
-
+
Args:
asset_path: Relative path to the asset
-
+
Returns:
Reference count (0 if not tracked)
"""
return self.reference_counts.get(asset_path, 0)
-
+
def serialize(self) -> Dict:
"""Serialize asset manager state"""
- return {
- "reference_counts": self.reference_counts
- }
-
+ return {"reference_counts": self.reference_counts}
+
def deserialize(self, data: Dict):
"""Deserialize asset manager state"""
self.reference_counts = data.get("reference_counts", {})
diff --git a/pyPhotoAlbum/async_backend.py b/pyPhotoAlbum/async_backend.py
index 898e55f..ca0f46b 100644
--- a/pyPhotoAlbum/async_backend.py
+++ b/pyPhotoAlbum/async_backend.py
@@ -28,16 +28,14 @@ logger = logging.getLogger(__name__)
class LoadPriority(Enum):
"""Priority levels for load requests."""
- LOW = 0 # Offscreen, not visible
- NORMAL = 1 # Potentially visible soon
- HIGH = 2 # Visible on screen
- URGENT = 3 # User is actively interacting with
+
+ LOW = 0 # Offscreen, not visible
+ NORMAL = 1 # Potentially visible soon
+ HIGH = 2 # Visible on screen
+ URGENT = 3 # User is actively interacting with
-def get_image_dimensions(
- image_path: str,
- max_size: Optional[int] = None
-) -> Optional[Tuple[int, int]]:
+def get_image_dimensions(image_path: str, max_size: Optional[int] = None) -> Optional[Tuple[int, int]]:
"""
Extract image dimensions without loading the full image.
@@ -78,6 +76,7 @@ def get_image_dimensions(
@dataclass(order=True)
class LoadRequest:
"""Request to load and process an image."""
+
priority: LoadPriority = field(compare=True)
request_id: int = field(compare=True) # Tie-breaker for same priority
path: Path = field(compare=False)
@@ -111,7 +110,7 @@ class ImageCache:
"""Estimate memory size of PIL image in bytes."""
# PIL images are typically width * height * bytes_per_pixel
# 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)
return img.width * img.height * bytes_per_pixel
@@ -164,8 +163,7 @@ class ImageCache:
self.current_memory_bytes -= old_size
# Evict LRU items if needed
- while (self.current_memory_bytes + img_size > self.max_memory_bytes
- and len(self._cache) > 0):
+ while self.current_memory_bytes + img_size > self.max_memory_bytes and len(self._cache) > 0:
evicted_key, (evicted_img, evicted_size) = self._cache.popitem(last=False)
self.current_memory_bytes -= evicted_size
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.current_memory_bytes += img_size
- logger.debug(f"Cache PUT: {key} ({img_size / 1024 / 1024:.1f}MB) "
- f"[Total: {self.current_memory_bytes / 1024 / 1024:.1f}MB / "
- f"{self.max_memory_bytes / 1024 / 1024:.1f}MB, "
- f"Items: {len(self._cache)}]")
+ logger.debug(
+ f"Cache PUT: {key} ({img_size / 1024 / 1024:.1f}MB) "
+ f"[Total: {self.current_memory_bytes / 1024 / 1024:.1f}MB / "
+ f"{self.max_memory_bytes / 1024 / 1024:.1f}MB, "
+ f"Items: {len(self._cache)}]"
+ )
def clear(self):
"""Clear entire cache."""
@@ -190,10 +190,10 @@ class ImageCache:
"""Get cache statistics."""
with self._lock:
return {
- 'items': len(self._cache),
- 'memory_mb': self.current_memory_bytes / 1024 / 1024,
- 'max_memory_mb': self.max_memory_bytes / 1024 / 1024,
- 'utilization': (self.current_memory_bytes / self.max_memory_bytes) * 100
+ "items": len(self._cache),
+ "memory_mb": self.current_memory_bytes / 1024 / 1024,
+ "max_memory_mb": self.max_memory_bytes / 1024 / 1024,
+ "utilization": (self.current_memory_bytes / self.max_memory_bytes) * 100,
}
@@ -213,7 +213,7 @@ class AsyncImageLoader(QObject):
# Signals for Qt integration
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):
"""
@@ -227,8 +227,7 @@ class AsyncImageLoader(QObject):
self.cache = cache or ImageCache()
self.max_workers = max_workers
- self.executor = ThreadPoolExecutor(max_workers=max_workers,
- thread_name_prefix="ImageLoader")
+ self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="ImageLoader")
# Priority queue and tracking
self._queue: asyncio.PriorityQueue = None # Created when event loop starts
@@ -251,9 +250,9 @@ class AsyncImageLoader(QObject):
return
self._shutdown = False
- self._loop_thread = threading.Thread(target=self._run_event_loop,
- daemon=True,
- name="AsyncImageLoader-EventLoop")
+ self._loop_thread = threading.Thread(
+ target=self._run_event_loop, daemon=True, name="AsyncImageLoader-EventLoop"
+ )
self._loop_thread.start()
logger.info("AsyncImageLoader event loop started")
@@ -354,12 +353,7 @@ class AsyncImageLoader(QObject):
# Load in thread pool (I/O bound)
loop = asyncio.get_event_loop()
- img = await loop.run_in_executor(
- self.executor,
- self._load_and_process_image,
- path,
- target_size
- )
+ img = await loop.run_in_executor(self.executor, self._load_and_process_image, path, target_size)
# Cache result
self.cache.put(path, img, target_size)
@@ -411,11 +405,13 @@ class AsyncImageLoader(QObject):
"""Emit load_failed signal (thread-safe)."""
self.load_failed.emit(path, error_msg, user_data)
- def request_load(self,
- path: Path,
- priority: LoadPriority = LoadPriority.NORMAL,
- target_size: Optional[Tuple[int, int]] = None,
- user_data: Any = None) -> bool:
+ def request_load(
+ self,
+ path: Path,
+ priority: LoadPriority = LoadPriority.NORMAL,
+ target_size: Optional[Tuple[int, int]] = None,
+ user_data: Any = None,
+ ) -> bool:
"""
Request image load with specified priority.
@@ -446,7 +442,7 @@ class AsyncImageLoader(QObject):
request_id=self._next_request_id,
path=path,
target_size=target_size,
- user_data=user_data
+ user_data=user_data,
)
self._next_request_id += 1
@@ -454,10 +450,7 @@ class AsyncImageLoader(QObject):
self._pending_requests[path] = request
# Submit to queue (thread-safe)
- asyncio.run_coroutine_threadsafe(
- self._queue.put(request),
- self._loop
- )
+ asyncio.run_coroutine_threadsafe(self._queue.put(request), self._loop)
logger.debug(f"Queued load: {path} (priority: {priority.name})")
return True
@@ -494,9 +487,9 @@ class AsyncImageLoader(QObject):
"""Get loader statistics."""
with self._lock:
return {
- 'pending': len(self._pending_requests),
- 'active': len(self._active_tasks),
- 'cache': self.cache.get_stats()
+ "pending": len(self._pending_requests),
+ "active": len(self._active_tasks),
+ "cache": self.cache.get_stats(),
}
@@ -517,8 +510,8 @@ class AsyncPDFGenerator(QObject):
# Signals for Qt integration
progress_updated = pyqtSignal(int, int, str) # (current, total, message)
- export_complete = pyqtSignal(bool, list) # (success, warnings)
- export_failed = pyqtSignal(str) # (error_message)
+ export_complete = pyqtSignal(bool, list) # (success, warnings)
+ export_failed = pyqtSignal(str) # (error_message)
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.max_workers = max_workers
- self.executor = ThreadPoolExecutor(max_workers=max_workers,
- thread_name_prefix="PDFGenerator")
+ self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="PDFGenerator")
# Export state
self._current_export: Optional[asyncio.Task] = None
@@ -554,9 +546,9 @@ class AsyncPDFGenerator(QObject):
return
self._shutdown = False
- self._loop_thread = threading.Thread(target=self._run_event_loop,
- daemon=True,
- name="AsyncPDFGenerator-EventLoop")
+ self._loop_thread = threading.Thread(
+ target=self._run_event_loop, daemon=True, name="AsyncPDFGenerator-EventLoop"
+ )
self._loop_thread.start()
logger.info("AsyncPDFGenerator event loop started")
@@ -621,8 +613,7 @@ class AsyncPDFGenerator(QObject):
# Submit export task
self._current_export = asyncio.run_coroutine_threadsafe(
- self._export_pdf_async(project, output_path, export_dpi),
- self._loop
+ self._export_pdf_async(project, output_path, export_dpi), self._loop
)
logger.info(f"PDF export started: {output_path}")
@@ -662,11 +653,7 @@ class AsyncPDFGenerator(QObject):
# Run export in thread pool
loop = asyncio.get_event_loop()
success, warnings = await loop.run_in_executor(
- self.executor,
- self._export_with_cache,
- exporter,
- output_path,
- progress_callback
+ self.executor, self._export_with_cache, exporter, output_path, progress_callback
)
# Emit completion signal
@@ -731,13 +718,9 @@ class AsyncPDFGenerator(QObject):
def is_exporting(self) -> bool:
"""Check if export is currently in progress."""
with self._lock:
- return (self._current_export is not None
- and not self._current_export.done())
+ return self._current_export is not None and not self._current_export.done()
def get_stats(self) -> Dict[str, Any]:
"""Get generator statistics."""
with self._lock:
- return {
- 'exporting': self.is_exporting(),
- 'cache': self.image_cache.get_stats()
- }
+ return {"exporting": self.is_exporting(), "cache": self.image_cache.get_stats()}
diff --git a/pyPhotoAlbum/async_project_loader.py b/pyPhotoAlbum/async_project_loader.py
index 8ee9d2f..c21342c 100644
--- a/pyPhotoAlbum/async_project_loader.py
+++ b/pyPhotoAlbum/async_project_loader.py
@@ -18,7 +18,7 @@ from pyPhotoAlbum.version_manager import (
CURRENT_DATA_VERSION,
check_version_compatibility,
VersionCompatibility,
- DataMigration
+ DataMigration,
)
@@ -78,7 +78,7 @@ class AsyncProjectLoader(QThread):
self.progress_updated.emit(10, 100, "Extracting project files...")
# 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()
total_files = len(file_list)
@@ -91,10 +91,7 @@ class AsyncProjectLoader(QThread):
# Update progress every 10 files or on last file
if i % 10 == 0 or i == total_files - 1:
progress = 10 + int((i / total_files) * 30) # 10-40%
- self.progress_updated.emit(
- progress, 100,
- f"Extracting files... ({i + 1}/{total_files})"
- )
+ self.progress_updated.emit(progress, 100, f"Extracting files... ({i + 1}/{total_files})")
if self._cancelled:
return
@@ -103,12 +100,12 @@ class AsyncProjectLoader(QThread):
self.progress_updated.emit(45, 100, "Loading project data...")
# 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):
self.load_failed.emit("Invalid project file: project.json not found")
return
- with open(project_json_path, 'r') as f:
+ with open(project_json_path, "r") as f:
project_data = json.load(f)
if self._cancelled:
@@ -118,7 +115,7 @@ class AsyncProjectLoader(QThread):
self.progress_updated.emit(55, 100, "Checking 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)
if not is_compatible:
@@ -141,7 +138,7 @@ class AsyncProjectLoader(QThread):
self.progress_updated.emit(70, 100, "Creating 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)
# Deserialize project data
@@ -197,14 +194,14 @@ class AsyncProjectLoader(QThread):
original_path = element.image_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
# Pattern 1: "./projects/XXX/assets/filename.jpg" -> "assets/filename.jpg"
- if '/assets/' in original_path:
- parts = original_path.split('/assets/')
+ if "/assets/" in original_path:
+ parts = original_path.split("/assets/")
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
normalized_count += 1
continue
@@ -222,9 +219,9 @@ class AsyncProjectLoader(QThread):
print(f"Normalized {normalized_count} asset paths")
-def load_from_zip_async(zip_path: str, extract_to: Optional[str] = None,
- progress_callback=None, complete_callback=None,
- error_callback=None) -> AsyncProjectLoader:
+def load_from_zip_async(
+ zip_path: str, extract_to: Optional[str] = None, progress_callback=None, complete_callback=None, error_callback=None
+) -> AsyncProjectLoader:
"""
Load a project from a ZIP file asynchronously.
diff --git a/pyPhotoAlbum/autosave_manager.py b/pyPhotoAlbum/autosave_manager.py
index b964914..827779b 100644
--- a/pyPhotoAlbum/autosave_manager.py
+++ b/pyPhotoAlbum/autosave_manager.py
@@ -86,11 +86,11 @@ class AutosaveManager:
"project_name": project.name,
"timestamp": datetime.now().isoformat(),
"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')
- with open(metadata_path, 'w') as f:
+ metadata_path = checkpoint_path.with_suffix(".json")
+ with open(metadata_path, "w") as f:
json.dump(metadata, f, indent=2)
def list_checkpoints(self, project_name: Optional[str] = None) -> List[Tuple[Path, dict]]:
@@ -106,23 +106,23 @@ class AutosaveManager:
checkpoints = []
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
metadata = {}
if metadata_file.exists():
try:
- with open(metadata_file, 'r') as f:
+ with open(metadata_file, "r") as f:
metadata = json.load(f)
except:
pass
# 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))
# 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
def load_checkpoint(self, checkpoint_path: Path):
@@ -157,7 +157,7 @@ class AutosaveManager:
checkpoint_path.unlink()
# Delete metadata file
- metadata_path = checkpoint_path.with_suffix('.json')
+ metadata_path = checkpoint_path.with_suffix(".json")
if metadata_path.exists():
metadata_path.unlink()
@@ -190,7 +190,7 @@ class AutosaveManager:
# Group checkpoints by project
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:
checkpoints_by_project[project_name] = []
checkpoints_by_project[project_name].append((checkpoint_path, metadata))
@@ -198,11 +198,11 @@ class AutosaveManager:
# Clean up each project's checkpoints
for project_name, checkpoints in checkpoints_by_project.items():
# 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):
# Delete if too old
- timestamp_str = metadata.get('timestamp')
+ timestamp_str = metadata.get("timestamp")
if timestamp_str:
try:
timestamp = datetime.fromisoformat(timestamp_str)
diff --git a/pyPhotoAlbum/commands.py b/pyPhotoAlbum/commands.py
index e6c5b0f..9079e62 100644
--- a/pyPhotoAlbum/commands.py
+++ b/pyPhotoAlbum/commands.py
@@ -11,11 +11,11 @@ from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, T
def _normalize_asset_path(image_path: str, asset_manager) -> str:
"""
Convert absolute path to relative for asset manager.
-
+
Args:
image_path: Image path (absolute or relative)
asset_manager: AssetManager instance
-
+
Returns:
Relative path suitable for asset manager
"""
@@ -29,79 +29,75 @@ def _normalize_asset_path(image_path: str, asset_manager) -> str:
class Command(ABC):
"""Abstract base class for all commands"""
-
+
@abstractmethod
def execute(self):
"""Execute the command"""
pass
-
+
@abstractmethod
def undo(self):
"""Undo the command"""
pass
-
+
@abstractmethod
def redo(self):
"""Redo the command (default implementation calls execute)"""
self.execute()
-
+
@abstractmethod
def serialize(self) -> Dict[str, Any]:
"""Serialize command to dictionary for saving"""
pass
-
+
@staticmethod
@abstractmethod
- def deserialize(data: Dict[str, Any], project) -> 'Command':
+ def deserialize(data: Dict[str, Any], project) -> "Command":
"""Deserialize command from dictionary"""
pass
class AddElementCommand(Command):
"""Command for adding an element to a page"""
-
+
def __init__(self, page_layout, element: BaseLayoutElement, asset_manager=None):
self.page_layout = page_layout
self.element = element
self.executed = False
self.asset_manager = asset_manager
-
+
# Acquire reference to asset when command is created
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)
self.asset_manager.acquire_reference(rel_path)
-
+
def execute(self):
"""Add the element to the page"""
if not self.executed:
self.page_layout.add_element(self.element)
self.executed = True
-
+
def undo(self):
"""Remove the element from the page"""
if self.executed:
self.page_layout.remove_element(self.element)
self.executed = False
-
+
def redo(self):
"""Re-add the element"""
self.execute()
-
+
def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary"""
- return {
- "type": "add_element",
- "element": self.element.serialize(),
- "executed": self.executed
- }
-
+ return {"type": "add_element", "element": self.element.serialize(), "executed": self.executed}
+
@staticmethod
- def deserialize(data: Dict[str, Any], project) -> 'AddElementCommand':
+ def deserialize(data: Dict[str, Any], project) -> "AddElementCommand":
"""Deserialize from dictionary"""
# Reconstruct element from serialized data
elem_data = data["element"]
elem_type = elem_data.get("type")
-
+
if elem_type == "image":
element = ImageData()
elif elem_type == "placeholder":
@@ -110,9 +106,9 @@ class AddElementCommand(Command):
element = TextBoxData()
else:
raise ValueError(f"Unknown element type: {elem_type}")
-
+
element.deserialize(elem_data)
-
+
# Note: We need to find the correct page_layout
# This will be handled by the CommandHistory deserializer
cmd = AddElementCommand(None, element)
@@ -122,48 +118,44 @@ class AddElementCommand(Command):
class DeleteElementCommand(Command):
"""Command for deleting an element from a page"""
-
+
def __init__(self, page_layout, element: BaseLayoutElement, asset_manager=None):
self.page_layout = page_layout
self.element = element
self.executed = False
self.asset_manager = asset_manager
-
+
# 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:
rel_path = _normalize_asset_path(self.element.image_path, self.asset_manager)
self.asset_manager.acquire_reference(rel_path)
-
+
def execute(self):
"""Remove the element from the page"""
if not self.executed:
self.page_layout.remove_element(self.element)
self.executed = True
-
+
def undo(self):
"""Re-add the element to the page"""
if self.executed:
self.page_layout.add_element(self.element)
self.executed = False
-
+
def redo(self):
"""Re-remove the element"""
self.execute()
-
+
def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary"""
- return {
- "type": "delete_element",
- "element": self.element.serialize(),
- "executed": self.executed
- }
-
+ return {"type": "delete_element", "element": self.element.serialize(), "executed": self.executed}
+
@staticmethod
- def deserialize(data: Dict[str, Any], project) -> 'DeleteElementCommand':
+ def deserialize(data: Dict[str, Any], project) -> "DeleteElementCommand":
"""Deserialize from dictionary"""
elem_data = data["element"]
elem_type = elem_data.get("type")
-
+
if elem_type == "image":
element = ImageData()
elif elem_type == "placeholder":
@@ -172,9 +164,9 @@ class DeleteElementCommand(Command):
element = TextBoxData()
else:
raise ValueError(f"Unknown element type: {elem_type}")
-
+
element.deserialize(elem_data)
-
+
cmd = DeleteElementCommand(None, element)
cmd.executed = data.get("executed", False)
return cmd
@@ -182,39 +174,39 @@ class DeleteElementCommand(Command):
class MoveElementCommand(Command):
"""Command for moving an element"""
-
+
def __init__(self, element: BaseLayoutElement, old_position: tuple, new_position: tuple):
self.element = element
self.old_position = old_position
self.new_position = new_position
-
+
def execute(self):
"""Move element to new position"""
self.element.position = self.new_position
-
+
def undo(self):
"""Move element back to old position"""
self.element.position = self.old_position
-
+
def redo(self):
"""Move element to new position again"""
self.execute()
-
+
def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary"""
return {
"type": "move_element",
"element": self.element.serialize(),
"old_position": self.old_position,
- "new_position": self.new_position
+ "new_position": self.new_position,
}
-
+
@staticmethod
- def deserialize(data: Dict[str, Any], project) -> 'MoveElementCommand':
+ def deserialize(data: Dict[str, Any], project) -> "MoveElementCommand":
"""Deserialize from dictionary"""
elem_data = data["element"]
elem_type = elem_data.get("type")
-
+
if elem_type == "image":
element = ImageData()
elif elem_type == "placeholder":
@@ -223,41 +215,38 @@ class MoveElementCommand(Command):
element = TextBoxData()
else:
raise ValueError(f"Unknown element type: {elem_type}")
-
+
element.deserialize(elem_data)
-
- return MoveElementCommand(
- element,
- tuple(data["old_position"]),
- tuple(data["new_position"])
- )
+
+ return MoveElementCommand(element, tuple(data["old_position"]), tuple(data["new_position"]))
class ResizeElementCommand(Command):
"""Command for resizing an element"""
-
- def __init__(self, element: BaseLayoutElement, old_position: tuple, old_size: tuple,
- new_position: tuple, new_size: tuple):
+
+ def __init__(
+ self, element: BaseLayoutElement, old_position: tuple, old_size: tuple, new_position: tuple, new_size: tuple
+ ):
self.element = element
self.old_position = old_position
self.old_size = old_size
self.new_position = new_position
self.new_size = new_size
-
+
def execute(self):
"""Resize element to new size"""
self.element.position = self.new_position
self.element.size = self.new_size
-
+
def undo(self):
"""Resize element back to old size"""
self.element.position = self.old_position
self.element.size = self.old_size
-
+
def redo(self):
"""Resize element to new size again"""
self.execute()
-
+
def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary"""
return {
@@ -266,15 +255,15 @@ class ResizeElementCommand(Command):
"old_position": self.old_position,
"old_size": self.old_size,
"new_position": self.new_position,
- "new_size": self.new_size
+ "new_size": self.new_size,
}
-
+
@staticmethod
- def deserialize(data: Dict[str, Any], project) -> 'ResizeElementCommand':
+ def deserialize(data: Dict[str, Any], project) -> "ResizeElementCommand":
"""Deserialize from dictionary"""
elem_data = data["element"]
elem_type = elem_data.get("type")
-
+
if elem_type == "image":
element = ImageData()
elif elem_type == "placeholder":
@@ -283,15 +272,15 @@ class ResizeElementCommand(Command):
element = TextBoxData()
else:
raise ValueError(f"Unknown element type: {elem_type}")
-
+
element.deserialize(elem_data)
-
+
return ResizeElementCommand(
element,
tuple(data["old_position"]),
tuple(data["old_size"]),
tuple(data["new_position"]),
- tuple(data["new_size"])
+ tuple(data["new_size"]),
)
@@ -308,7 +297,7 @@ class RotateElementCommand(Command):
self.old_size = element.size
# 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
else:
self.old_pil_rotation = None
@@ -344,9 +333,9 @@ class RotateElementCommand(Command):
self.element.position = (center_x - h / 2, center_y - w / 2)
# 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
- if hasattr(self.element, '_async_load_requested'):
+ if hasattr(self.element, "_async_load_requested"):
self.element._async_load_requested = False
# Keep visual rotation at 0
@@ -376,29 +365,29 @@ class RotateElementCommand(Command):
# For ImageData, restore PIL rotation and clear texture
if isinstance(self.element, ImageData) and self.old_pil_rotation is not None:
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._async_load_requested = False
def redo(self):
"""Rotate element to new angle again"""
self.execute()
-
+
def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary"""
return {
"type": "rotate_element",
"element": self.element.serialize(),
"old_rotation": self.old_rotation,
- "new_rotation": self.new_rotation
+ "new_rotation": self.new_rotation,
}
-
+
@staticmethod
- def deserialize(data: Dict[str, Any], project) -> 'RotateElementCommand':
+ def deserialize(data: Dict[str, Any], project) -> "RotateElementCommand":
"""Deserialize from dictionary"""
elem_data = data["element"]
elem_type = elem_data.get("type")
-
+
if elem_type == "image":
element = ImageData()
elif elem_type == "placeholder":
@@ -407,78 +396,70 @@ class RotateElementCommand(Command):
element = TextBoxData()
else:
raise ValueError(f"Unknown element type: {elem_type}")
-
+
element.deserialize(elem_data)
-
- return RotateElementCommand(
- element,
- data["old_rotation"],
- data["new_rotation"]
- )
+
+ return RotateElementCommand(element, data["old_rotation"], data["new_rotation"])
class AdjustImageCropCommand(Command):
"""Command for adjusting image crop/pan within frame"""
-
+
def __init__(self, element: ImageData, old_crop_info: tuple, new_crop_info: tuple):
self.element = element
self.old_crop_info = old_crop_info
self.new_crop_info = new_crop_info
-
+
def execute(self):
"""Apply new crop info"""
self.element.crop_info = self.new_crop_info
-
+
def undo(self):
"""Restore old crop info"""
self.element.crop_info = self.old_crop_info
-
+
def redo(self):
"""Apply new crop info again"""
self.execute()
-
+
def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary"""
return {
"type": "adjust_image_crop",
"element": self.element.serialize(),
"old_crop_info": self.old_crop_info,
- "new_crop_info": self.new_crop_info
+ "new_crop_info": self.new_crop_info,
}
-
+
@staticmethod
- def deserialize(data: Dict[str, Any], project) -> 'AdjustImageCropCommand':
+ def deserialize(data: Dict[str, Any], project) -> "AdjustImageCropCommand":
"""Deserialize from dictionary"""
elem_data = data["element"]
element = ImageData()
element.deserialize(elem_data)
-
- return AdjustImageCropCommand(
- element,
- tuple(data["old_crop_info"]),
- tuple(data["new_crop_info"])
- )
+
+ return AdjustImageCropCommand(element, tuple(data["old_crop_info"]), tuple(data["new_crop_info"]))
class AlignElementsCommand(Command):
"""Command for aligning multiple elements"""
-
+
def __init__(self, changes: List[tuple]):
"""
Args:
changes: List of (element, old_position) tuples
"""
self.changes = changes
-
+
def execute(self):
"""Positions have already been set by AlignmentManager"""
pass
-
+
def undo(self):
"""Restore old positions"""
for element, old_position in self.changes:
element.position = old_position
-
+
def redo(self):
"""Re-apply alignment (positions are stored in current state)"""
# Store current positions and restore them
@@ -488,28 +469,22 @@ class AlignElementsCommand(Command):
# Then re-apply new positions
for element, new_position in new_positions:
element.position = new_position
-
+
def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary"""
return {
"type": "align_elements",
- "changes": [
- {
- "element": elem.serialize(),
- "old_position": old_pos
- }
- for elem, old_pos in self.changes
- ]
+ "changes": [{"element": elem.serialize(), "old_position": old_pos} for elem, old_pos in self.changes],
}
-
+
@staticmethod
- def deserialize(data: Dict[str, Any], project) -> 'AlignElementsCommand':
+ def deserialize(data: Dict[str, Any], project) -> "AlignElementsCommand":
"""Deserialize from dictionary"""
changes = []
for change_data in data.get("changes", []):
elem_data = change_data["element"]
elem_type = elem_data.get("type")
-
+
if elem_type == "image":
element = ImageData()
elif elem_type == "placeholder":
@@ -518,17 +493,17 @@ class AlignElementsCommand(Command):
element = TextBoxData()
else:
continue
-
+
element.deserialize(elem_data)
old_position = tuple(change_data["old_position"])
changes.append((element, old_position))
-
+
return AlignElementsCommand(changes)
class ResizeElementsCommand(Command):
"""Command for resizing multiple elements"""
-
+
def __init__(self, changes: List[tuple]):
"""
Args:
@@ -536,45 +511,41 @@ class ResizeElementsCommand(Command):
"""
self.changes = changes
self.new_states = [(elem, elem.position, elem.size) for elem, _, _ in changes]
-
+
def execute(self):
"""Sizes have already been set by AlignmentManager"""
pass
-
+
def undo(self):
"""Restore old positions and sizes"""
for element, old_position, old_size in self.changes:
element.position = old_position
element.size = old_size
-
+
def redo(self):
"""Re-apply new sizes"""
for element, new_position, new_size in self.new_states:
element.position = new_position
element.size = new_size
-
+
def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary"""
return {
"type": "resize_elements",
"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
- ]
+ ],
}
-
+
@staticmethod
- def deserialize(data: Dict[str, Any], project) -> 'ResizeElementsCommand':
+ def deserialize(data: Dict[str, Any], project) -> "ResizeElementsCommand":
"""Deserialize from dictionary"""
changes = []
for change_data in data.get("changes", []):
elem_data = change_data["element"]
elem_type = elem_data.get("type")
-
+
if elem_type == "image":
element = ImageData()
elif elem_type == "placeholder":
@@ -583,57 +554,57 @@ class ResizeElementsCommand(Command):
element = TextBoxData()
else:
continue
-
+
element.deserialize(elem_data)
old_position = tuple(change_data["old_position"])
old_size = tuple(change_data["old_size"])
changes.append((element, old_position, old_size))
-
+
return ResizeElementsCommand(changes)
class ChangeZOrderCommand(Command):
"""Command for changing element z-order (list position)"""
-
+
def __init__(self, page_layout, element: BaseLayoutElement, old_index: int, new_index: int):
self.page_layout = page_layout
self.element = element
self.old_index = old_index
self.new_index = new_index
-
+
def execute(self):
"""Move element to new position in list"""
elements = self.page_layout.elements
if self.element in elements:
elements.remove(self.element)
elements.insert(self.new_index, self.element)
-
+
def undo(self):
"""Move element back to old position in list"""
elements = self.page_layout.elements
if self.element in elements:
elements.remove(self.element)
elements.insert(self.old_index, self.element)
-
+
def redo(self):
"""Move element to new position again"""
self.execute()
-
+
def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary"""
return {
"type": "change_zorder",
"element": self.element.serialize(),
"old_index": self.old_index,
- "new_index": self.new_index
+ "new_index": self.new_index,
}
-
+
@staticmethod
- def deserialize(data: Dict[str, Any], project) -> 'ChangeZOrderCommand':
+ def deserialize(data: Dict[str, Any], project) -> "ChangeZOrderCommand":
"""Deserialize from dictionary"""
elem_data = data["element"]
elem_type = elem_data.get("type")
-
+
if elem_type == "image":
element = ImageData()
elif elem_type == "placeholder":
@@ -642,25 +613,22 @@ class ChangeZOrderCommand(Command):
element = TextBoxData()
else:
raise ValueError(f"Unknown element type: {elem_type}")
-
+
element.deserialize(elem_data)
-
+
return ChangeZOrderCommand(
- None, # page_layout will be set by CommandHistory
- element,
- data["old_index"],
- data["new_index"]
+ None, element, data["old_index"], data["new_index"] # page_layout will be set by CommandHistory
)
class StateChangeCommand(Command):
"""
Generic command for operations that change state.
-
+
This command captures before/after snapshots of state and can restore them.
Used by the @undoable_operation decorator.
"""
-
+
def __init__(self, description: str, restore_func, before_state: Any, after_state: Any = None):
"""
Args:
@@ -673,31 +641,28 @@ class StateChangeCommand(Command):
self.restore_func = restore_func
self.before_state = before_state
self.after_state = after_state
-
+
def execute(self):
"""State is already applied, just store after_state if not set"""
# After state is captured by decorator after operation runs
pass
-
+
def undo(self):
"""Restore to before state"""
self.restore_func(self.before_state)
-
+
def redo(self):
"""Restore to after state"""
self.restore_func(self.after_state)
-
+
def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary"""
# For now, state change commands are not serialized
# This could be enhanced later if needed
- return {
- "type": "state_change",
- "description": self.description
- }
-
+ return {"type": "state_change", "description": self.description}
+
@staticmethod
- def deserialize(data: Dict[str, Any], project) -> 'StateChangeCommand':
+ def deserialize(data: Dict[str, Any], project) -> "StateChangeCommand":
"""Deserialize from dictionary"""
# Not implemented - would need to serialize state
raise NotImplementedError("StateChangeCommand deserialization not yet supported")
@@ -732,12 +697,12 @@ class CommandHistory:
if len(self.undo_stack) > self.max_history:
old_cmd = self.undo_stack.pop(0)
self._release_command_assets(old_cmd)
-
+
def _release_command_assets(self, command: Command):
"""Release asset references held by a command"""
if not self.asset_manager:
return
-
+
# Release asset references for commands that hold them
if isinstance(command, (AddElementCommand, DeleteElementCommand)):
if isinstance(command.element, ImageData) and command.element.image_path:
@@ -746,7 +711,7 @@ class CommandHistory:
if os.path.isabs(asset_path):
asset_path = os.path.relpath(asset_path, self.asset_manager.project_folder)
self.asset_manager.release_reference(asset_path)
-
+
def undo(self) -> bool:
"""Undo the last command"""
if not self.can_undo():
@@ -776,15 +741,15 @@ class CommandHistory:
self.project.mark_dirty()
return True
-
+
def can_undo(self) -> bool:
"""Check if undo is available"""
return len(self.undo_stack) > 0
-
+
def can_redo(self) -> bool:
"""Check if redo is available"""
return len(self.redo_stack) > 0
-
+
def clear(self):
"""Clear all history and release asset references"""
# Release all asset references
@@ -792,36 +757,36 @@ class CommandHistory:
self._release_command_assets(cmd)
for cmd in self.redo_stack:
self._release_command_assets(cmd)
-
+
self.undo_stack.clear()
self.redo_stack.clear()
-
+
def serialize(self) -> Dict[str, Any]:
"""Serialize history to dictionary"""
return {
"undo_stack": [cmd.serialize() for cmd in self.undo_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):
"""Deserialize history from dictionary"""
self.max_history = data.get("max_history", 100)
-
+
# Deserialize undo stack
self.undo_stack = []
for cmd_data in data.get("undo_stack", []):
cmd = self._deserialize_command(cmd_data, project)
if cmd:
self.undo_stack.append(cmd)
-
+
# Deserialize redo stack
self.redo_stack = []
for cmd_data in data.get("redo_stack", []):
cmd = self._deserialize_command(cmd_data, project)
if cmd:
self.redo_stack.append(cmd)
-
+
# Command type registry for deserialization
_COMMAND_DESERIALIZERS = {
"add_element": AddElementCommand.deserialize,
diff --git a/pyPhotoAlbum/decorators.py b/pyPhotoAlbum/decorators.py
index 884c3b8..e378c74 100644
--- a/pyPhotoAlbum/decorators.py
+++ b/pyPhotoAlbum/decorators.py
@@ -10,10 +10,10 @@ from typing import Optional, Callable
class RibbonAction:
"""
Decorator to mark methods as ribbon actions.
-
+
This decorator stores metadata about UI actions that should appear in the ribbon.
The metadata is used to auto-generate the ribbon configuration.
-
+
Example:
@RibbonAction(
label="New",
@@ -26,7 +26,7 @@ class RibbonAction:
def new_project(self):
...
"""
-
+
def __init__(
self,
label: str,
@@ -37,11 +37,11 @@ class RibbonAction:
shortcut: Optional[str] = None,
requires_page: bool = False,
requires_selection: bool = False,
- min_selection: int = 0
+ min_selection: int = 0,
):
"""
Initialize the ribbon action decorator.
-
+
Args:
label: Button label text
tooltip: Tooltip text shown on hover
@@ -62,35 +62,36 @@ class RibbonAction:
self.requires_page = requires_page
self.requires_selection = requires_selection
self.min_selection = min_selection
-
+
def __call__(self, func: Callable) -> Callable:
"""
Decorate the function with ribbon action metadata.
-
+
Args:
func: The function to decorate
-
+
Returns:
The decorated function with metadata attached
"""
+
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
-
+
# Store metadata on wrapper function
wrapper._ribbon_action = {
- 'label': self.label,
- 'tooltip': self.tooltip,
- 'tab': self.tab,
- 'group': self.group,
- 'icon': self.icon,
- 'shortcut': self.shortcut,
- 'action': func.__name__,
- 'requires_page': self.requires_page,
- 'requires_selection': self.requires_selection,
- 'min_selection': self.min_selection
+ "label": self.label,
+ "tooltip": self.tooltip,
+ "tab": self.tab,
+ "group": self.group,
+ "icon": self.icon,
+ "shortcut": self.shortcut,
+ "action": func.__name__,
+ "requires_page": self.requires_page,
+ "requires_selection": self.requires_selection,
+ "min_selection": self.min_selection,
}
-
+
return wrapper
@@ -103,13 +104,13 @@ def ribbon_action(
shortcut: Optional[str] = None,
requires_page: bool = False,
requires_selection: bool = False,
- min_selection: int = 0
+ min_selection: int = 0,
) -> Callable:
"""
Convenience function for the RibbonAction decorator.
-
+
This provides a lowercase function-style interface to the decorator.
-
+
Args:
label: Button label text
tooltip: Tooltip text shown on hover
@@ -120,7 +121,7 @@ def ribbon_action(
requires_page: Whether this action requires an active page
requires_selection: Whether this action requires selected elements
min_selection: Minimum number of selected elements required
-
+
Returns:
RibbonAction decorator instance
"""
@@ -133,17 +134,17 @@ def ribbon_action(
shortcut=shortcut,
requires_page=requires_page,
requires_selection=requires_selection,
- min_selection=min_selection
+ min_selection=min_selection,
)
class NumericalInput:
"""
Decorator to mark methods that require numerical width/height inputs.
-
+
This decorator stores metadata about numerical input fields that should
be presented in dialogs for methods that work with page dimensions.
-
+
Example:
@numerical_input(
fields=[
@@ -154,49 +155,48 @@ class NumericalInput:
def set_page_size(self, width, height):
...
"""
-
+
def __init__(self, fields: list):
"""
Initialize the numerical input decorator.
-
+
Args:
fields: List of tuples, each containing:
(param_name, label, unit, min_value, max_value)
"""
self.fields = fields
-
+
def __call__(self, func: Callable) -> Callable:
"""
Decorate the function with numerical input metadata.
-
+
Args:
func: The function to decorate
-
+
Returns:
The decorated function with metadata attached
"""
+
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
-
+
# Store metadata on wrapper function
- wrapper._numerical_input = {
- 'fields': self.fields
- }
-
+ wrapper._numerical_input = {"fields": self.fields}
+
return wrapper
def numerical_input(fields: list) -> Callable:
"""
Convenience function for the NumericalInput decorator.
-
+
This provides a lowercase function-style interface to the decorator.
-
+
Args:
fields: List of tuples, each containing:
(param_name, label, unit, min_value, max_value)
-
+
Returns:
NumericalInput decorator instance
"""
@@ -206,10 +206,10 @@ def numerical_input(fields: list) -> Callable:
class UndoableOperation:
"""
Decorator to automatically create undo/redo commands for operations.
-
+
This decorator captures state before and after an operation, then creates
a StateChangeCommand for undo/redo functionality.
-
+
Example:
@undoable_operation(capture='page_elements')
def apply_template(self):
@@ -217,11 +217,11 @@ class UndoableOperation:
self.template_manager.apply_template(...)
# 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.
-
+
Args:
capture: What to capture for undo/redo:
- 'page_elements': Capture elements of current page
@@ -230,89 +230,92 @@ class UndoableOperation:
"""
self.capture = capture
self.description = description
-
+
def __call__(self, func: Callable) -> Callable:
"""
Decorate the function with automatic undo/redo.
-
+
Args:
func: The function to decorate
-
+
Returns:
The decorated function
"""
+
@wraps(func)
def wrapper(self_instance, *args, **kwargs):
# Get description
- description = self.description or func.__name__.replace('_', ' ').title()
-
+ description = self.description or func.__name__.replace("_", " ").title()
+
# Capture before state
before_state = self._capture_state(self_instance, self.capture)
-
+
# Execute the operation
result = func(self_instance, *args, **kwargs)
-
- # Capture after state
+
+ # Capture after state
after_state = self._capture_state(self_instance, self.capture)
-
+
# Create restore function
def restore_state(state):
self._restore_state(self_instance, self.capture, state)
# Update view after restoring
- if hasattr(self_instance, 'update_view'):
+ if hasattr(self_instance, "update_view"):
self_instance.update_view()
-
+
# Create and execute command
from pyPhotoAlbum.commands import StateChangeCommand
+
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)
print(f"Undoable operation '{description}' executed")
-
+
return result
-
+
return wrapper
-
+
def _capture_state(self, instance, capture_type: str):
"""Capture current state based on capture type"""
- if capture_type == 'page_elements':
+ if capture_type == "page_elements":
# 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:
# Deep copy elements
return [copy.deepcopy(elem.serialize()) for elem in current_page.layout.elements]
return []
-
+
return None
-
+
def _restore_state(self, instance, capture_type: str, state):
"""Restore state based on capture type"""
- if capture_type == 'page_elements':
+ if capture_type == "page_elements":
# 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:
# Clear existing elements
current_page.layout.elements.clear()
-
+
# Restore elements from serialized state
from pyPhotoAlbum.models import ImageData, PlaceholderData, TextBoxData
+
for elem_data in state:
- elem_type = elem_data.get('type')
- if elem_type == 'image':
+ elem_type = elem_data.get("type")
+ if elem_type == "image":
elem = ImageData()
- elif elem_type == 'placeholder':
+ elif elem_type == "placeholder":
elem = PlaceholderData()
- elif elem_type == 'textbox':
+ elif elem_type == "textbox":
elem = TextBoxData()
else:
continue
-
+
elem.deserialize(elem_data)
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.
@@ -343,11 +346,7 @@ class DialogAction:
self.apply_page_setup(values)
"""
- def __init__(
- self,
- dialog_class: type,
- requires_pages: bool = True
- ):
+ def __init__(self, dialog_class: type, requires_pages: bool = True):
"""
Initialize the dialog action decorator.
@@ -368,6 +367,7 @@ class DialogAction:
Returns:
The decorated function
"""
+
@wraps(func)
def wrapper(self_instance, *args, **kwargs):
# Check preconditions
@@ -376,7 +376,7 @@ class DialogAction:
# Get initial page index if available
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()
# Create and show dialog
@@ -384,17 +384,15 @@ class DialogAction:
# Create dialog
dialog = self.dialog_class(
- parent=self_instance,
- project=self_instance.project,
- initial_page_index=initial_page_index,
- **kwargs
+ parent=self_instance, project=self_instance.project, initial_page_index=initial_page_index, **kwargs
)
# Show dialog and get result
from PyQt6.QtWidgets import QDialog
+
if dialog.exec() == QDialog.DialogCode.Accepted:
# Get values from dialog
- if hasattr(dialog, 'get_values'):
+ if hasattr(dialog, "get_values"):
values = dialog.get_values()
# Call the decorated function with values
return func(self_instance, values, *args, **kwargs)
@@ -406,10 +404,7 @@ class DialogAction:
return wrapper
-def dialog_action(
- dialog_class: type,
- requires_pages: bool = True
-) -> Callable:
+def dialog_action(dialog_class: type, requires_pages: bool = True) -> Callable:
"""
Convenience function for the DialogAction decorator.
diff --git a/pyPhotoAlbum/dialogs/__init__.py b/pyPhotoAlbum/dialogs/__init__.py
index 298a8ac..df46140 100644
--- a/pyPhotoAlbum/dialogs/__init__.py
+++ b/pyPhotoAlbum/dialogs/__init__.py
@@ -7,4 +7,4 @@ UI presentation logic separately from business logic.
from .page_setup_dialog import PageSetupDialog
-__all__ = ['PageSetupDialog']
+__all__ = ["PageSetupDialog"]
diff --git a/pyPhotoAlbum/dialogs/page_setup_dialog.py b/pyPhotoAlbum/dialogs/page_setup_dialog.py
index 65f170f..2244870 100644
--- a/pyPhotoAlbum/dialogs/page_setup_dialog.py
+++ b/pyPhotoAlbum/dialogs/page_setup_dialog.py
@@ -8,9 +8,16 @@ separating presentation from business logic.
import math
from typing import Optional, Dict, Any
from PyQt6.QtWidgets import (
- QDialog, QVBoxLayout, QHBoxLayout, QLabel,
- QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox,
- QComboBox, QCheckBox
+ QDialog,
+ QVBoxLayout,
+ QHBoxLayout,
+ QLabel,
+ QDoubleSpinBox,
+ QSpinBox,
+ QPushButton,
+ QGroupBox,
+ QComboBox,
+ QCheckBox,
)
from pyPhotoAlbum.project import Project
@@ -23,12 +30,7 @@ class PageSetupDialog(QDialog):
including page size, DPI settings, and cover configuration.
"""
- def __init__(
- self,
- parent,
- project: Project,
- initial_page_index: int = 0
- ):
+ def __init__(self, parent, project: Project, initial_page_index: int = 0):
"""
Initialize the page setup dialog.
@@ -105,9 +107,7 @@ class PageSetupDialog(QDialog):
# Cover checkbox
self.cover_checkbox = QCheckBox("Designate as Cover")
- self.cover_checkbox.setToolTip(
- "Mark this page as the book cover with wrap-around front/spine/back"
- )
+ self.cover_checkbox.setToolTip("Mark this page as the book cover with wrap-around front/spine/back")
layout.addWidget(self.cover_checkbox)
# Paper thickness
@@ -136,9 +136,7 @@ class PageSetupDialog(QDialog):
# Calculated spine width display
self.spine_info_label = QLabel()
- self.spine_info_label.setStyleSheet(
- "font-size: 9pt; color: #0066cc; padding: 5px;"
- )
+ self.spine_info_label.setStyleSheet("font-size: 9pt; color: #0066cc; padding: 5px;")
self.spine_info_label.setWordWrap(True)
layout.addWidget(self.spine_info_label)
@@ -170,9 +168,7 @@ class PageSetupDialog(QDialog):
# Set as default checkbox
self.set_default_checkbox = QCheckBox("Set as default for new pages")
- self.set_default_checkbox.setToolTip(
- "Update project default page size for future pages"
- )
+ self.set_default_checkbox.setToolTip("Update project default page size for future pages")
layout.addWidget(self.set_default_checkbox)
group.setLayout(layout)
@@ -248,7 +244,7 @@ class PageSetupDialog(QDialog):
return
selected_page = self.project.pages[index]
- is_first_page = (index == 0)
+ is_first_page = index == 0
# Show/hide cover settings based on page selection
self._cover_group.setVisible(is_first_page)
@@ -265,7 +261,7 @@ class PageSetupDialog(QDialog):
elif selected_page.is_double_spread:
display_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:
@@ -284,16 +280,12 @@ class PageSetupDialog(QDialog):
"""Update the spine information display."""
if self.cover_checkbox.isChecked():
# Calculate spine width with current settings
- content_pages = sum(
- p.get_page_count() for p in self.project.pages if not p.is_cover
- )
+ content_pages = sum(p.get_page_count() for p in self.project.pages if not p.is_cover)
sheets = math.ceil(content_pages / 4)
spine_width = sheets * self.thickness_spinbox.value() * 2
page_width = self.project.page_size_mm[0]
- total_width = (
- (page_width * 2) + spine_width + (self.bleed_spinbox.value() * 2)
- )
+ total_width = (page_width * 2) + spine_width + (self.bleed_spinbox.value() * 2)
self.spine_info_label.setText(
f"Cover Layout: Front ({page_width:.0f}mm) + "
@@ -317,14 +309,14 @@ class PageSetupDialog(QDialog):
selected_page = self.project.pages[selected_index]
return {
- 'selected_index': selected_index,
- 'selected_page': selected_page,
- 'is_cover': self.cover_checkbox.isChecked(),
- 'paper_thickness_mm': self.thickness_spinbox.value(),
- 'cover_bleed_mm': self.bleed_spinbox.value(),
- 'width_mm': self.width_spinbox.value(),
- 'height_mm': self.height_spinbox.value(),
- 'working_dpi': self.working_dpi_spinbox.value(),
- 'export_dpi': self.export_dpi_spinbox.value(),
- 'set_as_default': self.set_default_checkbox.isChecked()
+ "selected_index": selected_index,
+ "selected_page": selected_page,
+ "is_cover": self.cover_checkbox.isChecked(),
+ "paper_thickness_mm": self.thickness_spinbox.value(),
+ "cover_bleed_mm": self.bleed_spinbox.value(),
+ "width_mm": self.width_spinbox.value(),
+ "height_mm": self.height_spinbox.value(),
+ "working_dpi": self.working_dpi_spinbox.value(),
+ "export_dpi": self.export_dpi_spinbox.value(),
+ "set_as_default": self.set_default_checkbox.isChecked(),
}
diff --git a/pyPhotoAlbum/gl_imports.py b/pyPhotoAlbum/gl_imports.py
index 9081c77..be966d5 100644
--- a/pyPhotoAlbum/gl_imports.py
+++ b/pyPhotoAlbum/gl_imports.py
@@ -24,16 +24,13 @@ try:
GL_LINE_LOOP,
GL_LINES,
GL_TRIANGLE_FAN,
-
# Colors
glColor3f,
glColor4f,
-
# Line state
glLineWidth,
glLineStipple,
GL_LINE_STIPPLE,
-
# General state
glEnable,
glDisable,
@@ -42,7 +39,6 @@ try:
GL_SRC_ALPHA,
GL_ONE_MINUS_SRC_ALPHA,
glBlendFunc,
-
# Textures
glGenTextures,
glBindTexture,
@@ -56,7 +52,6 @@ try:
GL_TEXTURE_MAG_FILTER,
GL_LINEAR,
glTexCoord2f,
-
# Matrix operations
glPushMatrix,
glPopMatrix,
@@ -64,24 +59,22 @@ try:
glTranslatef,
glLoadIdentity,
glRotatef,
-
# Clear operations
glClear,
glClearColor,
GL_COLOR_BUFFER_BIT,
GL_DEPTH_BUFFER_BIT,
-
# Viewport
glViewport,
glMatrixMode,
glOrtho,
GL_PROJECTION,
GL_MODELVIEW,
-
# Info/debug
glGetString,
GL_VERSION,
)
+
GL_AVAILABLE = True
except ImportError:
diff --git a/pyPhotoAlbum/gl_widget.py b/pyPhotoAlbum/gl_widget.py
index 4c137af..0464374 100644
--- a/pyPhotoAlbum/gl_widget.py
+++ b/pyPhotoAlbum/gl_widget.py
@@ -34,7 +34,7 @@ class GLWidget(
MouseInteractionMixin,
UndoableInteractionMixin,
KeyboardNavigationMixin,
- QOpenGLWidget
+ QOpenGLWidget,
):
"""OpenGL widget for pyPhotoAlbum rendering and user interaction
@@ -77,8 +77,8 @@ class GLWidget(
def _get_project_folder(self):
"""Override AssetPathMixin to access project via main window."""
main_window = self.window()
- if hasattr(main_window, 'project') and main_window.project:
- return getattr(main_window.project, 'folder_path', None)
+ if hasattr(main_window, "project") and main_window.project:
+ return getattr(main_window.project, "folder_path", None)
return None
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 self.selected_element:
main_window = self.window()
- if hasattr(main_window, 'delete_selected_element'):
+ if hasattr(main_window, "delete_selected_element"):
main_window.delete_selected_element()
elif event.key() == Qt.Key.Key_Escape:
@@ -99,7 +99,7 @@ class GLWidget(
if self.selected_element:
self.rotation_mode = not self.rotation_mode
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"
main_window.show_status(f"Switched to {mode_text}", 2000)
print(f"Rotation mode: {self.rotation_mode}")
diff --git a/pyPhotoAlbum/image_utils.py b/pyPhotoAlbum/image_utils.py
index c863d2d..df6a75f 100644
--- a/pyPhotoAlbum/image_utils.py
+++ b/pyPhotoAlbum/image_utils.py
@@ -13,6 +13,7 @@ from PIL import Image
# Image Processing Utilities
# =============================================================================
+
def apply_pil_rotation(image: Image.Image, pil_rotation_90: int) -> Image.Image:
"""
Apply 90-degree rotation increments to a PIL image.
@@ -48,8 +49,8 @@ def convert_to_rgba(image: Image.Image) -> Image.Image:
Returns:
PIL Image in RGBA mode
"""
- if image.mode != 'RGBA':
- return image.convert('RGBA')
+ if image.mode != "RGBA":
+ return image.convert("RGBA")
return image
@@ -58,7 +59,7 @@ def calculate_center_crop_coords(
img_height: int,
target_width: 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]:
"""
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)
-def crop_image_to_coords(
- image: Image.Image,
- coords: Tuple[float, float, float, float]
-) -> Image.Image:
+def crop_image_to_coords(image: Image.Image, coords: Tuple[float, float, float, float]) -> Image.Image:
"""
Crop an image using normalized texture coordinates.
@@ -139,9 +137,7 @@ def crop_image_to_coords(
def resize_to_fit(
- image: Image.Image,
- max_size: int,
- resample: Image.Resampling = Image.Resampling.LANCZOS
+ image: Image.Image, max_size: int, resample: Image.Resampling = Image.Resampling.LANCZOS
) -> Image.Image:
"""
Resize image to fit within max_size while preserving aspect ratio.
diff --git a/pyPhotoAlbum/loading_widget.py b/pyPhotoAlbum/loading_widget.py
index a751213..e6f211a 100644
--- a/pyPhotoAlbum/loading_widget.py
+++ b/pyPhotoAlbum/loading_widget.py
@@ -29,7 +29,8 @@ class LoadingWidget(QWidget):
self.setFixedSize(280, 80)
# Styling
- self.setStyleSheet("""
+ self.setStyleSheet(
+ """
QWidget {
background-color: rgba(50, 50, 50, 230);
border-radius: 8px;
@@ -54,7 +55,8 @@ class LoadingWidget(QWidget):
stop:1 rgba(100, 160, 210, 220));
border-radius: 3px;
}
- """)
+ """
+ )
# Layout
layout = QVBoxLayout()
diff --git a/pyPhotoAlbum/main.py b/pyPhotoAlbum/main.py
index 7da212e..61989ba 100644
--- a/pyPhotoAlbum/main.py
+++ b/pyPhotoAlbum/main.py
@@ -9,8 +9,14 @@ import sys
from datetime import datetime
from pathlib import Path
from PyQt6.QtWidgets import (
- QApplication, QMainWindow, QVBoxLayout, QWidget,
- QStatusBar, QScrollBar, QHBoxLayout, QMessageBox
+ QApplication,
+ QMainWindow,
+ QVBoxLayout,
+ QWidget,
+ QStatusBar,
+ QScrollBar,
+ QHBoxLayout,
+ QMessageBox,
)
from PyQt6.QtCore import Qt, QSize, QTimer
from PyQt6.QtGui import QIcon
@@ -58,12 +64,12 @@ class MainWindow(
):
"""
Main application window using mixin architecture.
-
+
This class composes functionality from multiple mixins rather than
implementing everything directly. The ribbon configuration is
automatically generated from decorated methods in the mixins.
"""
-
+
def __init__(self):
super().__init__()
@@ -85,8 +91,8 @@ class MainWindow(
self._autosave_timer.start(5 * 60 * 1000) # 5 minutes in milliseconds
# Add a sample page for demonstration
- #self._add_sample_page()
-
+ # self._add_sample_page()
+
def _init_state(self):
"""Initialize shared application state"""
# Initialize project
@@ -94,17 +100,18 @@ class MainWindow(
# Set asset resolution context
from pyPhotoAlbum.models import set_asset_resolution_context
+
set_asset_resolution_context(self._project.folder_path)
# Initialize template manager
self._template_manager = TemplateManager()
-
+
def _init_ui(self):
"""Initialize user interface"""
# Basic window setup
self.setWindowTitle("pyPhotoAlbum")
self.resize(1200, 800)
-
+
# Set window icon
icon_path = Path(__file__).parent / "icons" / "icon.png"
print(f"Window icon path: {icon_path}")
@@ -113,20 +120,20 @@ class MainWindow(
icon = QIcon(str(icon_path))
print(f"Icon is null: {icon.isNull()}")
self.setWindowIcon(icon)
-
+
# Create main widget with layout
main_widget = QWidget()
main_layout = QVBoxLayout()
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
main_widget.setLayout(main_layout)
-
+
# Build ribbon config from decorated methods
ribbon_config = build_ribbon_config(self.__class__)
-
+
# Print summary (for debugging)
print_ribbon_summary(ribbon_config)
-
+
# Create ribbon with auto-generated config
self.ribbon = RibbonWidget(self, ribbon_config)
main_layout.addWidget(self.ribbon, 0)
@@ -164,13 +171,13 @@ class MainWindow(
canvas_widget.setLayout(canvas_layout)
main_layout.addWidget(canvas_widget, 1)
-
+
self.setCentralWidget(main_widget)
-
+
# Create status bar
self._status_bar = QStatusBar()
self.setStatusBar(self._status_bar)
-
+
# Register keyboard shortcuts
self._register_shortcuts()
@@ -206,8 +213,8 @@ class MainWindow(
viewport_width = self._gl_widget.width()
viewport_height = self._gl_widget.height()
- content_height = bounds['height']
- content_width = bounds['width']
+ content_height = bounds["height"]
+ content_width = bounds["width"]
# Vertical scrollbar
# Scrollbar value 0 = top of content
@@ -257,7 +264,7 @@ class MainWindow(
print(f"Registered shortcut: {shortcut_str} -> {method_name}")
# 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.activated.connect(self.redo)
print("Registered shortcut: Ctrl+Shift+Z -> redo")
@@ -265,31 +272,31 @@ class MainWindow(
def resizeEvent(self, event):
"""Handle window resize to reposition loading widget"""
super().resizeEvent(event)
- if hasattr(self, '_loading_widget'):
+ if hasattr(self, "_loading_widget"):
self._loading_widget.resizeParent()
-
+
def _add_sample_page(self):
"""Add a sample page with some elements for demonstration"""
from pyPhotoAlbum.project import Page
from pyPhotoAlbum.page_layout import PageLayout, GridLayout
from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
-
+
# Create a page with project default size
width_mm, height_mm = self.project.page_size_mm
page_layout = PageLayout(width=width_mm, height=height_mm)
grid = GridLayout(rows=2, columns=2, spacing=20.0)
page_layout.set_grid_layout(grid)
-
+
# Add some sample elements (scaled to new default size)
image = ImageData(image_path="sample.jpg", x=20, y=20, width=50, height=50)
page_layout.add_element(image)
-
+
text_box = TextBoxData(text_content="Sample Text", x=80, y=20, width=50, height=20)
page_layout.add_element(text_box)
-
+
placeholder = PlaceholderData(placeholder_type="image", x=20, y=80, width=50, height=50)
page_layout.add_element(placeholder)
-
+
# Create and add the page
page = Page(layout=page_layout, page_number=1)
page.manually_sized = False # Not manually sized, uses defaults
@@ -315,8 +322,8 @@ class MainWindow(
return
checkpoint_path, metadata = checkpoint_info
- project_name = metadata.get('project_name', 'Unknown')
- timestamp_str = metadata.get('timestamp', 'Unknown time')
+ project_name = metadata.get("project_name", "Unknown")
+ timestamp_str = metadata.get("timestamp", "Unknown time")
# Parse timestamp for better display
try:
@@ -334,7 +341,7 @@ class MainWindow(
f"Time: {time_display}\n\n"
f"Would you like to recover this checkpoint?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
- QMessageBox.StandardButton.Yes
+ QMessageBox.StandardButton.Yes,
)
if reply == QMessageBox.StandardButton.Yes:
@@ -343,7 +350,7 @@ class MainWindow(
if success:
# 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 = result
@@ -365,8 +372,10 @@ class MainWindow(
self,
"Unsaved Changes",
"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.Discard
+ | QMessageBox.StandardButton.Cancel,
+ QMessageBox.StandardButton.Save,
)
if reply == QMessageBox.StandardButton.Save:
@@ -390,7 +399,7 @@ class MainWindow(
self.project.cleanup()
# Stop autosave timer
- if hasattr(self, '_autosave_timer'):
+ if hasattr(self, "_autosave_timer"):
self._autosave_timer.stop()
# Cleanup old checkpoints
@@ -402,12 +411,12 @@ class MainWindow(
def main():
"""Application entry point"""
app = QApplication(sys.argv)
-
+
# Set application identity for proper taskbar/window manager integration
app.setApplicationName("pyPhotoAlbum")
app.setApplicationDisplayName("pyPhotoAlbum")
app.setDesktopFileName("pyphotoalbum.desktop")
-
+
# Set application icon
icon_path = Path(__file__).parent / "icons" / "icon.png"
print(f"Application icon path: {icon_path}")
@@ -416,17 +425,17 @@ def main():
icon = QIcon(str(icon_path))
print(f"Icon is null: {icon.isNull()}")
app.setWindowIcon(icon)
-
+
# Enable high DPI scaling
try:
app.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True)
app.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps, True)
except AttributeError:
pass # Qt version doesn't support these attributes
-
+
window = MainWindow()
window.show()
-
+
sys.exit(app.exec())
diff --git a/pyPhotoAlbum/merge_dialog.py b/pyPhotoAlbum/merge_dialog.py
index 8355c62..2918a65 100644
--- a/pyPhotoAlbum/merge_dialog.py
+++ b/pyPhotoAlbum/merge_dialog.py
@@ -3,9 +3,21 @@ Merge dialog for resolving project conflicts visually
"""
from PyQt6.QtWidgets import (
- QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
- QListWidget, QListWidgetItem, QSplitter, QWidget, QScrollArea,
- QRadioButton, QButtonGroup, QTextEdit, QComboBox, QGroupBox
+ QDialog,
+ QVBoxLayout,
+ QHBoxLayout,
+ QPushButton,
+ QLabel,
+ QListWidget,
+ QListWidgetItem,
+ QSplitter,
+ QWidget,
+ QScrollArea,
+ QRadioButton,
+ QButtonGroup,
+ QTextEdit,
+ QComboBox,
+ QGroupBox,
)
from PyQt6.QtCore import Qt, QSize, pyqtSignal
from PyQt6.QtGui import QPixmap, QPainter, QColor, QFont, QPen
@@ -22,10 +34,7 @@ class PagePreviewWidget(QWidget):
super().__init__(parent)
self.page_data = page_data
self.setMinimumSize(200, 280)
- self.setSizePolicy(
- self.sizePolicy().Policy.Expanding,
- self.sizePolicy().Policy.Expanding
- )
+ self.setSizePolicy(self.sizePolicy().Policy.Expanding, self.sizePolicy().Policy.Expanding)
def paintEvent(self, event):
"""Render the page preview"""
@@ -356,8 +365,4 @@ class MergeDialog(QDialog):
Returns:
Merged project data dictionary
"""
- return self.merge_manager.apply_resolutions(
- self.our_project_data,
- self.their_project_data,
- self.resolutions
- )
+ return self.merge_manager.apply_resolutions(self.our_project_data, self.their_project_data, self.resolutions)
diff --git a/pyPhotoAlbum/merge_manager.py b/pyPhotoAlbum/merge_manager.py
index 24ae9dc..ec5b617 100644
--- a/pyPhotoAlbum/merge_manager.py
+++ b/pyPhotoAlbum/merge_manager.py
@@ -16,6 +16,7 @@ from datetime import datetime, timezone
class ConflictType(Enum):
"""Types of merge conflicts"""
+
# Page-level conflicts
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
@@ -31,6 +32,7 @@ class ConflictType(Enum):
class MergeStrategy(Enum):
"""Automatic merge resolution strategies"""
+
LATEST_WINS = "latest_wins" # Most recent last_modified wins
OURS = "ours" # Always use our version
THEIRS = "theirs" # Always use their version
@@ -40,6 +42,7 @@ class MergeStrategy(Enum):
@dataclass
class ConflictInfo:
"""Information about a single merge conflict"""
+
conflict_type: ConflictType
page_uuid: Optional[str] # UUID of the page (if page-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
def detect_conflicts(
- self,
- our_project_data: Dict[str, Any],
- their_project_data: Dict[str, Any]
+ self, our_project_data: Dict[str, Any], their_project_data: Dict[str, Any]
) -> List[ConflictInfo]:
"""
Detect conflicts between two versions of the same project.
@@ -103,16 +104,18 @@ class MergeManager:
return self.conflicts
- def _detect_project_settings_conflicts(
- self,
- our_data: Dict[str, Any],
- their_data: Dict[str, Any]
- ):
+ def _detect_project_settings_conflicts(self, our_data: Dict[str, Any], their_data: Dict[str, Any]):
"""Detect conflicts in project-level settings."""
# Settings that can conflict
settings_keys = [
- "name", "page_size_mm", "working_dpi", "export_dpi",
- "has_cover", "paper_thickness_mm", "cover_bleed_mm", "binding_type"
+ "name",
+ "page_size_mm",
+ "working_dpi",
+ "export_dpi",
+ "has_cover",
+ "paper_thickness_mm",
+ "cover_bleed_mm",
+ "binding_type",
]
our_modified = our_data.get("last_modified")
@@ -124,20 +127,18 @@ class MergeManager:
# If values differ, it's a conflict
if our_value != their_value:
- self.conflicts.append(ConflictInfo(
- conflict_type=ConflictType.SETTINGS_MODIFIED_BOTH,
- page_uuid=None,
- element_uuid=None,
- our_version={key: our_value, "last_modified": our_modified},
- their_version={key: their_value, "last_modified": their_modified},
- description=f"Project setting '{key}' modified in both versions"
- ))
+ self.conflicts.append(
+ ConflictInfo(
+ conflict_type=ConflictType.SETTINGS_MODIFIED_BOTH,
+ page_uuid=None,
+ element_uuid=None,
+ our_version={key: our_value, "last_modified": our_modified},
+ their_version={key: their_value, "last_modified": their_modified},
+ description=f"Project setting '{key}' modified in both versions",
+ )
+ )
- def _detect_page_conflicts(
- self,
- our_data: Dict[str, Any],
- their_data: Dict[str, Any]
- ):
+ def _detect_page_conflicts(self, our_data: Dict[str, Any], their_data: Dict[str, Any]):
"""Detect conflicts at page level."""
our_pages = {page["uuid"]: page for page in our_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
pass
- def _detect_page_modification_conflicts(
- self,
- page_uuid: str,
- our_page: Dict[str, Any],
- their_page: Dict[str, Any]
- ):
+ def _detect_page_modification_conflicts(self, page_uuid: str, our_page: Dict[str, Any], their_page: Dict[str, Any]):
"""Detect conflicts in a specific page."""
our_modified = our_page.get("last_modified")
their_modified = their_page.get("last_modified")
@@ -180,14 +176,16 @@ class MergeManager:
# Check if one deleted, one modified
if our_page.get("deleted") != their_page.get("deleted"):
- self.conflicts.append(ConflictInfo(
- conflict_type=ConflictType.PAGE_DELETED_ONE,
- page_uuid=page_uuid,
- element_uuid=None,
- our_version=our_page,
- their_version=their_page,
- description=f"Page deleted in one version but modified in the other"
- ))
+ self.conflicts.append(
+ ConflictInfo(
+ conflict_type=ConflictType.PAGE_DELETED_ONE,
+ page_uuid=page_uuid,
+ element_uuid=None,
+ our_version=our_page,
+ their_version=their_page,
+ description=f"Page deleted in one version but modified in the other",
+ )
+ )
return
# Check page-level properties
@@ -201,24 +199,21 @@ class MergeManager:
# Only flag as conflict if properties differ AND timestamps are identical
# (See element conflict detection for detailed explanation of this strategy)
if page_modified and our_modified == their_modified:
- self.conflicts.append(ConflictInfo(
- conflict_type=ConflictType.PAGE_MODIFIED_BOTH,
- page_uuid=page_uuid,
- element_uuid=None,
- our_version=our_page,
- their_version=their_page,
- description=f"Page properties modified with same timestamp (possible conflict)"
- ))
+ self.conflicts.append(
+ ConflictInfo(
+ conflict_type=ConflictType.PAGE_MODIFIED_BOTH,
+ page_uuid=page_uuid,
+ element_uuid=None,
+ our_version=our_page,
+ their_version=their_page,
+ description=f"Page properties modified with same timestamp (possible conflict)",
+ )
+ )
# Check element-level conflicts
self._detect_element_conflicts(page_uuid, our_page, their_page)
- def _detect_element_conflicts(
- self,
- page_uuid: str,
- our_page: Dict[str, Any],
- their_page: Dict[str, Any]
- ):
+ def _detect_element_conflicts(self, page_uuid: str, our_page: Dict[str, Any], their_page: Dict[str, Any]):
"""Detect conflicts in elements within a page."""
our_layout = our_page.get("layout", {})
their_layout = their_page.get("layout", {})
@@ -238,16 +233,10 @@ class MergeManager:
continue
# Element exists in both - check for modifications
- self._detect_element_modification_conflicts(
- page_uuid, elem_uuid, our_elem, their_elem
- )
+ self._detect_element_modification_conflicts(page_uuid, elem_uuid, our_elem, their_elem)
def _detect_element_modification_conflicts(
- self,
- page_uuid: str,
- elem_uuid: str,
- our_elem: Dict[str, Any],
- their_elem: Dict[str, Any]
+ self, page_uuid: str, elem_uuid: str, our_elem: Dict[str, Any], their_elem: Dict[str, Any]
):
"""Detect conflicts in a specific element."""
our_modified = our_elem.get("last_modified")
@@ -259,14 +248,16 @@ class MergeManager:
# Check if one deleted, one modified
if our_elem.get("deleted") != their_elem.get("deleted"):
- self.conflicts.append(ConflictInfo(
- conflict_type=ConflictType.ELEMENT_DELETED_ONE,
- page_uuid=page_uuid,
- element_uuid=elem_uuid,
- our_version=our_elem,
- their_version=their_elem,
- description=f"Element deleted in one version but modified in the other"
- ))
+ self.conflicts.append(
+ ConflictInfo(
+ conflict_type=ConflictType.ELEMENT_DELETED_ONE,
+ page_uuid=page_uuid,
+ element_uuid=elem_uuid,
+ our_version=our_elem,
+ their_version=their_elem,
+ description=f"Element deleted in one version but modified in the other",
+ )
+ )
return
# Check element properties
@@ -298,22 +289,21 @@ class MergeManager:
# 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.
# Flag as conflict to be safe.
- self.conflicts.append(ConflictInfo(
- conflict_type=ConflictType.ELEMENT_MODIFIED_BOTH,
- page_uuid=page_uuid,
- element_uuid=elem_uuid,
- our_version=our_elem,
- their_version=their_elem,
- description=f"Element modified with same timestamp (possible conflict)"
- ))
+ self.conflicts.append(
+ ConflictInfo(
+ conflict_type=ConflictType.ELEMENT_MODIFIED_BOTH,
+ page_uuid=page_uuid,
+ element_uuid=elem_uuid,
+ our_version=our_elem,
+ 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.
# The _merge_non_conflicting_changes method will automatically use the newer version.
- def auto_resolve_conflicts(
- self,
- strategy: MergeStrategy = MergeStrategy.LATEST_WINS
- ) -> Dict[str, Any]:
+ def auto_resolve_conflicts(self, strategy: MergeStrategy = MergeStrategy.LATEST_WINS) -> Dict[str, Any]:
"""
Automatically resolve conflicts based on a strategy.
@@ -353,10 +343,7 @@ class MergeManager:
return None
def apply_resolutions(
- self,
- our_project_data: Dict[str, Any],
- their_project_data: Dict[str, Any],
- resolutions: Dict[int, str]
+ self, our_project_data: Dict[str, Any], their_project_data: Dict[str, Any], resolutions: Dict[int, str]
) -> Dict[str, Any]:
"""
Apply conflict resolutions to create merged project.
@@ -415,20 +402,12 @@ class MergeManager:
break
break
- def _merge_non_conflicting_changes(
- self,
- merged_data: Dict[str, Any],
- their_data: Dict[str, Any]
- ):
+ def _merge_non_conflicting_changes(self, merged_data: Dict[str, Any], their_data: Dict[str, Any]):
"""Add non-conflicting pages and elements from their version."""
self._add_missing_pages(merged_data, their_data)
self._merge_page_elements(merged_data, their_data)
- def _add_missing_pages(
- self,
- merged_data: Dict[str, Any],
- their_data: Dict[str, Any]
- ):
+ def _add_missing_pages(self, merged_data: Dict[str, Any], their_data: Dict[str, Any]):
"""Add pages that exist only in their version."""
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:
merged_data["pages"].append(their_page)
- def _merge_page_elements(
- self,
- merged_data: Dict[str, Any],
- their_data: Dict[str, Any]
- ):
+ def _merge_page_elements(self, merged_data: Dict[str, Any], their_data: Dict[str, Any]):
"""For pages that exist in both versions, merge their elements."""
their_pages = {page["uuid"]: page for page in their_data.get("pages", [])}
@@ -449,25 +424,15 @@ class MergeManager:
if not their_page:
continue
- our_elements = {
- elem["uuid"]: elem
- for elem in our_page.get("layout", {}).get("elements", [])
- }
+ our_elements = {elem["uuid"]: elem for elem in our_page.get("layout", {}).get("elements", [])}
for their_elem in their_page.get("layout", {}).get("elements", []):
self._merge_element(
- our_page=our_page,
- page_uuid=our_page["uuid"],
- their_elem=their_elem,
- our_elements=our_elements
+ our_page=our_page, page_uuid=our_page["uuid"], their_elem=their_elem, our_elements=our_elements
)
def _merge_element(
- self,
- our_page: Dict[str, Any],
- page_uuid: str,
- their_elem: Dict[str, Any],
- our_elements: Dict[str, Any]
+ self, 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."""
elem_uuid = their_elem["uuid"]
@@ -486,17 +451,10 @@ class MergeManager:
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."""
- return any(
- c.element_uuid == elem_uuid and c.page_uuid == page_uuid
- for c in self.conflicts
- )
+ return any(c.element_uuid == elem_uuid and c.page_uuid == page_uuid for c in self.conflicts)
def _merge_by_timestamp(
- self,
- our_page: Dict[str, Any],
- elem_uuid: str,
- their_elem: Dict[str, Any],
- our_elem: Dict[str, Any]
+ self, 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."""
our_modified = our_elem.get("last_modified")
@@ -513,10 +471,7 @@ class MergeManager:
break
-def concatenate_projects(
- project_a_data: Dict[str, Any],
- project_b_data: Dict[str, Any]
-) -> Dict[str, Any]:
+def concatenate_projects(project_a_data: Dict[str, Any], project_b_data: Dict[str, Any]) -> Dict[str, Any]:
"""
Concatenate two projects with different project_ids.
@@ -542,6 +497,8 @@ def concatenate_projects(
# Update last_modified to now
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
diff --git a/pyPhotoAlbum/mixins/__init__.py b/pyPhotoAlbum/mixins/__init__.py
index 35c8f9b..2c2696a 100644
--- a/pyPhotoAlbum/mixins/__init__.py
+++ b/pyPhotoAlbum/mixins/__init__.py
@@ -5,4 +5,4 @@ Mixin modules for pyPhotoAlbum
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
from pyPhotoAlbum.mixins.dialog_mixin import DialogMixin
-__all__ = ['ApplicationStateMixin', 'DialogMixin']
+__all__ = ["ApplicationStateMixin", "DialogMixin"]
diff --git a/pyPhotoAlbum/mixins/asset_drop.py b/pyPhotoAlbum/mixins/asset_drop.py
index 3408103..651322b 100644
--- a/pyPhotoAlbum/mixins/asset_drop.py
+++ b/pyPhotoAlbum/mixins/asset_drop.py
@@ -14,7 +14,7 @@ class AssetDropMixin:
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):
"""Handle drag enter events"""
@@ -66,7 +66,7 @@ class AssetDropMixin:
def _handle_drop_on_element(self, image_path, target_element):
"""Handle dropping an image onto an existing element"""
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
try:
@@ -89,7 +89,7 @@ class AssetDropMixin:
y=placeholder.position[1],
width=placeholder.size[0],
height=placeholder.size[1],
- z_index=placeholder.z_index
+ z_index=placeholder.z_index,
)
if not main_window.project.pages:
@@ -104,7 +104,7 @@ class AssetDropMixin:
def _handle_drop_on_empty_space(self, image_path, x, y):
"""Handle dropping an image onto empty space"""
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
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)
self._add_new_image_to_page(
- asset_path, target_page, page_index, page_renderer,
- x, y, img_width, img_height, main_window
+ asset_path, target_page, page_index, page_renderer, x, y, img_width, img_height, main_window
)
except Exception as e:
print(f"Error importing dropped image: {e}")
@@ -138,27 +137,18 @@ class AssetDropMixin:
# Fallback dimensions if image cannot be read
return 200, 150
- def _add_new_image_to_page(self, asset_path, target_page, page_index,
- page_renderer, x, y, img_width, img_height, main_window):
+ def _add_new_image_to_page(
+ 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)"""
if page_index >= 0:
self.current_page_index = page_index
page_local_x, page_local_y = page_renderer.screen_to_page(x, y)
- new_image = ImageData(
- image_path=asset_path,
- x=page_local_x,
- y=page_local_y,
- width=img_width,
- height=img_height
- )
+ new_image = ImageData(image_path=asset_path, x=page_local_x, y=page_local_y, width=img_width, height=img_height)
- cmd = AddElementCommand(
- target_page.layout,
- new_image,
- asset_manager=main_window.project.asset_manager
- )
+ cmd = AddElementCommand(target_page.layout, new_image, asset_manager=main_window.project.asset_manager)
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}")
diff --git a/pyPhotoAlbum/mixins/asset_path.py b/pyPhotoAlbum/mixins/asset_path.py
index 6a082e1..00c0483 100644
--- a/pyPhotoAlbum/mixins/asset_path.py
+++ b/pyPhotoAlbum/mixins/asset_path.py
@@ -63,6 +63,6 @@ class AssetPathMixin:
Override this method if the project is accessed differently.
Default implementation uses self.project.folder_path.
"""
- if hasattr(self, 'project') and self.project:
- return getattr(self.project, 'folder_path', None)
+ if hasattr(self, "project") and self.project:
+ return getattr(self.project, "folder_path", None)
return None
diff --git a/pyPhotoAlbum/mixins/async_loading.py b/pyPhotoAlbum/mixins/async_loading.py
index e03f929..abfa696 100644
--- a/pyPhotoAlbum/mixins/async_loading.py
+++ b/pyPhotoAlbum/mixins/async_loading.py
@@ -48,13 +48,13 @@ class AsyncLoadingMixin:
"""Cleanup async loading components."""
logger.info("Cleaning up async loading system...")
- if hasattr(self, 'async_image_loader'):
+ if hasattr(self, "async_image_loader"):
self.async_image_loader.stop()
- if hasattr(self, 'async_pdf_generator'):
+ if hasattr(self, "async_pdf_generator"):
self.async_pdf_generator.stop()
- if hasattr(self, 'image_cache'):
+ if hasattr(self, "image_cache"):
self.image_cache.clear()
logger.info("Async loading system cleaned up")
@@ -70,7 +70,7 @@ class AsyncLoadingMixin:
"""
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)
# Trigger re-render to show newly loaded image
@@ -87,7 +87,7 @@ class AsyncLoadingMixin:
"""
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)
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}")
# 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.setLabelText(message)
@@ -117,19 +117,16 @@ class AsyncLoadingMixin:
logger.info(f"PDF export complete: success={success}, warnings={len(warnings)}")
# 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 = None
# Show completion message
main_window = self.window()
- if hasattr(main_window, 'show_status'):
+ if hasattr(main_window, "show_status"):
if success:
if warnings:
- main_window.show_status(
- f"PDF exported successfully with {len(warnings)} warnings",
- 5000
- )
+ main_window.show_status(f"PDF exported successfully with {len(warnings)} warnings", 5000)
else:
main_window.show_status("PDF exported successfully", 3000)
else:
@@ -145,13 +142,13 @@ class AsyncLoadingMixin:
logger.error(f"PDF export failed: {error_msg}")
# 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 = None
# Show error message
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)
def request_image_load(self, image_data, priority: LoadPriority = LoadPriority.NORMAL):
@@ -162,7 +159,7 @@ class AsyncLoadingMixin:
image_data: ImageData element to load
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")
return
@@ -188,7 +185,7 @@ class AsyncLoadingMixin:
Path(image_full_path),
priority=priority,
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):
@@ -200,7 +197,7 @@ class AsyncLoadingMixin:
output_path: Output PDF file path
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")
return False
@@ -208,18 +205,9 @@ class AsyncLoadingMixin:
from PyQt6.QtWidgets import QProgressDialog
from PyQt6.QtCore import Qt
- total_pages = sum(
- 1 if page.is_cover else (2 if page.is_double_spread else 1)
- for page in project.pages
- )
+ total_pages = sum(1 if page.is_cover else (2 if page.is_double_spread else 1) for page in project.pages)
- self._pdf_progress_dialog = QProgressDialog(
- "Exporting to PDF...",
- "Cancel",
- 0,
- total_pages,
- self
- )
+ self._pdf_progress_dialog = QProgressDialog("Exporting to PDF...", "Cancel", 0, total_pages, self)
self._pdf_progress_dialog.setWindowModality(Qt.WindowModality.WindowModal)
self._pdf_progress_dialog.setWindowTitle("PDF Export")
self._pdf_progress_dialog.canceled.connect(self._on_pdf_cancel)
@@ -232,17 +220,17 @@ class AsyncLoadingMixin:
"""Handle 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()
def get_async_stats(self) -> dict:
"""Get async loading system statistics."""
stats = {}
- if hasattr(self, 'async_image_loader'):
- stats['image_loader'] = self.async_image_loader.get_stats()
+ if hasattr(self, "async_image_loader"):
+ stats["image_loader"] = self.async_image_loader.get_stats()
- if hasattr(self, 'async_pdf_generator'):
- stats['pdf_generator'] = self.async_pdf_generator.get_stats()
+ if hasattr(self, "async_pdf_generator"):
+ stats["pdf_generator"] = self.async_pdf_generator.get_stats()
return stats
diff --git a/pyPhotoAlbum/mixins/base.py b/pyPhotoAlbum/mixins/base.py
index 63be57a..1b044c0 100644
--- a/pyPhotoAlbum/mixins/base.py
+++ b/pyPhotoAlbum/mixins/base.py
@@ -9,50 +9,50 @@ from PyQt6.QtWidgets import QStatusBar, QMessageBox
class ApplicationStateMixin:
"""
Base mixin providing access to shared application state.
-
+
This mixin provides properties and helper methods for accessing
core application objects that are shared across all operation mixins.
-
+
Required attributes (must be set by MainWindow):
_project: Project instance
_gl_widget: GLWidget instance
_status_bar: QStatusBar instance
_template_manager: TemplateManager instance
"""
-
+
@property
def project(self):
"""Access to current project"""
- if not hasattr(self, '_project'):
+ if not hasattr(self, "_project"):
raise AttributeError("MainWindow must set _project attribute")
return self._project
-
+
@project.setter
def project(self, value):
"""Set the current project"""
self._project = value
-
+
@property
def gl_widget(self):
"""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")
return self._gl_widget
-
+
@property
def status_bar(self) -> QStatusBar:
"""Access to status bar"""
- if not hasattr(self, '_status_bar'):
+ if not hasattr(self, "_status_bar"):
raise AttributeError("MainWindow must set _status_bar attribute")
return self._status_bar
-
+
@property
def template_manager(self):
"""Access to template manager"""
- if not hasattr(self, '_template_manager'):
+ if not hasattr(self, "_template_manager"):
raise AttributeError("MainWindow must set _template_manager attribute")
return self._template_manager
-
+
# Common helper methods
def _get_most_visible_page_index(self):
@@ -62,7 +62,7 @@ class ApplicationStateMixin:
Returns:
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
# Get viewport dimensions
@@ -70,7 +70,7 @@ class ApplicationStateMixin:
viewport_center_y = viewport_height / 2
# 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
for renderer, page in self.gl_widget._page_renderers:
@@ -107,109 +107,106 @@ class ApplicationStateMixin:
return self.project.pages[index]
return None
-
+
def get_current_page_index(self) -> int:
"""
Get current page index.
-
+
Returns:
Current page index, or -1 if no page
"""
if not self.project or not self.project.pages:
return -1
return self.gl_widget.current_page_index
-
+
def show_status(self, message: str, timeout: int = 2000):
"""
Show message in status bar.
-
+
Args:
message: Message to display
timeout: Display duration in milliseconds
"""
if self.status_bar:
self.status_bar.showMessage(message, timeout)
-
+
def show_error(self, title: str, message: str):
"""
Show error dialog.
-
+
Args:
title: Dialog title
message: Error message
"""
QMessageBox.critical(self, title, message)
-
+
def show_warning(self, title: str, message: str):
"""
Show warning dialog.
-
+
Args:
title: Dialog title
message: Warning message
"""
QMessageBox.warning(self, title, message)
-
+
def show_info(self, title: str, message: str):
"""
Show information dialog.
-
+
Args:
title: Dialog title
message: Information message
"""
QMessageBox.information(self, title, message)
-
+
def require_page(self, show_warning: bool = True) -> bool:
"""
Check if a page is available and optionally show warning.
-
+
Args:
show_warning: Whether to show warning dialog if no page exists
-
+
Returns:
True if page exists, False otherwise
"""
current_page = self.get_current_page()
-
+
if current_page is None:
if show_warning:
self.show_warning("No Page", "Please create a page first.")
return False
-
+
return True
-
+
def require_selection(self, min_count: int = 1, show_warning: bool = True) -> bool:
"""
Check if required number of elements are selected.
-
+
Args:
min_count: Minimum number of selected elements required
show_warning: Whether to show warning dialog if requirement not met
-
+
Returns:
True if requirements met, False otherwise
"""
selected_count = len(self.gl_widget.selected_elements)
-
+
if selected_count < min_count:
if show_warning:
if min_count == 1:
self.show_info("No Selection", "Please select an element.")
else:
- self.show_info(
- "Selection Required",
- f"Please select at least {min_count} elements."
- )
+ self.show_info("Selection Required", f"Please select at least {min_count} elements.")
return False
-
+
return True
-
+
def update_view(self):
"""Trigger GL widget update to refresh the view"""
if self.gl_widget:
self.gl_widget.update()
# Update scrollbars to reflect new content
- if hasattr(self, 'update_scrollbars'):
+ if hasattr(self, "update_scrollbars"):
self.update_scrollbars()
diff --git a/pyPhotoAlbum/mixins/dialog_mixin.py b/pyPhotoAlbum/mixins/dialog_mixin.py
index 114ac81..2def4a9 100644
--- a/pyPhotoAlbum/mixins/dialog_mixin.py
+++ b/pyPhotoAlbum/mixins/dialog_mixin.py
@@ -16,12 +16,7 @@ class DialogMixin:
making it easier to create, test, and maintain complex dialogs.
"""
- def create_dialog(
- self,
- dialog_class: type,
- title: Optional[str] = None,
- **kwargs
- ) -> Optional[Any]:
+ def create_dialog(self, dialog_class: type, title: Optional[str] = None, **kwargs) -> Optional[Any]:
"""
Create and show a dialog, handling the result.
@@ -43,18 +38,13 @@ class DialogMixin:
# Show dialog and handle result
if dialog.exec() == QDialog.DialogCode.Accepted:
# Check if dialog has a get_values method
- if hasattr(dialog, 'get_values'):
+ if hasattr(dialog, "get_values"):
return dialog.get_values()
return True
return None
- def show_dialog(
- self,
- dialog_class: type,
- on_accept: Optional[Callable] = None,
- **kwargs
- ) -> bool:
+ def show_dialog(self, dialog_class: type, on_accept: Optional[Callable] = None, **kwargs) -> bool:
"""
Show a dialog and execute callback on acceptance.
diff --git a/pyPhotoAlbum/mixins/element_manipulation.py b/pyPhotoAlbum/mixins/element_manipulation.py
index 1a014fa..6cde1e7 100644
--- a/pyPhotoAlbum/mixins/element_manipulation.py
+++ b/pyPhotoAlbum/mixins/element_manipulation.py
@@ -27,11 +27,7 @@ class ElementManipulationMixin:
self.rotation_snap_angle: int = 15 # Default snap angle in degrees
# Snap state tracking
- self.snap_state = {
- 'is_snapped': False,
- 'last_position': None,
- 'last_size': None
- }
+ self.snap_state = {"is_snapped": False, "last_position": None, "last_size": None}
def _resize_element(self, dx: float, dy: float):
"""
@@ -49,7 +45,7 @@ class ElementManipulationMixin:
# Get the snapping system from the element's parent page
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)
return
@@ -62,6 +58,7 @@ class ElementManipulationMixin:
# Apply snapping to resize
from pyPhotoAlbum.snapping import SnapResizeParams
+
params = SnapResizeParams(
position=self.resize_start_pos,
size=self.resize_start_size,
@@ -70,7 +67,7 @@ class ElementManipulationMixin:
resize_handle=self.resize_handle,
page_size=page_size,
dpi=dpi,
- project=main_window.project
+ project=main_window.project,
)
new_pos, new_size = snap_sys.snap_resize(params)
@@ -100,16 +97,16 @@ class ElementManipulationMixin:
start_x, start_y = self.resize_start_pos
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.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.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.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)
# Ensure minimum size
@@ -121,7 +118,9 @@ class ElementManipulationMixin:
w, _ = self.selected_element.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.
diff --git a/pyPhotoAlbum/mixins/element_selection.py b/pyPhotoAlbum/mixins/element_selection.py
index 6766a3a..8f671c3 100644
--- a/pyPhotoAlbum/mixins/element_selection.py
+++ b/pyPhotoAlbum/mixins/element_selection.py
@@ -54,7 +54,7 @@ class ElementSelectionMixin:
Returns:
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
# Check each page from top to bottom (reverse z-order)
@@ -96,11 +96,11 @@ class ElementSelectionMixin:
return None
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
# 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
renderer = self.selected_element._page_renderer
@@ -117,10 +117,10 @@ class ElementSelectionMixin:
# Check handles (no rotation transformation needed - images are already rotated)
handles = {
- 'nw': (ex - 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),
- 'se': (ex + ew - handle_size/2, ey + eh - handle_size/2),
+ "nw": (ex - 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),
+ "se": (ex + ew - handle_size / 2, ey + eh - handle_size / 2),
}
for name, (hx, hy) in handles.items():
diff --git a/pyPhotoAlbum/mixins/interaction_command_builders.py b/pyPhotoAlbum/mixins/interaction_command_builders.py
index e1e8c57..b1d4687 100644
--- a/pyPhotoAlbum/mixins/interaction_command_builders.py
+++ b/pyPhotoAlbum/mixins/interaction_command_builders.py
@@ -59,7 +59,7 @@ class MoveCommandBuilder(CommandBuilder):
def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
"""Check if position changed significantly."""
- old_pos = start_state.get('position')
+ old_pos = start_state.get("position")
if old_pos is None:
return False
@@ -68,7 +68,7 @@ class MoveCommandBuilder(CommandBuilder):
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
"""Build a MoveElementCommand."""
- old_pos = start_state.get('position')
+ old_pos = start_state.get("position")
if old_pos is None:
return None
@@ -79,6 +79,7 @@ class MoveCommandBuilder(CommandBuilder):
return None
from pyPhotoAlbum.commands import MoveElementCommand
+
command = MoveElementCommand(element, 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:
"""Check if position or size changed significantly."""
- old_pos = start_state.get('position')
- old_size = start_state.get('size')
+ old_pos = start_state.get("position")
+ old_size = start_state.get("size")
if old_pos is None or old_size is None:
return False
@@ -106,8 +107,8 @@ class ResizeCommandBuilder(CommandBuilder):
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
"""Build a ResizeElementCommand."""
- old_pos = start_state.get('position')
- old_size = start_state.get('size')
+ old_pos = start_state.get("position")
+ old_size = start_state.get("size")
if old_pos is None or old_size is None:
return None
@@ -119,6 +120,7 @@ class ResizeCommandBuilder(CommandBuilder):
return None
from pyPhotoAlbum.commands import ResizeElementCommand
+
command = ResizeElementCommand(element, old_pos, old_size, new_pos, 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:
"""Check if rotation changed significantly."""
- old_rotation = start_state.get('rotation')
+ old_rotation = start_state.get("rotation")
if old_rotation is None:
return False
@@ -139,7 +141,7 @@ class RotateCommandBuilder(CommandBuilder):
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
"""Build a RotateElementCommand."""
- old_rotation = start_state.get('rotation')
+ old_rotation = start_state.get("rotation")
if old_rotation is None:
return None
@@ -150,6 +152,7 @@ class RotateCommandBuilder(CommandBuilder):
return None
from pyPhotoAlbum.commands import RotateElementCommand
+
command = RotateElementCommand(element, old_rotation, new_rotation)
self.log_command("Rotation", f"{old_rotation:.1f}° → {new_rotation:.1f}°")
@@ -166,7 +169,7 @@ class ImagePanCommandBuilder(CommandBuilder):
if not isinstance(element, ImageData):
return False
- old_crop = start_state.get('crop_info')
+ old_crop = start_state.get("crop_info")
if old_crop is None:
return False
@@ -181,7 +184,7 @@ class ImagePanCommandBuilder(CommandBuilder):
if not isinstance(element, ImageData):
return None
- old_crop = start_state.get('crop_info')
+ old_crop = start_state.get("crop_info")
if old_crop is None:
return None
@@ -193,6 +196,7 @@ class ImagePanCommandBuilder(CommandBuilder):
return None
from pyPhotoAlbum.commands import AdjustImageCropCommand
+
command = AdjustImageCropCommand(element, old_crop, new_crop)
self.log_command("Image pan", f"{old_crop} → {new_crop}")
diff --git a/pyPhotoAlbum/mixins/interaction_command_factory.py b/pyPhotoAlbum/mixins/interaction_command_factory.py
index 46958a0..693b465 100644
--- a/pyPhotoAlbum/mixins/interaction_command_factory.py
+++ b/pyPhotoAlbum/mixins/interaction_command_factory.py
@@ -12,7 +12,7 @@ from .interaction_command_builders import (
MoveCommandBuilder,
ResizeCommandBuilder,
RotateCommandBuilder,
- ImagePanCommandBuilder
+ ImagePanCommandBuilder,
)
@@ -31,10 +31,10 @@ class InteractionCommandFactory:
def _register_default_builders(self):
"""Register the default command builders."""
- self.register_builder('move', MoveCommandBuilder())
- self.register_builder('resize', ResizeCommandBuilder())
- self.register_builder('rotate', RotateCommandBuilder())
- self.register_builder('image_pan', ImagePanCommandBuilder())
+ self.register_builder("move", MoveCommandBuilder())
+ self.register_builder("resize", ResizeCommandBuilder())
+ self.register_builder("rotate", RotateCommandBuilder())
+ self.register_builder("image_pan", ImagePanCommandBuilder())
def register_builder(self, interaction_type: str, builder: CommandBuilder):
"""
@@ -46,11 +46,9 @@ class InteractionCommandFactory:
"""
self._builders[interaction_type] = builder
- def create_command(self,
- interaction_type: str,
- element: BaseLayoutElement,
- start_state: dict,
- **kwargs) -> Optional[Any]:
+ def create_command(
+ self, interaction_type: str, element: BaseLayoutElement, start_state: dict, **kwargs
+ ) -> Optional[Any]:
"""
Create a command based on interaction type and state changes.
@@ -91,13 +89,15 @@ class InteractionState:
the code more maintainable.
"""
- def __init__(self,
- element: Optional[BaseLayoutElement] = None,
- interaction_type: Optional[str] = None,
- position: Optional[tuple] = None,
- size: Optional[tuple] = None,
- rotation: Optional[float] = None,
- crop_info: Optional[tuple] = None):
+ def __init__(
+ self,
+ element: Optional[BaseLayoutElement] = None,
+ interaction_type: Optional[str] = None,
+ position: Optional[tuple] = None,
+ size: Optional[tuple] = None,
+ rotation: Optional[float] = None,
+ crop_info: Optional[tuple] = None,
+ ):
"""
Initialize interaction state.
@@ -125,13 +125,13 @@ class InteractionState:
"""
state = {}
if self.position is not None:
- state['position'] = self.position
+ state["position"] = self.position
if self.size is not None:
- state['size'] = self.size
+ state["size"] = self.size
if self.rotation is not None:
- state['rotation'] = self.rotation
+ state["rotation"] = self.rotation
if self.crop_info is not None:
- state['crop_info'] = self.crop_info
+ state["crop_info"] = self.crop_info
return state
def is_valid(self) -> bool:
diff --git a/pyPhotoAlbum/mixins/interaction_undo.py b/pyPhotoAlbum/mixins/interaction_undo.py
index 2eb20eb..01ead49 100644
--- a/pyPhotoAlbum/mixins/interaction_undo.py
+++ b/pyPhotoAlbum/mixins/interaction_undo.py
@@ -10,12 +10,12 @@ from .interaction_command_factory import InteractionCommandFactory, InteractionS
class UndoableInteractionMixin:
"""
Mixin providing automatic undo/redo for interactive mouse operations.
-
+
This mixin tracks the state of elements before interactive operations
(move, resize, rotate) and automatically creates appropriate Command
objects when the interaction completes.
"""
-
+
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -24,7 +24,7 @@ class UndoableInteractionMixin:
# Interaction state tracking
self._interaction_state = InteractionState()
-
+
def _begin_move(self, element: BaseLayoutElement):
"""
Begin tracking a move operation.
@@ -33,9 +33,9 @@ class UndoableInteractionMixin:
element: The element being moved
"""
self._interaction_state.element = element
- self._interaction_state.interaction_type = 'move'
+ self._interaction_state.interaction_type = "move"
self._interaction_state.position = element.position
-
+
def _begin_resize(self, element: BaseLayoutElement):
"""
Begin tracking a resize operation.
@@ -44,10 +44,10 @@ class UndoableInteractionMixin:
element: The element being resized
"""
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.size = element.size
-
+
def _begin_rotate(self, element: BaseLayoutElement):
"""
Begin tracking a rotate operation.
@@ -56,9 +56,9 @@ class UndoableInteractionMixin:
element: The element being rotated
"""
self._interaction_state.element = element
- self._interaction_state.interaction_type = 'rotate'
+ self._interaction_state.interaction_type = "rotate"
self._interaction_state.rotation = element.rotation
-
+
def _begin_image_pan(self, element):
"""
Begin tracking an image pan operation.
@@ -67,13 +67,14 @@ class UndoableInteractionMixin:
element: The ImageData element being panned
"""
from pyPhotoAlbum.models import ImageData
+
if not isinstance(element, ImageData):
return
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
-
+
def _end_interaction(self):
"""
End the current interaction and create appropriate undo/redo command.
@@ -88,7 +89,7 @@ class UndoableInteractionMixin:
# Get main window to access project history
main_window = self.window()
- if not hasattr(main_window, 'project'):
+ if not hasattr(main_window, "project"):
self._clear_interaction_state()
return
@@ -96,7 +97,7 @@ class UndoableInteractionMixin:
command = self._command_factory.create_command(
interaction_type=self._interaction_state.interaction_type,
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
@@ -105,11 +106,11 @@ class UndoableInteractionMixin:
# Clear interaction state
self._clear_interaction_state()
-
+
def _clear_interaction_state(self):
"""Clear all interaction tracking state"""
self._interaction_state.clear()
-
+
def _cancel_interaction(self):
"""Cancel the current interaction without creating a command"""
self._clear_interaction_state()
diff --git a/pyPhotoAlbum/mixins/interaction_validators.py b/pyPhotoAlbum/mixins/interaction_validators.py
index d13163c..2bfe66b 100644
--- a/pyPhotoAlbum/mixins/interaction_validators.py
+++ b/pyPhotoAlbum/mixins/interaction_validators.py
@@ -16,6 +16,7 @@ def significant_change(threshold: float = 0.1):
Returns:
None if change is insignificant, otherwise returns the command builder result
"""
+
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
@@ -23,7 +24,9 @@ def significant_change(threshold: float = 0.1):
if result is None:
return None
return result
+
return wrapper
+
return decorator
@@ -31,9 +34,9 @@ class ChangeValidator:
"""Validates whether changes are significant enough to create commands."""
@staticmethod
- def position_changed(old_pos: Optional[Tuple[float, float]],
- new_pos: Optional[Tuple[float, float]],
- threshold: float = 0.1) -> bool:
+ def position_changed(
+ old_pos: Optional[Tuple[float, float]], new_pos: Optional[Tuple[float, float]], threshold: float = 0.1
+ ) -> bool:
"""Check if position changed significantly."""
if old_pos is None or new_pos is None:
return False
@@ -43,9 +46,9 @@ class ChangeValidator:
return dx > threshold or dy > threshold
@staticmethod
- def size_changed(old_size: Optional[Tuple[float, float]],
- new_size: Optional[Tuple[float, float]],
- threshold: float = 0.1) -> bool:
+ def size_changed(
+ old_size: Optional[Tuple[float, float]], new_size: Optional[Tuple[float, float]], threshold: float = 0.1
+ ) -> bool:
"""Check if size changed significantly."""
if old_size is None or new_size is None:
return False
@@ -55,9 +58,7 @@ class ChangeValidator:
return dw > threshold or dh > threshold
@staticmethod
- def rotation_changed(old_rotation: Optional[float],
- new_rotation: Optional[float],
- threshold: float = 0.1) -> bool:
+ def rotation_changed(old_rotation: Optional[float], new_rotation: Optional[float], threshold: float = 0.1) -> bool:
"""Check if rotation changed significantly."""
if old_rotation is None or new_rotation is None:
return False
@@ -65,9 +66,11 @@ class ChangeValidator:
return abs(new_rotation - old_rotation) > threshold
@staticmethod
- def crop_changed(old_crop: Optional[Tuple[float, float, float, float]],
- new_crop: Optional[Tuple[float, float, float, float]],
- threshold: float = 0.001) -> bool:
+ def crop_changed(
+ old_crop: Optional[Tuple[float, float, float, float]],
+ new_crop: Optional[Tuple[float, float, float, float]],
+ threshold: float = 0.001,
+ ) -> bool:
"""Check if crop info changed significantly."""
if old_crop is None or new_crop is None:
return False
@@ -85,8 +88,7 @@ class InteractionChangeDetector:
self.threshold = threshold
self.validator = ChangeValidator()
- def detect_position_change(self, old_pos: Tuple[float, float],
- new_pos: Tuple[float, float]) -> Optional[dict]:
+ def detect_position_change(self, old_pos: Tuple[float, float], new_pos: Tuple[float, float]) -> Optional[dict]:
"""
Detect position change and return change info.
@@ -97,14 +99,13 @@ class InteractionChangeDetector:
return None
return {
- 'old_position': old_pos,
- 'new_position': new_pos,
- 'delta_x': new_pos[0] - old_pos[0],
- 'delta_y': new_pos[1] - old_pos[1]
+ "old_position": old_pos,
+ "new_position": new_pos,
+ "delta_x": new_pos[0] - old_pos[0],
+ "delta_y": new_pos[1] - old_pos[1],
}
- def detect_size_change(self, old_size: Tuple[float, float],
- new_size: Tuple[float, float]) -> Optional[dict]:
+ def detect_size_change(self, old_size: Tuple[float, float], new_size: Tuple[float, float]) -> Optional[dict]:
"""
Detect size change and return change info.
@@ -115,14 +116,13 @@ class InteractionChangeDetector:
return None
return {
- 'old_size': old_size,
- 'new_size': new_size,
- 'delta_width': new_size[0] - old_size[0],
- 'delta_height': new_size[1] - old_size[1]
+ "old_size": old_size,
+ "new_size": new_size,
+ "delta_width": new_size[0] - old_size[0],
+ "delta_height": new_size[1] - old_size[1],
}
- def detect_rotation_change(self, old_rotation: float,
- new_rotation: float) -> Optional[dict]:
+ def detect_rotation_change(self, old_rotation: float, new_rotation: float) -> Optional[dict]:
"""
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):
return None
- return {
- 'old_rotation': old_rotation,
- 'new_rotation': new_rotation,
- 'delta_angle': new_rotation - old_rotation
- }
+ return {"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],
- new_crop: Tuple[float, float, float, float]) -> Optional[dict]:
+ def detect_crop_change(
+ self, old_crop: Tuple[float, float, float, float], new_crop: Tuple[float, float, float, float]
+ ) -> Optional[dict]:
"""
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):
return None
- return {
- 'old_crop': old_crop,
- 'new_crop': new_crop,
- 'delta': tuple(new_crop[i] - old_crop[i] for i in range(4))
- }
+ return {"old_crop": old_crop, "new_crop": new_crop, "delta": tuple(new_crop[i] - old_crop[i] for i in range(4))}
diff --git a/pyPhotoAlbum/mixins/keyboard_navigation.py b/pyPhotoAlbum/mixins/keyboard_navigation.py
index 848b6e0..4366b5a 100644
--- a/pyPhotoAlbum/mixins/keyboard_navigation.py
+++ b/pyPhotoAlbum/mixins/keyboard_navigation.py
@@ -16,7 +16,7 @@ class KeyboardNavigationMixin:
def _navigate_to_next_page(self):
"""Navigate to the next page using Page Down key"""
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
current_index = main_window._get_most_visible_page_index()
@@ -24,14 +24,14 @@ class KeyboardNavigationMixin:
next_page = main_window.project.pages[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)
main_window.show_status(f"Navigated to {page_name}", 2000)
def _navigate_to_previous_page(self):
"""Navigate to the previous page using Page Up key"""
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
current_index = main_window._get_most_visible_page_index()
@@ -39,7 +39,7 @@ class KeyboardNavigationMixin:
prev_page = main_window.project.pages[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)
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
"""
main_window = self.window()
- if not hasattr(main_window, 'project'):
+ if not hasattr(main_window, "project"):
return
dpi = main_window.project.working_dpi
@@ -79,14 +79,14 @@ class KeyboardNavigationMixin:
self.pan_offset[1] = target_pan_y
# Clamp pan offset to content bounds
- if hasattr(self, 'clamp_pan_offset'):
+ if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset()
self.update()
# Update scrollbars if available
main_window = self.window()
- if hasattr(main_window, 'update_scrollbars'):
+ if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars()
def _move_viewport_with_arrow_keys(self, key):
@@ -109,14 +109,14 @@ class KeyboardNavigationMixin:
self.pan_offset[0] -= move_amount
# Clamp pan offset to content bounds
- if hasattr(self, 'clamp_pan_offset'):
+ if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset()
self.update()
# Update scrollbars if available
main_window = self.window()
- if hasattr(main_window, 'update_scrollbars'):
+ if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars()
def _move_selected_elements_with_arrow_keys(self, key):
@@ -127,7 +127,7 @@ class KeyboardNavigationMixin:
key: The Qt key code (Up, Down, Left, Right)
"""
main_window = self.window()
- if not hasattr(main_window, 'project'):
+ if not hasattr(main_window, "project"):
return
# Movement amount in mm
@@ -151,7 +151,7 @@ class KeyboardNavigationMixin:
new_y = current_y + dy
# 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
snap_sys = page.layout.snapping_system
page_size = page.layout.size
@@ -162,7 +162,7 @@ class KeyboardNavigationMixin:
size=element.size,
page_size=page_size,
dpi=dpi,
- project=main_window.project
+ project=main_window.project,
)
element.position = snapped_pos
else:
@@ -170,7 +170,7 @@ class KeyboardNavigationMixin:
self.update()
- if hasattr(main_window, 'show_status'):
+ if hasattr(main_window, "show_status"):
count = len(self.selected_elements)
elem_text = "element" if count == 1 else "elements"
main_window.show_status(f"Moved {count} {elem_text}", 1000)
diff --git a/pyPhotoAlbum/mixins/mouse_interaction.py b/pyPhotoAlbum/mixins/mouse_interaction.py
index dc3128a..a8af0b8 100644
--- a/pyPhotoAlbum/mixins/mouse_interaction.py
+++ b/pyPhotoAlbum/mixins/mouse_interaction.py
@@ -66,7 +66,9 @@ class MouseInteractionMixin:
element = self._get_element_at(x, y)
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
if ctrl_pressed and isinstance(element, ImageData) and not self.rotation_mode:
# Enter image pan mode - pan image within frame
@@ -129,7 +131,7 @@ class MouseInteractionMixin:
self.pan_offset[1] += dy
# Clamp pan offset to content bounds
- if hasattr(self, 'clamp_pan_offset'):
+ if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset()
self.drag_start_pos = (x, y)
@@ -137,7 +139,7 @@ class MouseInteractionMixin:
# Update scrollbars if available
main_window = self.window()
- if hasattr(main_window, 'update_scrollbars'):
+ if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars()
return
@@ -152,7 +154,7 @@ class MouseInteractionMixin:
elif self.rotation_mode:
# Rotation mode
- if not hasattr(self.selected_element, '_page_renderer'):
+ if not hasattr(self.selected_element, "_page_renderer"):
return
renderer = self.selected_element._page_renderer
@@ -173,7 +175,7 @@ class MouseInteractionMixin:
self.selected_element.rotation = angle
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)
elif self.resize_handle:
@@ -189,11 +191,13 @@ class MouseInteractionMixin:
# Move mode
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
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:
total_dx = (x - self.drag_start_pos[0]) / 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,
page_size=page_size,
dpi=dpi,
- project=main_window.project
+ project=main_window.project,
)
self.selected_element.position = snapped_pos
@@ -238,11 +242,7 @@ class MouseInteractionMixin:
self.rotation_start_angle = None
self.image_pan_mode = False
self.image_pan_start_crop = None
- self.snap_state = {
- 'is_snapped': False,
- 'last_position': None,
- 'last_size': None
- }
+ self.snap_state = {"is_snapped": False, "last_position": None, "last_size": None}
self.setCursor(Qt.CursorShape.ArrowCursor)
elif event.button() == Qt.MouseButton.MiddleButton:
@@ -257,6 +257,7 @@ class MouseInteractionMixin:
element = self._get_element_at(x, y)
from pyPhotoAlbum.models import TextBoxData
+
if isinstance(element, TextBoxData):
self._edit_text_element(element)
return
@@ -293,23 +294,20 @@ class MouseInteractionMixin:
if self.is_dragging and self.drag_start_pos:
pan_delta_x = self.pan_offset[0] - old_pan_x
pan_delta_y = self.pan_offset[1] - old_pan_y
- self.drag_start_pos = (
- self.drag_start_pos[0] + pan_delta_x,
- self.drag_start_pos[1] + pan_delta_y
- )
+ self.drag_start_pos = (self.drag_start_pos[0] + pan_delta_x, self.drag_start_pos[1] + pan_delta_y)
# Clamp pan offset to content bounds
- if hasattr(self, 'clamp_pan_offset'):
+ if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset()
self.update()
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)
# Update scrollbars if available
- if hasattr(main_window, 'update_scrollbars'):
+ if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars()
else:
# Regular wheel: Vertical scroll
@@ -318,22 +316,19 @@ class MouseInteractionMixin:
self.pan_offset[1] += scroll_amount
# Clamp pan offset to content bounds
- if hasattr(self, 'clamp_pan_offset'):
+ if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset()
# If dragging, adjust drag_start_pos to account for pan_offset change
if self.is_dragging and self.drag_start_pos:
pan_delta_y = self.pan_offset[1] - old_pan_y
- self.drag_start_pos = (
- self.drag_start_pos[0],
- self.drag_start_pos[1] + pan_delta_y
- )
+ self.drag_start_pos = (self.drag_start_pos[0], self.drag_start_pos[1] + pan_delta_y)
self.update()
# Update scrollbars if available
main_window = self.window()
- if hasattr(main_window, 'update_scrollbars'):
+ if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars()
def _edit_text_element(self, text_element):
@@ -344,9 +339,9 @@ class MouseInteractionMixin:
if dialog.exec() == TextEditDialog.DialogCode.Accepted:
values = dialog.get_values()
- text_element.text_content = values['text_content']
- text_element.font_settings = values['font_settings']
- text_element.alignment = values['alignment']
+ text_element.text_content = values["text_content"]
+ text_element.font_settings = values["font_settings"]
+ text_element.alignment = values["alignment"]
self.update()
diff --git a/pyPhotoAlbum/mixins/operations/__init__.py b/pyPhotoAlbum/mixins/operations/__init__.py
index 479a7a7..b597bd1 100644
--- a/pyPhotoAlbum/mixins/operations/__init__.py
+++ b/pyPhotoAlbum/mixins/operations/__init__.py
@@ -15,15 +15,15 @@ from pyPhotoAlbum.mixins.operations.zorder_ops import ZOrderOperationsMixin
from pyPhotoAlbum.mixins.operations.merge_ops import MergeOperationsMixin
__all__ = [
- 'FileOperationsMixin',
- 'EditOperationsMixin',
- 'ElementOperationsMixin',
- 'PageOperationsMixin',
- 'TemplateOperationsMixin',
- 'ViewOperationsMixin',
- 'AlignmentOperationsMixin',
- 'DistributionOperationsMixin',
- 'SizeOperationsMixin',
- 'ZOrderOperationsMixin',
- 'MergeOperationsMixin',
+ "FileOperationsMixin",
+ "EditOperationsMixin",
+ "ElementOperationsMixin",
+ "PageOperationsMixin",
+ "TemplateOperationsMixin",
+ "ViewOperationsMixin",
+ "AlignmentOperationsMixin",
+ "DistributionOperationsMixin",
+ "SizeOperationsMixin",
+ "ZOrderOperationsMixin",
+ "MergeOperationsMixin",
]
diff --git a/pyPhotoAlbum/mixins/operations/alignment_ops.py b/pyPhotoAlbum/mixins/operations/alignment_ops.py
index 5d0a6ca..ce4c744 100644
--- a/pyPhotoAlbum/mixins/operations/alignment_ops.py
+++ b/pyPhotoAlbum/mixins/operations/alignment_ops.py
@@ -9,130 +9,130 @@ from pyPhotoAlbum.commands import AlignElementsCommand, ResizeElementsCommand
class AlignmentOperationsMixin:
"""Mixin providing element alignment operations"""
-
+
def _get_selected_elements_list(self):
"""Get list of selected elements for alignment operations"""
return list(self.gl_widget.selected_elements) if self.gl_widget.selected_elements else []
-
+
@ribbon_action(
label="Align Left",
tooltip="Align selected elements to the left",
tab="Arrange",
group="Align",
requires_selection=True,
- min_selection=2
+ min_selection=2,
)
def align_left(self):
"""Align selected elements to the left"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2):
return
-
+
changes = AlignmentManager.align_left(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Aligned {len(elements)} elements to left", 2000)
-
+
@ribbon_action(
label="Align Right",
tooltip="Align selected elements to the right",
tab="Arrange",
group="Align",
requires_selection=True,
- min_selection=2
+ min_selection=2,
)
def align_right(self):
"""Align selected elements to the right"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2):
return
-
+
changes = AlignmentManager.align_right(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Aligned {len(elements)} elements to right", 2000)
-
+
@ribbon_action(
label="Align Top",
tooltip="Align selected elements to the top",
tab="Arrange",
group="Align",
requires_selection=True,
- min_selection=2
+ min_selection=2,
)
def align_top(self):
"""Align selected elements to the top"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2):
return
-
+
changes = AlignmentManager.align_top(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Aligned {len(elements)} elements to top", 2000)
-
+
@ribbon_action(
label="Align Bottom",
tooltip="Align selected elements to the bottom",
tab="Arrange",
group="Align",
requires_selection=True,
- min_selection=2
+ min_selection=2,
)
def align_bottom(self):
"""Align selected elements to the bottom"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2):
return
-
+
changes = AlignmentManager.align_bottom(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Aligned {len(elements)} elements to bottom", 2000)
-
+
@ribbon_action(
label="Align H-Center",
tooltip="Align selected elements to horizontal center",
tab="Arrange",
group="Align",
requires_selection=True,
- min_selection=2
+ min_selection=2,
)
def align_horizontal_center(self):
"""Align selected elements to horizontal center"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2):
return
-
+
changes = AlignmentManager.align_horizontal_center(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Aligned {len(elements)} elements to horizontal center", 2000)
-
+
@ribbon_action(
label="Align V-Center",
tooltip="Align selected elements to vertical center",
tab="Arrange",
group="Align",
requires_selection=True,
- min_selection=2
+ min_selection=2,
)
def align_vertical_center(self):
"""Align selected elements to vertical center"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2):
return
-
+
changes = AlignmentManager.align_vertical_center(elements)
if changes:
cmd = AlignElementsCommand(changes)
@@ -146,7 +146,7 @@ class AlignmentOperationsMixin:
tab="Arrange",
group="Size",
requires_selection=True,
- min_selection=1
+ min_selection=1,
)
def maximize_pattern(self):
"""Maximize selected elements until they are close to borders or each other"""
diff --git a/pyPhotoAlbum/mixins/operations/distribution_ops.py b/pyPhotoAlbum/mixins/operations/distribution_ops.py
index b1f377c..7a07da2 100644
--- a/pyPhotoAlbum/mixins/operations/distribution_ops.py
+++ b/pyPhotoAlbum/mixins/operations/distribution_ops.py
@@ -9,88 +9,88 @@ from pyPhotoAlbum.commands import AlignElementsCommand
class DistributionOperationsMixin:
"""Mixin providing element distribution and spacing operations"""
-
+
def _get_selected_elements_list(self):
"""Get list of selected elements for distribution operations"""
return list(self.gl_widget.selected_elements) if self.gl_widget.selected_elements else []
-
+
@ribbon_action(
label="Distribute H",
tooltip="Distribute selected elements evenly horizontally",
tab="Arrange",
group="Distribute",
requires_selection=True,
- min_selection=3
+ min_selection=3,
)
def distribute_horizontally(self):
"""Distribute selected elements evenly horizontally"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=3):
return
-
+
changes = AlignmentManager.distribute_horizontally(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Distributed {len(elements)} elements horizontally", 2000)
-
+
@ribbon_action(
label="Distribute V",
tooltip="Distribute selected elements evenly vertically",
tab="Arrange",
group="Distribute",
requires_selection=True,
- min_selection=3
+ min_selection=3,
)
def distribute_vertically(self):
"""Distribute selected elements evenly vertically"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=3):
return
-
+
changes = AlignmentManager.distribute_vertically(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Distributed {len(elements)} elements vertically", 2000)
-
+
@ribbon_action(
label="Space H",
tooltip="Space selected elements equally horizontally",
tab="Arrange",
group="Distribute",
requires_selection=True,
- min_selection=3
+ min_selection=3,
)
def space_horizontally(self):
"""Space selected elements equally horizontally"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=3):
return
-
+
changes = AlignmentManager.space_horizontally(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Spaced {len(elements)} elements horizontally", 2000)
-
+
@ribbon_action(
label="Space V",
tooltip="Space selected elements equally vertically",
tab="Arrange",
group="Distribute",
requires_selection=True,
- min_selection=3
+ min_selection=3,
)
def space_vertically(self):
"""Space selected elements equally vertically"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=3):
return
-
+
changes = AlignmentManager.space_vertically(elements)
if changes:
cmd = AlignElementsCommand(changes)
diff --git a/pyPhotoAlbum/mixins/operations/edit_ops.py b/pyPhotoAlbum/mixins/operations/edit_ops.py
index fdfc920..6dfd3c6 100644
--- a/pyPhotoAlbum/mixins/operations/edit_ops.py
+++ b/pyPhotoAlbum/mixins/operations/edit_ops.py
@@ -8,14 +8,8 @@ from pyPhotoAlbum.commands import DeleteElementCommand, RotateElementCommand
class EditOperationsMixin:
"""Mixin providing edit-related operations"""
-
- @ribbon_action(
- label="Undo",
- tooltip="Undo last action (Ctrl+Z)",
- tab="Home",
- group="Edit",
- shortcut="Ctrl+Z"
- )
+
+ @ribbon_action(label="Undo", tooltip="Undo last action (Ctrl+Z)", tab="Home", group="Edit", shortcut="Ctrl+Z")
def undo(self):
"""Undo last action"""
if self.project.history.undo():
@@ -25,13 +19,9 @@ class EditOperationsMixin:
else:
self.show_status("Nothing to undo", 2000)
print("Nothing to undo")
-
+
@ribbon_action(
- label="Redo",
- tooltip="Redo last action (Ctrl+Y or Ctrl+Shift+Z)",
- tab="Home",
- group="Edit",
- shortcut="Ctrl+Y"
+ label="Redo", tooltip="Redo last action (Ctrl+Y or Ctrl+Shift+Z)", tab="Home", group="Edit", shortcut="Ctrl+Y"
)
def redo(self):
"""Redo last action"""
@@ -42,117 +32,113 @@ class EditOperationsMixin:
else:
self.show_status("Nothing to redo", 2000)
print("Nothing to redo")
-
+
@ribbon_action(
label="Delete",
tooltip="Delete selected element (Delete key)",
tab="Home",
group="Edit",
shortcut="Delete",
- requires_selection=True
+ requires_selection=True,
)
def delete_selected_element(self):
"""Delete the currently selected element"""
if not self.require_selection(min_count=1):
return
-
+
current_page = self.get_current_page()
if not current_page:
return
-
+
# Delete the first selected element (for backward compatibility)
# In the future, we could delete all selected elements
selected_element = next(iter(self.gl_widget.selected_elements))
-
+
try:
- cmd = DeleteElementCommand(
- current_page.layout,
- selected_element,
- asset_manager=self.project.asset_manager
- )
+ cmd = DeleteElementCommand(current_page.layout, selected_element, asset_manager=self.project.asset_manager)
self.project.history.execute(cmd)
-
+
# Clear selection
self.gl_widget.selected_elements.clear()
-
+
# Update display
self.update_view()
-
+
self.show_status("Element deleted (Ctrl+Z to undo)", 2000)
print("Deleted selected element")
-
+
except Exception as e:
self.show_error("Error", f"Failed to delete element: {str(e)}")
print(f"Error deleting element: {e}")
-
+
@ribbon_action(
label="Rotate Left",
tooltip="Rotate selected element 90° counter-clockwise",
tab="Home",
group="Transform",
- requires_selection=True
+ requires_selection=True,
)
def rotate_left(self):
"""Rotate selected element 90 degrees counter-clockwise"""
if not self.require_selection(min_count=1):
return
-
+
selected_element = next(iter(self.gl_widget.selected_elements))
old_rotation = selected_element.rotation
new_rotation = (old_rotation - 90) % 360
-
+
cmd = RotateElementCommand(selected_element, old_rotation, new_rotation)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Rotated left (Ctrl+Z to undo)", 2000)
print(f"Rotated element left: {old_rotation}° → {new_rotation}°")
-
+
@ribbon_action(
label="Rotate Right",
tooltip="Rotate selected element 90° clockwise",
tab="Home",
group="Transform",
- requires_selection=True
+ requires_selection=True,
)
def rotate_right(self):
"""Rotate selected element 90 degrees clockwise"""
if not self.require_selection(min_count=1):
return
-
+
selected_element = next(iter(self.gl_widget.selected_elements))
old_rotation = selected_element.rotation
new_rotation = (old_rotation + 90) % 360
-
+
cmd = RotateElementCommand(selected_element, old_rotation, new_rotation)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Rotated right (Ctrl+Z to undo)", 2000)
print(f"Rotated element right: {old_rotation}° → {new_rotation}°")
-
+
@ribbon_action(
label="Reset Rotation",
tooltip="Reset selected element rotation to 0°",
tab="Home",
group="Transform",
- requires_selection=True
+ requires_selection=True,
)
def reset_rotation(self):
"""Reset selected element rotation to 0 degrees"""
if not self.require_selection(min_count=1):
return
-
+
selected_element = next(iter(self.gl_widget.selected_elements))
old_rotation = selected_element.rotation
-
+
if old_rotation == 0:
self.show_status("Element already at 0°", 2000)
return
-
+
cmd = RotateElementCommand(selected_element, old_rotation, 0)
self.project.history.execute(cmd)
-
+
self.update_view()
self.show_status("Reset rotation to 0° (Ctrl+Z to undo)", 2000)
print(f"Reset element rotation: {old_rotation}° → 0°")
diff --git a/pyPhotoAlbum/mixins/operations/element_ops.py b/pyPhotoAlbum/mixins/operations/element_ops.py
index f38622e..770a1fb 100644
--- a/pyPhotoAlbum/mixins/operations/element_ops.py
+++ b/pyPhotoAlbum/mixins/operations/element_ops.py
@@ -11,33 +11,26 @@ from pyPhotoAlbum.async_backend import get_image_dimensions
class ElementOperationsMixin:
"""Mixin providing element creation and manipulation operations"""
-
+
@ribbon_action(
- label="Image",
- tooltip="Add an image to the current page",
- tab="Insert",
- group="Media",
- requires_page=True
+ label="Image", tooltip="Add an image to the current page", tab="Insert", group="Media", requires_page=True
)
def add_image(self):
"""Add an image to the current page"""
if not self.require_page():
return
-
+
file_path, _ = QFileDialog.getOpenFileName(
- self,
- "Select Image",
- "",
- "Image Files (*.jpg *.jpeg *.png *.gif *.bmp *.tiff *.webp);;All Files (*)"
+ self, "Select Image", "", "Image Files (*.jpg *.jpeg *.png *.gif *.bmp *.tiff *.webp);;All Files (*)"
)
-
+
if not file_path:
return
-
+
current_page = self.get_current_page()
if not current_page:
return
-
+
try:
# Import asset to project
asset_path = self.project.asset_manager.import_asset(file_path)
@@ -54,111 +47,87 @@ class ElementOperationsMixin:
# Create image element at center of page
page_width_mm = current_page.layout.size[0]
page_height_mm = current_page.layout.size[1]
-
+
# Center position
x = (page_width_mm - img_width) / 2
y = (page_height_mm - img_height) / 2
-
- new_image = ImageData(
- image_path=asset_path,
- x=x,
- y=y,
- width=img_width,
- height=img_height
- )
-
+
+ new_image = ImageData(image_path=asset_path, x=x, y=y, width=img_width, height=img_height)
+
# Add element using command pattern for undo/redo
- cmd = AddElementCommand(
- current_page.layout,
- new_image,
- asset_manager=self.project.asset_manager
- )
+ cmd = AddElementCommand(current_page.layout, new_image, asset_manager=self.project.asset_manager)
self.project.history.execute(cmd)
-
+
self.update_view()
self.show_status("Added image (Ctrl+Z to undo)", 2000)
print(f"Added image to page {self.get_current_page_index() + 1}: {asset_path}")
-
+
except Exception as e:
self.show_error("Error", f"Failed to add image: {str(e)}")
print(f"Error adding image: {e}")
-
+
@ribbon_action(
- label="Text",
- tooltip="Add a text box to the current page",
- tab="Insert",
- group="Media",
- requires_page=True
+ label="Text", tooltip="Add a text box to the current page", tab="Insert", group="Media", requires_page=True
)
def add_text(self):
"""Add text to the current page"""
if not self.require_page():
return
-
+
current_page = self.get_current_page()
if not current_page:
return
-
+
# Create text box element at center of page
page_width_mm = current_page.layout.size[0]
page_height_mm = current_page.layout.size[1]
-
+
text_width = 200
text_height = 50
-
+
# Center position
x = (page_width_mm - text_width) / 2
y = (page_height_mm - text_height) / 2
-
- new_text = TextBoxData(
- text_content="New Text",
- x=x,
- y=y,
- width=text_width,
- height=text_height
- )
-
+
+ new_text = TextBoxData(text_content="New Text", x=x, y=y, width=text_width, height=text_height)
+
current_page.layout.add_element(new_text)
self.update_view()
-
+
print(f"Added text box to page {self.get_current_page_index() + 1}")
-
+
@ribbon_action(
label="Placeholder",
tooltip="Add a placeholder to the current page",
tab="Insert",
group="Media",
- requires_page=True
+ requires_page=True,
)
def add_placeholder(self):
"""Add a placeholder to the current page"""
if not self.require_page():
return
-
+
current_page = self.get_current_page()
if not current_page:
return
-
+
# Create placeholder element at center of page
page_width_mm = current_page.layout.size[0]
page_height_mm = current_page.layout.size[1]
-
+
placeholder_width = 200
placeholder_height = 150
-
+
# Center position
x = (page_width_mm - placeholder_width) / 2
y = (page_height_mm - placeholder_height) / 2
-
+
new_placeholder = PlaceholderData(
- placeholder_type="image",
- x=x,
- y=y,
- width=placeholder_width,
- height=placeholder_height
+ placeholder_type="image", x=x, y=y, width=placeholder_width, height=placeholder_height
)
-
+
current_page.layout.add_element(new_placeholder)
self.update_view()
-
+
print(f"Added placeholder to page {self.get_current_page_index() + 1}")
diff --git a/pyPhotoAlbum/mixins/operations/file_ops.py b/pyPhotoAlbum/mixins/operations/file_ops.py
index 96bfcb8..7918558 100644
--- a/pyPhotoAlbum/mixins/operations/file_ops.py
+++ b/pyPhotoAlbum/mixins/operations/file_ops.py
@@ -5,9 +5,19 @@ File operations mixin for pyPhotoAlbum
import os
from PyQt6.QtWidgets import (
- QFileDialog, QDialog, QVBoxLayout, QHBoxLayout, QLabel,
- QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox, QRadioButton,
- QButtonGroup, QLineEdit, QTextEdit
+ QFileDialog,
+ QDialog,
+ QVBoxLayout,
+ QHBoxLayout,
+ QLabel,
+ QDoubleSpinBox,
+ QSpinBox,
+ QPushButton,
+ QGroupBox,
+ QRadioButton,
+ QButtonGroup,
+ QLineEdit,
+ QTextEdit,
)
from pyPhotoAlbum.decorators import ribbon_action, numerical_input
from pyPhotoAlbum.project import Project, Page
@@ -21,23 +31,17 @@ from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
class FileOperationsMixin:
"""Mixin providing file-related operations"""
-
- @ribbon_action(
- label="New",
- tooltip="Create a new project",
- tab="Home",
- group="File",
- shortcut="Ctrl+N"
- )
+
+ @ribbon_action(label="New", tooltip="Create a new project", tab="Home", group="File", shortcut="Ctrl+N")
def new_project(self):
"""Create a new project with initial setup dialog"""
# Create new project setup dialog
dialog = QDialog(self)
dialog.setWindowTitle("New Project Setup")
dialog.setMinimumWidth(450)
-
+
layout = QVBoxLayout()
-
+
# Project name group
name_group = QGroupBox("Project Name")
name_layout = QVBoxLayout()
@@ -47,16 +51,16 @@ class FileOperationsMixin:
name_layout.addWidget(name_input)
name_group.setLayout(name_layout)
layout.addWidget(name_group)
-
+
# Default page size group
size_group = QGroupBox("Default Page Size")
size_layout = QVBoxLayout()
-
+
info_label = QLabel("This will be the default size for all new pages in this project.")
info_label.setWordWrap(True)
info_label.setStyleSheet("font-size: 9pt; color: gray;")
size_layout.addWidget(info_label)
-
+
# Width
width_layout = QHBoxLayout()
width_layout.addWidget(QLabel("Width:"))
@@ -66,7 +70,7 @@ class FileOperationsMixin:
width_spinbox.setSuffix(" mm")
width_layout.addWidget(width_spinbox)
size_layout.addLayout(width_layout)
-
+
# Height
height_layout = QHBoxLayout()
height_layout.addWidget(QLabel("Height:"))
@@ -76,37 +80,37 @@ class FileOperationsMixin:
height_spinbox.setSuffix(" mm")
height_layout.addWidget(height_spinbox)
size_layout.addLayout(height_layout)
-
+
# Add common size presets
presets_layout = QHBoxLayout()
presets_layout.addWidget(QLabel("Presets:"))
-
+
def set_preset(w, h):
width_spinbox.setValue(w)
height_spinbox.setValue(h)
-
+
preset_a4 = QPushButton("A4 (210×297)")
preset_a4.clicked.connect(lambda: set_preset(210, 297))
presets_layout.addWidget(preset_a4)
-
+
preset_a5 = QPushButton("A5 (148×210)")
preset_a5.clicked.connect(lambda: set_preset(148, 210))
presets_layout.addWidget(preset_a5)
-
+
preset_square = QPushButton("Square (200×200)")
preset_square.clicked.connect(lambda: set_preset(200, 200))
presets_layout.addWidget(preset_square)
-
+
presets_layout.addStretch()
size_layout.addLayout(presets_layout)
-
+
size_group.setLayout(size_layout)
layout.addWidget(size_group)
-
+
# DPI settings group
dpi_group = QGroupBox("DPI Settings")
dpi_layout = QVBoxLayout()
-
+
# Working DPI
working_dpi_layout = QHBoxLayout()
working_dpi_layout.addWidget(QLabel("Working DPI:"))
@@ -115,7 +119,7 @@ class FileOperationsMixin:
working_dpi_spinbox.setValue(300)
working_dpi_layout.addWidget(working_dpi_spinbox)
dpi_layout.addLayout(working_dpi_layout)
-
+
# Export DPI
export_dpi_layout = QHBoxLayout()
export_dpi_layout.addWidget(QLabel("Export DPI:"))
@@ -124,10 +128,10 @@ class FileOperationsMixin:
export_dpi_spinbox.setValue(300)
export_dpi_layout.addWidget(export_dpi_spinbox)
dpi_layout.addLayout(export_dpi_layout)
-
+
dpi_group.setLayout(dpi_layout)
layout.addWidget(dpi_group)
-
+
# Buttons
button_layout = QHBoxLayout()
cancel_btn = QPushButton("Cancel")
@@ -135,14 +139,14 @@ class FileOperationsMixin:
create_btn = QPushButton("Create Project")
create_btn.clicked.connect(dialog.accept)
create_btn.setDefault(True)
-
+
button_layout.addStretch()
button_layout.addWidget(cancel_btn)
button_layout.addWidget(create_btn)
layout.addLayout(button_layout)
-
+
dialog.setLayout(layout)
-
+
# Show dialog
if dialog.exec() == QDialog.DialogCode.Accepted:
# Get values
@@ -153,7 +157,7 @@ class FileOperationsMixin:
export_dpi = export_dpi_spinbox.value()
# Cleanup old project if it exists
- if hasattr(self, 'project') and self.project:
+ if hasattr(self, "project") and self.project:
self.project.cleanup()
# Create project with custom settings
@@ -167,34 +171,25 @@ class FileOperationsMixin:
# Update view
self.update_view()
-
+
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")
else:
# User cancelled - keep current project
print("New project creation cancelled")
-
- @ribbon_action(
- label="Open",
- tooltip="Open an existing project",
- tab="Home",
- group="File",
- shortcut="Ctrl+O"
- )
+
+ @ribbon_action(label="Open", tooltip="Open an existing project", tab="Home", group="File", shortcut="Ctrl+O")
def open_project(self):
"""Open an existing project with async loading and progress bar"""
file_path, _ = QFileDialog.getOpenFileName(
- self,
- "Open Project",
- "",
- "pyPhotoAlbum Projects (*.ppz);;All Files (*)"
+ self, "Open Project", "", "pyPhotoAlbum Projects (*.ppz);;All Files (*)"
)
if file_path:
print(f"Opening project: {file_path}")
# Create loading widget if not exists
- if not hasattr(self, '_loading_widget'):
+ if not hasattr(self, "_loading_widget"):
self._loading_widget = LoadingWidget(self)
# Show loading widget
@@ -214,29 +209,29 @@ class FileOperationsMixin:
def _on_load_progress(self, current: int, total: int, message: str):
"""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_status(message)
def _on_load_complete(self, project):
"""Handle successful project load"""
# Cleanup old project if it exists
- if hasattr(self, 'project') and self.project:
+ if hasattr(self, "project") and self.project:
self.project.cleanup()
# Set new project
self.project = project
# 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
- delattr(self, '_opening_file_path')
+ delattr(self, "_opening_file_path")
self.project.mark_clean()
self.gl_widget.current_page_index = 0 # Reset to first page
# Hide loading widget
- if hasattr(self, '_loading_widget'):
+ if hasattr(self, "_loading_widget"):
self._loading_widget.hide_loading()
# Update view (this will trigger progressive image loading)
@@ -254,32 +249,23 @@ class FileOperationsMixin:
def _on_load_failed(self, error_msg: str):
"""Handle project load failure"""
# Hide loading widget
- if hasattr(self, '_loading_widget'):
+ if hasattr(self, "_loading_widget"):
self._loading_widget.hide_loading()
error_msg = f"Failed to open project: {error_msg}"
self.show_status(error_msg)
self.show_error("Load Failed", error_msg)
print(error_msg)
-
- @ribbon_action(
- label="Save",
- tooltip="Save the current project",
- tab="Home",
- group="File",
- shortcut="Ctrl+S"
- )
+
+ @ribbon_action(label="Save", tooltip="Save the current project", tab="Home", group="File", shortcut="Ctrl+S")
def save_project(self):
"""Save the current project"""
# 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:
file_path, _ = QFileDialog.getSaveFileName(
- self,
- "Save Project",
- "",
- "pyPhotoAlbum Projects (*.ppz);;All Files (*)"
+ self, "Save Project", "", "pyPhotoAlbum Projects (*.ppz);;All Files (*)"
)
if file_path:
@@ -298,12 +284,7 @@ class FileOperationsMixin:
self.show_status(error_msg)
print(error_msg)
- @ribbon_action(
- label="Heal Assets",
- tooltip="Reconnect missing image assets",
- tab="Home",
- group="File"
- )
+ @ribbon_action(label="Heal Assets", tooltip="Reconnect missing image assets", tab="Home", group="File")
def heal_assets(self):
"""Open the asset healing dialog to reconnect missing images"""
dialog = AssetHealDialog(self.project, self)
@@ -348,10 +329,12 @@ class FileOperationsMixin:
msg.setWindowTitle("Missing Assets")
msg.setText(f"{len(missing_assets)} image(s) could not be found in the assets folder:")
msg.setInformativeText(asset_list)
- msg.setDetailedText("These images need to be reconnected using the 'Heal Assets' feature.\n\n"
- "Go to: Home → Heal Assets\n\n"
- "Add search paths where the original images might be located, "
- "then click 'Attempt Healing' to find and import them.")
+ msg.setDetailedText(
+ "These images need to be reconnected using the 'Heal Assets' feature.\n\n"
+ "Go to: Home → Heal Assets\n\n"
+ "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.button(QMessageBox.StandardButton.Open).setText("Open Heal Assets")
@@ -360,30 +343,22 @@ class FileOperationsMixin:
self.heal_assets()
@ribbon_action(
- label="Project Settings",
- 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)
- ]
+ label="Project Settings", 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)])
def project_settings(self):
"""Configure project-wide settings including default page size"""
# Create dialog
dialog = QDialog(self)
dialog.setWindowTitle("Project Settings")
dialog.setMinimumWidth(500)
-
+
layout = QVBoxLayout()
-
+
# Page size group
size_group = QGroupBox("Default Page Size")
size_layout = QVBoxLayout()
-
+
# Width
width_layout = QHBoxLayout()
width_layout.addWidget(QLabel("Width:"))
@@ -393,7 +368,7 @@ class FileOperationsMixin:
width_spinbox.setSuffix(" mm")
width_layout.addWidget(width_spinbox)
size_layout.addLayout(width_layout)
-
+
# Height
height_layout = QHBoxLayout()
height_layout.addWidget(QLabel("Height:"))
@@ -403,14 +378,14 @@ class FileOperationsMixin:
height_spinbox.setSuffix(" mm")
height_layout.addWidget(height_spinbox)
size_layout.addLayout(height_layout)
-
+
size_group.setLayout(size_layout)
layout.addWidget(size_group)
-
+
# DPI settings group
dpi_group = QGroupBox("DPI Settings")
dpi_layout = QVBoxLayout()
-
+
# Working DPI
working_dpi_layout = QHBoxLayout()
working_dpi_layout.addWidget(QLabel("Working DPI:"))
@@ -419,7 +394,7 @@ class FileOperationsMixin:
working_dpi_spinbox.setValue(self.project.working_dpi)
working_dpi_layout.addWidget(working_dpi_spinbox)
dpi_layout.addLayout(working_dpi_layout)
-
+
# Export DPI
export_dpi_layout = QHBoxLayout()
export_dpi_layout.addWidget(QLabel("Export DPI:"))
@@ -428,48 +403,50 @@ class FileOperationsMixin:
export_dpi_spinbox.setValue(self.project.export_dpi)
export_dpi_layout.addWidget(export_dpi_spinbox)
dpi_layout.addLayout(export_dpi_layout)
-
+
dpi_group.setLayout(dpi_layout)
layout.addWidget(dpi_group)
-
+
# Content scaling options (only if pages exist and size is changing)
scaling_group = None
scaling_buttons = None
-
+
if self.project.pages:
scaling_group = QGroupBox("Apply to Existing Pages")
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)
scaling_layout.addWidget(info_label)
-
+
scaling_buttons = QButtonGroup()
-
+
proportional_radio = QRadioButton("Resize proportionally (fit to smallest axis)")
proportional_radio.setToolTip("Scale content uniformly to fit the new page size")
scaling_buttons.addButton(proportional_radio, 0)
scaling_layout.addWidget(proportional_radio)
-
+
stretch_radio = QRadioButton("Resize on both axes (stretch)")
stretch_radio.setToolTip("Scale width and height independently")
scaling_buttons.addButton(stretch_radio, 1)
scaling_layout.addWidget(stretch_radio)
-
+
reposition_radio = QRadioButton("Keep content size, reposition to center")
reposition_radio.setToolTip("Maintain element sizes but center them on new page")
scaling_buttons.addButton(reposition_radio, 2)
scaling_layout.addWidget(reposition_radio)
-
+
none_radio = QRadioButton("Don't adjust content (page size only)")
none_radio.setToolTip("Only change page size, leave content as-is")
none_radio.setChecked(True) # Default
scaling_buttons.addButton(none_radio, 3)
scaling_layout.addWidget(none_radio)
-
+
scaling_group.setLayout(scaling_layout)
layout.addWidget(scaling_group)
-
+
# Buttons
button_layout = QHBoxLayout()
cancel_btn = QPushButton("Cancel")
@@ -477,14 +454,14 @@ class FileOperationsMixin:
ok_btn = QPushButton("OK")
ok_btn.clicked.connect(dialog.accept)
ok_btn.setDefault(True)
-
+
button_layout.addStretch()
button_layout.addWidget(cancel_btn)
button_layout.addWidget(ok_btn)
layout.addLayout(button_layout)
-
+
dialog.setLayout(layout)
-
+
# Show dialog
if dialog.exec() == QDialog.DialogCode.Accepted:
# Get new values
@@ -492,32 +469,32 @@ class FileOperationsMixin:
new_height = height_spinbox.value()
new_working_dpi = working_dpi_spinbox.value()
new_export_dpi = export_dpi_spinbox.value()
-
+
# Determine scaling mode
- scaling_mode = 'none'
+ scaling_mode = "none"
if scaling_buttons:
selected_id = scaling_buttons.checkedId()
- modes = {0: 'proportional', 1: 'stretch', 2: 'reposition', 3: 'none'}
- scaling_mode = modes.get(selected_id, 'none')
-
+ modes = {0: "proportional", 1: "stretch", 2: "reposition", 3: "none"}
+ scaling_mode = modes.get(selected_id, "none")
+
# Apply settings
old_size = self.project.page_size_mm
self.project.page_size_mm = (new_width, new_height)
self.project.working_dpi = new_working_dpi
self.project.export_dpi = new_export_dpi
-
+
# Update existing pages (exclude manually sized ones)
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.update_view()
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}")
-
+
def _apply_page_size_to_project(self, old_size, new_size, scaling_mode):
"""
Apply new page size to all non-manually-sized pages
-
+
Args:
old_size: Old 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
new_width, new_height = new_size
-
+
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
-
+
for page in self.project.pages:
# Skip manually sized pages
if page.manually_sized:
continue
-
+
# Update page size
old_page_width, old_page_height = page.layout.size
-
+
# For double spreads, maintain the 2x multiplier
if page.is_double_spread:
page.layout.size = (new_width * 2, new_height)
else:
page.layout.size = (new_width, new_height)
-
+
# Apply content scaling based on mode
- if scaling_mode == 'proportional':
+ if scaling_mode == "proportional":
# Use smallest ratio to fit content
scale = min(width_ratio, height_ratio)
self._scale_page_elements(page, scale, scale)
- elif scaling_mode == 'stretch':
+ elif scaling_mode == "stretch":
# Scale independently on each axis
self._scale_page_elements(page, width_ratio, height_ratio)
- elif scaling_mode == 'reposition':
+ elif scaling_mode == "reposition":
# Keep size, center content
self._reposition_page_elements(page, old_size, new_size)
# 'none' - do nothing to elements
-
+
def _scale_page_elements(self, page, x_scale, y_scale):
"""
Scale all elements on a page
-
+
Args:
page: Page object
x_scale: Horizontal scale factor
@@ -569,15 +546,15 @@ class FileOperationsMixin:
# Scale position
x, y = element.position
element.position = (x * x_scale, y * y_scale)
-
+
# Scale size
width, height = element.size
element.size = (width * x_scale, height * y_scale)
-
+
def _reposition_page_elements(self, page, old_size, new_size):
"""
Reposition elements to center them on the new page size
-
+
Args:
page: Page object
old_size: Old page size (width, height) in mm
@@ -585,20 +562,15 @@ class FileOperationsMixin:
"""
old_width, old_height = old_size
new_width, new_height = new_size
-
+
x_offset = (new_width - old_width) / 2.0
y_offset = (new_height - old_height) / 2.0
-
+
for element in page.layout.elements:
x, y = element.position
element.position = (x + x_offset, y + y_offset)
-
- @ribbon_action(
- label="Export PDF",
- tooltip="Export project to PDF",
- tab="Export",
- group="Export"
- )
+
+ @ribbon_action(label="Export PDF", tooltip="Export project to PDF", tab="Export", group="Export")
def export_pdf(self):
"""Export project to PDF using async backend (non-blocking)"""
# Check if we have pages to export
@@ -607,19 +579,14 @@ class FileOperationsMixin:
return
# Show file save dialog
- file_path, _ = QFileDialog.getSaveFileName(
- self,
- "Export to PDF",
- "",
- "PDF Files (*.pdf);;All Files (*)"
- )
+ file_path, _ = QFileDialog.getSaveFileName(self, "Export to PDF", "", "PDF Files (*.pdf);;All Files (*)")
if not file_path:
return
# Ensure .pdf extension
- if not file_path.lower().endswith('.pdf'):
- file_path += '.pdf'
+ if not file_path.lower().endswith(".pdf"):
+ file_path += ".pdf"
# Use async PDF export (non-blocking, UI stays responsive)
success = self.gl_widget.export_pdf_async(self.project, file_path, export_dpi=300)
@@ -628,12 +595,7 @@ class FileOperationsMixin:
else:
self.show_status("PDF export failed to start", 3000)
- @ribbon_action(
- label="About",
- tooltip="About pyPhotoAlbum and data format version",
- tab="Home",
- group="File"
- )
+ @ribbon_action(label="About", tooltip="About pyPhotoAlbum and data format version", tab="Home", group="File")
def show_about(self):
"""Show about dialog with version information"""
dialog = QDialog(self)
diff --git a/pyPhotoAlbum/mixins/operations/merge_ops.py b/pyPhotoAlbum/mixins/operations/merge_ops.py
index 1b44faf..5029b59 100644
--- a/pyPhotoAlbum/mixins/operations/merge_ops.py
+++ b/pyPhotoAlbum/mixins/operations/merge_ops.py
@@ -20,7 +20,7 @@ class MergeOperationsMixin:
label="Merge Projects",
tooltip="Merge another project file with the current project",
tab="File",
- group="Import/Export"
+ group="Import/Export",
)
def merge_projects(self):
"""
@@ -35,22 +35,19 @@ class MergeOperationsMixin:
self,
"Unsaved Changes",
"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:
return
elif reply == QMessageBox.StandardButton.Yes:
# Save current project first
- if hasattr(self, 'save_project'):
+ if hasattr(self, "save_project"):
self.save_project()
# Select file to merge
file_path, _ = QFileDialog.getOpenFileName(
- self,
- "Select Project to Merge",
- "",
- "Photo Album Projects (*.ppz);;All Files (*)"
+ self, "Select Project to Merge", "", "Photo Album Projects (*.ppz);;All Files (*)"
)
if not file_path:
@@ -58,7 +55,7 @@ class MergeOperationsMixin:
try:
# Disable autosave during merge
- if hasattr(self, '_autosave_timer'):
+ if hasattr(self, "_autosave_timer"):
self._autosave_timer.stop()
# Load the other project
@@ -82,14 +79,10 @@ class MergeOperationsMixin:
self._perform_concatenation(our_data, their_data)
except Exception as e:
- QMessageBox.critical(
- self,
- "Merge Error",
- f"Failed to merge projects:\n{str(e)}"
- )
+ QMessageBox.critical(self, "Merge Error", f"Failed to merge projects:\n{str(e)}")
finally:
# Re-enable autosave
- if hasattr(self, '_autosave_timer'):
+ if hasattr(self, "_autosave_timer"):
self._autosave_timer.start()
def _perform_merge_with_conflicts(self, our_data, their_data):
@@ -104,7 +97,7 @@ class MergeOperationsMixin:
self,
"No Conflicts",
"No conflicts detected. Merge projects automatically?",
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
@@ -117,11 +110,7 @@ class MergeOperationsMixin:
dialog = MergeDialog(our_data, their_data, self)
if dialog.exec() != QMessageBox.DialogCode.Accepted:
- QMessageBox.information(
- self,
- "Merge Cancelled",
- "Merge operation cancelled."
- )
+ QMessageBox.information(self, "Merge Cancelled", "Merge operation cancelled.")
return
# Get merged data from dialog
@@ -135,7 +124,7 @@ class MergeOperationsMixin:
"Merge Complete",
f"Projects merged successfully.\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):
@@ -147,7 +136,7 @@ class MergeOperationsMixin:
f" • {our_data.get('name', 'Untitled')}\n"
f" • {their_data.get('name', 'Untitled')}\n\n"
f"Concatenate them (combine all pages)?",
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
@@ -162,8 +151,7 @@ class MergeOperationsMixin:
QMessageBox.information(
self,
"Concatenation Complete",
- f"Projects concatenated successfully.\n"
- f"Total pages: {len(merged_data.get('pages', []))}"
+ f"Projects concatenated successfully.\n" f"Total pages: {len(merged_data.get('pages', []))}",
)
def _apply_merged_data(self, merged_data):
@@ -182,9 +170,9 @@ class MergeOperationsMixin:
new_project.mark_dirty()
# Update UI
- if hasattr(self, 'gl_widget'):
+ if hasattr(self, "gl_widget"):
self.gl_widget.set_project(new_project)
self.gl_widget.update()
- if hasattr(self, 'status_bar'):
+ if hasattr(self, "status_bar"):
self.status_bar.showMessage("Merge completed successfully", 3000)
diff --git a/pyPhotoAlbum/mixins/operations/page_ops.py b/pyPhotoAlbum/mixins/operations/page_ops.py
index 9c22bfb..be0ad97 100644
--- a/pyPhotoAlbum/mixins/operations/page_ops.py
+++ b/pyPhotoAlbum/mixins/operations/page_ops.py
@@ -10,16 +10,11 @@ from pyPhotoAlbum.page_layout import PageLayout
class PageOperationsMixin:
"""Mixin providing page management operations"""
-
+
# Note: Previous/Next page navigation removed - now using scrollable multi-page view
# User can scroll through all pages vertically
-
- @ribbon_action(
- label="Add Page",
- tooltip="Add a new page to the project",
- tab="Layout",
- group="Page"
- )
+
+ @ribbon_action(label="Add Page", tooltip="Add a new page to the project", tab="Layout", group="Page")
def add_page(self):
"""Add a new page to the project after the current page"""
# Get the most visible page in viewport to determine insertion point
@@ -72,13 +67,8 @@ class PageOperationsMixin:
# Get display name for status message
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")
-
- @ribbon_action(
- label="Page Setup",
- tooltip="Configure page size and settings",
- tab="Layout",
- group="Page"
- )
+
+ @ribbon_action(label="Page Setup", tooltip="Configure page size and settings", tab="Layout", group="Page")
@dialog_action(dialog_class=PageSetupDialog, requires_pages=True)
def page_setup(self, values):
"""
@@ -90,17 +80,17 @@ class PageOperationsMixin:
Args:
values: Dictionary of values from the dialog
"""
- selected_page = values['selected_page']
- selected_index = values['selected_index']
+ selected_page = values["selected_page"]
+ selected_index = values["selected_index"]
# Update project cover settings
- self.project.paper_thickness_mm = values['paper_thickness_mm']
- self.project.cover_bleed_mm = values['cover_bleed_mm']
+ self.project.paper_thickness_mm = values["paper_thickness_mm"]
+ self.project.cover_bleed_mm = values["cover_bleed_mm"]
# Handle cover designation (only for first page)
if selected_index == 0:
was_cover = selected_page.is_cover
- is_cover = values['is_cover']
+ is_cover = values["is_cover"]
if was_cover != is_cover:
selected_page.is_cover = is_cover
@@ -116,8 +106,8 @@ class PageOperationsMixin:
print(f"Cover removed from page 1")
# Get new values
- width_mm = values['width_mm']
- height_mm = values['height_mm']
+ width_mm = values["width_mm"]
+ height_mm = values["height_mm"]
# Don't allow manual size changes for covers
if not selected_page.is_cover:
@@ -126,11 +116,11 @@ class PageOperationsMixin:
if selected_page.is_double_spread:
old_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
)
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:
# Update double spread
@@ -143,7 +133,7 @@ class PageOperationsMixin:
)
else:
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:
# Update single page
@@ -151,16 +141,15 @@ class PageOperationsMixin:
selected_page.layout.base_width = width_mm
selected_page.manually_sized = True
print(
- f"{self.project.get_page_display_name(selected_page)} "
- f"updated to {width_mm}×{height_mm} mm"
+ f"{self.project.get_page_display_name(selected_page)} " f"updated to {width_mm}×{height_mm} mm"
)
# Update DPI settings
- self.project.working_dpi = values['working_dpi']
- self.project.export_dpi = values['export_dpi']
+ self.project.working_dpi = values["working_dpi"]
+ self.project.export_dpi = values["export_dpi"]
# 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)
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"
else:
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)"
self.show_status(status_msg, 2000)
@ribbon_action(
- label="Toggle Spread",
- tooltip="Toggle double page spread for current page",
- tab="Layout",
- group="Page"
+ label="Toggle Spread", tooltip="Toggle double page spread for current page", tab="Layout", group="Page"
)
def toggle_double_spread(self):
"""Toggle double spread for the current page"""
@@ -195,31 +181,31 @@ class PageOperationsMixin:
page_index = 0
current_page = self.project.pages[page_index]
-
+
# Toggle the state
is_double = not current_page.is_double_spread
current_page.is_double_spread = is_double
-
+
# Mark as manually sized when toggling spread
current_page.manually_sized = True
-
+
# Update the page layout width
current_width = current_page.layout.size[0]
current_height = current_page.layout.size[1]
-
+
# 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
else:
# 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
-
+
# Set new width based on double spread state
new_width = base_width * 2 if is_double else base_width
current_page.layout.base_width = base_width
current_page.layout.is_facing_page = is_double
current_page.layout.size = (new_width, current_height)
-
+
# Update display
self.update_view()
@@ -227,13 +213,8 @@ class PageOperationsMixin:
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)
print(f"{page_name}: Double spread {status}, width = {new_width}mm")
-
- @ribbon_action(
- label="Remove Page",
- tooltip="Remove the currently selected page",
- tab="Layout",
- group="Page"
- )
+
+ @ribbon_action(label="Remove Page", tooltip="Remove the currently selected page", tab="Layout", group="Page")
def remove_page(self):
"""Remove the currently selected page"""
if len(self.project.pages) <= 1:
diff --git a/pyPhotoAlbum/mixins/operations/size_ops.py b/pyPhotoAlbum/mixins/operations/size_ops.py
index 7c48cc5..39bf4e6 100644
--- a/pyPhotoAlbum/mixins/operations/size_ops.py
+++ b/pyPhotoAlbum/mixins/operations/size_ops.py
@@ -9,60 +9,60 @@ from pyPhotoAlbum.commands import ResizeElementsCommand
class SizeOperationsMixin:
"""Mixin providing element sizing operations"""
-
+
def _get_selected_elements_list(self):
"""Get list of selected elements for size operations"""
return list(self.gl_widget.selected_elements) if self.gl_widget.selected_elements else []
-
+
@ribbon_action(
label="Same Size",
tooltip="Make all selected elements the same size",
tab="Arrange",
group="Size",
requires_selection=True,
- min_selection=2
+ min_selection=2,
)
def make_same_size(self):
"""Make all selected elements the same size"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2):
return
-
+
changes = AlignmentManager.make_same_size(elements)
if changes:
cmd = ResizeElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Resized {len(elements)} elements to same size", 2000)
-
+
@ribbon_action(
label="Same Width",
tooltip="Make all selected elements the same width",
tab="Arrange",
group="Size",
requires_selection=True,
- min_selection=2
+ min_selection=2,
)
def make_same_width(self):
"""Make all selected elements the same width"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2):
return
-
+
changes = AlignmentManager.make_same_width(elements)
if changes:
cmd = ResizeElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Resized {len(elements)} elements to same width", 2000)
-
+
@ribbon_action(
label="Same Height",
tooltip="Make all selected elements the same height",
tab="Arrange",
group="Size",
requires_selection=True,
- min_selection=2
+ min_selection=2,
)
def make_same_height(self):
"""Make all selected elements the same height"""
@@ -83,7 +83,7 @@ class SizeOperationsMixin:
tab="Arrange",
group="Size",
requires_selection=True,
- min_selection=1
+ min_selection=1,
)
def fit_to_width(self):
"""Fit selected element to page width"""
@@ -114,7 +114,7 @@ class SizeOperationsMixin:
tab="Arrange",
group="Size",
requires_selection=True,
- min_selection=1
+ min_selection=1,
)
def fit_to_height(self):
"""Fit selected element to page height"""
@@ -145,7 +145,7 @@ class SizeOperationsMixin:
tab="Arrange",
group="Size",
requires_selection=True,
- min_selection=1
+ min_selection=1,
)
def fit_to_page(self):
"""Fit selected element to page dimensions"""
@@ -176,7 +176,7 @@ class SizeOperationsMixin:
tab="Arrange",
group="Size",
requires_selection=True,
- min_selection=1
+ min_selection=1,
)
def expand_image(self):
"""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]
# 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
page_width, page_height = page.layout.size
- change = AlignmentManager.expand_to_bounds(
- element,
- (page_width, page_height),
- other_elements,
- min_gap
- )
+ change = AlignmentManager.expand_to_bounds(element, (page_width, page_height), other_elements, min_gap)
if change:
cmd = ResizeElementsCommand([change])
diff --git a/pyPhotoAlbum/mixins/operations/template_ops.py b/pyPhotoAlbum/mixins/operations/template_ops.py
index 4e9bc54..e7e38f6 100644
--- a/pyPhotoAlbum/mixins/operations/template_ops.py
+++ b/pyPhotoAlbum/mixins/operations/template_ops.py
@@ -3,82 +3,75 @@ Template operations mixin for pyPhotoAlbum
"""
from PyQt6.QtWidgets import (
- QInputDialog, QDialog, QVBoxLayout, QLabel, QComboBox,
- QRadioButton, QButtonGroup, QPushButton, QHBoxLayout,
- QDoubleSpinBox
+ QInputDialog,
+ QDialog,
+ QVBoxLayout,
+ QLabel,
+ QComboBox,
+ QRadioButton,
+ QButtonGroup,
+ QPushButton,
+ QHBoxLayout,
+ QDoubleSpinBox,
)
from pyPhotoAlbum.decorators import ribbon_action, undoable_operation
class TemplateOperationsMixin:
"""Mixin providing template-related operations"""
-
+
@ribbon_action(
label="Save as Template",
tooltip="Save current page as a reusable template",
tab="Layout",
group="Templates",
- requires_page=True
+ requires_page=True,
)
def save_page_as_template(self):
"""Save current page as a template"""
current_page = self.get_current_page()
if not current_page:
return
-
+
# Check if page has any elements
if not current_page.layout.elements:
self.show_warning("Empty Page", "Cannot save an empty page as a template.")
return
-
+
# Ask for template name
name, ok = QInputDialog.getText(
self,
"Save Template",
"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:
return
-
+
# Ask for optional description
- description, ok = QInputDialog.getText(
- self,
- "Template Description",
- "Enter description (optional):"
- )
-
+ description, ok = QInputDialog.getText(self, "Template Description", "Enter description (optional):")
+
if not ok:
description = ""
-
+
try:
# Create template from page
- template = self.template_manager.create_template_from_page(
- current_page,
- name,
- description
- )
-
+ template = self.template_manager.create_template_from_page(current_page, name, description)
+
# Save template
self.template_manager.save_template(template)
-
- self.show_info(
- "Template Saved",
- f"Template '{name}' has been saved successfully."
- )
-
+
+ self.show_info("Template Saved", f"Template '{name}' has been saved successfully.")
+
print(f"Saved template: {name}")
-
+
except Exception as e:
self.show_error("Error", f"Failed to save template: {str(e)}")
print(f"Error saving template: {e}")
-
+
@ribbon_action(
- label="New from Template",
- tooltip="Create a new page from a template",
- tab="Layout",
- group="Templates"
+ label="New from Template", tooltip="Create a new page from a template", tab="Layout", group="Templates"
)
def new_page_from_template(self):
"""Create a new page from a template"""
@@ -87,8 +80,7 @@ class TemplateOperationsMixin:
if not templates:
self.show_info(
- "No Templates",
- "No templates available. Create a template first by using 'Save as Template'."
+ "No Templates", "No templates available. Create a template first by using 'Save as Template'."
)
return
@@ -178,7 +170,7 @@ class TemplateOperationsMixin:
page_number=new_page_number,
target_size_mm=self.project.page_size_mm,
scale_mode=scale_mode,
- margin_percent=margin_percent
+ margin_percent=margin_percent,
)
# Add to project
@@ -194,63 +186,62 @@ class TemplateOperationsMixin:
except Exception as e:
self.show_error("Error", f"Failed to create page from template: {str(e)}")
print(f"Error creating page from template: {e}")
-
+
@ribbon_action(
label="Apply Template",
tooltip="Apply a template layout to current page",
tab="Layout",
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):
"""Apply a template to the current page"""
current_page = self.get_current_page()
if not current_page:
return
-
+
# Get available templates
templates = self.template_manager.list_templates()
-
+
if not templates:
self.show_info(
- "No Templates",
- "No templates available. Create a template first by using 'Save as Template'."
+ "No Templates", "No templates available. Create a template first by using 'Save as Template'."
)
return
-
+
# Create dialog for template application options
dialog = QDialog(self)
dialog.setWindowTitle("Apply Template")
dialog.setMinimumWidth(400)
-
+
layout = QVBoxLayout()
-
+
# Template selection
layout.addWidget(QLabel("Select Template:"))
template_combo = QComboBox()
template_combo.addItems(templates)
layout.addWidget(template_combo)
-
+
layout.addSpacing(10)
-
+
# Mode selection
layout.addWidget(QLabel("Mode:"))
mode_group = QButtonGroup(dialog)
-
+
replace_radio = QRadioButton("Replace with placeholders")
replace_radio.setChecked(True)
replace_radio.setToolTip("Clear page and add template placeholders")
mode_group.addButton(replace_radio, 0)
layout.addWidget(replace_radio)
-
+
reflow_radio = QRadioButton("Reflow existing content")
reflow_radio.setToolTip("Keep existing images and reposition to template slots")
mode_group.addButton(reflow_radio, 1)
layout.addWidget(reflow_radio)
-
+
layout.addSpacing(10)
-
+
# Margin/Spacing percentage
layout.addWidget(QLabel("Margin/Spacing:"))
margin_layout = QHBoxLayout()
@@ -264,9 +255,9 @@ class TemplateOperationsMixin:
margin_layout.addWidget(margin_spinbox)
margin_layout.addStretch()
layout.addLayout(margin_layout)
-
+
layout.addSpacing(10)
-
+
# Scaling selection
layout.addWidget(QLabel("Scaling:"))
scale_group = QButtonGroup(dialog)
@@ -279,13 +270,13 @@ class TemplateOperationsMixin:
stretch_radio.setChecked(True)
scale_group.addButton(stretch_radio, 1)
layout.addWidget(stretch_radio)
-
+
center_radio = QRadioButton("Center (no scaling)")
scale_group.addButton(center_radio, 2)
layout.addWidget(center_radio)
-
+
layout.addSpacing(20)
-
+
# Buttons
button_layout = QHBoxLayout()
cancel_btn = QPushButton("Cancel")
@@ -293,38 +284,34 @@ class TemplateOperationsMixin:
apply_btn = QPushButton("Apply")
apply_btn.clicked.connect(dialog.accept)
apply_btn.setDefault(True)
-
+
button_layout.addStretch()
button_layout.addWidget(cancel_btn)
button_layout.addWidget(apply_btn)
layout.addLayout(button_layout)
-
+
dialog.setLayout(layout)
-
+
# Show dialog
if dialog.exec() != QDialog.DialogCode.Accepted:
return
-
+
# Get selections
template_name = template_combo.currentText()
mode_id = mode_group.checkedId()
scale_id = scale_group.checkedId()
margin_percent = margin_spinbox.value()
-
+
mode = "replace" if mode_id == 0 else "reflow"
scale_mode = ["proportional", "stretch", "center"][scale_id]
-
+
try:
# Load template
template = self.template_manager.load_template(template_name)
# Apply template to page
self.template_manager.apply_template_to_page(
- template,
- current_page,
- mode=mode,
- scale_mode=scale_mode,
- margin_percent=margin_percent
+ template, current_page, mode=mode, scale_mode=scale_mode, margin_percent=margin_percent
)
# Update display
@@ -332,7 +319,7 @@ class TemplateOperationsMixin:
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}")
-
+
except Exception as e:
self.show_error("Error", f"Failed to apply template: {str(e)}")
print(f"Error applying template: {e}")
diff --git a/pyPhotoAlbum/mixins/operations/view_ops.py b/pyPhotoAlbum/mixins/operations/view_ops.py
index 25e3c17..23ea652 100644
--- a/pyPhotoAlbum/mixins/operations/view_ops.py
+++ b/pyPhotoAlbum/mixins/operations/view_ops.py
@@ -7,14 +7,8 @@ from pyPhotoAlbum.decorators import ribbon_action
class ViewOperationsMixin:
"""Mixin providing view-related operations"""
-
- @ribbon_action(
- label="Zoom In",
- tooltip="Zoom in",
- tab="View",
- group="Zoom",
- shortcut="Ctrl++"
- )
+
+ @ribbon_action(label="Zoom In", tooltip="Zoom in", tab="View", group="Zoom", shortcut="Ctrl++")
def zoom_in(self):
"""Zoom in"""
self.gl_widget.zoom_level *= 1.2
@@ -22,14 +16,8 @@ class ViewOperationsMixin:
self.gl_widget.zoom_level = 5.0
self.update_view()
self.show_status(f"Zoom: {int(self.gl_widget.zoom_level * 100)}%", 2000)
-
- @ribbon_action(
- label="Zoom Out",
- tooltip="Zoom out",
- tab="View",
- group="Zoom",
- shortcut="Ctrl+-"
- )
+
+ @ribbon_action(label="Zoom Out", tooltip="Zoom out", tab="View", group="Zoom", shortcut="Ctrl+-")
def zoom_out(self):
"""Zoom out"""
self.gl_widget.zoom_level /= 1.2
@@ -37,47 +25,36 @@ class ViewOperationsMixin:
self.gl_widget.zoom_level = 0.1
self.update_view()
self.show_status(f"Zoom: {int(self.gl_widget.zoom_level * 100)}%", 2000)
-
- @ribbon_action(
- label="Fit to Window",
- tooltip="Fit page to window",
- tab="View",
- group="Zoom",
- shortcut="Ctrl+0"
- )
+
+ @ribbon_action(label="Fit to Window", tooltip="Fit page to window", tab="View", group="Zoom", shortcut="Ctrl+0")
def zoom_fit(self):
"""Fit page to window"""
if not self.project.pages:
return
-
+
current_page = self.project.pages[self.gl_widget.current_page_index]
page_width_mm = current_page.layout.size[0]
page_height_mm = current_page.layout.size[1]
-
+
# Convert to pixels
dpi = self.project.working_dpi
page_width_px = page_width_mm * dpi / 25.4
page_height_px = page_height_mm * dpi / 25.4
-
+
# Get widget size
widget_width = self.gl_widget.width() - 100 # Margins
widget_height = self.gl_widget.height() - 100
-
+
# Calculate zoom to fit
zoom_w = widget_width / page_width_px
zoom_h = widget_height / page_height_px
-
+
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.update_view()
self.show_status(f"Zoom: {int(self.gl_widget.zoom_level * 100)}%", 2000)
-
- @ribbon_action(
- label="Toggle Grid Snap",
- tooltip="Toggle snapping to grid",
- tab="View",
- group="Snapping"
- )
+
+ @ribbon_action(label="Toggle Grid Snap", tooltip="Toggle snapping to grid", tab="View", group="Snapping")
def toggle_grid_snap(self):
"""Toggle grid snapping"""
if not self.project:
@@ -89,13 +66,8 @@ class ViewOperationsMixin:
self.update_view()
self.show_status(f"Grid snapping {status}", 2000)
print(f"Grid snapping {status}")
-
- @ribbon_action(
- label="Toggle Edge Snap",
- tooltip="Toggle snapping to page edges",
- tab="View",
- group="Snapping"
- )
+
+ @ribbon_action(label="Toggle Edge Snap", tooltip="Toggle snapping to page edges", tab="View", group="Snapping")
def toggle_edge_snap(self):
"""Toggle edge snapping"""
if not self.project:
@@ -107,13 +79,8 @@ class ViewOperationsMixin:
self.update_view()
self.show_status(f"Edge snapping {status}", 2000)
print(f"Edge snapping {status}")
-
- @ribbon_action(
- label="Toggle Guide Snap",
- tooltip="Toggle snapping to guides",
- tab="View",
- group="Snapping"
- )
+
+ @ribbon_action(label="Toggle Guide Snap", tooltip="Toggle snapping to guides", tab="View", group="Snapping")
def toggle_guide_snap(self):
"""Toggle guide snapping"""
if not self.project:
@@ -125,13 +92,8 @@ class ViewOperationsMixin:
self.update_view()
self.show_status(f"Guide snapping {status}", 2000)
print(f"Guide snapping {status}")
-
- @ribbon_action(
- label="Show Grid",
- tooltip="Toggle visibility of grid lines",
- tab="View",
- group="Snapping"
- )
+
+ @ribbon_action(label="Show Grid", tooltip="Toggle visibility of grid lines", tab="View", group="Snapping")
def toggle_show_grid(self):
"""Toggle grid visibility"""
if not self.project:
@@ -144,12 +106,7 @@ class ViewOperationsMixin:
self.show_status(f"Grid {status}", 2000)
print(f"Grid {status}")
- @ribbon_action(
- label="Show Guides",
- tooltip="Toggle visibility of guide lines",
- tab="View",
- group="Snapping"
- )
+ @ribbon_action(label="Show Guides", tooltip="Toggle visibility of guide lines", tab="View", group="Snapping")
def toggle_snap_lines(self):
"""Toggle guide lines visibility"""
if not self.project:
@@ -161,71 +118,53 @@ class ViewOperationsMixin:
self.update_view()
self.show_status(f"Guides {status}", 2000)
print(f"Guides {status}")
-
- @ribbon_action(
- label="Add H Guide",
- tooltip="Add horizontal guide at page center",
- tab="View",
- group="Guides"
- )
+
+ @ribbon_action(label="Add H Guide", tooltip="Add horizontal guide at page center", tab="View", group="Guides")
def add_horizontal_guide(self):
"""Add a horizontal guide at page center"""
current_page = self.get_current_page()
if not current_page:
return
-
+
# Add guide at vertical center (in mm)
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.show_status(f"Added horizontal guide at {center_y:.1f} mm", 2000)
print(f"Added horizontal guide at {center_y:.1f} mm")
-
- @ribbon_action(
- label="Add V Guide",
- tooltip="Add vertical guide at page center",
- tab="View",
- group="Guides"
- )
+
+ @ribbon_action(label="Add V Guide", tooltip="Add vertical guide at page center", tab="View", group="Guides")
def add_vertical_guide(self):
"""Add a vertical guide at page center"""
current_page = self.get_current_page()
if not current_page:
return
-
+
# Add guide at horizontal center (in mm)
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.show_status(f"Added vertical guide at {center_x:.1f} mm", 2000)
print(f"Added vertical guide at {center_x:.1f} mm")
-
- @ribbon_action(
- label="Clear Guides",
- tooltip="Clear all guides from current page",
- tab="View",
- group="Guides"
- )
+
+ @ribbon_action(label="Clear Guides", tooltip="Clear all guides from current page", tab="View", group="Guides")
def clear_guides(self):
"""Clear all guides from current page"""
current_page = self.get_current_page()
if not current_page:
return
-
+
guide_count = len(current_page.layout.snapping_system.guides)
current_page.layout.snapping_system.clear_guides()
-
+
self.update_view()
self.show_status(f"Cleared {guide_count} guides", 2000)
print(f"Cleared {guide_count} guides")
-
+
@ribbon_action(
- label="Set Grid Size...",
- tooltip="Configure grid spacing for snapping",
- tab="View",
- group="Snapping"
+ label="Set Grid Size...", tooltip="Configure grid spacing for snapping", tab="View", group="Snapping"
)
def set_grid_size(self):
"""Open dialog to set grid size"""
@@ -295,67 +234,49 @@ class ViewOperationsMixin:
self.update_view()
self.show_status(f"Grid size: {new_grid_size:.1f}mm, Threshold: {new_threshold:.1f}mm", 2000)
print(f"Updated grid settings - Size: {new_grid_size:.1f}mm, Threshold: {new_threshold:.1f}mm")
-
+
# ===== Layout Tab Snapping Controls =====
# These provide easy access to snapping features during layout work
-
+
@ribbon_action(
label="Grid Snap",
tooltip="Enable/disable snapping to grid (Ctrl+G)",
tab="Layout",
group="Snapping",
- shortcut="Ctrl+G"
+ shortcut="Ctrl+G",
)
def layout_toggle_grid_snap(self):
"""Toggle grid snapping (Layout tab)"""
self.toggle_grid_snap()
-
+
@ribbon_action(
label="Edge Snap",
tooltip="Enable/disable snapping to page edges (Ctrl+E)",
tab="Layout",
group="Snapping",
- shortcut="Ctrl+E"
+ shortcut="Ctrl+E",
)
def layout_toggle_edge_snap(self):
"""Toggle edge snapping (Layout tab)"""
self.toggle_edge_snap()
-
- @ribbon_action(
- label="Guide Snap",
- tooltip="Enable/disable snapping to guides",
- tab="Layout",
- group="Snapping"
- )
+
+ @ribbon_action(label="Guide Snap", tooltip="Enable/disable snapping to guides", tab="Layout", group="Snapping")
def layout_toggle_guide_snap(self):
"""Toggle guide snapping (Layout tab)"""
self.toggle_guide_snap()
-
- @ribbon_action(
- label="Show Grid",
- tooltip="Toggle visibility of grid lines",
- tab="Layout",
- group="Snapping"
- )
+
+ @ribbon_action(label="Show Grid", tooltip="Toggle visibility of grid lines", tab="Layout", group="Snapping")
def layout_toggle_show_grid(self):
"""Toggle grid visibility (Layout tab)"""
self.toggle_show_grid()
- @ribbon_action(
- label="Show Guides",
- tooltip="Toggle visibility of guide lines",
- tab="Layout",
- group="Snapping"
- )
+ @ribbon_action(label="Show Guides", tooltip="Toggle visibility of guide lines", tab="Layout", group="Snapping")
def layout_toggle_snap_lines(self):
"""Toggle guide lines visibility (Layout tab)"""
self.toggle_snap_lines()
-
+
@ribbon_action(
- label="Grid Settings...",
- tooltip="Configure grid size and snap threshold",
- tab="Layout",
- group="Snapping"
+ label="Grid Settings...", tooltip="Configure grid size and snap threshold", tab="Layout", group="Snapping"
)
def layout_set_grid_size(self):
"""Open grid settings dialog (Layout tab)"""
diff --git a/pyPhotoAlbum/mixins/operations/zorder_ops.py b/pyPhotoAlbum/mixins/operations/zorder_ops.py
index 10c0d3c..e2117d3 100644
--- a/pyPhotoAlbum/mixins/operations/zorder_ops.py
+++ b/pyPhotoAlbum/mixins/operations/zorder_ops.py
@@ -8,159 +8,159 @@ from pyPhotoAlbum.commands import ChangeZOrderCommand
class ZOrderOperationsMixin:
"""Mixin providing z-order/layer control operations"""
-
+
@ribbon_action(
label="Bring to Front",
tooltip="Bring selected element to front",
tab="Arrange",
group="Order",
shortcut="Ctrl+Shift+]",
- requires_selection=True
+ requires_selection=True,
)
def bring_to_front(self):
"""Bring selected element to front (end of list)"""
if not self.gl_widget.selected_element:
return
-
+
current_page = self.get_current_page()
if not current_page:
return
-
+
element = self.gl_widget.selected_element
elements = current_page.layout.elements
-
+
if element not in elements:
return
-
+
old_index = elements.index(element)
new_index = len(elements) - 1
-
+
if old_index == new_index:
self.show_status("Element is already at front", 2000)
return
-
+
# Create and execute command
cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index)
self.project.history.execute(cmd)
-
+
self.update_view()
self.show_status("Brought element to front (Ctrl+Z to undo)", 2000)
print(f"Brought element to front: {old_index} → {new_index}")
-
+
@ribbon_action(
label="Send to Back",
tooltip="Send selected element to back",
tab="Arrange",
group="Order",
shortcut="Ctrl+Shift+[",
- requires_selection=True
+ requires_selection=True,
)
def send_to_back(self):
"""Send selected element to back (start of list)"""
if not self.gl_widget.selected_element:
return
-
+
current_page = self.get_current_page()
if not current_page:
return
-
+
element = self.gl_widget.selected_element
elements = current_page.layout.elements
-
+
if element not in elements:
return
-
+
old_index = elements.index(element)
new_index = 0
-
+
if old_index == new_index:
self.show_status("Element is already at back", 2000)
return
-
+
# Create and execute command
cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index)
self.project.history.execute(cmd)
-
+
self.update_view()
self.show_status("Sent element to back (Ctrl+Z to undo)", 2000)
print(f"Sent element to back: {old_index} → {new_index}")
-
+
@ribbon_action(
label="Bring Forward",
tooltip="Bring selected element forward one layer",
tab="Arrange",
group="Order",
shortcut="Ctrl+]",
- requires_selection=True
+ requires_selection=True,
)
def bring_forward(self):
"""Move selected element forward one position in list"""
if not self.gl_widget.selected_element:
return
-
+
current_page = self.get_current_page()
if not current_page:
return
-
+
element = self.gl_widget.selected_element
elements = current_page.layout.elements
-
+
if element not in elements:
return
-
+
old_index = elements.index(element)
new_index = old_index + 1
-
+
if new_index >= len(elements):
self.show_status("Element is already at front", 2000)
return
-
+
# Create and execute command
cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index)
self.project.history.execute(cmd)
-
+
self.update_view()
self.show_status("Brought element forward (Ctrl+Z to undo)", 2000)
print(f"Brought element forward: {old_index} → {new_index}")
-
+
@ribbon_action(
label="Send Backward",
tooltip="Send selected element backward one layer",
tab="Arrange",
group="Order",
shortcut="Ctrl+[",
- requires_selection=True
+ requires_selection=True,
)
def send_backward(self):
"""Move selected element backward one position in list"""
if not self.gl_widget.selected_element:
return
-
+
current_page = self.get_current_page()
if not current_page:
return
-
+
element = self.gl_widget.selected_element
elements = current_page.layout.elements
-
+
if element not in elements:
return
-
+
old_index = elements.index(element)
new_index = old_index - 1
-
+
if new_index < 0:
self.show_status("Element is already at back", 2000)
return
-
+
# Create and execute command
cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index)
self.project.history.execute(cmd)
-
+
self.update_view()
self.show_status("Sent element backward (Ctrl+Z to undo)", 2000)
print(f"Sent element backward: {old_index} → {new_index}")
-
+
@ribbon_action(
label="Swap Order",
tooltip="Swap z-order of two selected elements",
@@ -168,21 +168,21 @@ class ZOrderOperationsMixin:
group="Order",
shortcut="Ctrl+Shift+X",
requires_selection=True,
- min_selection=2
+ min_selection=2,
)
def swap_order(self):
"""Swap the z-order of two selected elements"""
if len(self.gl_widget.selected_elements) != 2:
self.show_status("Please select exactly 2 elements to swap", 2000)
return
-
+
current_page = self.get_current_page()
if not current_page:
return
-
+
elements = current_page.layout.elements
selected = list(self.gl_widget.selected_elements)
-
+
# Get indices of both elements
try:
index1 = elements.index(selected[0])
@@ -190,10 +190,10 @@ class ZOrderOperationsMixin:
except ValueError:
self.show_status("Selected elements not found on current page", 2000)
return
-
+
# Swap them in the list
elements[index1], elements[index2] = elements[index2], elements[index1]
-
+
self.update_view()
self.show_status(f"Swapped z-order of elements", 2000)
print(f"Swapped elements at indices {index1} and {index2}")
diff --git a/pyPhotoAlbum/mixins/page_navigation.py b/pyPhotoAlbum/mixins/page_navigation.py
index b97abec..5e10d44 100644
--- a/pyPhotoAlbum/mixins/page_navigation.py
+++ b/pyPhotoAlbum/mixins/page_navigation.py
@@ -33,11 +33,11 @@ class PageNavigationMixin:
Returns:
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
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
# 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)
"""
main_window = self.window()
- if not hasattr(main_window, 'project'):
+ if not hasattr(main_window, "project"):
return []
dpi = main_window.project.working_dpi
@@ -77,7 +77,7 @@ class PageNavigationMixin:
# First, render cover if it exists
for page in main_window.project.pages:
if page.is_cover:
- result.append(('page', page, current_y))
+ result.append(("page", page, current_y))
# Calculate cover height in pixels
page_height_mm = page.layout.size[1]
@@ -91,7 +91,7 @@ class PageNavigationMixin:
layout_with_ghosts = main_window.project.calculate_page_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)
result.append((page_type, page_obj, current_y))
@@ -103,7 +103,7 @@ class PageNavigationMixin:
# Move to next position (add height + spacing)
current_y += page_height_px + spacing_px
- elif page_type == 'ghost':
+ elif page_type == "ghost":
# Ghost page - use default page size
page_size_mm = main_window.project.page_size_mm
from pyPhotoAlbum.models import GhostPageData
@@ -131,11 +131,11 @@ class PageNavigationMixin:
Returns:
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
main_window = self.window()
- if not hasattr(main_window, 'project'):
+ if not hasattr(main_window, "project"):
return False
# Get page positions which includes ghosts
@@ -144,7 +144,7 @@ class PageNavigationMixin:
# Check each position for ghost pages
for idx, (page_type, page_or_ghost, y_offset) in enumerate(page_positions):
# Skip non-ghost pages
- if page_type != 'ghost':
+ if page_type != "ghost":
continue
ghost = page_or_ghost
@@ -156,20 +156,21 @@ class PageNavigationMixin:
screen_y = (y_offset * self.zoom_level) + self.pan_offset[1]
from pyPhotoAlbum.page_renderer import PageRenderer
+
renderer = PageRenderer(
page_width_mm=ghost_width_mm,
page_height_mm=ghost_height_mm,
screen_x=screen_x,
screen_y=screen_y,
dpi=dpi,
- zoom=self.zoom_level
+ zoom=self.zoom_level,
)
# Check if click is anywhere on the ghost page (entire page is clickable)
if renderer.is_point_in_page(x, y):
# User clicked the ghost page!
# 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")
@@ -181,10 +182,9 @@ class PageNavigationMixin:
new_page_number = insert_index + 1
new_page = Page(
layout=PageLayout(
- width=main_window.project.page_size_mm[0],
- height=main_window.project.page_size_mm[1]
+ width=main_window.project.page_size_mm[0], 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
@@ -209,10 +209,10 @@ class PageNavigationMixin:
y: Screen Y coordinate
"""
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
- if not hasattr(self, '_page_renderers') or not self._page_renderers:
+ if not hasattr(self, "_page_renderers") or not self._page_renderers:
return
# Get total page count (accounting for double spreads = 2 pages each)
@@ -228,7 +228,7 @@ class PageNavigationMixin:
if page.is_double_spread:
side = renderer.get_sub_page_at(x, is_facing_page=True)
page_nums = page.get_page_numbers()
- if side == 'left':
+ if side == "left":
current_page_info = f"Page {page_nums[0]}"
else:
current_page_info = f"Page {page_nums[1]}"
@@ -237,8 +237,10 @@ class PageNavigationMixin:
break
# Update status bar
- if hasattr(main_window, 'status_bar'):
+ if hasattr(main_window, "status_bar"):
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:
main_window.status_bar.showMessage(f"Total pages: {total_pages} | Zoom: {int(self.zoom_level * 100)}%")
diff --git a/pyPhotoAlbum/mixins/rendering.py b/pyPhotoAlbum/mixins/rendering.py
index 184c2ea..7fa6ff2 100644
--- a/pyPhotoAlbum/mixins/rendering.py
+++ b/pyPhotoAlbum/mixins/rendering.py
@@ -26,7 +26,7 @@ class RenderingMixin:
glLoadIdentity()
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
# 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
main_window = self.window()
- if hasattr(main_window, 'update_scrollbars'):
+ if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars()
dpi = main_window.project.working_dpi
@@ -55,7 +55,7 @@ class RenderingMixin:
for page_info in page_positions:
page_type, page_or_ghost, y_offset = page_info
- if page_type == 'page':
+ if page_type == "page":
page = page_or_ghost
page_width_mm, page_height_mm = page.layout.size
@@ -68,7 +68,7 @@ class RenderingMixin:
screen_x=screen_x,
screen_y=screen_y,
dpi=dpi,
- zoom=self.zoom_level
+ zoom=self.zoom_level,
)
self._page_renderers.append((renderer, page))
@@ -79,7 +79,7 @@ class RenderingMixin:
page.layout.render(dpi=dpi, project=main_window.project)
renderer.end_render()
- elif page_type == 'ghost':
+ elif page_type == "ghost":
ghost = page_or_ghost
ghost_width_mm, ghost_height_mm = ghost.page_size
@@ -92,14 +92,14 @@ class RenderingMixin:
screen_x=screen_x,
screen_y=screen_y,
dpi=dpi,
- zoom=self.zoom_level
+ zoom=self.zoom_level,
)
self._render_ghost_page(ghost, renderer)
# Update PageRenderer references for 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:
if page is element._parent_page:
element._page_renderer = renderer
@@ -118,10 +118,10 @@ class RenderingMixin:
return
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
- if not hasattr(element, '_page_renderer'):
+ if not hasattr(element, "_page_renderer"):
return
renderer = element._page_renderer
@@ -188,10 +188,10 @@ class RenderingMixin:
glEnd()
else:
handles = [
- (x - 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 + w - handle_size/2, y + h - handle_size/2),
+ (x - 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 + w - handle_size / 2, y + h - handle_size / 2),
]
glColor3f(1.0, 1.0, 1.0)
@@ -214,7 +214,7 @@ class RenderingMixin:
def _render_text_overlays(self):
"""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
painter = QPainter(self)
@@ -236,12 +236,12 @@ class RenderingMixin:
screen_w = w * renderer.zoom
screen_h = h * renderer.zoom
- font_family = element.font_settings.get('family', 'Arial')
- font_size = int(element.font_settings.get('size', 12) * renderer.zoom)
+ font_family = element.font_settings.get("family", "Arial")
+ font_size = int(element.font_settings.get("size", 12) * renderer.zoom)
font = QFont(font_family, font_size)
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):
color = QColor(*font_color)
else:
@@ -260,9 +260,9 @@ class RenderingMixin:
rect = QRectF(screen_x, screen_y, screen_w, screen_h)
alignment = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop
- if element.alignment == 'center':
+ if element.alignment == "center":
alignment = Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop
- elif element.alignment == 'right':
+ elif element.alignment == "right":
alignment = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop
text_flags = Qt.TextFlag.TextWordWrap
diff --git a/pyPhotoAlbum/mixins/viewport.py b/pyPhotoAlbum/mixins/viewport.py
index 99d44e7..80bb88d 100644
--- a/pyPhotoAlbum/mixins/viewport.py
+++ b/pyPhotoAlbum/mixins/viewport.py
@@ -61,7 +61,7 @@ class ViewportMixin:
# Update scrollbars when viewport size changes
main_window = self.window()
- if hasattr(main_window, 'update_scrollbars'):
+ if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars()
def _calculate_fit_to_screen_zoom(self):
@@ -72,7 +72,7 @@ class ViewportMixin:
float: Zoom level (1.0 = 100%, 0.5 = 50%, etc.)
"""
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
window_width = self.width()
@@ -106,7 +106,7 @@ class ViewportMixin:
list: [x_offset, y_offset] to center the page
"""
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]
window_width = self.width()
@@ -141,8 +141,8 @@ class ViewportMixin:
dict: {'min_x', 'max_x', 'min_y', 'max_y', 'width', 'height'} in pixels
"""
main_window = self.window()
- 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}
+ 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}
dpi = main_window.project.working_dpi
PAGE_MARGIN = 50
@@ -167,12 +167,12 @@ class ViewportMixin:
total_height += PAGE_MARGIN
return {
- 'min_x': 0,
- 'max_x': total_width,
- 'min_y': 0,
- 'max_y': total_height,
- 'width': total_width,
- 'height': total_height
+ "min_x": 0,
+ "max_x": total_width,
+ "min_y": 0,
+ "max_y": total_height,
+ "width": total_width,
+ "height": total_height,
}
def clamp_pan_offset(self):
@@ -188,7 +188,7 @@ class ViewportMixin:
when zooming on pages of different widths.
"""
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
viewport_width = self.width()
@@ -200,7 +200,7 @@ class ViewportMixin:
# Vertical clamping
bounds = self.get_content_bounds()
- content_height = bounds['height']
+ content_height = bounds["height"]
# Save original pan_offset[1] BEFORE clamping for page selection
# This prevents clamping from changing which page we think we're on
diff --git a/pyPhotoAlbum/models.py b/pyPhotoAlbum/models.py
index b5acf21..7dbb23f 100644
--- a/pyPhotoAlbum/models.py
+++ b/pyPhotoAlbum/models.py
@@ -13,13 +13,39 @@ from PIL import Image
from pyPhotoAlbum.image_utils import apply_pil_rotation, calculate_center_crop_coords
from pyPhotoAlbum.gl_imports import (
- GL_AVAILABLE, glBegin, glEnd, glVertex2f, glColor3f, glColor4f,
- GL_QUADS, GL_LINE_LOOP, glEnable, glDisable, GL_TEXTURE_2D,
- glBindTexture, glTexCoord2f, glTexParameteri, GL_TEXTURE_MIN_FILTER,
- GL_TEXTURE_MAG_FILTER, GL_LINEAR, glGenTextures, glTexImage2D,
- GL_RGBA, GL_UNSIGNED_BYTE, glDeleteTextures, glGetString, GL_VERSION,
- glLineStipple, GL_LINE_STIPPLE, glPushMatrix, glPopMatrix,
- glTranslatef, glRotatef, GL_BLEND, glBlendFunc, GL_SRC_ALPHA,
+ GL_AVAILABLE,
+ glBegin,
+ glEnd,
+ glVertex2f,
+ glColor3f,
+ glColor4f,
+ GL_QUADS,
+ GL_LINE_LOOP,
+ glEnable,
+ glDisable,
+ GL_TEXTURE_2D,
+ glBindTexture,
+ glTexCoord2f,
+ glTexParameteri,
+ GL_TEXTURE_MIN_FILTER,
+ GL_TEXTURE_MAG_FILTER,
+ GL_LINEAR,
+ glGenTextures,
+ glTexImage2D,
+ GL_RGBA,
+ GL_UNSIGNED_BYTE,
+ glDeleteTextures,
+ glGetString,
+ GL_VERSION,
+ glLineStipple,
+ GL_LINE_STIPPLE,
+ glPushMatrix,
+ glPopMatrix,
+ glTranslatef,
+ glRotatef,
+ GL_BLEND,
+ glBlendFunc,
+ GL_SRC_ALPHA,
GL_ONE_MINUS_SRC_ALPHA,
)
@@ -48,10 +74,13 @@ def get_asset_search_paths() -> Tuple[Optional[str], List[str]]:
"""Get the current asset resolution context."""
return _primary_project_folder, _asset_search_paths
+
class BaseLayoutElement(ABC):
"""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.size = (width, height)
self.rotation = rotation
@@ -118,11 +147,17 @@ class BaseLayoutElement(ABC):
"""Deserialize from a dictionary"""
pass
+
class ImageData(BaseLayoutElement):
"""Class to store image data and properties"""
- def __init__(self, image_path: str = "", crop_info: Optional[Tuple] = None,
- image_dimensions: Optional[Tuple[int, int]] = None, **kwargs):
+ def __init__(
+ self,
+ image_path: str = "",
+ crop_info: Optional[Tuple] = None,
+ image_dimensions: Optional[Tuple[int, int]] = None,
+ **kwargs,
+ ):
super().__init__(**kwargs)
self.image_path = image_path
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)
# 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()
# 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
# 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
elif 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)
# Calculate texture coordinates for center crop with element's crop_info
- tx_min, ty_min, tx_max, ty_max = calculate_center_crop_coords(
- img_width, img_height, w, h, self.crop_info
- )
+ tx_min, ty_min, tx_max, ty_max = calculate_center_crop_coords(img_width, img_height, w, h, self.crop_info)
# Enable texturing and draw with crop
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
glBegin(GL_QUADS)
- glTexCoord2f(tx_min, ty_min); glVertex2f(x, y)
- glTexCoord2f(tx_max, ty_min); glVertex2f(x + w, y)
- glTexCoord2f(tx_max, ty_max); glVertex2f(x + w, y + h)
- glTexCoord2f(tx_min, ty_max); glVertex2f(x, y + h)
+ glTexCoord2f(tx_min, ty_min)
+ glVertex2f(x, y)
+ glTexCoord2f(tx_max, ty_min)
+ glVertex2f(x + w, y)
+ glTexCoord2f(tx_max, ty_max)
+ glVertex2f(x + w, y + h)
+ glTexCoord2f(tx_min, ty_max)
+ glVertex2f(x, y + h)
glEnd()
glDisable(GL_TEXTURE_2D)
-
+
# If no image or loading failed, draw placeholder
if not texture_id:
glColor3f(0.7, 0.85, 1.0) # Light blue
@@ -234,7 +271,7 @@ class ImageData(BaseLayoutElement):
glVertex2f(x + w, y + h)
glVertex2f(x, y + h)
glEnd()
-
+
# Draw border
glColor3f(0.0, 0.0, 0.0) # Black border
glBegin(GL_LINE_LOOP)
@@ -254,7 +291,7 @@ class ImageData(BaseLayoutElement):
"z_index": self.z_index,
"image_path": self.image_path,
"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
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}")
# 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)
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.
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
try:
@@ -355,11 +392,11 @@ class ImageData(BaseLayoutElement):
pil_image = self._pending_pil_image
# Ensure RGBA format for GL_RGBA texture (defensive check)
- if pil_image.mode != 'RGBA':
- pil_image = pil_image.convert('RGBA')
+ if pil_image.mode != "RGBA":
+ pil_image = pil_image.convert("RGBA")
# 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])
# Create GPU texture from pre-processed PIL image
@@ -369,8 +406,9 @@ class ImageData(BaseLayoutElement):
glBindTexture(GL_TEXTURE_2D, texture_id)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_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,
- 0, GL_RGBA, GL_UNSIGNED_BYTE, img_data)
+ glTexImage2D(
+ GL_TEXTURE_2D, 0, GL_RGBA, pil_image.width, pil_image.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, img_data
+ )
# Cache texture
self._texture_id = texture_id
@@ -380,8 +418,8 @@ class ImageData(BaseLayoutElement):
self._pending_pil_image = None
# Clear the warning flag if we successfully created the texture
- if hasattr(self, '_gl_context_warned'):
- delattr(self, '_gl_context_warned')
+ if hasattr(self, "_gl_context_warned"):
+ delattr(self, "_gl_context_warned")
logger.info(f"ImageData: Successfully created texture for {self.image_path}")
return True
@@ -390,11 +428,13 @@ class ImageData(BaseLayoutElement):
error_str = str(e)
# Check if this is a GL context error (err 1282 = GL_INVALID_OPERATION)
# 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
# Don't spam the console with repeated messages
- if not hasattr(self, '_gl_context_warned'):
- logger.warning(f"ImageData: GL context error (1282) for {self.image_path}, will retry on next render")
+ if not hasattr(self, "_gl_context_warned"):
+ logger.warning(
+ f"ImageData: GL context error (1282) for {self.image_path}, will retry on next render"
+ )
self._gl_context_warned = True
return False
else:
@@ -415,6 +455,7 @@ class ImageData(BaseLayoutElement):
self._async_loading = False
self._async_load_requested = False
+
class PlaceholderData(BaseLayoutElement):
"""Class to store placeholder data"""
@@ -428,7 +469,7 @@ class PlaceholderData(BaseLayoutElement):
x, y = self.position
w, h = self.size
-
+
# Apply rotation if needed
if self.rotation != 0:
glPushMatrix()
@@ -440,7 +481,7 @@ class PlaceholderData(BaseLayoutElement):
glTranslatef(-w / 2, -h / 2, 0)
# Now render at origin (rotation pivot is at element center)
x, y = 0, 0
-
+
# Draw a light gray rectangle as placeholder background
glColor3f(0.9, 0.9, 0.9) # Light gray
glBegin(GL_QUADS)
@@ -449,7 +490,7 @@ class PlaceholderData(BaseLayoutElement):
glVertex2f(x + w, y + h)
glVertex2f(x, y + h)
glEnd()
-
+
# Draw dashed border for placeholder
glEnable(GL_LINE_STIPPLE)
glLineStipple(1, 0x00FF) # Dashed pattern
@@ -461,7 +502,7 @@ class PlaceholderData(BaseLayoutElement):
glVertex2f(x, y + h)
glEnd()
glDisable(GL_LINE_STIPPLE)
-
+
# Pop matrix if we pushed for rotation
if self.rotation != 0:
glPopMatrix()
@@ -475,7 +516,7 @@ class PlaceholderData(BaseLayoutElement):
"rotation": self.rotation,
"z_index": self.z_index,
"placeholder_type": self.placeholder_type,
- "default_content": self.default_content
+ "default_content": self.default_content,
}
# Add base fields (v3.0+)
data.update(self._serialize_base_fields())
@@ -493,6 +534,7 @@ class PlaceholderData(BaseLayoutElement):
self.placeholder_type = data.get("placeholder_type", "image")
self.default_content = data.get("default_content", "")
+
class TextBoxData(BaseLayoutElement):
"""Class to store text box data"""
@@ -506,7 +548,7 @@ class TextBoxData(BaseLayoutElement):
"""Render the text box using OpenGL"""
x, y = self.position
w, h = self.size
-
+
# Apply rotation if needed
if self.rotation != 0:
glPushMatrix()
@@ -518,11 +560,11 @@ class TextBoxData(BaseLayoutElement):
glTranslatef(-w / 2, -h / 2, 0)
# Now render at origin (rotation pivot is at element center)
x, y = 0, 0
-
+
# Enable alpha blending for transparency
glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
-
+
# Draw a semi-transparent yellow rectangle as text box background
glColor4f(1.0, 1.0, 0.7, 0.3) # Light yellow with 30% opacity
glBegin(GL_QUADS)
@@ -531,9 +573,9 @@ class TextBoxData(BaseLayoutElement):
glVertex2f(x + w, y + h)
glVertex2f(x, y + h)
glEnd()
-
+
glDisable(GL_BLEND)
-
+
# Draw border
glColor3f(0.0, 0.0, 0.0) # Black border
glBegin(GL_LINE_LOOP)
@@ -542,11 +584,11 @@ class TextBoxData(BaseLayoutElement):
glVertex2f(x + w, y + h)
glVertex2f(x, y + h)
glEnd()
-
+
# Pop matrix if we pushed for rotation
if self.rotation != 0:
glPopMatrix()
-
+
# Note: Text content is rendered using QPainter overlay in GLWidget.paintGL()
def serialize(self) -> Dict[str, Any]:
@@ -559,7 +601,7 @@ class TextBoxData(BaseLayoutElement):
"z_index": self.z_index,
"text_content": self.text_content,
"font_settings": self.font_settings,
- "alignment": self.alignment
+ "alignment": self.alignment,
}
# Add base fields (v3.0+)
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.alignment = data.get("alignment", "left")
+
class GhostPageData(BaseLayoutElement):
"""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
# PageRenderer will handle transformation to screen coordinates
x, y = 0, 0
-
+
# Calculate dimensions from page_size (in mm) - assume 300 DPI for now
# This will be overridden by proper size calculation in PageRenderer
dpi = 300 # Default DPI for rendering
w = self.page_size[0] * dpi / 25.4
h = self.page_size[1] * dpi / 25.4
-
+
# Enable alpha blending for transparency
glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
-
+
# 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
glBegin(GL_QUADS)
@@ -611,9 +654,9 @@ class GhostPageData(BaseLayoutElement):
glVertex2f(x + w, y + h)
glVertex2f(x, y + h)
glEnd()
-
+
glDisable(GL_BLEND)
-
+
# Draw dashed border
glEnable(GL_LINE_STIPPLE)
glLineStipple(2, 0x0F0F) # Dashed pattern
@@ -625,7 +668,7 @@ class GhostPageData(BaseLayoutElement):
glVertex2f(x, y + h)
glEnd()
glDisable(GL_LINE_STIPPLE)
-
+
# Note: "Click to Add Page" text is rendered using QPainter overlay in GLWidget
# The entire page is clickable
@@ -640,12 +683,7 @@ class GhostPageData(BaseLayoutElement):
def serialize(self) -> Dict[str, Any]:
"""Serialize ghost page data to dictionary"""
- data = {
- "type": "ghostpage",
- "position": self.position,
- "size": self.size,
- "page_size": self.page_size
- }
+ data = {"type": "ghostpage", "position": self.position, "size": self.size, "page_size": self.page_size}
# Add base fields (v3.0+)
data.update(self._serialize_base_fields())
return data
diff --git a/pyPhotoAlbum/page_layout.py b/pyPhotoAlbum/page_layout.py
index bc6d2a5..a812f77 100644
--- a/pyPhotoAlbum/page_layout.py
+++ b/pyPhotoAlbum/page_layout.py
@@ -6,19 +6,32 @@ from typing import List, Dict, Any, Optional, Tuple
from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData
from pyPhotoAlbum.snapping import SnappingSystem
from pyPhotoAlbum.gl_imports import (
- glBegin, glEnd, glVertex2f, glColor3f, glColor4f,
- GL_QUADS, GL_LINE_LOOP, GL_LINES, glLineWidth,
- glEnable, glDisable, GL_DEPTH_TEST, GL_BLEND,
- glBlendFunc, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA,
+ glBegin,
+ glEnd,
+ glVertex2f,
+ glColor3f,
+ glColor4f,
+ GL_QUADS,
+ GL_LINE_LOOP,
+ GL_LINES,
+ glLineWidth,
+ glEnable,
+ glDisable,
+ GL_DEPTH_TEST,
+ GL_BLEND,
+ glBlendFunc,
+ GL_SRC_ALPHA,
+ GL_ONE_MINUS_SRC_ALPHA,
)
+
class PageLayout:
"""Class to manage page layout and templates"""
def __init__(self, width: float = 210, height: float = 297, is_facing_page: bool = False):
"""
Initialize page layout.
-
+
Args:
width: Width in mm (doubled automatically if is_facing_page=True)
height: Height in mm
@@ -41,7 +54,7 @@ class PageLayout:
"""Remove a layout element from the page"""
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"""
self.grid_layout = grid
@@ -58,15 +71,15 @@ class PageLayout:
"""
# Disable depth testing for 2D rendering
glDisable(GL_DEPTH_TEST)
-
+
# Convert size from mm to pixels based on DPI
width_px = self.size[0] * dpi / 25.4
height_px = self.size[1] * dpi / 25.4
-
+
# All rendering is at page origin (0, 0) in page-local coordinates
page_x = 0
page_y = 0
-
+
# Draw drop shadow FIRST (behind everything)
shadow_offset = 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 + shadow_offset, page_y + height_px + shadow_offset)
glEnd()
-
+
glBegin(GL_QUADS)
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 + height_px)
glVertex2f(page_x + width_px, page_y + height_px)
glEnd()
-
+
# Draw page background (slightly off-white to distinguish from canvas)
glColor3f(0.98, 0.98, 0.98)
glBegin(GL_QUADS)
@@ -97,11 +110,11 @@ class PageLayout:
# For ImageData elements, request async loading if available
for element in self.elements:
# 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
- if hasattr(self, '_async_loader'):
+ if hasattr(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
else:
loader = None
@@ -109,14 +122,15 @@ class PageLayout:
# Request async load if loader is available and not already requested
if loader and not element._async_load_requested:
from pyPhotoAlbum.async_backend import LoadPriority
+
# 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)
element._async_load_requested = True
element._async_loading = True
element.render()
-
+
# Draw page border LAST (on top of everything)
glColor3f(0.7, 0.7, 0.7)
glLineWidth(2.0)
@@ -127,7 +141,7 @@ class PageLayout:
glVertex2f(page_x, page_y + height_px)
glEnd()
glLineWidth(1.0)
-
+
# Draw center line for facing pages
if self.is_facing_page:
center_x = page_x + (width_px / 2)
@@ -138,13 +152,13 @@ class PageLayout:
glVertex2f(center_x, page_y + height_px)
glEnd()
glLineWidth(1.0)
-
+
# Always render snap lines (grid shows when show_grid is on, guides show when show_snap_lines is on)
self._render_snap_lines(dpi, page_x, page_y, project)
# Re-enable depth testing
glEnable(GL_DEPTH_TEST)
-
+
def _render_snap_lines(self, dpi: int, page_x: float, page_y: float, project=None):
"""Render snap lines (grid, edges, guides)"""
# 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
from pyPhotoAlbum.snapping import SnappingSystem
+
temp_snap_sys = SnappingSystem(snap_threshold_mm=snap_threshold_mm)
temp_snap_sys.grid_size_mm = grid_size_mm
temp_snap_sys.snap_to_grid = snap_to_grid
@@ -183,12 +198,12 @@ class PageLayout:
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
# 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
glLineWidth(1.0)
- for orientation, position in snap_lines['grid']:
+ for orientation, position in snap_lines["grid"]:
glBegin(GL_LINES)
- if orientation == 'vertical':
+ if orientation == "vertical":
glVertex2f(page_x + position, page_y)
glVertex2f(page_x + position, page_y + self.size[1] * dpi / 25.4)
else: # horizontal
@@ -197,12 +212,12 @@ class PageLayout:
glEnd()
# 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
glLineWidth(1.5)
- for orientation, position in snap_lines['guides']:
+ for orientation, position in snap_lines["guides"]:
glBegin(GL_LINES)
- if orientation == 'vertical':
+ if orientation == "vertical":
glVertex2f(page_x + position, page_y)
glVertex2f(page_x + position, page_y + self.size[1] * dpi / 25.4)
else: # horizontal
@@ -223,7 +238,7 @@ class PageLayout:
"elements": [elem.serialize() for elem in self.elements],
"grid_layout": self.grid_layout.serialize() if self.grid_layout else None,
"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]):
@@ -250,7 +265,7 @@ class PageLayout:
elem.deserialize(elem_data)
elem_list.append(elem)
-
+
# 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)
self.elements = elem_list
@@ -260,14 +275,15 @@ class PageLayout:
if grid_data:
self.grid_layout = GridLayout()
self.grid_layout.deserialize(grid_data)
-
+
# Deserialize snapping system
snap_data = data.get("snapping_system")
if snap_data:
self.snapping_system.deserialize(snap_data)
-
+
self.show_snap_lines = data.get("show_snap_lines", True)
+
class GridLayout:
"""Class to manage grid layouts"""
@@ -281,7 +297,9 @@ class GridLayout:
"""Merge cells in the grid"""
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"""
cell_width = (page_width - (self.spacing * (self.columns + 1))) / self.columns
cell_height = (page_height - (self.spacing * (self.rows + 1))) / self.rows
@@ -300,12 +318,7 @@ class GridLayout:
def serialize(self) -> Dict[str, Any]:
"""Serialize grid layout to dictionary"""
- return {
- "rows": self.rows,
- "columns": self.columns,
- "spacing": self.spacing,
- "merged_cells": self.merged_cells
- }
+ return {"rows": self.rows, "columns": self.columns, "spacing": self.spacing, "merged_cells": self.merged_cells}
def deserialize(self, data: Dict[str, Any]):
"""Deserialize from dictionary"""
diff --git a/pyPhotoAlbum/page_renderer.py b/pyPhotoAlbum/page_renderer.py
index 25fba26..219a85c 100644
--- a/pyPhotoAlbum/page_renderer.py
+++ b/pyPhotoAlbum/page_renderer.py
@@ -17,21 +17,17 @@ from pyPhotoAlbum.gl_imports import glPushMatrix, glPopMatrix, glScalef, glTrans
class PageRenderer:
"""
Handles rendering and coordinate transformations for a single page.
-
+
This class encapsulates all coordinate transformations needed to render
a page and its elements consistently.
"""
-
- def __init__(self,
- page_width_mm: float,
- page_height_mm: float,
- screen_x: float,
- screen_y: float,
- dpi: int,
- zoom: float):
+
+ def __init__(
+ self, page_width_mm: float, page_height_mm: float, screen_x: float, screen_y: float, dpi: int, zoom: float
+ ):
"""
Initialize a page renderer.
-
+
Args:
page_width_mm: Page width in millimeters
page_height_mm: Page height in millimeters
@@ -46,81 +42,83 @@ class PageRenderer:
self.screen_y = screen_y
self.dpi = dpi
self.zoom = zoom
-
+
# Calculate page dimensions in pixels
self.page_width_px = page_width_mm * dpi / 25.4
self.page_height_px = page_height_mm * dpi / 25.4
-
+
# Calculate screen dimensions (with zoom applied)
self.screen_width = self.page_width_px * zoom
self.screen_height = self.page_height_px * zoom
-
+
def page_to_screen(self, page_x: float, page_y: float) -> Tuple[float, float]:
"""
Convert page-local coordinates (in pixels) to screen coordinates.
-
+
Args:
page_x: X coordinate in page-local space (pixels)
page_y: Y coordinate in page-local space (pixels)
-
+
Returns:
Tuple of (screen_x, screen_y)
"""
screen_x = self.screen_x + page_x * self.zoom
screen_y = self.screen_y + page_y * self.zoom
return (screen_x, screen_y)
-
+
def screen_to_page(self, screen_x: float, screen_y: float) -> Tuple[float, float]:
"""
Convert screen coordinates to page-local coordinates (in pixels).
-
+
Args:
screen_x: X coordinate in screen space
screen_y: Y coordinate in screen space
-
+
Returns:
Tuple of (page_x, page_y) in pixels, or None if outside page bounds
"""
page_x = (screen_x - self.screen_x) / self.zoom
page_y = (screen_y - self.screen_y) / self.zoom
return (page_x, page_y)
-
+
def is_point_in_page(self, screen_x: float, screen_y: float) -> bool:
"""
Check if a screen coordinate is within the page bounds.
-
+
Args:
screen_x: X coordinate in screen space
screen_y: Y coordinate in screen space
-
+
Returns:
True if the point is within the page bounds
"""
- return (self.screen_x <= screen_x <= self.screen_x + self.screen_width and
- self.screen_y <= screen_y <= self.screen_y + self.screen_height)
-
+ return (
+ 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]:
"""
For facing page spreads, determine if mouse is on left or right page.
-
+
Args:
screen_x: X coordinate in screen space
is_facing_page: Whether this is a facing page spread
-
+
Returns:
'left' or 'right' for facing pages, None for single pages
"""
if not is_facing_page:
return None
-
+
# Calculate the center line of the spread
center_x = self.screen_x + self.screen_width / 2
-
+
if screen_x < center_x:
- return 'left'
+ return "left"
else:
- return 'right'
-
+ return "right"
+
def begin_render(self):
"""
Set up OpenGL transformations for rendering this page.
@@ -131,27 +129,27 @@ class PageRenderer:
glScalef(self.zoom, self.zoom, 1.0)
# Translate to page position (in zoomed coordinates)
glTranslatef(self.screen_x / self.zoom, self.screen_y / self.zoom, 0.0)
-
+
def end_render(self):
"""
Clean up OpenGL transformations after rendering this page.
Call this after rendering page content.
"""
glPopMatrix()
-
+
def get_page_bounds_screen(self) -> Tuple[float, float, float, float]:
"""
Get the page bounds in screen coordinates.
-
+
Returns:
Tuple of (x, y, width, height) in screen space
"""
return (self.screen_x, self.screen_y, self.screen_width, self.screen_height)
-
+
def get_page_bounds_page(self) -> Tuple[float, float, float, float]:
"""
Get the page bounds in page-local coordinates.
-
+
Returns:
Tuple of (x, y, width, height) in page-local space (pixels)
"""
diff --git a/pyPhotoAlbum/pdf_exporter.py b/pyPhotoAlbum/pdf_exporter.py
index 64b6855..c8f9c4f 100644
--- a/pyPhotoAlbum/pdf_exporter.py
+++ b/pyPhotoAlbum/pdf_exporter.py
@@ -25,6 +25,7 @@ from pyPhotoAlbum.image_utils import (
@dataclass
class RenderContext:
"""Parameters for rendering an image element"""
+
canvas: canvas.Canvas
image_element: ImageData
x_pt: float
@@ -41,6 +42,7 @@ class RenderContext:
@dataclass
class SplitRenderParams:
"""Parameters for rendering a split element"""
+
canvas: canvas.Canvas
element: any
x_offset_mm: float
@@ -53,15 +55,15 @@ class SplitRenderParams:
class PDFExporter:
"""Handles PDF export of photo album projects"""
-
+
# Conversion constants
MM_TO_POINTS = 2.834645669 # 1mm = 2.834645669 points
SPLIT_THRESHOLD_RATIO = 0.002 # 1:500 threshold for tiny elements
-
+
def __init__(self, project, export_dpi: int = 300):
"""
Initialize PDF exporter with a project.
-
+
Args:
project: The Project instance to export
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.warnings = []
self.current_pdf_page = 1
-
+
def export(self, output_path: str, progress_callback=None) -> Tuple[bool, List[str]]:
"""
Export the project to PDF.
-
+
Args:
output_path: Path where PDF should be saved
progress_callback: Optional callback(current, total, message) for progress updates
-
+
Returns:
Tuple of (success: bool, warnings: List[str])
"""
self.warnings = []
self.current_pdf_page = 1
-
+
try:
# Calculate total pages for progress (cover counts as 1)
total_pages = sum(
- 1 if page.is_cover else (2 if page.is_double_spread else 1)
- for page in self.project.pages
+ 1 if page.is_cover else (2 if page.is_double_spread else 1) for page in self.project.pages
)
-
+
# Get page dimensions from project (in mm)
page_width_mm, page_height_mm = self.project.page_size_mm
-
+
# Convert to PDF points
page_width_pt = page_width_mm * self.MM_TO_POINTS
page_height_pt = page_height_mm * self.MM_TO_POINTS
-
+
# Create PDF canvas
c = canvas.Canvas(output_path, pagesize=(page_width_pt, page_height_pt))
-
+
# Process each page
pages_processed = 0
for page in self.project.pages:
# Get display name for progress
page_name = self.project.get_page_display_name(page)
-
+
if progress_callback:
- progress_callback(pages_processed, total_pages,
- f"Exporting {page_name}...")
-
+ progress_callback(pages_processed, total_pages, f"Exporting {page_name}...")
+
if page.is_cover:
# Export cover as single page with wrap-around design
self._export_cover(c, page, page_width_pt, page_height_pt)
@@ -124,9 +124,8 @@ class PDFExporter:
c.showPage() # Finish current page
self.current_pdf_page += 1
if progress_callback:
- progress_callback(pages_processed, total_pages,
- f"Inserting blank page for alignment...")
-
+ progress_callback(pages_processed, total_pages, f"Inserting blank page for alignment...")
+
# Export spread as two pages
self._export_spread(c, page, page_width_pt, page_height_pt)
pages_processed += 2
@@ -134,112 +133,109 @@ class PDFExporter:
# Export single page
self._export_single_page(c, page, page_width_pt, page_height_pt)
pages_processed += 1
-
+
# Save PDF
c.save()
-
+
if progress_callback:
progress_callback(total_pages, total_pages, "Export complete!")
-
+
return True, self.warnings
-
+
except Exception as e:
self.warnings.append(f"Export failed: {str(e)}")
return False, self.warnings
-
- def _export_cover(self, c: canvas.Canvas, page, page_width_pt: float,
- page_height_pt: float):
+
+ def _export_cover(self, c: canvas.Canvas, page, page_width_pt: float, page_height_pt: float):
"""
Export a cover page to PDF.
Cover has different dimensions (wrap-around: front + spine + back + bleed).
"""
# Get cover dimensions (already calculated in page.layout.size)
cover_width_mm, cover_height_mm = page.layout.size
-
+
# Convert to PDF points
cover_width_pt = cover_width_mm * self.MM_TO_POINTS
cover_height_pt = cover_height_mm * self.MM_TO_POINTS
-
+
# Create a new page with cover dimensions
c.setPageSize((cover_width_pt, cover_height_pt))
-
+
# Render all elements on the cover
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")
-
+
# Draw guide lines for front/spine/back zones
self._draw_cover_guides(c, cover_width_pt, cover_height_pt)
-
+
c.showPage() # Finish cover page
self.current_pdf_page += 1
-
+
# Reset page size for content pages
c.setPageSize((page_width_pt, page_height_pt))
-
+
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)"""
from reportlab.lib.colors import lightgrey
-
+
# Calculate zone boundaries
bleed_pt = self.project.cover_bleed_mm * 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
-
+
# Zone boundaries (from left to right)
# Bleed | Back | Spine | Front | Bleed
back_start = bleed_pt
spine_start = bleed_pt + page_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
-
+
# Draw dashed lines at zone boundaries
c.saveState()
c.setStrokeColor(lightgrey)
c.setDash(3, 3)
c.setLineWidth(0.5)
-
+
# Back/Spine boundary
c.line(spine_start, 0, spine_start, cover_height_pt)
-
+
# Spine/Front boundary
c.line(front_start, 0, front_start, cover_height_pt)
-
+
# Bleed boundaries (outer edges)
if bleed_pt > 0:
c.line(back_start, 0, back_start, cover_height_pt)
c.line(front_end, 0, front_end, cover_height_pt)
-
+
c.restoreState()
-
- def _export_single_page(self, c: canvas.Canvas, page, page_width_pt: float,
- page_height_pt: float):
+
+ def _export_single_page(self, c: canvas.Canvas, page, page_width_pt: float, page_height_pt: float):
"""Export a single page to PDF"""
# Render all elements
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)
-
+
c.showPage() # Finish this page
self.current_pdf_page += 1
-
- def _export_spread(self, c: canvas.Canvas, page, page_width_pt: float,
- page_height_pt: float):
+
+ def _export_spread(self, c: canvas.Canvas, page, page_width_pt: float, page_height_pt: float):
"""Export a double-page spread as two PDF pages"""
# Get center line position in mm
page_width_mm = self.project.page_size_mm[0]
center_mm = page_width_mm # Center of the spread (which is 2x width)
-
+
# Convert center line to pixels for comparison
dpi = self.project.working_dpi
center_px = center_mm * dpi / 25.4
-
+
# Calculate threshold for tiny elements (1:500) in pixels
threshold_px = page_width_mm * self.SPLIT_THRESHOLD_RATIO * dpi / 25.4
-
+
# Process elements for left page
for element in sorted(page.layout.elements, key=lambda x: x.z_index):
element_x_px, element_y_px = element.position
element_width_px, element_height_px = element.size
-
+
# 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:
# Entirely on left page
@@ -257,23 +253,22 @@ class PDFExporter:
page_width_pt=page_width_pt,
page_height_pt=page_height_pt,
page_number=page.page_number,
- side='left'
+ side="left",
)
self._render_split_element(params)
-
+
c.showPage() # Finish left page
self.current_pdf_page += 1
-
+
# Process elements for right page
for element in sorted(page.layout.elements, key=lambda x: x.z_index):
element_x_px, element_y_px = element.position
element_width_px, element_height_px = element.size
-
+
# 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:
# Entirely on right page or mostly on right
- self._render_element(c, element, center_mm, page_width_pt, page_height_pt,
- page.page_number + 1)
+ self._render_element(c, element, center_mm, page_width_pt, page_height_pt, page.page_number + 1)
elif element_x_px < center_px and element_x_px + element_width_px > center_px + threshold_px:
# Spanning element - render right portion
params = SplitRenderParams(
@@ -284,18 +279,25 @@ class PDFExporter:
page_width_pt=page_width_pt,
page_height_pt=page_height_pt,
page_number=page.page_number + 1,
- side='right'
+ side="right",
)
self._render_split_element(params)
-
+
c.showPage() # Finish right page
self.current_pdf_page += 1
-
- def _render_element(self, c: canvas.Canvas, element, x_offset_mm: float,
- page_width_pt: float, page_height_pt: float, page_number: int):
+
+ def _render_element(
+ 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.
-
+
Args:
c: ReportLab canvas
element: The layout element to render
@@ -307,27 +309,27 @@ class PDFExporter:
# Skip placeholders
if isinstance(element, PlaceholderData):
return
-
+
# Get element position and size (in PIXELS from OpenGL coordinates)
element_x_px, element_y_px = element.position
element_width_px, element_height_px = element.size
-
+
# Convert from pixels to mm using the working DPI
dpi = self.project.working_dpi
element_x_mm = element_x_px * 25.4 / dpi
element_y_mm = element_y_px * 25.4 / dpi
element_width_mm = element_width_px * 25.4 / dpi
element_height_mm = element_height_px * 25.4 / dpi
-
+
# Adjust x position for offset (now in mm)
adjusted_x_mm = element_x_mm - x_offset_mm
-
+
# Convert to PDF points and flip Y coordinate (PDF origin is bottom-left)
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)
width_pt = element_width_mm * self.MM_TO_POINTS
height_pt = element_height_mm * self.MM_TO_POINTS
-
+
if isinstance(element, ImageData):
ctx = RenderContext(
canvas=c,
@@ -336,12 +338,12 @@ class PDFExporter:
y_pt=y_pt,
width_pt=width_pt,
height_pt=height_pt,
- page_number=page_number
+ page_number=page_number,
)
self._render_image(ctx)
elif isinstance(element, TextBoxData):
self._render_textbox(c, element, x_pt, y_pt, width_pt, height_pt)
-
+
def _render_split_element(self, params: SplitRenderParams):
"""
Render a split element (only the portion on one side of the split line).
@@ -352,7 +354,7 @@ class PDFExporter:
# Skip placeholders
if isinstance(params.element, PlaceholderData):
return
-
+
# Get element position and size in pixels
element_x_px, element_y_px = params.element.position
element_width_px, element_height_px = params.element.size
@@ -366,7 +368,7 @@ class PDFExporter:
if isinstance(params.element, ImageData):
# Calculate which portion of the image to render
- if params.side == 'left':
+ if params.side == "left":
# Render from element start to split line
crop_width_mm = params.split_line_mm - element_x_mm
crop_x_start = 0
@@ -402,7 +404,7 @@ class PDFExporter:
crop_left=crop_x_start / element_width_mm,
crop_right=(crop_x_start + crop_width_mm) / element_width_mm,
original_width_pt=original_width_pt,
- original_height_pt=original_height_pt
+ original_height_pt=original_height_pt,
)
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
# where most of it appears (simpler than trying to split text)
element_center_mm = element_x_mm + element_width_mm / 2
- if (params.side == 'left' and element_center_mm < params.split_line_mm) or \
- (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)
-
+ if (params.side == "left" and element_center_mm < params.split_line_mm) or (
+ 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,
+ )
+
def _render_image(self, ctx: RenderContext):
"""
Render an image element on the PDF canvas.
@@ -438,7 +447,7 @@ class PDFExporter:
img = convert_to_rgba(img)
# 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)
# Get element's crop_info and combine with split cropping if applicable
@@ -458,27 +467,28 @@ class PDFExporter:
# Calculate center crop coordinates
img_width, img_height = img.size
crop_coords = calculate_center_crop_coords(
- img_width, img_height,
- target_width, target_height,
- (final_crop_x_min, crop_y_min, final_crop_x_max, crop_y_max)
+ img_width,
+ img_height,
+ target_width,
+ target_height,
+ (final_crop_x_min, crop_y_min, final_crop_x_max, crop_y_max),
)
# Crop the image
cropped_img = crop_image_to_coords(img, crop_coords)
-
+
# Downsample image to target resolution based on export DPI
# This prevents embedding huge images and reduces PDF file size
# 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_height_px = int((ctx.height_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
-
+
# Only downsample if current image is larger than target
# Don't upscale small images as that would reduce quality
current_width, current_height = cropped_img.size
if current_width > target_width_px or current_height > target_height_px:
# Use LANCZOS resampling for high quality downsampling
- cropped_img = cropped_img.resize((target_width_px, target_height_px),
- Image.Resampling.LANCZOS)
+ cropped_img = cropped_img.resize((target_width_px, target_height_px), Image.Resampling.LANCZOS)
# Note: Rotation is applied at the canvas level (below), not here
# to avoid double-rotation issues
@@ -495,12 +505,20 @@ class PDFExporter:
ctx.canvas.rotate(-ctx.image_element.rotation)
ctx.canvas.translate(-ctx.width_pt / 2, -ctx.height_pt / 2)
# Draw at origin after transformation
- ctx.canvas.drawImage(ImageReader(cropped_img), 0, 0, ctx.width_pt, ctx.height_pt,
- mask='auto', preserveAspectRatio=False)
+ ctx.canvas.drawImage(
+ ImageReader(cropped_img), 0, 0, ctx.width_pt, ctx.height_pt, mask="auto", preserveAspectRatio=False
+ )
else:
# Draw without rotation
- ctx.canvas.drawImage(ImageReader(cropped_img), ctx.x_pt, ctx.y_pt, ctx.width_pt, ctx.height_pt,
- mask='auto', preserveAspectRatio=False)
+ ctx.canvas.drawImage(
+ ImageReader(cropped_img),
+ ctx.x_pt,
+ ctx.y_pt,
+ ctx.width_pt,
+ ctx.height_pt,
+ mask="auto",
+ preserveAspectRatio=False,
+ )
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)}"
print(f"WARNING: {warning}")
self.warnings.append(warning)
-
- def _render_textbox(self, c: canvas.Canvas, text_element: 'TextBoxData',
- x_pt: float, y_pt: float, width_pt: float, height_pt: float):
+
+ def _render_textbox(
+ 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.
Text is word-wrapped to fit within the box boundaries.
@@ -524,9 +543,9 @@ class PDFExporter:
return
# Get font settings
- font_family = text_element.font_settings.get('family', 'Helvetica')
- font_size_px = text_element.font_settings.get('size', 12)
- font_color = text_element.font_settings.get('color', (0, 0, 0))
+ font_family = text_element.font_settings.get("family", "Helvetica")
+ font_size_px = text_element.font_settings.get("size", 12)
+ font_color = text_element.font_settings.get("color", (0, 0, 0))
# 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
@@ -536,34 +555,32 @@ class PDFExporter:
# Map common font names to ReportLab standard fonts
font_map = {
- 'Arial': 'Helvetica',
- 'Times New Roman': 'Times-Roman',
- 'Courier New': 'Courier',
+ "Arial": "Helvetica",
+ "Times New Roman": "Times-Roman",
+ "Courier New": "Courier",
}
font_family = font_map.get(font_family, font_family)
# Normalize color to hex for Paragraph style
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:
# Convert 0-1 range to 0-255 then to hex
- color_hex = '#{:02x}{:02x}{:02x}'.format(
- int(font_color[0] * 255),
- int(font_color[1] * 255),
- int(font_color[2] * 255)
+ color_hex = "#{:02x}{:02x}{:02x}".format(
+ int(font_color[0] * 255), int(font_color[1] * 255), int(font_color[2] * 255)
)
# Map alignment to ReportLab constants
alignment_map = {
- 'left': TA_LEFT,
- 'center': TA_CENTER,
- 'right': TA_RIGHT,
+ "left": TA_LEFT,
+ "center": TA_CENTER,
+ "right": TA_RIGHT,
}
text_alignment = alignment_map.get(text_element.alignment, TA_LEFT)
# Create paragraph style with word wrapping
style = ParagraphStyle(
- 'textbox',
+ "textbox",
fontName=font_family,
fontSize=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
tags
text_content = text_element.text_content
- text_content = text_content.replace('&', '&')
- text_content = text_content.replace('<', '<')
- text_content = text_content.replace('>', '>')
- text_content = text_content.replace('\n', '
')
+ text_content = text_content.replace("&", "&")
+ text_content = text_content.replace("<", "<")
+ text_content = text_content.replace(">", ">")
+ text_content = text_content.replace("\n", "
")
# Create paragraph with the text
para = Paragraph(text_content, style)
diff --git a/pyPhotoAlbum/project.py b/pyPhotoAlbum/project.py
index e8938b0..719479b 100644
--- a/pyPhotoAlbum/project.py
+++ b/pyPhotoAlbum/project.py
@@ -11,6 +11,7 @@ from pyPhotoAlbum.page_layout import PageLayout
from pyPhotoAlbum.commands import CommandHistory
from pyPhotoAlbum.asset_manager import AssetManager
+
class Page:
"""Class representing a single page in the photo album"""
@@ -39,7 +40,7 @@ class Page:
# Deletion tracking for merge (v3.0+)
self.deleted = False
self.deleted_at: Optional[str] = None
-
+
# Create layout with appropriate width
if layout is None:
self.layout = PageLayout(is_facing_page=is_double_spread)
@@ -51,21 +52,21 @@ class Page:
self.layout.is_facing_page = is_double_spread
height = self.layout.size[1]
# 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
else:
# If base_width not set, assume current width is correct
# and derive base_width from current state
base_width = self.layout.size[0] / 2 if not is_double_spread else self.layout.size[0]
self.layout.base_width = base_width
-
+
# Set the new width based on facing page state
self.layout.size = (base_width * 2 if is_double_spread else base_width, height)
-
+
def get_page_numbers(self) -> List[int]:
"""
Get the page numbers this page represents.
-
+
Returns:
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]
else:
return [self.page_number]
-
+
def get_page_count(self) -> int:
"""
Get the number of physical pages this represents.
@@ -133,6 +134,7 @@ class Page:
self.layout = PageLayout()
self.layout.deserialize(layout_data)
+
class Project:
"""Class representing the entire photo album project"""
@@ -230,37 +232,33 @@ class Project:
if self.has_cover and self.pages:
self.update_cover_dimensions()
self.mark_dirty()
-
+
def calculate_spine_width(self) -> float:
"""
Calculate spine width based on page count and paper thickness.
-
+
For saddle stitch binding:
- Each sheet = 4 pages (2 pages per side when folded)
- Spine width = (Number of sheets × Paper thickness × 2)
-
+
Returns:
Spine width in mm
"""
if not self.has_cover:
return 0.0
-
+
# Count content pages (excluding cover)
- content_page_count = sum(
- page.get_page_count()
- for page in self.pages
- if not page.is_cover
- )
-
+ content_page_count = sum(page.get_page_count() for page in self.pages if not page.is_cover)
+
if self.binding_type == "saddle_stitch":
# Calculate number of sheets (each sheet = 4 pages)
sheets = math.ceil(content_page_count / 4)
# Spine width = sheets × paper thickness × 2 (folded)
spine_width = sheets * self.paper_thickness_mm * 2
return spine_width
-
+
return 0.0
-
+
def update_cover_dimensions(self):
"""
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:
return
-
+
# Find cover page (should be first page)
cover_page = None
for page in self.pages:
if page.is_cover:
cover_page = page
break
-
+
if not cover_page:
return
-
+
# Get standard page dimensions
page_width_mm, page_height_mm = self.page_size_mm
-
+
# Calculate spine width
spine_width = self.calculate_spine_width()
-
+
# Calculate cover dimensions
# Cover = Front + Spine + Back + Bleed on all sides
cover_width = (page_width_mm * 2) + spine_width + (self.cover_bleed_mm * 2)
cover_height = page_height_mm + (self.cover_bleed_mm * 2)
-
+
# Update cover page layout
cover_page.layout.size = (cover_width, cover_height)
cover_page.layout.base_width = page_width_mm # Store base width for reference
cover_page.manually_sized = True # Mark as manually sized
-
- print(f"Cover dimensions updated: {cover_width:.1f} × {cover_height:.1f} mm "
- f"(Front: {page_width_mm}, Spine: {spine_width:.2f}, Back: {page_width_mm}, "
- f"Bleed: {self.cover_bleed_mm})")
-
+
+ print(
+ f"Cover dimensions updated: {cover_width:.1f} × {cover_height:.1f} 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:
"""
Get display name for a page.
-
+
Args:
page: The page to get the display name for
-
+
Returns:
Display name like "Cover", "Page 1", "Pages 1-2", etc.
"""
if page.is_cover:
return "Cover"
-
+
# Calculate adjusted page number (excluding cover from count)
adjusted_num = page.page_number
if self.has_cover:
# Subtract 1 to account for cover
adjusted_num = page.page_number - 1
-
+
if page.is_double_spread:
return f"Pages {adjusted_num}-{adjusted_num + 1}"
else:
return f"Page {adjusted_num}"
-
+
def calculate_page_layout_with_ghosts(self) -> List[Tuple[str, Any, int]]:
"""
Calculate page layout including ghost pages for alignment.
Excludes cover from spread calculations.
-
+
Returns:
List of tuples (page_type, page_or_ghost, logical_position)
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.)
"""
from pyPhotoAlbum.models import GhostPageData
-
+
layout = []
current_position = 1 # Start at position 1 (right page)
-
+
for page in self.pages:
# Skip cover in spread calculations
if page.is_cover:
@@ -354,32 +354,32 @@ class Project:
elif not page.is_double_spread and current_position % 2 == 1:
# Current position is odd (right page), single page is fine
pass
-
+
# Actually, let me reconsider the logic:
# In a photobook:
# - Position 1 is the right page (when opened, first content page)
# - Position 2 is the left page of the next spread
# - Position 3 is the right page of the next spread
# - etc.
- #
+ #
# 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
-
+
# Check if this is a double spread starting at an odd position
if page.is_double_spread and current_position % 2 == 1:
# 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
-
+
# Add the actual page
- layout.append(('page', page, current_position))
-
+ layout.append(("page", page, current_position))
+
# Update position based on page type
if page.is_double_spread:
current_position += 2 # Double spread takes 2 positions
else:
current_position += 1 # Single page takes 1 position
-
+
return layout
def render_all_pages(self):
@@ -453,7 +453,7 @@ class Project:
now = datetime.now(timezone.utc).isoformat()
self.created = data.get("created", now)
self.last_modified = data.get("last_modified", now)
-
+
self.pages = []
# Deserialize asset manager
@@ -461,13 +461,13 @@ class Project:
asset_data = data.get("asset_manager")
if asset_data:
self.asset_manager.deserialize(asset_data)
-
+
# Deserialize pages
for page_data in data.get("pages", []):
page = Page()
page.deserialize(page_data)
self.pages.append(page)
-
+
# Deserialize command history with asset manager and project reference
history_data = data.get("history")
if history_data:
diff --git a/pyPhotoAlbum/project_serializer.py b/pyPhotoAlbum/project_serializer.py
index ef9873e..d4066a1 100644
--- a/pyPhotoAlbum/project_serializer.py
+++ b/pyPhotoAlbum/project_serializer.py
@@ -14,7 +14,7 @@ from pyPhotoAlbum.version_manager import (
CURRENT_DATA_VERSION,
check_version_compatibility,
VersionCompatibility,
- DataMigration
+ DataMigration,
)
@@ -44,7 +44,7 @@ def _import_external_images(project: Project):
# Absolute path - definitely external
is_external = True
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
# Check if it exists relative to project folder
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
# 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
# Try to extract just the filename or relative path from assets folder
# Pattern 1: "./projects/XXX/assets/filename.jpg" -> "assets/filename.jpg"
- if '/assets/' in original_path:
- parts = original_path.split('/assets/')
+ if "/assets/" in original_path:
+ parts = original_path.split("/assets/")
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
normalized_count += 1
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:
# Ensure .ppz extension
- if not zip_path.lower().endswith('.ppz'):
- zip_path += '.ppz'
+ if not zip_path.lower().endswith(".ppz"):
+ zip_path += ".ppz"
# Check for and import any external images before saving
_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()
# Add version information
- project_data['serialization_version'] = SERIALIZATION_VERSION # Legacy field
- project_data['data_version'] = CURRENT_DATA_VERSION # New versioning system
+ project_data["serialization_version"] = SERIALIZATION_VERSION # Legacy field
+ project_data["data_version"] = CURRENT_DATA_VERSION # New versioning system
# 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
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
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)
# Extract ZIP contents
- with zipfile.ZipFile(zip_path, 'r') as zipf:
+ with zipfile.ZipFile(zip_path, "r") as zipf:
zipf.extractall(extract_to)
# 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):
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)
# Check version compatibility
# 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
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}")
# 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)
# 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
# Only set project folder - search paths are reserved for healing functionality
from pyPhotoAlbum.models import set_asset_resolution_context
+
set_asset_resolution_context(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]:
"""
Get basic information about a project without fully loading it.
-
+
Args:
zip_path: Path to the ZIP file
-
+
Returns:
Dictionary with project info, or None if error
"""
try:
- with zipfile.ZipFile(zip_path, 'r') as zipf:
+ with zipfile.ZipFile(zip_path, "r") as zipf:
# 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)
-
+
return {
- 'name': project_data.get('name', 'Unknown'),
- 'version': project_data.get('serialization_version', 'Unknown'),
- 'page_count': len(project_data.get('pages', [])),
- 'page_size_mm': project_data.get('page_size_mm', (0, 0)),
- 'working_dpi': project_data.get('working_dpi', 300),
+ "name": project_data.get("name", "Unknown"),
+ "version": project_data.get("serialization_version", "Unknown"),
+ "page_count": len(project_data.get("pages", [])),
+ "page_size_mm": project_data.get("page_size_mm", (0, 0)),
+ "working_dpi": project_data.get("working_dpi", 300),
}
except Exception as e:
print(f"Error reading project info: {e}")
diff --git a/pyPhotoAlbum/ribbon_builder.py b/pyPhotoAlbum/ribbon_builder.py
index 92df0fd..a7b1900 100644
--- a/pyPhotoAlbum/ribbon_builder.py
+++ b/pyPhotoAlbum/ribbon_builder.py
@@ -12,14 +12,14 @@ from collections import defaultdict
def build_ribbon_config(window_class: Type) -> Dict[str, Any]:
"""
Extract decorated methods and build ribbon configuration.
-
+
This function scans all methods in the window class and its mixins
for methods decorated with @ribbon_action, then builds a nested
configuration structure suitable for the RibbonWidget.
-
+
Args:
window_class: The MainWindow class with decorated methods
-
+
Returns:
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
tabs = defaultdict(lambda: defaultdict(list))
-
+
# Scan all methods in the class and its bases (mixins)
for attr_name in dir(window_class):
try:
attr = getattr(window_class, attr_name)
-
+
# Check if this attribute has ribbon action metadata
- if hasattr(attr, '_ribbon_action'):
+ if hasattr(attr, "_ribbon_action"):
action_data = attr._ribbon_action
-
+
# Extract tab and group information
- tab_name = action_data['tab']
- group_name = action_data['group']
-
+ tab_name = action_data["tab"]
+ group_name = action_data["group"]
+
# Add action to the appropriate tab and group
- tabs[tab_name][group_name].append({
- 'label': action_data['label'],
- 'action': action_data['action'],
- 'tooltip': action_data['tooltip'],
- 'icon': action_data.get('icon'),
- 'shortcut': action_data.get('shortcut'),
- })
+ tabs[tab_name][group_name].append(
+ {
+ "label": action_data["label"],
+ "action": action_data["action"],
+ "tooltip": action_data["tooltip"],
+ "icon": action_data.get("icon"),
+ "shortcut": action_data.get("shortcut"),
+ }
+ )
except (AttributeError, TypeError):
# Skip attributes that can't be inspected
continue
-
+
# Convert to the expected ribbon config format
ribbon_config = {}
-
+
# 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
all_tabs = list(tabs.keys())
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])
-
+
for tab_name in ordered_tabs:
groups_dict = tabs[tab_name]
-
+
# Convert groups dictionary to list format
groups_list = []
-
+
# Define group order per tab (if needed)
group_orders = {
- 'Home': ['File', 'Edit'],
- 'Insert': ['Media'],
- 'Layout': ['Navigation', 'Page', 'Templates'],
- 'Arrange': ['Align', 'Size', 'Distribute'],
- 'View': ['Zoom'],
- 'Export': ['Export'],
+ "Home": ["File", "Edit"],
+ "Insert": ["Media"],
+ "Layout": ["Navigation", "Page", "Templates"],
+ "Arrange": ["Align", "Size", "Distribute"],
+ "View": ["Zoom"],
+ "Export": ["Export"],
}
-
+
# Get the group order for this tab, or use alphabetical
if tab_name in group_orders:
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])
else:
group_order = sorted(groups_dict.keys())
-
+
for group_name in group_order:
if group_name in groups_dict:
actions = groups_dict[group_name]
- groups_list.append({
- 'name': group_name,
- 'actions': actions
- })
-
- ribbon_config[tab_name] = {
- 'groups': groups_list
- }
-
+ groups_list.append({"name": group_name, "actions": actions})
+
+ ribbon_config[tab_name] = {"groups": groups_list}
+
return ribbon_config
def get_keyboard_shortcuts(window_class: Type) -> Dict[str, str]:
"""
Extract keyboard shortcuts from decorated methods.
-
+
Args:
window_class: The MainWindow class with decorated methods
-
+
Returns:
Dictionary mapping shortcut strings to method names
Example: {'Ctrl+N': 'new_project', 'Ctrl+S': 'save_project'}
"""
shortcuts = {}
-
+
for attr_name in dir(window_class):
try:
attr = getattr(window_class, attr_name)
-
- if hasattr(attr, '_ribbon_action'):
+
+ if hasattr(attr, "_ribbon_action"):
action_data = attr._ribbon_action
- shortcut = action_data.get('shortcut')
-
+ shortcut = action_data.get("shortcut")
+
if shortcut:
- shortcuts[shortcut] = action_data['action']
+ shortcuts[shortcut] = action_data["action"]
except (AttributeError, TypeError):
continue
-
+
return shortcuts
def validate_ribbon_config(config: Dict[str, Any]) -> List[str]:
"""
Validate the ribbon configuration structure.
-
+
Args:
config: The ribbon configuration dictionary
-
+
Returns:
List of validation error messages (empty if valid)
"""
errors = []
-
+
if not isinstance(config, dict):
errors.append("Config must be a dictionary")
return errors
-
+
for tab_name, tab_data in config.items():
if not isinstance(tab_data, dict):
errors.append(f"Tab '{tab_name}' data must be a dictionary")
continue
-
- if 'groups' not in tab_data:
+
+ if "groups" not in tab_data:
errors.append(f"Tab '{tab_name}' missing 'groups' key")
continue
-
- groups = tab_data['groups']
+
+ groups = tab_data["groups"]
if not isinstance(groups, list):
errors.append(f"Tab '{tab_name}' groups must be a list")
continue
-
+
for i, group in enumerate(groups):
if not isinstance(group, dict):
errors.append(f"Tab '{tab_name}' group {i} must be a dictionary")
continue
-
- if 'name' not in group:
+
+ if "name" not in group:
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'")
continue
-
- actions = group['actions']
+
+ actions = group["actions"]
if not isinstance(actions, list):
errors.append(f"Tab '{tab_name}' group {i} actions must be a list")
continue
-
+
for j, action in enumerate(actions):
if not isinstance(action, dict):
errors.append(f"Tab '{tab_name}' group {i} action {j} must be a dictionary")
continue
-
- required_keys = ['label', 'action', 'tooltip']
+
+ required_keys = ["label", "action", "tooltip"]
for key in required_keys:
if key not in action:
- errors.append(
- f"Tab '{tab_name}' group {i} action {j} missing '{key}'"
- )
-
+ errors.append(f"Tab '{tab_name}' group {i} action {j} missing '{key}'")
+
return errors
def print_ribbon_summary(config: Dict[str, Any]):
"""
Print a summary of the ribbon configuration.
-
+
Args:
config: The ribbon configuration dictionary
"""
print("\n=== Ribbon Configuration Summary ===\n")
-
+
total_tabs = len(config)
- total_groups = sum(len(tab_data['groups']) for tab_data in config.values())
- total_actions = sum(
- len(group['actions'])
- for tab_data in config.values()
- for group in tab_data['groups']
- )
-
+ total_groups = sum(len(tab_data["groups"]) for tab_data in config.values())
+ total_actions = sum(len(group["actions"]) for tab_data in config.values() for group in tab_data["groups"])
+
print(f"Total Tabs: {total_tabs}")
print(f"Total Groups: {total_groups}")
print(f"Total Actions: {total_actions}\n")
-
+
for tab_name, tab_data in config.items():
print(f"📑 {tab_name}")
- for group in tab_data['groups']:
+ for group in tab_data["groups"]:
print(f" 📦 {group['name']} ({len(group['actions'])} actions)")
- for action in group['actions']:
- shortcut = f" ({action['shortcut']})" if action.get('shortcut') else ""
+ for action in group["actions"]:
+ shortcut = f" ({action['shortcut']})" if action.get("shortcut") else ""
print(f" • {action['label']}{shortcut}")
print()
diff --git a/pyPhotoAlbum/ribbon_widget.py b/pyPhotoAlbum/ribbon_widget.py
index 72d46a3..2f6067a 100644
--- a/pyPhotoAlbum/ribbon_widget.py
+++ b/pyPhotoAlbum/ribbon_widget.py
@@ -17,10 +17,11 @@ class RibbonWidget(QWidget):
# Use provided config or fall back to importing the old one
if ribbon_config is None:
from ribbon_config import RIBBON_CONFIG
+
self.ribbon_config = RIBBON_CONFIG
else:
self.ribbon_config = ribbon_config
-
+
# Main layout
main_layout = QVBoxLayout()
main_layout.setContentsMargins(0, 0, 0, 0)
diff --git a/pyPhotoAlbum/snapping.py b/pyPhotoAlbum/snapping.py
index 5f37be3..6ea762d 100644
--- a/pyPhotoAlbum/snapping.py
+++ b/pyPhotoAlbum/snapping.py
@@ -11,28 +11,24 @@ from dataclasses import dataclass
@dataclass
class Guide:
"""Represents a snapping guide (vertical or horizontal line)"""
+
position: float # Position in mm
orientation: str # 'vertical' or 'horizontal'
def serialize(self) -> dict:
"""Serialize guide to dictionary"""
- return {
- "position": self.position,
- "orientation": self.orientation
- }
+ return {"position": self.position, "orientation": self.orientation}
@staticmethod
- def deserialize(data: dict) -> 'Guide':
+ def deserialize(data: dict) -> "Guide":
"""Deserialize guide from dictionary"""
- return Guide(
- position=data.get("position", 0),
- orientation=data.get("orientation", "vertical")
- )
+ return Guide(position=data.get("position", 0), orientation=data.get("orientation", "vertical"))
@dataclass
class SnapResizeParams:
"""Parameters for snap resize operations"""
+
position: Tuple[float, float]
size: Tuple[float, float]
dx: float
@@ -45,11 +41,11 @@ class SnapResizeParams:
class SnappingSystem:
"""Manages snapping behavior for layout elements"""
-
+
def __init__(self, snap_threshold_mm: float = 5.0):
"""
Initialize snapping system
-
+
Args:
snap_threshold_mm: Distance in mm within which snapping occurs
"""
@@ -59,28 +55,30 @@ class SnappingSystem:
self.snap_to_edges = True
self.snap_to_guides = True
self.guides: List[Guide] = []
-
+
def add_guide(self, position: float, orientation: str):
"""Add a new guide"""
guide = Guide(position=position, orientation=orientation)
self.guides.append(guide)
return guide
-
+
def remove_guide(self, guide: Guide):
"""Remove a guide"""
if guide in self.guides:
self.guides.remove(guide)
-
+
def clear_guides(self):
"""Remove all guides"""
self.guides.clear()
-
- def snap_position(self,
- position: Tuple[float, float],
- size: Tuple[float, float],
- page_size: Tuple[float, float],
- dpi: int = 300,
- project=None) -> Tuple[float, float]:
+
+ def snap_position(
+ self,
+ position: Tuple[float, float],
+ size: Tuple[float, float],
+ page_size: Tuple[float, float],
+ dpi: int = 300,
+ project=None,
+ ) -> Tuple[float, float]:
"""
Apply snapping to a position using combined distance threshold
@@ -114,43 +112,47 @@ class SnappingSystem:
# Convert threshold from mm to pixels
snap_threshold_px = snap_threshold_mm * dpi / 25.4
-
+
# Collect all potential snap points for both edges of the element
snap_points = []
-
+
# 1. Page edge snap points
if snap_to_edges:
page_width_px = page_width_mm * dpi / 25.4
page_height_px = page_height_mm * dpi / 25.4
# Corners where element's top-left can snap
- snap_points.extend([
- (0, 0), # Top-left corner
- (page_width_px - width, 0), # Top-right corner
- (0, page_height_px - height), # Bottom-left corner
- (page_width_px - width, page_height_px - height), # Bottom-right corner
- ])
+ snap_points.extend(
+ [
+ (0, 0), # Top-left corner
+ (page_width_px - width, 0), # Top-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)
- snap_points.extend([
- (0, y), # Left edge
- (page_width_px - width, y), # Right edge
- (x, 0), # Top edge
- (x, page_height_px - height), # Bottom edge
- ])
+ snap_points.extend(
+ [
+ (0, y), # Left edge
+ (page_width_px - width, y), # Right edge
+ (x, 0), # Top edge
+ (x, page_height_px - height), # Bottom edge
+ ]
+ )
# 2. Grid snap points
if snap_to_grid:
grid_size_px = grid_size_mm * dpi / 25.4
page_width_px = page_width_mm * dpi / 25.4
page_height_px = page_height_mm * dpi / 25.4
-
+
# Calculate grid intersection points within range
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)
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)
-
+
grid_x = x_start
while grid_x <= x_end:
grid_y = y_start
@@ -163,12 +165,12 @@ class SnappingSystem:
snap_points.append((grid_x, grid_y - height))
grid_y += grid_size_px
grid_x += grid_size_px
-
+
# 3. Guide snap points
if snap_to_guides:
- vertical_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == 'vertical']
- horizontal_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == 'horizontal']
-
+ 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"]
+
# Guide intersections (when both vertical and horizontal guides exist)
for vg in vertical_guides:
for hg in horizontal_guides:
@@ -177,23 +179,23 @@ class SnappingSystem:
snap_points.append((vg - width, hg))
snap_points.append((vg, hg - height))
snap_points.append((vg - width, hg - height))
-
+
# Find the nearest snap point using Euclidean distance
best_snap_point = None
best_distance = snap_threshold_px
-
+
for snap_x, snap_y in snap_points:
distance = math.sqrt((x - snap_x) ** 2 + (y - snap_y) ** 2)
if distance < best_distance:
best_snap_point = (snap_x, snap_y)
best_distance = distance
-
+
# Return snapped position or original position
if best_snap_point:
return best_snap_point
else:
return (x, y)
-
+
def snap_resize(self, params: SnapResizeParams) -> Tuple[Tuple[float, float], Tuple[float, float]]:
"""
Apply snapping during resize operations
@@ -222,32 +224,32 @@ class SnappingSystem:
new_width, new_height = width, height
# Apply resize based on handle
- if params.resize_handle in ['nw', 'n', 'ne']:
+ if params.resize_handle in ["nw", "n", "ne"]:
# Top edge moving
new_y = y + 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
new_height = height + params.dy
- if params.resize_handle in ['nw', 'w', 'sw']:
+ if params.resize_handle in ["nw", "w", "sw"]:
# Left edge moving
new_x = x + 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
new_width = width + params.dx
-
+
# Now apply snapping to the edges that are being moved
# Use _snap_edge_to_targets consistently for all edges
# 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
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:
# Adjust width to compensate for position change
@@ -256,21 +258,21 @@ class SnappingSystem:
new_width += width_adjustment
# 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
right_edge = new_x + new_width
# Try to snap the right edge
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:
new_width = snapped_right - new_x
# 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
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:
# Adjust height to compensate for position change
@@ -279,30 +281,32 @@ class SnappingSystem:
new_height += height_adjustment
# 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
bottom_edge = new_y + new_height
# Try to snap the bottom edge
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:
new_height = snapped_bottom - new_y
-
+
# Ensure minimum size
min_size = 10 # Minimum 10 pixels
new_width = max(new_width, min_size)
new_height = max(new_height, min_size)
-
+
return ((new_x, new_y), (new_width, new_height))
-
- def _snap_edge_to_targets(self,
- edge_position: float,
- page_size_mm: float,
- dpi: int,
- snap_threshold_px: float,
- orientation: str,
- project=None) -> Optional[float]:
+
+ def _snap_edge_to_targets(
+ self,
+ edge_position: float,
+ page_size_mm: float,
+ dpi: int,
+ snap_threshold_px: float,
+ orientation: str,
+ project=None,
+ ) -> Optional[float]:
"""
Snap an edge position to available targets (grid, edges, guides)
@@ -354,28 +358,24 @@ class SnappingSystem:
if guide.orientation == orientation:
guide_pos_px = guide.position * dpi / 25.4
snap_candidates.append((guide_pos_px, abs(edge_position - guide_pos_px)))
-
+
# Find the best snap candidate within threshold
best_snap = None
best_distance = snap_threshold_px
-
+
for snap_pos, distance in snap_candidates:
if distance < best_distance:
best_snap = snap_pos
best_distance = distance
-
+
return best_snap
-
- def _snap_axis(self,
- position: float,
- size: float,
- page_size_mm: float,
- dpi: int,
- snap_threshold_px: float,
- orientation: str) -> float:
+
+ def _snap_axis(
+ self, position: float, size: float, page_size_mm: float, dpi: int, snap_threshold_px: float, orientation: str
+ ) -> float:
"""
Snap along a single axis
-
+
Args:
position: Current position along axis in pixels
size: Element size along axis in pixels
@@ -383,115 +383,108 @@ class SnappingSystem:
dpi: DPI for conversion
snap_threshold_px: Snap threshold in pixels
orientation: 'vertical' for x-axis, 'horizontal' for y-axis
-
+
Returns:
Snapped position in pixels
"""
snap_candidates = []
-
+
# 1. Page edge snapping
if self.snap_to_edges:
# Snap to start edge (0)
snap_candidates.append((0, abs(position - 0)))
-
+
# Snap to end edge
page_size_px = page_size_mm * dpi / 25.4
snap_candidates.append((page_size_px - size, abs(position - (page_size_px - size))))
-
+
# Also snap element's far edge to page edge
snap_candidates.append((page_size_px - size, abs((position + size) - page_size_px)))
-
+
# 2. Grid snapping
if self.snap_to_grid:
grid_size_px = self.grid_size_mm * dpi / 25.4
-
+
# Snap to nearest grid line
nearest_grid = round(position / grid_size_px) * grid_size_px
snap_candidates.append((nearest_grid, abs(position - nearest_grid)))
-
+
# Also try snapping element's far edge to grid
element_end = position + size
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)))
-
+
# 3. Guide snapping
if self.snap_to_guides:
for guide in self.guides:
if guide.orientation == orientation:
guide_pos_px = guide.position * dpi / 25.4
-
+
# Snap start edge to guide
snap_candidates.append((guide_pos_px, abs(position - guide_pos_px)))
-
+
# Snap end edge to guide
element_end = position + size
snap_candidates.append((guide_pos_px - size, abs(element_end - guide_pos_px)))
-
+
# Find the best snap candidate within threshold
best_snap = None
best_distance = snap_threshold_px
-
+
for snap_pos, distance in snap_candidates:
if distance < best_distance:
best_snap = snap_pos
best_distance = distance
-
+
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:
"""
Get all snap lines for visualization
-
+
Args:
page_size: Page size (width, height) in mm
dpi: DPI for conversion
-
+
Returns:
Dictionary with 'grid', 'edges', and 'guides' lists
"""
page_width_mm, page_height_mm = page_size
page_width_px = page_width_mm * dpi / 25.4
page_height_px = page_height_mm * dpi / 25.4
-
- result = {
- 'grid': [],
- 'edges': [],
- 'guides': []
- }
-
+
+ result = {"grid": [], "edges": [], "guides": []}
+
# Grid lines
if self.snap_to_grid:
grid_size_px = self.grid_size_mm * dpi / 25.4
-
+
# Vertical grid lines
x = 0
while x <= page_width_px:
- result['grid'].append(('vertical', x))
+ result["grid"].append(("vertical", x))
x += grid_size_px
-
+
# Horizontal grid lines
y = 0
while y <= page_height_px:
- result['grid'].append(('horizontal', y))
+ result["grid"].append(("horizontal", y))
y += grid_size_px
-
+
# Edge lines
if self.snap_to_edges:
- result['edges'].extend([
- ('vertical', 0),
- ('vertical', page_width_px),
- ('horizontal', 0),
- ('horizontal', page_height_px)
- ])
-
+ result["edges"].extend(
+ [("vertical", 0), ("vertical", page_width_px), ("horizontal", 0), ("horizontal", page_height_px)]
+ )
+
# Guide lines
if self.snap_to_guides:
for guide in self.guides:
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
-
+
def serialize(self) -> dict:
"""Serialize snapping system to dictionary"""
return {
@@ -500,9 +493,9 @@ class SnappingSystem:
"snap_to_grid": self.snap_to_grid,
"snap_to_edges": self.snap_to_edges,
"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):
"""Deserialize from dictionary"""
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_edges = data.get("snap_to_edges", True)
self.snap_to_guides = data.get("snap_to_guides", True)
-
+
self.guides = []
for guide_data in data.get("guides", []):
self.guides.append(Guide.deserialize(guide_data))
diff --git a/pyPhotoAlbum/template_manager.py b/pyPhotoAlbum/template_manager.py
index bfbacd2..c109fc2 100644
--- a/pyPhotoAlbum/template_manager.py
+++ b/pyPhotoAlbum/template_manager.py
@@ -14,7 +14,9 @@ from pyPhotoAlbum.project import Page
class 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.description = description
self.page_size_mm = page_size_mm
@@ -30,16 +32,16 @@ class Template:
"name": self.name,
"description": self.description,
"page_size_mm": self.page_size_mm,
- "elements": [elem.serialize() for elem in self.elements]
+ "elements": [elem.serialize() for elem in self.elements],
}
@classmethod
- def from_dict(cls, data: Dict[str, Any]) -> 'Template':
+ def from_dict(cls, data: Dict[str, Any]) -> "Template":
"""Deserialize template from dictionary"""
template = cls(
name=data.get("name", "Untitled Template"),
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
@@ -59,13 +61,13 @@ class Template:
def save_to_file(self, file_path: str):
"""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)
@classmethod
- def load_from_file(cls, file_path: str) -> 'Template':
+ def load_from_file(cls, file_path: str) -> "Template":
"""Load template from JSON file"""
- with open(file_path, 'r') as f:
+ with open(file_path, "r") as f:
data = json.load(f)
return cls.from_dict(data)
@@ -94,7 +96,7 @@ class TemplateManager:
def _ensure_templates_directory(self):
"""Create templates directory if it doesn't exist"""
self.templates_dir.mkdir(parents=True, exist_ok=True)
-
+
# Also ensure built-in templates directory exists
builtin_dir = self._get_builtin_templates_directory()
builtin_dir.mkdir(parents=True, exist_ok=True)
@@ -102,33 +104,33 @@ class TemplateManager:
def list_templates(self) -> List[str]:
"""List all available template names (embedded + user + built-in)"""
templates = []
-
+
# List embedded templates (priority)
if self.project and self.project.embedded_templates:
for template_name in self.project.embedded_templates.keys():
templates.append(f"[Embedded] {template_name}")
-
+
# List user templates
if self.templates_dir.exists():
for file in self.templates_dir.glob("*.json"):
templates.append(file.stem)
-
+
# List built-in templates
builtin_dir = self._get_builtin_templates_directory()
if builtin_dir.exists():
for file in builtin_dir.glob("*.json"):
template_name = f"[Built-in] {file.stem}"
templates.append(template_name)
-
+
return sorted(templates)
def load_template(self, name: str) -> Template:
"""
Load a template by name with priority: embedded > user > built-in.
-
+
Args:
name: Template name (may include prefix like '[Embedded]' or '[Built-in]')
-
+
Returns:
Template instance
"""
@@ -139,12 +141,12 @@ class TemplateManager:
template_data = self.project.embedded_templates[actual_name]
return Template.from_dict(template_data)
raise FileNotFoundError(f"Embedded template '{actual_name}' not found")
-
+
# Check embedded templates even without prefix (for backward compatibility)
if self.project and name in self.project.embedded_templates:
template_data = self.project.embedded_templates[name]
return Template.from_dict(template_data)
-
+
# Check if it's a built-in template
if name.startswith("[Built-in] "):
actual_name = name.replace("[Built-in] ", "")
@@ -152,16 +154,16 @@ class TemplateManager:
else:
# User template
template_path = self.templates_dir / f"{name}.json"
-
+
if not template_path.exists():
raise FileNotFoundError(f"Template '{name}' not found")
-
+
return Template.load_from_file(str(template_path))
def save_template(self, template: Template, embed_in_project: bool = False):
"""
Save a template to filesystem or embed in project.
-
+
Args:
template: Template to save
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)"""
if name.startswith("[Built-in] "):
raise PermissionError("Cannot delete built-in templates")
-
+
# Check if it's an embedded template
if name.startswith("[Embedded] "):
actual_name = name.replace("[Embedded] ", "")
@@ -188,7 +190,7 @@ class TemplateManager:
print(f"Removed embedded template '{actual_name}'")
return
raise FileNotFoundError(f"Embedded template '{actual_name}' not found")
-
+
# User template from filesystem
template_path = self.templates_dir / f"{name}.json"
if template_path.exists():
@@ -197,26 +199,22 @@ class TemplateManager:
def embed_template(self, template: Template):
"""
Embed a template in the project.
-
+
Args:
template: Template to embed
"""
if not self.project:
raise RuntimeError("No project associated with this TemplateManager")
-
+
self.project.embedded_templates[template.name] = template.to_dict()
print(f"Embedded template '{template.name}' in project")
-
+
def create_template_from_page(self, page: Page, name: str, description: str = "") -> Template:
"""
Create a template from an existing page.
Converts all ImageData elements to PlaceholderData.
"""
- template = Template(
- name=name,
- description=description,
- page_size_mm=page.layout.size
- )
+ template = Template(name=name, description=description, page_size_mm=page.layout.size)
# Convert elements
for element in page.layout.elements:
@@ -229,7 +227,7 @@ class TemplateManager:
width=element.size[0],
height=element.size[1],
rotation=element.rotation,
- z_index=element.z_index
+ z_index=element.z_index,
)
template.add_element(placeholder)
elif isinstance(element, TextBoxData):
@@ -243,7 +241,7 @@ class TemplateManager:
width=element.size[0],
height=element.size[1],
rotation=element.rotation,
- z_index=element.z_index
+ z_index=element.z_index,
)
template.add_element(text_box)
elif isinstance(element, PlaceholderData):
@@ -256,7 +254,7 @@ class TemplateManager:
width=element.size[0],
height=element.size[1],
rotation=element.rotation,
- z_index=element.z_index
+ z_index=element.z_index,
)
template.add_element(placeholder)
@@ -268,32 +266,32 @@ class TemplateManager:
from_size: Tuple[float, float],
to_size: Tuple[float, float],
scale_mode: str = "proportional",
- margin_percent: float = 0.0
+ margin_percent: float = 0.0,
) -> List[BaseLayoutElement]:
"""
Scale template elements to fit target page size with adjustable margins.
-
+
Args:
elements: List of elements to scale
from_size: Original template size (width, height) in mm
to_size: Target page size (width, height) in mm
scale_mode: "proportional", "stretch", or "center"
margin_percent: Percentage of page size to use for margins (0-10%)
-
+
Returns:
List of scaled elements
"""
from_width, from_height = from_size
to_width, to_height = to_size
-
+
# Calculate target margins from percentage
margin_x = to_width * (margin_percent / 100.0)
margin_y = to_height * (margin_percent / 100.0)
-
+
# Available content area after margins
content_width = to_width - (2 * margin_x)
content_height = to_height - (2 * margin_y)
-
+
# Calculate scale factors based on mode
if scale_mode == "stretch":
# Stretch to fill content area independently in each dimension
@@ -323,14 +321,13 @@ class TemplateManager:
# Create a new element of the same type
if isinstance(element, PlaceholderData):
new_elem = PlaceholderData(
- placeholder_type=element.placeholder_type,
- default_content=element.default_content
+ placeholder_type=element.placeholder_type, default_content=element.default_content
)
elif isinstance(element, TextBoxData):
new_elem = TextBoxData(
text_content=element.text_content,
font_settings=element.font_settings.copy() if element.font_settings else None,
- alignment=element.alignment
+ alignment=element.alignment,
)
else:
continue # Skip other types
@@ -339,14 +336,8 @@ class TemplateManager:
old_x, old_y = element.position
old_w, old_h = element.size
- new_elem.position = (
- old_x * scale_x + offset_x,
- old_y * scale_y + offset_y
- )
- new_elem.size = (
- old_w * scale_x,
- old_h * scale_y
- )
+ new_elem.position = (old_x * scale_x + offset_x, old_y * scale_y + offset_y)
+ new_elem.size = (old_w * scale_x, old_h * scale_y)
new_elem.rotation = element.rotation
new_elem.z_index = element.z_index
@@ -362,15 +353,9 @@ class TemplateManager:
for elem in scaled_elements:
# Convert position from mm to pixels
- elem.position = (
- elem.position[0] * mm_to_px,
- elem.position[1] * mm_to_px
- )
+ elem.position = (elem.position[0] * mm_to_px, elem.position[1] * mm_to_px)
# Convert size from mm to pixels
- elem.size = (
- elem.size[0] * mm_to_px,
- elem.size[1] * mm_to_px
- )
+ elem.size = (elem.size[0] * mm_to_px, elem.size[1] * mm_to_px)
return scaled_elements
@@ -381,15 +366,15 @@ class TemplateManager:
mode: str = "replace",
scale_mode: str = "proportional",
margin_percent: float = 2.5,
- auto_embed: bool = True
+ auto_embed: bool = True,
):
"""
Apply template to an existing page with adjustable margins.
-
+
Args:
template: Template to apply
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
scale_mode: "proportional", "stretch", or "center"
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 template.name not in self.project.embedded_templates:
self.embed_template(template)
-
+
if mode == "replace":
# Clear existing elements
page.layout.elements.clear()
-
+
# Scale template elements to fit page
scaled_elements = self.scale_template_elements(
- template.elements,
- template.page_size_mm,
- page.layout.size,
- scale_mode,
- margin_percent
+ template.elements, template.page_size_mm, page.layout.size, scale_mode, margin_percent
)
-
+
# Add scaled elements to page
for element in scaled_elements:
page.layout.add_element(element)
-
+
elif mode == "reflow":
# Keep existing content but reposition to template slots
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)]
-
+
# Get template placeholders (scaled)
scaled_elements = self.scale_template_elements(
- template.elements,
- template.page_size_mm,
- page.layout.size,
- scale_mode,
- margin_percent
+ template.elements, template.page_size_mm, page.layout.size, scale_mode, margin_percent
)
-
+
template_placeholders = [e for e in scaled_elements if isinstance(e, PlaceholderData)]
template_text = [e for e in scaled_elements if isinstance(e, TextBoxData)]
-
+
# Clear page
page.layout.elements.clear()
-
+
# Reflow images into placeholder slots
for i, placeholder in enumerate(template_placeholders):
if i < len(existing_images):
@@ -449,11 +426,11 @@ class TemplateManager:
else:
# Add placeholder if no more images
page.layout.add_element(placeholder)
-
+
# 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)
-
+
# Add template text boxes
for text_elem in template_text:
page.layout.add_element(text_elem)
@@ -465,7 +442,7 @@ class TemplateManager:
target_size_mm: Optional[Tuple[float, float]] = None,
scale_mode: str = "proportional",
margin_percent: float = 2.5,
- auto_embed: bool = True
+ auto_embed: bool = True,
) -> Page:
"""
Create a new page from a template.
@@ -494,11 +471,7 @@ class TemplateManager:
page_size = target_size_mm
# Scale template elements with margins
elements = self.scale_template_elements(
- template.elements,
- template.page_size_mm,
- target_size_mm,
- scale_mode,
- margin_percent
+ template.elements, template.page_size_mm, target_size_mm, scale_mode, margin_percent
)
# Create new page layout
diff --git a/pyPhotoAlbum/text_edit_dialog.py b/pyPhotoAlbum/text_edit_dialog.py
index 34edfe8..df74fe4 100644
--- a/pyPhotoAlbum/text_edit_dialog.py
+++ b/pyPhotoAlbum/text_edit_dialog.py
@@ -3,8 +3,15 @@ Text editing dialog for pyPhotoAlbum
"""
from PyQt6.QtWidgets import (
- QDialog, QVBoxLayout, QHBoxLayout, QPushButton,
- QTextEdit, QLabel, QComboBox, QSpinBox, QColorDialog
+ QDialog,
+ QVBoxLayout,
+ QHBoxLayout,
+ QPushButton,
+ QTextEdit,
+ QLabel,
+ QComboBox,
+ QSpinBox,
+ QColorDialog,
)
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QFont, QColor
@@ -12,58 +19,57 @@ from PyQt6.QtGui import QFont, QColor
class TextEditDialog(QDialog):
"""Dialog for editing text box content and properties"""
-
+
def __init__(self, text_element, parent=None):
super().__init__(parent)
self.text_element = text_element
self.setWindowTitle("Edit Text")
self.resize(500, 400)
-
+
# Create UI
self._init_ui()
-
+
# Load current values
self._load_values()
-
+
def _init_ui(self):
"""Initialize the user interface"""
layout = QVBoxLayout()
-
+
# Text editor
text_label = QLabel("Text:")
self.text_edit = QTextEdit()
self.text_edit.setAcceptRichText(False) # Plain text only
layout.addWidget(text_label)
layout.addWidget(self.text_edit)
-
+
# Font settings
font_layout = QHBoxLayout()
-
+
# Font family
font_layout.addWidget(QLabel("Font:"))
self.font_combo = QComboBox()
- self.font_combo.addItems([
- "Arial", "Times New Roman", "Courier New",
- "Helvetica", "Verdana", "Georgia", "Comic Sans MS"
- ])
+ self.font_combo.addItems(
+ ["Arial", "Times New Roman", "Courier New", "Helvetica", "Verdana", "Georgia", "Comic Sans MS"]
+ )
font_layout.addWidget(self.font_combo)
-
+
# Font size
font_layout.addWidget(QLabel("Size:"))
self.font_size_spin = QSpinBox()
self.font_size_spin.setRange(6, 72)
self.font_size_spin.setValue(12)
font_layout.addWidget(self.font_size_spin)
-
+
# Text color
self.color_button = QPushButton("Color")
self.color_button.clicked.connect(self._choose_color)
self.current_color = QColor(0, 0, 0) # Default black
font_layout.addWidget(self.color_button)
-
+
font_layout.addStretch()
layout.addLayout(font_layout)
-
+
# Alignment
alignment_layout = QHBoxLayout()
alignment_layout.addWidget(QLabel("Alignment:"))
@@ -72,84 +78,76 @@ class TextEditDialog(QDialog):
alignment_layout.addWidget(self.alignment_combo)
alignment_layout.addStretch()
layout.addLayout(alignment_layout)
-
+
# Buttons
button_layout = QHBoxLayout()
button_layout.addStretch()
-
+
cancel_button = QPushButton("Cancel")
cancel_button.clicked.connect(self.reject)
button_layout.addWidget(cancel_button)
-
+
ok_button = QPushButton("OK")
ok_button.clicked.connect(self.accept)
ok_button.setDefault(True)
button_layout.addWidget(ok_button)
-
+
layout.addLayout(button_layout)
-
+
self.setLayout(layout)
-
+
def _load_values(self):
"""Load current values from text element"""
# Load text content
self.text_edit.setPlainText(self.text_element.text_content)
-
+
# 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)
if index >= 0:
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))
-
+
# 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):
# Color in 0-255 range
self.current_color = QColor(*color)
else:
# Color in 0-1 range
- self.current_color = QColor(
- int(color[0] * 255),
- int(color[1] * 255),
- int(color[2] * 255)
- )
+ self.current_color = QColor(int(color[0] * 255), int(color[1] * 255), int(color[2] * 255))
self._update_color_button()
-
+
# Load alignment
alignment = self.text_element.alignment
index = self.alignment_combo.findText(alignment)
if index >= 0:
self.alignment_combo.setCurrentIndex(index)
-
+
def _choose_color(self):
"""Open color picker dialog"""
color = QColorDialog.getColor(self.current_color, self, "Choose Text Color")
if color.isValid():
self.current_color = color
self._update_color_button()
-
+
def _update_color_button(self):
"""Update color button appearance"""
self.color_button.setStyleSheet(
f"background-color: {self.current_color.name()}; "
f"color: {'white' if self.current_color.lightness() < 128 else 'black'};"
)
-
+
def get_values(self):
"""Get the edited values"""
return {
- 'text_content': self.text_edit.toPlainText(),
- 'font_settings': {
- 'family': self.font_combo.currentText(),
- 'size': self.font_size_spin.value(),
- 'color': (
- self.current_color.red(),
- self.current_color.green(),
- self.current_color.blue()
- )
+ "text_content": self.text_edit.toPlainText(),
+ "font_settings": {
+ "family": self.font_combo.currentText(),
+ "size": self.font_size_spin.value(),
+ "color": (self.current_color.red(), self.current_color.green(), self.current_color.blue()),
},
- 'alignment': self.alignment_combo.currentText()
+ "alignment": self.alignment_combo.currentText(),
}
diff --git a/pyPhotoAlbum/version_manager.py b/pyPhotoAlbum/version_manager.py
index 84b073e..9062d84 100644
--- a/pyPhotoAlbum/version_manager.py
+++ b/pyPhotoAlbum/version_manager.py
@@ -24,7 +24,7 @@ VERSION_HISTORY = {
"released": "2025-01-11",
"breaking_changes": [
"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
},
@@ -38,7 +38,7 @@ VERSION_HISTORY = {
"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
- }
+ },
}
@@ -120,9 +120,11 @@ class DataMigration:
@classmethod
def register_migration(cls, from_version: str, to_version: str):
"""Decorator to register a migration function"""
+
def decorator(func):
cls._migrations[(from_version, to_version)] = func
return func
+
return decorator
@classmethod
@@ -164,6 +166,7 @@ class DataMigration:
# Register migrations
+
@DataMigration.register_migration("1.0", "2.0")
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")
# Update version in data
- data['data_version'] = "2.0"
+ data["data_version"] = "2.0"
# Note: Actual path normalization is handled in load_from_zip
# 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
# Update version
- data['data_version'] = "3.0"
+ data["data_version"] = "3.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" Description: {file_info.get('description', '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:
error_msg += f" Breaking changes:\n"
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" Released: {version_info.get('released', 'Unknown')}")
- breaking_changes = version_info.get('breaking_changes', [])
+ breaking_changes = version_info.get("breaking_changes", [])
if breaking_changes:
info.append(f" Breaking changes:")
for change in breaking_changes:
diff --git a/tests/conftest.py b/tests/conftest.py
index 801c5f3..20b4b39 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -15,9 +15,9 @@ from pyPhotoAlbum.project import Project, Page
@pytest.fixture
def temp_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
- img = Image.new('RGB', (100, 100), color='red')
+ img = Image.new("RGB", (100, 100), color="red")
img.save(f.name)
yield f.name
# Cleanup
@@ -37,37 +37,19 @@ def temp_dir():
@pytest.fixture
def sample_image_data(temp_image_file):
"""Create a sample ImageData instance"""
- return ImageData(
- image_path=temp_image_file,
- x=10.0,
- y=20.0,
- width=100.0,
- height=150.0
- )
+ return ImageData(image_path=temp_image_file, x=10.0, y=20.0, width=100.0, height=150.0)
@pytest.fixture
def sample_placeholder_data():
"""Create a sample PlaceholderData instance"""
- return PlaceholderData(
- placeholder_type="image",
- x=50.0,
- y=60.0,
- width=200.0,
- height=150.0
- )
+ return PlaceholderData(placeholder_type="image", x=50.0, y=60.0, width=200.0, height=150.0)
@pytest.fixture
def sample_textbox_data():
"""Create a sample TextBoxData instance"""
- return TextBoxData(
- text_content="Sample Text",
- x=30.0,
- y=40.0,
- width=150.0,
- height=50.0
- )
+ return TextBoxData(text_content="Sample Text", x=30.0, y=40.0, width=150.0, height=50.0)
@pytest.fixture
@@ -119,10 +101,7 @@ def mock_main_window():
window.project = Project(name="Test Project")
# Add a test page
- page = Page(
- layout=PageLayout(width=210, height=297), # A4 size in mm
- page_number=1
- )
+ page = Page(layout=PageLayout(width=210, height=297), page_number=1) # A4 size in mm
window.project.pages.append(page)
window.project.working_dpi = 96
window.project.page_size_mm = (210, 297)
@@ -139,39 +118,19 @@ def mock_main_window():
@pytest.fixture
def sample_image_element():
"""Create a sample ImageData element for testing"""
- return ImageData(
- image_path="test.jpg",
- x=100,
- y=100,
- width=200,
- height=150,
- z_index=1
- )
+ return ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150, z_index=1)
@pytest.fixture
def sample_placeholder_element():
"""Create a sample PlaceholderData element for testing"""
- return PlaceholderData(
- x=50,
- y=50,
- width=100,
- height=100,
- z_index=0
- )
+ return PlaceholderData(x=50, y=50, width=100, height=100, z_index=0)
@pytest.fixture
def sample_textbox_element():
"""Create a sample TextBoxData element for testing"""
- return TextBoxData(
- x=10,
- y=10,
- width=180,
- height=50,
- text_content="Test Text",
- z_index=2
- )
+ return TextBoxData(x=10, y=10, width=180, height=50, text_content="Test Text", z_index=2)
@pytest.fixture
@@ -189,19 +148,19 @@ def mock_page_renderer():
# Mock coordinate conversion methods
def page_to_screen(x, y):
- return (renderer.screen_x + x * renderer.zoom,
- renderer.screen_y + y * renderer.zoom)
+ return (renderer.screen_x + x * renderer.zoom, renderer.screen_y + y * renderer.zoom)
def screen_to_page(x, y):
- return ((x - renderer.screen_x) / renderer.zoom,
- (y - renderer.screen_y) / renderer.zoom)
+ return ((x - renderer.screen_x) / renderer.zoom, (y - renderer.screen_y) / renderer.zoom)
def is_point_in_page(x, y):
# Simple bounds check (assume 210mm x 297mm page at 96 DPI)
page_width_px = 210 * 96 / 25.4
page_height_px = 297 * 96 / 25.4
- return (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)
+ return (
+ 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.screen_to_page = screen_to_page
@@ -213,8 +172,8 @@ def mock_page_renderer():
@pytest.fixture
def create_mouse_event():
"""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
Args:
@@ -224,19 +183,15 @@ def create_mouse_event():
modifiers: Keyboard modifiers
"""
pos = QPointF(x, y)
- return QMouseEvent(
- event_type,
- pos,
- button,
- button,
- modifiers
- )
+ return QMouseEvent(event_type, pos, button, button, modifiers)
+
return _create_event
@pytest.fixture
def create_wheel_event():
"""Factory fixture for creating QWheelEvent objects"""
+
def _create_event(x, y, delta_y=120, modifiers=Qt.KeyboardModifier.NoModifier):
"""Create a QWheelEvent for testing
@@ -257,38 +212,22 @@ def create_wheel_event():
Qt.MouseButton.NoButton,
modifiers,
Qt.ScrollPhase.NoScrollPhase,
- False
+ False,
)
+
return _create_event
@pytest.fixture
def populated_page():
"""Create a page with multiple elements for testing"""
- page = Page(
- layout=PageLayout(width=210, height=297),
- page_number=1
- )
+ page = Page(layout=PageLayout(width=210, height=297), page_number=1)
# Add various elements
- page.layout.add_element(ImageData(
- image_path="img1.jpg",
- x=10, y=10,
- width=100, height=75,
- z_index=0
- ))
+ page.layout.add_element(ImageData(image_path="img1.jpg", x=10, y=10, width=100, height=75, z_index=0))
- page.layout.add_element(PlaceholderData(
- x=120, y=10,
- width=80, height=60,
- z_index=1
- ))
+ page.layout.add_element(PlaceholderData(x=120, y=10, width=80, height=60, z_index=1))
- page.layout.add_element(TextBoxData(
- x=10, y=100,
- width=190, height=40,
- text_content="Sample Text",
- z_index=2
- ))
+ page.layout.add_element(TextBoxData(x=10, y=100, width=190, height=40, text_content="Sample Text", z_index=2))
return page
diff --git a/tests/test_alignment.py b/tests/test_alignment.py
index f660d61..31dc115 100755
--- a/tests/test_alignment.py
+++ b/tests/test_alignment.py
@@ -20,7 +20,7 @@ class TestAlignmentManager:
"""Test get_bounds with single element"""
elem = ImageData(x=10, y=20, width=100, height=50)
bounds = AlignmentManager.get_bounds([elem])
-
+
# min_x, min_y, max_x, max_y
assert bounds == (10, 20, 110, 70)
@@ -29,9 +29,9 @@ class TestAlignmentManager:
elem1 = ImageData(x=10, y=20, width=100, height=50)
elem2 = ImageData(x=50, y=10, width=80, height=60)
elem3 = ImageData(x=5, y=30, width=90, height=40)
-
+
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)
assert bounds[0] == 5 # min_x
assert bounds[1] == 10 # min_y
@@ -55,14 +55,14 @@ class TestAlignmentManager:
elem1 = ImageData(x=50, y=20, width=100, height=50)
elem2 = ImageData(x=30, y=40, width=80, height=60)
elem3 = ImageData(x=70, y=60, width=90, height=40)
-
+
changes = AlignmentManager.align_left([elem1, elem2, elem3])
-
+
# All should align to x=30 (leftmost)
assert elem1.position == (30, 20)
assert elem2.position == (30, 40)
assert elem3.position == (30, 60)
-
+
# Check undo information
assert len(changes) == 3
assert changes[0] == (elem1, (50, 20))
@@ -72,16 +72,16 @@ class TestAlignmentManager:
def test_align_right_multiple_elements(self):
"""Test align_right with multiple elements"""
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
- elem3 = ImageData(x=70, y=60, width=90, height=40) # right edge at 160
-
+ 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
+
changes = AlignmentManager.align_right([elem1, elem2, elem3])
-
+
# All right edges should align to x=160 (rightmost)
assert elem1.position[0] == 60 # 160 - 100
assert elem2.position[0] == 80 # 160 - 80
assert elem3.position[0] == 70 # 160 - 90
-
+
# Y positions should not change
assert elem1.position[1] == 20
assert elem2.position[1] == 40
@@ -92,14 +92,14 @@ class TestAlignmentManager:
elem1 = ImageData(x=50, y=30, width=100, height=50)
elem2 = ImageData(x=30, y=20, width=80, height=60)
elem3 = ImageData(x=70, y=40, width=90, height=40)
-
+
changes = AlignmentManager.align_top([elem1, elem2, elem3])
-
+
# All should align to y=20 (topmost)
assert elem1.position[1] == 20
assert elem2.position[1] == 20
assert elem3.position[1] == 20
-
+
# X positions should not change
assert elem1.position[0] == 50
assert elem2.position[0] == 30
@@ -108,16 +108,16 @@ class TestAlignmentManager:
def test_align_bottom_multiple_elements(self):
"""Test align_bottom with multiple elements"""
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
- elem3 = ImageData(x=70, y=40, width=90, height=50) # bottom at 90
-
+ 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
+
changes = AlignmentManager.align_bottom([elem1, elem2, elem3])
-
+
# All bottom edges should align to y=90 (bottommost)
assert elem1.position[1] == 40 # 90 - 50
assert elem2.position[1] == 30 # 90 - 60
assert elem3.position[1] == 40 # 90 - 50
-
+
# X positions should not change
assert elem1.position[0] == 50
assert elem2.position[0] == 30
@@ -125,18 +125,18 @@ class TestAlignmentManager:
def test_align_horizontal_center_multiple_elements(self):
"""Test align_horizontal_center with multiple elements"""
- 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
- elem3 = ImageData(x=70, y=60, width=60, height=40) # 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
+ elem3 = ImageData(x=70, y=60, width=60, height=40) # center at 100
+
changes = AlignmentManager.align_horizontal_center([elem1, elem2, elem3])
-
+
# Average center = (100 + 70 + 100) / 3 = 90
# All elements should center at x=90
- 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(elem3.position[0] + elem3.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(elem3.position[0] + elem3.size[0] / 2 - 90) < 0.01
+
# Y positions should not change
assert elem1.position[1] == 20
assert elem2.position[1] == 40
@@ -144,18 +144,18 @@ class TestAlignmentManager:
def test_align_vertical_center_multiple_elements(self):
"""Test align_vertical_center with multiple elements"""
- 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
- elem3 = ImageData(x=70, y=30, width=60, height=40) # center at 50
-
+ 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
+ elem3 = ImageData(x=70, y=30, width=60, height=40) # center at 50
+
changes = AlignmentManager.align_vertical_center([elem1, elem2, elem3])
-
+
# Average center = (45 + 70 + 50) / 3 = 55
# All elements should center at y=55
- 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(elem3.position[1] + elem3.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(elem3.position[1] + elem3.size[1] / 2 - 55) < 0.01
+
# X positions should not change
assert elem1.position[0] == 50
assert elem2.position[0] == 30
@@ -178,14 +178,14 @@ class TestAlignmentManager:
elem1 = ImageData(x=50, y=20, width=100, height=50)
elem2 = ImageData(x=30, y=40, width=80, height=60)
elem3 = ImageData(x=70, y=60, width=90, height=40)
-
+
changes = AlignmentManager.make_same_size([elem1, elem2, elem3])
-
+
# All should match elem1's size
assert elem1.size == (100, 50)
assert elem2.size == (100, 50)
assert elem3.size == (100, 50)
-
+
# Check undo information (only elem2 and elem3 change)
assert len(changes) == 2
assert changes[0][0] == elem2
@@ -198,14 +198,14 @@ class TestAlignmentManager:
elem1 = ImageData(x=50, y=20, width=100, height=50)
elem2 = ImageData(x=30, y=40, width=80, height=60)
elem3 = ImageData(x=70, y=60, width=90, height=40)
-
+
changes = AlignmentManager.make_same_width([elem1, elem2, elem3])
-
+
# All widths should match elem1
assert elem1.size[0] == 100
assert elem2.size[0] == 100
assert elem3.size[0] == 100
-
+
# Heights should not change
assert elem1.size[1] == 50
assert elem2.size[1] == 60
@@ -216,14 +216,14 @@ class TestAlignmentManager:
elem1 = ImageData(x=50, y=20, width=100, height=50)
elem2 = ImageData(x=30, y=40, width=80, height=60)
elem3 = ImageData(x=70, y=60, width=90, height=40)
-
+
changes = AlignmentManager.make_same_height([elem1, elem2, elem3])
-
+
# All heights should match elem1
assert elem1.size[1] == 50
assert elem2.size[1] == 50
assert elem3.size[1] == 50
-
+
# Widths should not change
assert elem1.size[0] == 100
assert elem2.size[0] == 80
@@ -233,7 +233,7 @@ class TestAlignmentManager:
"""Test distribute_horizontally with less than 3 elements"""
elem1 = ImageData(x=50, y=20, width=100, height=50)
elem2 = ImageData(x=30, y=40, width=80, height=60)
-
+
changes = AlignmentManager.distribute_horizontally([elem1, elem2])
assert changes == []
@@ -242,15 +242,15 @@ class TestAlignmentManager:
elem1 = ImageData(x=0, y=20, width=100, height=50)
elem2 = ImageData(x=50, y=40, width=80, height=60)
elem3 = ImageData(x=200, y=60, width=90, height=40)
-
+
changes = AlignmentManager.distribute_horizontally([elem1, elem2, elem3])
-
+
# Elements should be distributed evenly by their left edges
# min_x = 0, max_x = 200, span = 200
# spacing = 200 / (3-1) = 100
positions = [elem.position[0] for elem in [elem1, elem2, elem3]]
sorted_positions = sorted(positions)
-
+
assert sorted_positions[0] == 0
assert sorted_positions[1] == 100
assert sorted_positions[2] == 200
@@ -260,15 +260,15 @@ class TestAlignmentManager:
elem1 = ImageData(x=20, y=0, width=100, height=50)
elem2 = ImageData(x=40, y=50, width=80, height=60)
elem3 = ImageData(x=60, y=300, width=90, height=40)
-
+
changes = AlignmentManager.distribute_vertically([elem1, elem2, elem3])
-
+
# Elements should be distributed evenly by their top edges
# min_y = 0, max_y = 300, span = 300
# spacing = 300 / (3-1) = 150
positions = [elem.position[1] for elem in [elem1, elem2, elem3]]
sorted_positions = sorted(positions)
-
+
assert sorted_positions[0] == 0
assert sorted_positions[1] == 150
assert sorted_positions[2] == 300
@@ -277,7 +277,7 @@ class TestAlignmentManager:
"""Test space_horizontally with less than 3 elements"""
elem1 = ImageData(x=50, y=20, width=100, height=50)
elem2 = ImageData(x=200, y=40, width=80, height=60)
-
+
changes = AlignmentManager.space_horizontally([elem1, elem2])
assert changes == []
@@ -286,17 +286,17 @@ class TestAlignmentManager:
elem1 = ImageData(x=0, y=20, width=100, height=50)
elem2 = ImageData(x=150, y=40, width=50, height=60)
elem3 = ImageData(x=250, y=60, width=100, height=40)
-
+
changes = AlignmentManager.space_horizontally([elem1, elem2, elem3])
-
+
# Total width = 100 + 50 + 100 = 250
# Span = 0 to 350 (250 + 100 from elem3)
# Available space = 350 - 0 - 250 = 100
# Spacing = 100 / (3-1) = 50
-
+
# 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])
-
+
assert sorted_elements[0].position[0] == 0
assert sorted_elements[1].position[0] == 150 # 0 + 100 + 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)
elem2 = ImageData(x=40, y=100, width=80, height=30)
elem3 = ImageData(x=60, y=200, width=90, height=50)
-
+
changes = AlignmentManager.space_vertically([elem1, elem2, elem3])
-
+
# Total height = 50 + 30 + 50 = 130
# Span = 0 to 250 (200 + 50 from elem3)
# Available space = 250 - 0 - 130 = 120
# Spacing = 120 / (3-1) = 60
-
+
# 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])
-
+
assert sorted_elements[0].position[1] == 0
assert sorted_elements[1].position[1] == 110 # 0 + 50 + 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)
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)
-
+
# Test align_left
changes = AlignmentManager.align_left([elem1, elem2, elem3])
-
+
assert elem1.position[0] == 30
assert elem2.position[0] == 30
assert elem3.position[0] == 30
@@ -339,23 +339,23 @@ class TestAlignmentManager:
elem1 = ImageData(x=50, y=20, width=100, height=50)
elem2 = ImageData(x=30, y=40, width=80, height=60)
elem3 = ImageData(x=70, y=60, width=90, height=40)
-
+
# Test position changes
changes = AlignmentManager.align_left([elem1, elem2, elem3])
-
+
for change in changes:
assert len(change) == 2 # (element, old_position)
assert isinstance(change[0], ImageData)
assert isinstance(change[1], tuple)
assert len(change[1]) == 2 # (x, y)
-
+
# Test size changes
elem1 = ImageData(x=50, y=20, width=100, height=50)
elem2 = ImageData(x=30, y=40, width=80, height=60)
elem3 = ImageData(x=70, y=60, width=90, height=40)
-
+
changes = AlignmentManager.make_same_size([elem1, elem2, elem3])
-
+
for change in changes:
assert len(change) == 3 # (element, old_position, old_size)
assert isinstance(change[0], ImageData)
@@ -368,15 +368,15 @@ class TestAlignmentManager:
"""Test that alignment operations only change intended properties"""
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)
-
+
AlignmentManager.align_left([elem1, elem2])
-
+
# Rotation and z_index should not change
assert elem1.rotation == 45
assert elem1.z_index == 5
assert elem2.rotation == 90
assert elem2.z_index == 3
-
+
# Heights should not change
assert elem1.size[1] == 50
assert elem2.size[1] == 60
@@ -387,10 +387,10 @@ class TestAlignmentManager:
elem3 = ImageData(x=200, y=60, width=90, height=40)
elem1 = ImageData(x=0, y=20, width=100, height=50)
elem2 = ImageData(x=100, y=40, width=80, height=60)
-
+
# Pass in random order
changes = AlignmentManager.distribute_horizontally([elem3, elem1, elem2])
-
+
# Should still distribute correctly
positions = sorted([elem1.position[0], elem2.position[0], elem3.position[0]])
assert positions[0] == 0
@@ -402,21 +402,21 @@ class TestAlignmentManager:
elem1 = ImageData(x=0, y=0, width=50, height=50)
elem2 = ImageData(x=100, y=0, width=100, height=50)
elem3 = ImageData(x=250, y=0, width=75, height=50)
-
+
changes = AlignmentManager.space_horizontally([elem1, elem2, elem3])
-
+
# Calculate expected spacing
# Total width = 50 + 100 + 75 = 225
# rightmost edge = 250 + 75 = 325
# Available space = 325 - 0 - 225 = 100
# Spacing = 100 / 2 = 50
-
+
sorted_elements = sorted([elem1, elem2, elem3], key=lambda e: e.position[0])
-
+
# Verify spacing between elements is equal
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])
-
+
assert abs(gap1 - 50) < 0.01
assert abs(gap2 - 50) < 0.01
@@ -452,7 +452,7 @@ class TestAlignmentManager:
assert len(changes) == 1
assert changes[0][0] == elem
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):
"""Test maximize_pattern with two elements side by side"""
@@ -469,11 +469,11 @@ class TestAlignmentManager:
# Elements should not overlap (min_gap = 2.0)
gap_x = max(
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(
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
@@ -510,11 +510,11 @@ class TestAlignmentManager:
# Calculate gaps between rectangles
gap_x = max(
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(
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
@@ -564,11 +564,7 @@ class TestAlignmentManager:
elem4 = ImageData(x=140, y=90, width=10, height=10)
page_size = (160, 110)
- changes = AlignmentManager.maximize_pattern(
- [elem1, elem2, elem3, elem4],
- page_size,
- min_gap=2.0
- )
+ changes = AlignmentManager.maximize_pattern([elem1, elem2, elem3, elem4], page_size, min_gap=2.0)
# All elements should grow
for elem in [elem1, elem2, elem3, elem4]:
@@ -584,11 +580,11 @@ class TestAlignmentManager:
gap_x = max(
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(
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
diff --git a/tests/test_alignment_ops_mixin.py b/tests/test_alignment_ops_mixin.py
index 86543d1..d9fb28c 100755
--- a/tests/test_alignment_ops_mixin.py
+++ b/tests/test_alignment_ops_mixin.py
@@ -81,7 +81,7 @@ class TestGetSelectedElementsList:
class TestAlignLeft:
"""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):
"""Test aligning elements to the left"""
window = TestAlignmentWindow()
@@ -93,10 +93,7 @@ class TestAlignLeft:
window.gl_widget.selected_elements = {element1, element2}
# Mock AlignmentManager to return changes
- mock_manager.align_left.return_value = [
- (element1, (100, 0)),
- (element2, (200, 100))
- ]
+ mock_manager.align_left.return_value = [(element1, (100, 0)), (element2, (200, 100))]
window.align_left()
@@ -106,7 +103,7 @@ class TestAlignLeft:
assert "aligned" 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):
"""Test align left when no changes needed"""
window = TestAlignmentWindow()
@@ -143,7 +140,7 @@ class TestAlignLeft:
class TestAlignRight:
"""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):
"""Test aligning elements to the right"""
window = TestAlignmentWindow()
@@ -154,10 +151,7 @@ class TestAlignRight:
window.gl_widget.selected_elements = {element1, element2}
- mock_manager.align_right.return_value = [
- (element1, (100, 0)),
- (element2, (200, 100))
- ]
+ mock_manager.align_right.return_value = [(element1, (100, 0)), (element2, (200, 100))]
window.align_right()
@@ -169,7 +163,7 @@ class TestAlignRight:
class TestAlignTop:
"""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):
"""Test aligning elements to the top"""
window = TestAlignmentWindow()
@@ -180,10 +174,7 @@ class TestAlignTop:
window.gl_widget.selected_elements = {element1, element2}
- mock_manager.align_top.return_value = [
- (element1, (0, 50)),
- (element2, (100, 100))
- ]
+ mock_manager.align_top.return_value = [(element1, (0, 50)), (element2, (100, 100))]
window.align_top()
@@ -195,7 +186,7 @@ class TestAlignTop:
class TestAlignBottom:
"""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):
"""Test aligning elements to the bottom"""
window = TestAlignmentWindow()
@@ -206,10 +197,7 @@ class TestAlignBottom:
window.gl_widget.selected_elements = {element1, element2}
- mock_manager.align_bottom.return_value = [
- (element1, (0, 50)),
- (element2, (100, 100))
- ]
+ mock_manager.align_bottom.return_value = [(element1, (0, 50)), (element2, (100, 100))]
window.align_bottom()
@@ -221,7 +209,7 @@ class TestAlignBottom:
class TestAlignHorizontalCenter:
"""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):
"""Test aligning elements to horizontal center"""
window = TestAlignmentWindow()
@@ -232,10 +220,7 @@ class TestAlignHorizontalCenter:
window.gl_widget.selected_elements = {element1, element2}
- mock_manager.align_horizontal_center.return_value = [
- (element1, (0, 0)),
- (element2, (200, 100))
- ]
+ mock_manager.align_horizontal_center.return_value = [(element1, (0, 0)), (element2, (200, 100))]
window.align_horizontal_center()
@@ -247,7 +232,7 @@ class TestAlignHorizontalCenter:
class TestAlignVerticalCenter:
"""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):
"""Test aligning elements to vertical center"""
window = TestAlignmentWindow()
@@ -258,10 +243,7 @@ class TestAlignVerticalCenter:
window.gl_widget.selected_elements = {element1, element2}
- mock_manager.align_vertical_center.return_value = [
- (element1, (0, 0)),
- (element2, (100, 200))
- ]
+ mock_manager.align_vertical_center.return_value = [(element1, (0, 0)), (element2, (100, 200))]
window.align_vertical_center()
@@ -273,7 +255,7 @@ class TestAlignVerticalCenter:
class TestAlignmentCommandPattern:
"""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):
"""Test that alignment creates a command for undo"""
window = TestAlignmentWindow()
@@ -284,10 +266,7 @@ class TestAlignmentCommandPattern:
window.gl_widget.selected_elements = {element1, element2}
- mock_manager.align_left.return_value = [
- (element1, (100, 0)),
- (element2, (200, 100))
- ]
+ mock_manager.align_left.return_value = [(element1, (100, 0)), (element2, (200, 100))]
# Should have no commands initially
assert not window.project.history.can_undo()
@@ -297,7 +276,7 @@ class TestAlignmentCommandPattern:
# Should have created a command
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):
"""Test that alignment can be undone and redone"""
window = TestAlignmentWindow()
@@ -309,10 +288,7 @@ class TestAlignmentCommandPattern:
window.gl_widget.selected_elements = {element1, element2}
# Mock alignment to return changes (command will handle actual moves)
- mock_manager.align_top.return_value = [
- (element1, (100, 0)),
- (element2, (200, 100))
- ]
+ mock_manager.align_top.return_value = [(element1, (100, 0)), (element2, (200, 100))]
# Execute alignment - command created
window.align_top()
diff --git a/tests/test_asset_drop_mixin.py b/tests/test_asset_drop_mixin.py
index 83e3f66..ab7b4d8 100755
--- a/tests/test_asset_drop_mixin.py
+++ b/tests/test_asset_drop_mixin.py
@@ -28,8 +28,8 @@ class TestAssetDropWidget(AssetDropMixin, AssetPathMixin, PageNavigationMixin, V
def _get_project_folder(self):
"""Override to access project via window mock"""
main_window = self.window()
- if hasattr(main_window, 'project') and main_window.project:
- return getattr(main_window.project, 'folder_path', None)
+ if hasattr(main_window, "project") and main_window.project:
+ return getattr(main_window.project, "folder_path", None)
return None
@@ -43,7 +43,7 @@ class TestAssetDropInitialization:
# Should accept drops (set in GLWidget.__init__)
# This is a property of the widget, not the mixin
- assert hasattr(widget, 'acceptDrops')
+ assert hasattr(widget, "acceptDrops")
class TestDragEnterEvent:
@@ -141,7 +141,7 @@ class TestDragMoveEvent:
class TestDropEvent:
"""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):
"""Test dropping image file creates ImageData element"""
widget = TestAssetDropWidget()
@@ -239,7 +239,7 @@ class TestDropEvent:
# Create a real test image file
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
mock_window = Mock()
@@ -248,6 +248,7 @@ class TestDropEvent:
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
from pyPhotoAlbum.models import PlaceholderData
+
placeholder = PlaceholderData(x=100, y=100, width=200, height=150)
page.layout.elements.append(placeholder)
@@ -280,7 +281,7 @@ class TestDropEvent:
# Image path should now be in assets folder (imported)
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):
"""Test dropping first image from multiple files"""
widget = TestAssetDropWidget()
@@ -311,11 +312,13 @@ class TestDropEvent:
# Create drop event with multiple files (only first is used)
mime_data = QMimeData()
- mime_data.setUrls([
- QUrl.fromLocalFile("/path/to/image1.jpg"),
- QUrl.fromLocalFile("/path/to/image2.png"),
- QUrl.fromLocalFile("/path/to/image3.jpg")
- ])
+ mime_data.setUrls(
+ [
+ QUrl.fromLocalFile("/path/to/image1.jpg"),
+ QUrl.fromLocalFile("/path/to/image2.png"),
+ QUrl.fromLocalFile("/path/to/image3.jpg"),
+ ]
+ )
event = Mock()
event.mimeData = Mock(return_value=mime_data)
@@ -364,7 +367,7 @@ class TestDropEvent:
# Create a real test image file
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
mock_window = Mock()
@@ -372,10 +375,7 @@ class TestDropEvent:
mock_window.project.working_dpi = 96
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
- existing_image = ImageData(
- image_path="assets/old_image.jpg",
- x=100, y=100, width=200, height=150
- )
+ existing_image = ImageData(image_path="assets/old_image.jpg", x=100, y=100, width=200, height=150)
page.layout.elements.append(existing_image)
mock_window.project.pages = [page]
@@ -407,24 +407,19 @@ class TestDropEvent:
widget.update = Mock()
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.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
- existing_image = ImageData(
- image_path="assets/old.jpg",
- x=100, y=100, width=200, height=150
- )
+ existing_image = ImageData(image_path="assets/old.jpg", x=100, y=100, width=200, height=150)
page.layout.elements.append(existing_image)
mock_window.project.pages = [page]
# Mock asset manager to raise exception
mock_window.project.asset_manager = Mock()
- mock_window.project.asset_manager.import_asset = Mock(
- side_effect=Exception("Import failed")
- )
+ mock_window.project.asset_manager.import_asset = Mock(side_effect=Exception("Import failed"))
widget.window = Mock(return_value=mock_window)
widget._get_element_at = Mock(return_value=existing_image)
@@ -454,7 +449,7 @@ class TestDropEvent:
# Create a corrupted/invalid image file
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.project = Project(name="Test")
@@ -488,7 +483,8 @@ class TestDropEvent:
# Should use default dimensions (200, 150) from _calculate_image_dimensions
# Check that AddElementCommand was called with an ImageData
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
widget.dropEvent(event)
assert mock_cmd.called
@@ -527,10 +523,7 @@ class TestExtractImagePathEdgeCases:
widget.update = Mock()
mime_data = QMimeData()
- mime_data.setUrls([
- QUrl.fromLocalFile("/path/to/document.pdf"),
- QUrl.fromLocalFile("/path/to/file.txt")
- ])
+ mime_data.setUrls([QUrl.fromLocalFile("/path/to/document.pdf"), QUrl.fromLocalFile("/path/to/file.txt")])
event = Mock()
event.mimeData = Mock(return_value=mime_data)
@@ -576,7 +569,7 @@ class TestPlaceholderReplacementEdgeCases:
widget.update = Mock()
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
mock_window = Mock()
@@ -585,6 +578,7 @@ class TestPlaceholderReplacementEdgeCases:
mock_window.project.pages = [] # Empty pages list
from pyPhotoAlbum.models import PlaceholderData
+
placeholder = PlaceholderData(x=100, y=100, width=200, height=150)
mock_window.project.asset_manager = Mock()
diff --git a/tests/test_asset_path_mixin.py b/tests/test_asset_path_mixin.py
new file mode 100644
index 0000000..64def84
--- /dev/null
+++ b/tests/test_asset_path_mixin.py
@@ -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
diff --git a/tests/test_async_loading_mixin.py b/tests/test_async_loading_mixin.py
new file mode 100644
index 0000000..e40f307
--- /dev/null
+++ b/tests/test_async_loading_mixin.py
@@ -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
diff --git a/tests/test_autosave_manager.py b/tests/test_autosave_manager.py
new file mode 100644
index 0000000..a108a61
--- /dev/null
+++ b/tests/test_autosave_manager.py
@@ -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
diff --git a/tests/test_base_mixin.py b/tests/test_base_mixin.py
index aea24dd..9db9655 100755
--- a/tests/test_base_mixin.py
+++ b/tests/test_base_mixin.py
@@ -249,7 +249,7 @@ class TestDialogMethods:
qtbot.addWidget(window)
mock_critical = Mock()
- monkeypatch.setattr(QMessageBox, 'critical', mock_critical)
+ monkeypatch.setattr(QMessageBox, "critical", mock_critical)
window.show_error("Error Title", "Error message")
@@ -260,7 +260,7 @@ class TestDialogMethods:
qtbot.addWidget(window)
mock_warning = Mock()
- monkeypatch.setattr(QMessageBox, 'warning', mock_warning)
+ monkeypatch.setattr(QMessageBox, "warning", mock_warning)
window.show_warning("Warning Title", "Warning message")
@@ -271,7 +271,7 @@ class TestDialogMethods:
qtbot.addWidget(window)
mock_info = Mock()
- monkeypatch.setattr(QMessageBox, 'information', mock_info)
+ monkeypatch.setattr(QMessageBox, "information", mock_info)
window.show_info("Info Title", "Info message")
@@ -309,7 +309,7 @@ class TestRequirePage:
window._gl_widget = Mock()
mock_warning = Mock()
- monkeypatch.setattr(QMessageBox, 'warning', mock_warning)
+ monkeypatch.setattr(QMessageBox, "warning", mock_warning)
result = window.require_page(show_warning=True)
@@ -366,7 +366,7 @@ class TestRequireSelection:
window._gl_widget = gl_widget
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)
@@ -386,7 +386,7 @@ class TestRequireSelection:
window._gl_widget = gl_widget
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)
diff --git a/tests/test_commands.py b/tests/test_commands.py
index 9fbd140..70ec2eb 100755
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -16,7 +16,7 @@ from pyPhotoAlbum.commands import (
ChangeZOrderCommand,
StateChangeCommand,
CommandHistory,
- _normalize_asset_path
+ _normalize_asset_path,
)
from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
from pyPhotoAlbum.page_layout import PageLayout
@@ -107,9 +107,9 @@ class TestAddElementCommand:
data = cmd.serialize()
- assert data['type'] == 'add_element'
- assert 'element' in data
- assert data['executed'] is True
+ assert data["type"] == "add_element"
+ assert "element" in data
+ assert data["executed"] is True
def test_add_element_with_asset_manager(self):
"""Test add element with asset manager reference"""
@@ -164,8 +164,8 @@ class TestDeleteElementCommand:
cmd = DeleteElementCommand(layout, element)
data = cmd.serialize()
- assert data['type'] == 'delete_element'
- assert 'element' in data
+ assert data["type"] == "delete_element"
+ assert "element" in data
class TestMoveElementCommand:
@@ -198,9 +198,9 @@ class TestMoveElementCommand:
cmd = MoveElementCommand(element, old_position=(100, 100), new_position=(200, 200))
data = cmd.serialize()
- assert data['type'] == 'move_element'
- assert data['old_position'] == (100, 100)
- assert data['new_position'] == (200, 200)
+ assert data["type"] == "move_element"
+ assert data["old_position"] == (100, 100)
+ assert data["new_position"] == (200, 200)
class TestResizeElementCommand:
@@ -211,11 +211,7 @@ class TestResizeElementCommand:
element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150)
cmd = ResizeElementCommand(
- element,
- old_position=(100, 100),
- old_size=(200, 150),
- new_position=(100, 100),
- new_size=(300, 225)
+ element, old_position=(100, 100), old_size=(200, 150), new_position=(100, 100), new_size=(300, 225)
)
cmd.execute()
@@ -226,11 +222,7 @@ class TestResizeElementCommand:
element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150)
cmd = ResizeElementCommand(
- element,
- old_position=(100, 100),
- old_size=(200, 150),
- new_position=(100, 100),
- new_size=(300, 225)
+ element, old_position=(100, 100), old_size=(200, 150), new_position=(100, 100), new_size=(300, 225)
)
cmd.execute()
@@ -243,11 +235,7 @@ class TestResizeElementCommand:
element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150)
cmd = ResizeElementCommand(
- element,
- old_position=(100, 100),
- old_size=(200, 150),
- new_position=(90, 90),
- new_size=(220, 165)
+ element, old_position=(100, 100), old_size=(200, 150), new_position=(90, 90), new_size=(220, 165)
)
cmd.execute()
@@ -298,9 +286,9 @@ class TestRotateElementCommand:
cmd = RotateElementCommand(element, old_rotation=0, new_rotation=45)
data = cmd.serialize()
- assert data['type'] == 'rotate_element'
- assert data['old_rotation'] == 0
- assert data['new_rotation'] == 45
+ assert data["type"] == "rotate_element"
+ assert data["old_rotation"] == 0
+ assert data["new_rotation"] == 45
class TestAdjustImageCropCommand:
@@ -310,32 +298,25 @@ class TestAdjustImageCropCommand:
"""Test adjusting image crop"""
element = ImageData(
image_path="/test.jpg",
- x=100, y=100,
- width=200, height=150,
- crop_info={'x': 0.0, 'y': 0.0, 'width': 1.0, 'height': 1.0}
+ x=100,
+ y=100,
+ 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}
- cmd = AdjustImageCropCommand(
- element,
- old_crop_info=element.crop_info.copy(),
- new_crop_info=new_crop
- )
+ new_crop = {"x": 0.1, "y": 0.1, "width": 0.8, "height": 0.8}
+ cmd = AdjustImageCropCommand(element, old_crop_info=element.crop_info.copy(), new_crop_info=new_crop)
cmd.execute()
assert element.crop_info == new_crop
def test_adjust_crop_undo(self):
"""Test undoing crop adjustment"""
- old_crop = {'x': 0.0, 'y': 0.0, 'width': 1.0, 'height': 1.0}
- element = ImageData(
- image_path="/test.jpg",
- x=100, y=100,
- width=200, height=150,
- crop_info=old_crop.copy()
- )
+ old_crop = {"x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0}
+ element = ImageData(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.execute()
@@ -399,10 +380,7 @@ class TestResizeElementsCommand:
element2.size = (300, 300)
# Command expects list of (element, old_position, old_size) tuples
- changes = [
- (element1, (100, 100), (100, 100)),
- (element2, (200, 200), (150, 150))
- ]
+ changes = [(element1, (100, 100), (100, 100)), (element2, (200, 200), (150, 150))]
cmd = ResizeElementsCommand(changes)
cmd.execute()
@@ -421,10 +399,7 @@ class TestResizeElementsCommand:
element2.size = (300, 300)
# Command expects list of (element, old_position, old_size) tuples
- changes = [
- (element1, (100, 100), (100, 100)),
- (element2, (200, 200), (150, 150))
- ]
+ changes = [(element1, (100, 100), (100, 100)), (element2, (200, 200), (150, 150))]
cmd = ResizeElementsCommand(changes)
cmd.execute()
@@ -476,81 +451,68 @@ class TestStateChangeCommand:
def test_state_change_undo(self):
"""Test undoing state change"""
- element = TextBoxData(
- text_content="Old Text",
- x=100, y=100,
- width=200, height=100
- )
+ element = TextBoxData(text_content="Old Text", x=100, y=100, width=200, height=100)
# Define restore function
def restore_state(state):
- element.text_content = state['text_content']
+ element.text_content = state["text_content"]
- old_state = {'text_content': 'Old Text'}
- new_state = {'text_content': 'New Text'}
+ old_state = {"text_content": "Old Text"}
+ new_state = {"text_content": "New Text"}
# Apply new state first
- element.text_content = 'New Text'
+ element.text_content = "New Text"
cmd = StateChangeCommand(
- description="Change text",
- restore_func=restore_state,
- before_state=old_state,
- after_state=new_state
+ description="Change text", restore_func=restore_state, before_state=old_state, after_state=new_state
)
# Undo should restore old state
cmd.undo()
- assert element.text_content == 'Old Text'
+ assert element.text_content == "Old Text"
def test_state_change_redo(self):
"""Test redoing state change"""
- element = TextBoxData(
- text_content="Old Text",
- x=100, y=100,
- width=200, height=100
- )
+ element = TextBoxData(text_content="Old Text", x=100, y=100, width=200, height=100)
# Define restore function
def restore_state(state):
- element.text_content = state['text_content']
+ element.text_content = state["text_content"]
- old_state = {'text_content': 'Old Text'}
- new_state = {'text_content': 'New Text'}
+ old_state = {"text_content": "Old Text"}
+ new_state = {"text_content": "New Text"}
# Apply new state first
- element.text_content = 'New Text'
+ element.text_content = "New Text"
cmd = StateChangeCommand(
- description="Change text",
- restore_func=restore_state,
- before_state=old_state,
- after_state=new_state
+ description="Change text", restore_func=restore_state, before_state=old_state, after_state=new_state
)
# Undo then redo
cmd.undo()
- assert element.text_content == 'Old Text'
+ assert element.text_content == "Old Text"
cmd.redo()
- assert element.text_content == 'New Text'
+ assert element.text_content == "New Text"
def test_state_change_serialization(self):
"""Test serializing state change command"""
+
def restore_func(state):
pass
cmd = StateChangeCommand(
description="Test operation",
restore_func=restore_func,
- before_state={'test': 'before'},
- after_state={'test': 'after'}
+ before_state={"test": "before"},
+ after_state={"test": "after"},
)
data = cmd.serialize()
- assert data['type'] == 'state_change'
- assert data['description'] == 'Test operation'
+ assert data["type"] == "state_change"
+ assert data["description"] == "Test operation"
class TestCommandHistory:
@@ -656,7 +618,7 @@ class TestCommandHistory:
layout = PageLayout(width=210, height=297)
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))
# Should only have 3 commands in history (max_history)
@@ -678,8 +640,8 @@ class TestCommandHistory:
# Serialize
data = history.serialize()
- assert len(data['undo_stack']) == 1
- assert data['undo_stack'][0]['type'] == 'add_element'
+ assert len(data["undo_stack"]) == 1
+ assert data["undo_stack"][0]["type"] == "add_element"
# Create mock project for deserialization
mock_project = Mock()
@@ -734,7 +696,7 @@ class TestCommandHistory:
# Manually build serialized history data
data = {
- 'undo_stack': [
+ "undo_stack": [
cmd1.serialize(),
cmd2.serialize(),
cmd3.serialize(),
@@ -745,8 +707,8 @@ class TestCommandHistory:
cmd8.serialize(),
cmd9.serialize(),
],
- 'redo_stack': [],
- 'max_history': 100
+ "redo_stack": [],
+ "max_history": 100,
}
# Create mock project
@@ -758,15 +720,15 @@ class TestCommandHistory:
new_history.deserialize(data, mock_project)
assert len(new_history.undo_stack) == 9
- assert new_history.undo_stack[0].__class__.__name__ == 'AddElementCommand'
- assert new_history.undo_stack[1].__class__.__name__ == 'DeleteElementCommand'
- assert new_history.undo_stack[2].__class__.__name__ == 'MoveElementCommand'
- assert new_history.undo_stack[3].__class__.__name__ == 'ResizeElementCommand'
- assert new_history.undo_stack[4].__class__.__name__ == 'RotateElementCommand'
- assert new_history.undo_stack[5].__class__.__name__ == 'AdjustImageCropCommand'
- assert new_history.undo_stack[6].__class__.__name__ == 'AlignElementsCommand'
- assert new_history.undo_stack[7].__class__.__name__ == 'ResizeElementsCommand'
- assert new_history.undo_stack[8].__class__.__name__ == 'ChangeZOrderCommand'
+ assert new_history.undo_stack[0].__class__.__name__ == "AddElementCommand"
+ assert new_history.undo_stack[1].__class__.__name__ == "DeleteElementCommand"
+ assert new_history.undo_stack[2].__class__.__name__ == "MoveElementCommand"
+ assert new_history.undo_stack[3].__class__.__name__ == "ResizeElementCommand"
+ assert new_history.undo_stack[4].__class__.__name__ == "RotateElementCommand"
+ assert new_history.undo_stack[5].__class__.__name__ == "AdjustImageCropCommand"
+ assert new_history.undo_stack[6].__class__.__name__ == "AlignElementsCommand"
+ assert new_history.undo_stack[7].__class__.__name__ == "ResizeElementsCommand"
+ assert new_history.undo_stack[8].__class__.__name__ == "ChangeZOrderCommand"
def test_history_deserialize_unknown_command_type(self):
"""Test deserializing unknown command type returns None and continues"""
@@ -774,12 +736,12 @@ class TestCommandHistory:
mock_project = Mock()
data = {
- 'undo_stack': [
- {'type': 'unknown_command', 'data': 'test'},
- {'type': 'add_element', 'element': ImageData().serialize(), 'executed': True}
+ "undo_stack": [
+ {"type": "unknown_command", "data": "test"},
+ {"type": "add_element", "element": ImageData().serialize(), "executed": True},
],
- 'redo_stack': [],
- 'max_history': 100
+ "redo_stack": [],
+ "max_history": 100,
}
# Should not raise exception, just skip unknown command
@@ -787,7 +749,7 @@ class TestCommandHistory:
# Should only have the valid command
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):
"""Test deserializing malformed command handles exception gracefully"""
@@ -795,13 +757,17 @@ class TestCommandHistory:
mock_project = Mock()
data = {
- 'undo_stack': [
- {'type': 'add_element'}, # Missing required 'element' field
- {'type': 'move_element', 'element': ImageData().serialize(),
- 'old_position': (0, 0), 'new_position': (10, 10)}
+ "undo_stack": [
+ {"type": "add_element"}, # Missing required 'element' field
+ {
+ "type": "move_element",
+ "element": ImageData().serialize(),
+ "old_position": (0, 0),
+ "new_position": (10, 10),
+ },
],
- 'redo_stack': [],
- 'max_history': 100
+ "redo_stack": [],
+ "max_history": 100,
}
# Should not raise exception, just skip malformed command
@@ -809,7 +775,7 @@ class TestCommandHistory:
# Should only have the valid command
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):
"""Test serializing and deserializing with items in redo stack"""
@@ -826,8 +792,8 @@ class TestCommandHistory:
# Serialize
data = history.serialize()
- assert len(data['undo_stack']) == 1
- assert len(data['redo_stack']) == 1
+ assert len(data["undo_stack"]) == 1
+ assert len(data["redo_stack"]) == 1
# Deserialize
mock_project = Mock()
diff --git a/tests/test_distribution_ops_mixin.py b/tests/test_distribution_ops_mixin.py
index 83d2498..7b5dd8e 100755
--- a/tests/test_distribution_ops_mixin.py
+++ b/tests/test_distribution_ops_mixin.py
@@ -56,7 +56,7 @@ class TestGetSelectedElementsList:
class TestDistributeHorizontally:
"""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):
window = TestDistributionWindow()
qtbot.addWidget(window)
@@ -70,7 +70,7 @@ class TestDistributeHorizontally:
mock_manager.distribute_horizontally.return_value = [
(element1, (0, 0)),
(element2, (150, 0)),
- (element3, (500, 0))
+ (element3, (500, 0)),
]
window.distribute_horizontally()
@@ -98,7 +98,7 @@ class TestDistributeHorizontally:
class TestDistributeVertically:
"""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):
window = TestDistributionWindow()
qtbot.addWidget(window)
@@ -112,7 +112,7 @@ class TestDistributeVertically:
mock_manager.distribute_vertically.return_value = [
(element1, (0, 0)),
(element2, (0, 150)),
- (element3, (0, 500))
+ (element3, (0, 500)),
]
window.distribute_vertically()
@@ -125,7 +125,7 @@ class TestDistributeVertically:
class TestSpaceHorizontally:
"""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):
window = TestDistributionWindow()
qtbot.addWidget(window)
@@ -136,11 +136,7 @@ class TestSpaceHorizontally:
window.gl_widget.selected_elements = {element1, element2, element3}
- mock_manager.space_horizontally.return_value = [
- (element1, (0, 0)),
- (element2, (100, 0)),
- (element3, (200, 0))
- ]
+ mock_manager.space_horizontally.return_value = [(element1, (0, 0)), (element2, (100, 0)), (element3, (200, 0))]
window.space_horizontally()
@@ -152,7 +148,7 @@ class TestSpaceHorizontally:
class TestSpaceVertically:
"""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):
window = TestDistributionWindow()
qtbot.addWidget(window)
@@ -163,11 +159,7 @@ class TestSpaceVertically:
window.gl_widget.selected_elements = {element1, element2, element3}
- mock_manager.space_vertically.return_value = [
- (element1, (0, 0)),
- (element2, (0, 100)),
- (element3, (0, 200))
- ]
+ mock_manager.space_vertically.return_value = [(element1, (0, 0)), (element2, (0, 100)), (element3, (0, 200))]
window.space_vertically()
@@ -178,7 +170,7 @@ class TestSpaceVertically:
class TestDistributionCommandPattern:
"""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):
window = TestDistributionWindow()
qtbot.addWidget(window)
@@ -192,7 +184,7 @@ class TestDistributionCommandPattern:
mock_manager.distribute_horizontally.return_value = [
(element1, (0, 0)),
(element2, (100, 0)),
- (element3, (200, 0))
+ (element3, (200, 0)),
]
assert not window.project.history.can_undo()
diff --git a/tests/test_edit_ops_mixin.py b/tests/test_edit_ops_mixin.py
index cd5afcd..d0df1b4 100755
--- a/tests/test_edit_ops_mixin.py
+++ b/tests/test_edit_ops_mixin.py
@@ -31,7 +31,7 @@ class TestEditWindow(EditOperationsMixin, QMainWindow):
return len(self.gl_widget.selected_elements) >= min_count
def get_current_page(self):
- if hasattr(self, '_current_page'):
+ if hasattr(self, "_current_page"):
return self._current_page
return None
diff --git a/tests/test_element_manipulation_mixin.py b/tests/test_element_manipulation_mixin.py
index af9989c..859a0fd 100755
--- a/tests/test_element_manipulation_mixin.py
+++ b/tests/test_element_manipulation_mixin.py
@@ -15,6 +15,7 @@ from pyPhotoAlbum.page_layout import PageLayout
# Create test widget combining necessary mixins
class TestManipulationWidget(ElementManipulationMixin, ElementSelectionMixin, QOpenGLWidget):
"""Test widget combining manipulation and selection mixins"""
+
def __init__(self):
super().__init__()
self._page_renderers = []
@@ -36,11 +37,7 @@ class TestElementManipulationInitialization:
assert widget.rotation_mode is False
assert widget.rotation_start_angle is None
assert widget.rotation_snap_angle == 15
- assert widget.snap_state == {
- 'is_snapped': False,
- 'last_position': None,
- 'last_size': None
- }
+ assert widget.snap_state == {"is_snapped": False, "last_position": None, "last_size": None}
def test_rotation_mode_is_mutable(self, qtbot):
"""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)
widget.selected_element = elem
- widget.resize_handle = 'se'
+ widget.resize_handle = "se"
widget.resize_start_pos = (100, 100)
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)
widget.selected_element = elem
- widget.resize_handle = 'nw'
+ widget.resize_handle = "nw"
widget.resize_start_pos = (100, 100)
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)
widget.selected_element = elem
- widget.resize_handle = 'ne'
+ widget.resize_handle = "ne"
widget.resize_start_pos = (100, 100)
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)
widget.selected_element = elem
- widget.resize_handle = 'sw'
+ widget.resize_handle = "sw"
widget.resize_start_pos = (100, 100)
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)
widget.selected_element = elem
- widget.resize_handle = 'se'
+ widget.resize_handle = "se"
widget.resize_start_pos = (100, 100)
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)
widget.selected_element = elem
- widget.resize_handle = 'se'
+ widget.resize_handle = "se"
# Don't set resize_start_pos or resize_start_size
original_pos = elem.position
@@ -185,7 +182,7 @@ class TestResizeElementWithSnap:
elem._parent_page = page
widget.selected_element = elem
- widget.resize_handle = 'se'
+ widget.resize_handle = "se"
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (200, 150)
@@ -208,7 +205,7 @@ class TestResizeElementWithSnap:
params = call_args[0][0]
assert params.dx == 50
assert params.dy == 30
- assert params.resize_handle == 'se'
+ assert params.resize_handle == "se"
# Verify element was updated
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)
widget.selected_element = elem
- widget.resize_handle = 'se'
+ widget.resize_handle = "se"
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (200, 150)
@@ -241,7 +238,7 @@ class TestResizeElementWithSnap:
elem._parent_page = page
widget.selected_element = elem
- widget.resize_handle = 'se'
+ widget.resize_handle = "se"
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (50, 50)
@@ -344,20 +341,20 @@ class TestManipulationStateManagement:
widget = TestManipulationWidget()
qtbot.addWidget(widget)
- assert 'is_snapped' in widget.snap_state
- assert 'last_position' in widget.snap_state
- assert 'last_size' in widget.snap_state
+ assert "is_snapped" in widget.snap_state
+ assert "last_position" in widget.snap_state
+ assert "last_size" in widget.snap_state
def test_resize_state_can_be_set(self, qtbot):
"""Test resize state variables can be set"""
widget = TestManipulationWidget()
qtbot.addWidget(widget)
- widget.resize_handle = 'nw'
+ widget.resize_handle = "nw"
widget.resize_start_pos = (10, 20)
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_size == (100, 200)
diff --git a/tests/test_element_maximizer.py b/tests/test_element_maximizer.py
index 72c8191..e8497dc 100644
--- a/tests/test_element_maximizer.py
+++ b/tests/test_element_maximizer.py
@@ -258,6 +258,7 @@ class TestElementMaximizer:
def test_maximize_empty_elements(self):
"""Test maximize with empty element list."""
from pyPhotoAlbum.alignment import AlignmentManager
+
result = AlignmentManager.maximize_pattern([], (200.0, 200.0))
assert result == []
diff --git a/tests/test_element_ops_mixin.py b/tests/test_element_ops_mixin.py
index dcae6b4..34340ff 100755
--- a/tests/test_element_ops_mixin.py
+++ b/tests/test_element_ops_mixin.py
@@ -43,11 +43,11 @@ class TestElementWindow(ElementOperationsMixin, AssetPathMixin, QMainWindow):
def require_page(self):
"""Track require_page calls"""
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):
"""Return mock current page"""
- if hasattr(self, '_current_page'):
+ if hasattr(self, "_current_page"):
return self._current_page
return None
@@ -71,8 +71,8 @@ class TestElementWindow(ElementOperationsMixin, AssetPathMixin, QMainWindow):
class TestAddImage:
"""Test add_image method"""
- @patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
- @patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions')
+ @patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName")
+ @patch("pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions")
def test_add_image_success(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test successfully adding an image"""
window = TestElementWindow()
@@ -106,7 +106,7 @@ class TestAddImage:
assert window._update_view_called
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):
"""Test cancelling image selection"""
window = TestElementWindow()
@@ -139,8 +139,8 @@ class TestAddImage:
assert window._require_page_called
assert not window._update_view_called
- @patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
- @patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions')
+ @patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName")
+ @patch("pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions")
def test_add_image_scales_large_image(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test that large images are scaled down"""
window = TestElementWindow()
@@ -164,8 +164,8 @@ class TestAddImage:
# Image should be added (scaled down by get_image_dimensions)
assert window._update_view_called
- @patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
- @patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions')
+ @patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName")
+ @patch("pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions")
def test_add_image_fallback_dimensions(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test fallback dimensions when get_image_dimensions returns None"""
window = TestElementWindow()
@@ -294,8 +294,8 @@ class TestAddPlaceholder:
class TestElementOperationsIntegration:
"""Test integration between element operations"""
- @patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
- @patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions')
+ @patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName")
+ @patch("pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions")
def test_add_multiple_elements(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test adding multiple different element types"""
window = TestElementWindow()
@@ -326,8 +326,8 @@ class TestElementOperationsIntegration:
# Should have added all three elements
assert window._update_view_called
- @patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
- @patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions')
+ @patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName")
+ @patch("pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions")
def test_add_image_with_undo(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test that adding image can be undone"""
window = TestElementWindow()
diff --git a/tests/test_element_selection_mixin.py b/tests/test_element_selection_mixin.py
index e80a978..464ef03 100755
--- a/tests/test_element_selection_mixin.py
+++ b/tests/test_element_selection_mixin.py
@@ -23,19 +23,19 @@ def mock_page_renderer():
# Mock coordinate conversion methods
def page_to_screen(x, y):
- return (renderer.screen_x + x * renderer.zoom,
- renderer.screen_y + y * renderer.zoom)
+ return (renderer.screen_x + x * renderer.zoom, renderer.screen_y + y * renderer.zoom)
def screen_to_page(x, y):
- return ((x - renderer.screen_x) / renderer.zoom,
- (y - renderer.screen_y) / renderer.zoom)
+ return ((x - renderer.screen_x) / renderer.zoom, (y - renderer.screen_y) / renderer.zoom)
def is_point_in_page(x, y):
# Simple bounds check (assume 210mm x 297mm page at 96 DPI)
page_width_px = 210 * 96 / 25.4
page_height_px = 297 * 96 / 25.4
- return (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)
+ return (
+ 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.screen_to_page = screen_to_page
@@ -47,6 +47,7 @@ def mock_page_renderer():
# Create a minimal test widget class
class TestSelectionWidget(ElementSelectionMixin, QOpenGLWidget):
"""Test widget combining ElementSelectionMixin with QOpenGLWidget"""
+
def __init__(self):
super().__init__()
self._page_renderers = []
@@ -60,7 +61,7 @@ class TestElementSelectionInitialization:
widget = TestSelectionWidget()
qtbot.addWidget(widget)
- assert hasattr(widget, 'selected_elements')
+ assert hasattr(widget, "selected_elements")
assert isinstance(widget.selected_elements, set)
assert len(widget.selected_elements) == 0
@@ -172,8 +173,8 @@ class TestGetElementAt:
assert result is not None
assert result == elem
- assert hasattr(result, '_page_renderer')
- assert hasattr(result, '_parent_page')
+ assert hasattr(result, "_page_renderer")
+ assert hasattr(result, "_parent_page")
def test_get_element_at_finds_topmost_element(self, qtbot, mock_page_renderer):
"""Test _get_element_at returns topmost element when overlapping"""
@@ -246,8 +247,8 @@ class TestGetElementAt:
# Screen coords: (50 + 200, 50 + 175) = (250, 225)
result = widget._get_element_at(250, 225)
assert result == elem
- assert hasattr(result, '_page_renderer')
- assert hasattr(result, '_parent_page')
+ assert hasattr(result, "_page_renderer")
+ assert hasattr(result, "_parent_page")
def test_get_element_at_rotated_element_outside(self, qtbot, mock_page_renderer):
"""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
assert result is not None
assert result == elem
- assert hasattr(result, '_page_renderer')
- assert hasattr(result, '_parent_page')
+ assert hasattr(result, "_page_renderer")
+ assert hasattr(result, "_parent_page")
class TestGetResizeHandleAt:
@@ -330,6 +331,7 @@ class TestGetResizeHandleAt:
# Mock window with project
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
+
mock_window = Mock()
mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
@@ -351,6 +353,7 @@ class TestGetResizeHandleAt:
# Mock window with project
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
+
mock_window = Mock()
mock_window.project = Project(name="Test")
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)
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):
"""Test _get_resize_handle_at detects all four corners"""
@@ -373,6 +376,7 @@ class TestGetResizeHandleAt:
# Mock window
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
+
mock_window = Mock()
mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
@@ -380,16 +384,16 @@ class TestGetResizeHandleAt:
widget.window = Mock(return_value=mock_window)
# 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)
- 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)
- 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)
- 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):
"""Test _get_resize_handle_at returns None for element center"""
@@ -403,6 +407,7 @@ class TestGetResizeHandleAt:
# Mock window
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
+
mock_window = Mock()
mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
@@ -426,6 +431,7 @@ class TestGetResizeHandleAt:
# Mock window
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
+
mock_window = Mock()
mock_window.project = Project(name="Test")
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
result = widget._get_resize_handle_at(150, 150)
# 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):
"""Test _get_resize_handle_at handles 90-degree rotated elements"""
@@ -451,6 +457,7 @@ class TestGetResizeHandleAt:
# Mock window
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
+
mock_window = Mock()
mock_window.project = Project(name="Test")
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
# Just verify the method runs without crashing
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:
diff --git a/tests/test_embedded_templates.py b/tests/test_embedded_templates.py
index 18b14a0..eb04c4b 100755
--- a/tests/test_embedded_templates.py
+++ b/tests/test_embedded_templates.py
@@ -15,21 +15,18 @@ def test_embed_template_in_project():
"""Test embedding a template in a project"""
# Create a project
project = Project(name="Test Project")
-
+
# Create a template manager with the project
template_manager = TemplateManager(project=project)
-
+
# Create a simple template
template = Template(name="Test Template", description="A test template")
- placeholder = PlaceholderData(
- placeholder_type="image",
- x=10, y=10, width=100, height=100
- )
+ placeholder = PlaceholderData(placeholder_type="image", x=10, y=10, width=100, height=100)
template.add_element(placeholder)
-
+
# Embed the template
template_manager.embed_template(template)
-
+
# Verify it's embedded
assert "Test Template" in project.embedded_templates
assert project.embedded_templates["Test Template"]["name"] == "Test Template"
@@ -40,22 +37,19 @@ def test_load_embedded_template():
"""Test loading an embedded template"""
# Create a project
project = Project(name="Test Project")
-
+
# Create a template manager with the project
template_manager = TemplateManager(project=project)
-
+
# Create and embed a template
template = Template(name="Test Template", description="A test template")
- placeholder = PlaceholderData(
- placeholder_type="image",
- x=10, y=10, width=100, height=100
- )
+ placeholder = PlaceholderData(placeholder_type="image", x=10, y=10, width=100, height=100)
template.add_element(placeholder)
template_manager.embed_template(template)
-
+
# Load the embedded template
loaded_template = template_manager.load_template("Test Template")
-
+
assert loaded_template.name == "Test Template"
assert loaded_template.description == "A test template"
assert len(loaded_template.elements) == 1
@@ -65,18 +59,18 @@ def test_list_embedded_templates():
"""Test listing embedded templates alongside filesystem templates"""
# Create a project
project = Project(name="Test Project")
-
+
# Create a template manager with the project
template_manager = TemplateManager(project=project)
-
+
# Embed some templates
for i in range(3):
template = Template(name=f"Embedded_{i}")
template_manager.embed_template(template)
-
+
# List all templates
templates = template_manager.list_templates()
-
+
# Check embedded templates are listed with prefix
embedded_templates = [t for t in templates if t.startswith("[Embedded]")]
assert len(embedded_templates) == 3
@@ -89,14 +83,14 @@ def test_embedded_template_priority():
"""Test that embedded templates take priority over filesystem templates"""
# Create a project
project = Project(name="Test Project")
-
+
# Create a template manager with the project
template_manager = TemplateManager(project=project)
-
+
# Embed a template with a common name
embedded_template = Template(name="Common", description="Embedded version")
template_manager.embed_template(embedded_template)
-
+
# Load by name without prefix (should get embedded version)
loaded = template_manager.load_template("Common")
assert loaded.description == "Embedded version"
@@ -106,22 +100,19 @@ def test_serialize_project_with_embedded_templates():
"""Test serializing a project with embedded templates"""
# Create a project
project = Project(name="Test Project")
-
+
# Create a template manager with the project
template_manager = TemplateManager(project=project)
-
+
# Create and embed a template
template = Template(name="Test Template", description="A test template")
- placeholder = PlaceholderData(
- placeholder_type="image",
- x=10, y=10, width=100, height=100
- )
+ placeholder = PlaceholderData(placeholder_type="image", x=10, y=10, width=100, height=100)
template.add_element(placeholder)
template_manager.embed_template(template)
-
+
# Serialize the project
serialized = project.serialize()
-
+
# Verify embedded templates are in serialization
assert "embedded_templates" in serialized
assert "Test Template" in serialized["embedded_templates"]
@@ -133,26 +124,23 @@ def test_deserialize_project_with_embedded_templates():
# Create a project with embedded template
project = Project(name="Test Project")
template_manager = TemplateManager(project=project)
-
+
template = Template(name="Test Template", description="A test template")
- placeholder = PlaceholderData(
- placeholder_type="image",
- x=10, y=10, width=100, height=100
- )
+ placeholder = PlaceholderData(placeholder_type="image", x=10, y=10, width=100, height=100)
template.add_element(placeholder)
template_manager.embed_template(template)
-
+
# Serialize the project
serialized = project.serialize()
-
+
# Create a new project and deserialize
new_project = Project(name="New Project")
new_project.deserialize(serialized)
-
+
# Verify embedded templates were restored
assert "Test Template" in new_project.embedded_templates
assert new_project.embedded_templates["Test Template"]["name"] == "Test Template"
-
+
# Verify we can load the template from the new project
new_template_manager = TemplateManager(project=new_project)
loaded_template = new_template_manager.load_template("Test Template")
@@ -166,21 +154,18 @@ def test_auto_embed_on_apply():
project = Project(name="Test Project")
page = Page()
project.add_page(page)
-
+
# Create a template manager with the project
template_manager = TemplateManager(project=project)
-
+
# Create a template (not embedded yet)
template = Template(name="Auto Embed Test", description="Should auto-embed")
- placeholder = PlaceholderData(
- placeholder_type="image",
- x=10, y=10, width=100, height=100
- )
+ placeholder = PlaceholderData(placeholder_type="image", x=10, y=10, width=100, height=100)
template.add_element(placeholder)
-
+
# Apply template with auto_embed=True (default)
template_manager.apply_template_to_page(template, page)
-
+
# Verify template was auto-embedded
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"""
# Create a project
project = Project(name="Test Project")
-
+
# Create a template manager with the project
template_manager = TemplateManager(project=project)
-
+
# Create a template (not embedded yet)
template = Template(name="Auto Embed Page Test", description="Should auto-embed")
- placeholder = PlaceholderData(
- placeholder_type="image",
- x=10, y=10, width=100, height=100
- )
+ placeholder = PlaceholderData(placeholder_type="image", x=10, y=10, width=100, height=100)
template.add_element(placeholder)
-
+
# Create page from template with auto_embed=True (default)
page = template_manager.create_page_from_template(template, page_number=1)
-
+
# Verify template was auto-embedded
assert "Auto Embed Page Test" in project.embedded_templates
@@ -212,19 +194,19 @@ def test_delete_embedded_template():
"""Test deleting an embedded template"""
# Create a project
project = Project(name="Test Project")
-
+
# Create a template manager with the project
template_manager = TemplateManager(project=project)
-
+
# Embed a template
template = Template(name="To Delete")
template_manager.embed_template(template)
-
+
assert "To Delete" in project.embedded_templates
-
+
# Delete the embedded template
template_manager.delete_template("[Embedded] To Delete")
-
+
assert "To Delete" not in project.embedded_templates
@@ -232,22 +214,19 @@ def test_embedded_template_with_text():
"""Test embedding template with text elements"""
# Create a project
project = Project(name="Test Project")
-
+
# Create a template manager with the project
template_manager = TemplateManager(project=project)
-
+
# Create a template with text
template = Template(name="Text Template")
- textbox = TextBoxData(
- text_content="Sample Text",
- x=10, y=10, width=200, height=50
- )
+ textbox = TextBoxData(text_content="Sample Text", x=10, y=10, width=200, height=50)
template.add_element(textbox)
-
+
# Embed and reload
template_manager.embed_template(template)
loaded = template_manager.load_template("Text Template")
-
+
assert len(loaded.elements) == 1
assert isinstance(loaded.elements[0], TextBoxData)
assert loaded.elements[0].text_content == "Sample Text"
@@ -258,46 +237,38 @@ def test_roundtrip_serialization():
# Create a project with pages and embedded template
project = Project(name="Roundtrip Test")
template_manager = TemplateManager(project=project)
-
+
# Create a template
template = Template(name="Roundtrip Template", page_size_mm=(200, 300))
- placeholder1 = PlaceholderData(
- placeholder_type="image",
- x=10, y=10, width=80, height=80
- )
- placeholder2 = PlaceholderData(
- placeholder_type="image",
- x=110, y=10, width=80, height=80
- )
+ placeholder1 = PlaceholderData(placeholder_type="image", 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(placeholder2)
-
+
# Create a page from this template
page = template_manager.create_page_from_template(template, page_number=1)
project.add_page(page)
-
+
# Serialize
serialized = project.serialize()
-
+
# Create new project and deserialize
new_project = Project(name="New Roundtrip")
new_project.deserialize(serialized)
-
+
# Verify embedded template
assert "Roundtrip Template" in new_project.embedded_templates
-
+
# Verify we can use the template
new_template_manager = TemplateManager(project=new_project)
loaded_template = new_template_manager.load_template("Roundtrip Template")
-
+
assert loaded_template.name == "Roundtrip Template"
assert loaded_template.page_size_mm == (200, 300)
assert len(loaded_template.elements) == 2
-
+
# Create another page from the loaded template
new_page = new_template_manager.create_page_from_template(
- loaded_template,
- page_number=2,
- auto_embed=False # Don't embed again
+ loaded_template, page_number=2, auto_embed=False # Don't embed again
)
assert len(new_page.layout.elements) == 2
diff --git a/tests/test_gl_widget_integration.py b/tests/test_gl_widget_integration.py
index 2de157a..e4cf8d6 100755
--- a/tests/test_gl_widget_integration.py
+++ b/tests/test_gl_widget_integration.py
@@ -21,13 +21,13 @@ class TestGLWidgetInitialization:
qtbot.addWidget(widget)
# Verify mixin state is initialized
- assert hasattr(widget, 'zoom_level')
- assert hasattr(widget, 'pan_offset')
- assert hasattr(widget, 'selected_elements')
- assert hasattr(widget, 'drag_start_pos')
- assert hasattr(widget, 'is_dragging')
- assert hasattr(widget, 'is_panning')
- assert hasattr(widget, 'rotation_mode')
+ assert hasattr(widget, "zoom_level")
+ assert hasattr(widget, "pan_offset")
+ assert hasattr(widget, "selected_elements")
+ assert hasattr(widget, "drag_start_pos")
+ assert hasattr(widget, "is_dragging")
+ assert hasattr(widget, "is_panning")
+ assert hasattr(widget, "rotation_mode")
def test_gl_widget_accepts_drops(self, qtbot):
"""Test GLWidget is configured to accept drops"""
@@ -105,7 +105,7 @@ class TestGLWidgetMixinIntegration:
QPointF(75, 75),
Qt.MouseButton.LeftButton,
Qt.MouseButton.LeftButton,
- Qt.KeyboardModifier.NoModifier
+ Qt.KeyboardModifier.NoModifier,
)
widget.mousePressEvent(event)
@@ -125,7 +125,7 @@ class TestGLWidgetMixinIntegration:
# Begin operation (should be tracked for undo)
widget._begin_move(element)
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)
# End operation
@@ -152,11 +152,8 @@ class TestGLWidgetKeyEvents:
# Create key press event for Escape
from PyQt6.QtGui import QKeyEvent
- event = QKeyEvent(
- QKeyEvent.Type.KeyPress,
- Qt.Key.Key_Escape,
- Qt.KeyboardModifier.NoModifier
- )
+
+ event = QKeyEvent(QKeyEvent.Type.KeyPress, Qt.Key.Key_Escape, Qt.KeyboardModifier.NoModifier)
widget.keyPressEvent(event)
@@ -186,11 +183,8 @@ class TestGLWidgetKeyEvents:
# Create key press event for Tab
from PyQt6.QtGui import QKeyEvent
- event = QKeyEvent(
- QKeyEvent.Type.KeyPress,
- Qt.Key.Key_Tab,
- Qt.KeyboardModifier.NoModifier
- )
+
+ event = QKeyEvent(QKeyEvent.Type.KeyPress, Qt.Key.Key_Tab, Qt.KeyboardModifier.NoModifier)
widget.keyPressEvent(event)
@@ -220,11 +214,8 @@ class TestGLWidgetKeyEvents:
# Create key press event for Delete
from PyQt6.QtGui import QKeyEvent
- event = QKeyEvent(
- QKeyEvent.Type.KeyPress,
- Qt.Key.Key_Delete,
- Qt.KeyboardModifier.NoModifier
- )
+
+ event = QKeyEvent(QKeyEvent.Type.KeyPress, Qt.Key.Key_Delete, Qt.KeyboardModifier.NoModifier)
widget.keyPressEvent(event)
@@ -257,7 +248,7 @@ class TestGLWidgetWithProject:
# Verify we can access project through widget
main_window = widget.window()
- assert hasattr(main_window, 'project')
+ assert hasattr(main_window, "project")
assert main_window.project.name == "Test Project"
assert len(main_window.project.pages) == 1
assert len(main_window.project.pages[0].layout.elements) == 1
@@ -321,6 +312,7 @@ class TestGLWidgetOpenGL:
# Should have NoPartialUpdate set
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
+
assert widget.updateBehavior() == QOpenGLWidget.UpdateBehavior.NoPartialUpdate
diff --git a/tests/test_image_pan_mixin.py b/tests/test_image_pan_mixin.py
index cc81232..0743195 100755
--- a/tests/test_image_pan_mixin.py
+++ b/tests/test_image_pan_mixin.py
@@ -14,6 +14,7 @@ from pyPhotoAlbum.models import ImageData, PlaceholderData
# Create test widget combining necessary mixins
class TestImagePanWidget(ImagePanMixin, ElementSelectionMixin, ViewportMixin, QOpenGLWidget):
"""Test widget combining image pan, selection, and viewport mixins"""
+
def __init__(self):
super().__init__()
self.drag_start_pos = None
diff --git a/tests/test_interaction_command_builders.py b/tests/test_interaction_command_builders.py
index 2b4dcc8..c7933ef 100644
--- a/tests/test_interaction_command_builders.py
+++ b/tests/test_interaction_command_builders.py
@@ -8,7 +8,7 @@ from pyPhotoAlbum.mixins.interaction_command_builders import (
MoveCommandBuilder,
ResizeCommandBuilder,
RotateCommandBuilder,
- ImagePanCommandBuilder
+ ImagePanCommandBuilder,
)
from pyPhotoAlbum.mixins.interaction_validators import InteractionChangeDetector
@@ -22,7 +22,7 @@ class TestMoveCommandBuilder:
element = Mock()
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)
@@ -32,7 +32,7 @@ class TestMoveCommandBuilder:
element = Mock()
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)
@@ -52,7 +52,7 @@ class TestMoveCommandBuilder:
element = Mock()
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)
@@ -65,7 +65,7 @@ class TestMoveCommandBuilder:
element = Mock()
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)
@@ -82,10 +82,7 @@ class TestResizeCommandBuilder:
element.position = (0.0, 0.0)
element.size = (200.0, 200.0)
- start_state = {
- 'position': (0.0, 0.0),
- 'size': (100.0, 100.0)
- }
+ start_state = {"position": (0.0, 0.0), "size": (100.0, 100.0)}
assert builder.can_build(element, start_state)
@@ -96,10 +93,7 @@ class TestResizeCommandBuilder:
element.position = (10.0, 10.0)
element.size = (100.0, 100.0)
- start_state = {
- 'position': (0.0, 0.0),
- 'size': (100.0, 100.0)
- }
+ start_state = {"position": (0.0, 0.0), "size": (100.0, 100.0)}
assert builder.can_build(element, start_state)
@@ -110,10 +104,7 @@ class TestResizeCommandBuilder:
element.position = (10.0, 10.0)
element.size = (200.0, 200.0)
- start_state = {
- 'position': (0.0, 0.0),
- 'size': (100.0, 100.0)
- }
+ start_state = {"position": (0.0, 0.0), "size": (100.0, 100.0)}
assert builder.can_build(element, start_state)
@@ -124,10 +115,7 @@ class TestResizeCommandBuilder:
element.position = (0.0, 0.0)
element.size = (100.0, 100.0)
- start_state = {
- 'position': (0.0, 0.0),
- 'size': (100.0, 100.0)
- }
+ start_state = {"position": (0.0, 0.0), "size": (100.0, 100.0)}
assert not builder.can_build(element, start_state)
@@ -138,10 +126,7 @@ class TestResizeCommandBuilder:
element.position = (10.0, 10.0)
element.size = (200.0, 200.0)
- start_state = {
- 'position': (0.0, 0.0),
- 'size': (100.0, 100.0)
- }
+ start_state = {"position": (0.0, 0.0), "size": (100.0, 100.0)}
command = builder.build(element, start_state)
@@ -158,7 +143,7 @@ class TestRotateCommandBuilder:
element = Mock()
element.rotation = 45.0
- start_state = {'rotation': 0.0}
+ start_state = {"rotation": 0.0}
assert builder.can_build(element, start_state)
@@ -168,7 +153,7 @@ class TestRotateCommandBuilder:
element = Mock()
element.rotation = 0.05
- start_state = {'rotation': 0.0}
+ start_state = {"rotation": 0.0}
assert not builder.can_build(element, start_state)
@@ -178,7 +163,7 @@ class TestRotateCommandBuilder:
element = Mock()
element.rotation = 45.0
- start_state = {'rotation': 0.0}
+ start_state = {"rotation": 0.0}
command = builder.build(element, start_state)
@@ -197,7 +182,7 @@ class TestImagePanCommandBuilder:
element = Mock(spec=ImageData)
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)
@@ -207,7 +192,7 @@ class TestImagePanCommandBuilder:
element = Mock()
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)
@@ -219,7 +204,7 @@ class TestImagePanCommandBuilder:
element = Mock(spec=ImageData)
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)
@@ -231,7 +216,7 @@ class TestImagePanCommandBuilder:
element = Mock(spec=ImageData)
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)
@@ -250,7 +235,7 @@ class TestCommandBuilderIntegration:
element = Mock()
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
assert not builder.can_build(element, start_state)
@@ -261,7 +246,7 @@ class TestCommandBuilderIntegration:
element = Mock()
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)
diff --git a/tests/test_interaction_command_factory.py b/tests/test_interaction_command_factory.py
index d924c1e..60c7837 100644
--- a/tests/test_interaction_command_factory.py
+++ b/tests/test_interaction_command_factory.py
@@ -4,10 +4,7 @@ Unit tests for interaction command factory.
import pytest
from unittest.mock import Mock
-from pyPhotoAlbum.mixins.interaction_command_factory import (
- InteractionCommandFactory,
- InteractionState
-)
+from pyPhotoAlbum.mixins.interaction_command_factory import InteractionCommandFactory, InteractionState
from pyPhotoAlbum.mixins.interaction_command_builders import CommandBuilder
@@ -18,71 +15,49 @@ class TestInteractionState:
"""Test that InteractionState initializes correctly."""
element = Mock()
state = InteractionState(
- element=element,
- interaction_type='move',
- position=(0.0, 0.0),
- size=(100.0, 100.0),
- rotation=0.0
+ element=element, interaction_type="move", position=(0.0, 0.0), size=(100.0, 100.0), rotation=0.0
)
assert state.element == element
- assert state.interaction_type == 'move'
+ assert state.interaction_type == "move"
assert state.position == (0.0, 0.0)
assert state.size == (100.0, 100.0)
assert state.rotation == 0.0
def test_to_dict(self):
"""Test that to_dict returns correct dictionary."""
- state = InteractionState(
- position=(0.0, 0.0),
- size=(100.0, 100.0)
- )
+ state = InteractionState(position=(0.0, 0.0), size=(100.0, 100.0))
result = state.to_dict()
- assert result == {
- 'position': (0.0, 0.0),
- 'size': (100.0, 100.0)
- }
+ assert result == {"position": (0.0, 0.0), "size": (100.0, 100.0)}
def test_to_dict_excludes_none(self):
"""Test that to_dict excludes None values."""
- state = InteractionState(
- position=(0.0, 0.0),
- size=None
- )
+ state = InteractionState(position=(0.0, 0.0), size=None)
result = state.to_dict()
- assert 'position' in result
- assert 'size' not in result
+ assert "position" in result
+ assert "size" not in result
def test_is_valid_with_required_fields(self):
"""Test that is_valid returns True when required fields are present."""
element = Mock()
- state = InteractionState(
- element=element,
- interaction_type='move'
- )
+ state = InteractionState(element=element, interaction_type="move")
assert state.is_valid()
def test_is_valid_without_element(self):
"""Test that is_valid returns False without element."""
- state = InteractionState(
- element=None,
- interaction_type='move'
- )
+ state = InteractionState(element=None, interaction_type="move")
assert not state.is_valid()
def test_is_valid_without_interaction_type(self):
"""Test that is_valid returns False without interaction_type."""
element = Mock()
- state = InteractionState(
- element=element,
- interaction_type=None
- )
+ state = InteractionState(element=element, interaction_type=None)
assert not state.is_valid()
@@ -90,11 +65,7 @@ class TestInteractionState:
"""Test that clear resets all fields."""
element = Mock()
state = InteractionState(
- element=element,
- interaction_type='move',
- position=(0.0, 0.0),
- size=(100.0, 100.0),
- rotation=0.0
+ element=element, interaction_type="move", position=(0.0, 0.0), size=(100.0, 100.0), rotation=0.0
)
state.clear()
@@ -113,19 +84,19 @@ class TestInteractionCommandFactory:
"""Test that factory initializes with default builders."""
factory = InteractionCommandFactory()
- assert factory.has_builder('move')
- assert factory.has_builder('resize')
- assert factory.has_builder('rotate')
- assert factory.has_builder('image_pan')
+ assert factory.has_builder("move")
+ assert factory.has_builder("resize")
+ assert factory.has_builder("rotate")
+ assert factory.has_builder("image_pan")
def test_register_builder(self):
"""Test registering a custom builder."""
factory = InteractionCommandFactory()
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):
"""Test getting list of supported types."""
@@ -133,10 +104,10 @@ class TestInteractionCommandFactory:
types = factory.get_supported_types()
- assert 'move' in types
- assert 'resize' in types
- assert 'rotate' in types
- assert 'image_pan' in types
+ assert "move" in types
+ assert "resize" in types
+ assert "rotate" in types
+ assert "image_pan" in types
def test_create_command_move(self):
"""Test creating a move command."""
@@ -144,9 +115,9 @@ class TestInteractionCommandFactory:
element = Mock()
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
@@ -157,12 +128,9 @@ class TestInteractionCommandFactory:
element.position = (10.0, 10.0)
element.size = (200.0, 200.0)
- start_state = {
- 'position': (0.0, 0.0),
- 'size': (100.0, 100.0)
- }
+ start_state = {"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
@@ -172,9 +140,9 @@ class TestInteractionCommandFactory:
element = Mock()
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
@@ -183,7 +151,7 @@ class TestInteractionCommandFactory:
factory = InteractionCommandFactory()
element = Mock()
- command = factory.create_command('unknown', element, {})
+ command = factory.create_command("unknown", element, {})
assert command is None
captured = capsys.readouterr()
@@ -195,9 +163,9 @@ class TestInteractionCommandFactory:
element = Mock()
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
@@ -211,12 +179,12 @@ class TestInteractionCommandFactory:
custom_builder.can_build.return_value = True
custom_builder.build.return_value = mock_command
- factory.register_builder('custom', custom_builder)
+ factory.register_builder("custom", custom_builder)
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
custom_builder.can_build.assert_called_once()
@@ -232,17 +200,9 @@ class TestInteractionStateIntegration:
element = Mock()
element.position = (10.0, 10.0)
- state = InteractionState(
- element=element,
- interaction_type='move',
- position=(0.0, 0.0)
- )
+ state = InteractionState(element=element, interaction_type="move", position=(0.0, 0.0))
- command = factory.create_command(
- state.interaction_type,
- state.element,
- state.to_dict()
- )
+ command = factory.create_command(state.interaction_type, state.element, state.to_dict())
assert command is not None
@@ -254,7 +214,7 @@ class TestInteractionStateIntegration:
# Begin interaction
state = InteractionState()
state.element = element
- state.interaction_type = 'move'
+ state.interaction_type = "move"
state.position = element.position
assert state.is_valid()
@@ -264,11 +224,7 @@ class TestInteractionStateIntegration:
# Create command
factory = InteractionCommandFactory()
- command = factory.create_command(
- state.interaction_type,
- state.element,
- state.to_dict()
- )
+ command = factory.create_command(state.interaction_type, state.element, state.to_dict())
assert command is not None
diff --git a/tests/test_interaction_undo_mixin.py b/tests/test_interaction_undo_mixin.py
index d6a4c8f..f20dc52 100755
--- a/tests/test_interaction_undo_mixin.py
+++ b/tests/test_interaction_undo_mixin.py
@@ -29,8 +29,8 @@ class TestUndoableInteractionInitialization:
qtbot.addWidget(widget)
# Should have initialized tracking state object
- assert hasattr(widget, '_interaction_state')
- assert hasattr(widget, '_command_factory')
+ assert hasattr(widget, "_interaction_state")
+ assert hasattr(widget, "_command_factory")
# State should be clear initially
assert widget._interaction_state.element is None
@@ -53,7 +53,7 @@ class TestBeginMove:
widget._begin_move(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.size is None
assert widget._interaction_state.rotation is None
@@ -87,7 +87,7 @@ class TestBeginResize:
widget._begin_resize(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.size == (200, 150)
assert widget._interaction_state.rotation is None
@@ -107,7 +107,7 @@ class TestBeginRotate:
widget._begin_rotate(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.size is None
assert widget._interaction_state.rotation == 45.0
@@ -121,17 +121,12 @@ class TestBeginImagePan:
widget = TestUndoableWidget()
qtbot.addWidget(widget)
- element = ImageData(
- image_path="/test.jpg",
- x=100, y=100,
- width=200, height=150,
- crop_info=(0.1, 0.2, 0.8, 0.7)
- )
+ element = ImageData(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)
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)
def test_begin_image_pan_ignores_non_image(self, qtbot):
@@ -151,7 +146,7 @@ class TestBeginImagePan:
class TestEndInteraction:
"""Test _end_interaction method"""
- @patch('pyPhotoAlbum.commands.MoveElementCommand')
+ @patch("pyPhotoAlbum.commands.MoveElementCommand")
def test_end_interaction_creates_move_command(self, mock_cmd_class, qtbot):
"""Test that ending move interaction creates MoveElementCommand"""
widget = TestUndoableWidget()
@@ -177,7 +172,7 @@ class TestEndInteraction:
mock_cmd_class.assert_called_once_with(element, (100, 100), (150, 160))
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):
"""Test that ending resize interaction creates ResizeElementCommand"""
widget = TestUndoableWidget()
@@ -204,12 +199,12 @@ class TestEndInteraction:
element,
(100, 100), # old position
(200, 150), # old size
- (90, 90), # new position
- (250, 200) # new size
+ (90, 90), # new position
+ (250, 200), # new size
)
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):
"""Test that ending rotate interaction creates RotateElementCommand"""
widget = TestUndoableWidget()
@@ -235,7 +230,7 @@ class TestEndInteraction:
mock_cmd_class.assert_called_once_with(element, 0, 90)
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):
"""Test that ending image pan interaction creates AdjustImageCropCommand"""
widget = TestUndoableWidget()
@@ -248,9 +243,11 @@ class TestEndInteraction:
element = ImageData(
image_path="/test.jpg",
- x=100, y=100,
- width=200, height=150,
- crop_info=(0.0, 0.0, 1.0, 1.0) # Tuple format used in code
+ x=100,
+ y=100,
+ width=200,
+ height=150,
+ crop_info=(0.0, 0.0, 1.0, 1.0), # Tuple format used in code
)
widget._begin_image_pan(element)
@@ -391,12 +388,7 @@ class TestClearInteractionState:
widget = TestUndoableWidget()
qtbot.addWidget(widget)
- element = ImageData(
- image_path="/test.jpg",
- x=100, y=100,
- width=200, height=150,
- crop_info=(0.0, 0.0, 1.0, 1.0)
- )
+ element = ImageData(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)
# After begin_image_pan, crop_info should be stored
@@ -452,10 +444,10 @@ class TestInteractionEdgeCases:
widget._begin_rotate(element)
# 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
- @patch('pyPhotoAlbum.commands.ResizeElementCommand')
+ @patch("pyPhotoAlbum.commands.ResizeElementCommand")
def test_resize_with_only_size_change(self, mock_cmd_class, qtbot):
"""Test resize command when only size changes (position same)"""
widget = TestUndoableWidget()
@@ -479,7 +471,7 @@ class TestInteractionEdgeCases:
assert mock_cmd_class.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):
"""Test resize command when only position changes (size same)"""
widget = TestUndoableWidget()
diff --git a/tests/test_interaction_undo_refactored.py b/tests/test_interaction_undo_refactored.py
index c9496db..a39417a 100644
--- a/tests/test_interaction_undo_refactored.py
+++ b/tests/test_interaction_undo_refactored.py
@@ -31,8 +31,8 @@ class TestUndoableInteractionMixinRefactored:
"""Test that mixin initializes correctly."""
widget = MockWidget()
- assert hasattr(widget, '_command_factory')
- assert hasattr(widget, '_interaction_state')
+ assert hasattr(widget, "_command_factory")
+ assert hasattr(widget, "_interaction_state")
def test_begin_move(self):
"""Test beginning a move interaction."""
@@ -43,7 +43,7 @@ class TestUndoableInteractionMixinRefactored:
widget._begin_move(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)
def test_begin_resize(self):
@@ -56,7 +56,7 @@ class TestUndoableInteractionMixinRefactored:
widget._begin_resize(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.size == (100.0, 100.0)
@@ -69,7 +69,7 @@ class TestUndoableInteractionMixinRefactored:
widget._begin_rotate(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
def test_begin_image_pan(self):
@@ -83,7 +83,7 @@ class TestUndoableInteractionMixinRefactored:
widget._begin_image_pan(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)
def test_begin_image_pan_non_image_element(self):
@@ -211,7 +211,7 @@ class TestUndoableInteractionMixinRefactored:
"""Test that ending interaction without project is safe."""
widget = MockWidget()
# Remove the project attribute entirely
- delattr(widget._mock_window, 'project')
+ delattr(widget._mock_window, "project")
element = Mock(spec=BaseLayoutElement)
element.position = (0.0, 0.0)
diff --git a/tests/test_interaction_validators.py b/tests/test_interaction_validators.py
index 733bc7c..5c8fa85 100644
--- a/tests/test_interaction_validators.py
+++ b/tests/test_interaction_validators.py
@@ -3,10 +3,7 @@ Unit tests for interaction validators and change detection.
"""
import pytest
-from pyPhotoAlbum.mixins.interaction_validators import (
- ChangeValidator,
- InteractionChangeDetector
-)
+from pyPhotoAlbum.mixins.interaction_validators import ChangeValidator, InteractionChangeDetector
class TestChangeValidator:
@@ -104,10 +101,10 @@ class TestInteractionChangeDetector:
change = detector.detect_position_change(old_pos, new_pos)
assert change is not None
- assert change['old_position'] == old_pos
- assert change['new_position'] == new_pos
- assert change['delta_x'] == 5.0
- assert change['delta_y'] == 3.0
+ assert change["old_position"] == old_pos
+ assert change["new_position"] == new_pos
+ assert change["delta_x"] == 5.0
+ assert change["delta_y"] == 3.0
def test_detect_position_change_insignificant(self):
"""Test that insignificant position changes return None."""
@@ -128,10 +125,10 @@ class TestInteractionChangeDetector:
change = detector.detect_size_change(old_size, new_size)
assert change is not None
- assert change['old_size'] == old_size
- assert change['new_size'] == new_size
- assert change['delta_width'] == 50.0
- assert change['delta_height'] == 20.0
+ assert change["old_size"] == old_size
+ assert change["new_size"] == new_size
+ assert change["delta_width"] == 50.0
+ assert change["delta_height"] == 20.0
def test_detect_rotation_change_significant(self):
"""Test detecting significant rotation changes."""
@@ -142,9 +139,9 @@ class TestInteractionChangeDetector:
change = detector.detect_rotation_change(old_rotation, new_rotation)
assert change is not None
- assert change['old_rotation'] == old_rotation
- assert change['new_rotation'] == new_rotation
- assert change['delta_angle'] == 45.0
+ assert change["old_rotation"] == old_rotation
+ assert change["new_rotation"] == new_rotation
+ assert change["delta_angle"] == 45.0
def test_detect_crop_change_significant(self):
"""Test detecting significant crop changes."""
@@ -155,13 +152,13 @@ class TestInteractionChangeDetector:
change = detector.detect_crop_change(old_crop, new_crop)
assert change is not None
- assert change['old_crop'] == old_crop
- assert change['new_crop'] == new_crop
+ assert change["old_crop"] == old_crop
+ assert change["new_crop"] == new_crop
# Use approximate comparison for floating point
- assert abs(change['delta'][0] - 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'][3] - (-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"][2] - (-0.1)) < 0.001
+ assert abs(change["delta"][3] - (-0.1)) < 0.001
def test_custom_threshold(self):
"""Test using custom threshold values."""
diff --git a/tests/test_merge.py b/tests/test_merge.py
index 0f4a2c0..99cdedf 100755
--- a/tests/test_merge.py
+++ b/tests/test_merge.py
@@ -26,10 +26,7 @@ def create_base_project():
# Add a page with text
page = Page(page_number=1)
- text = TextBoxData(
- text_content="Original Text",
- x=10, y=10, width=100, height=50
- )
+ text = TextBoxData(text_content="Original Text", x=10, y=10, width=100, height=50)
page.layout.add_element(text)
project.add_page(page)
@@ -145,7 +142,7 @@ def test_different_project_concatenation():
print("\n3. Concatenating projects...")
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" ✓ Combined name: {merged_data['name']}")
@@ -252,6 +249,7 @@ def run_all_tests():
except Exception as e:
print(f"\n❌ Test '{name}' FAILED with exception: {e}")
import traceback
+
traceback.print_exc()
results.append((name, False))
@@ -293,20 +291,20 @@ def test_merge_helper_add_missing_pages():
data_b = project_b.serialize()
# 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.detect_conflicts(data_a, data_b)
# Test _add_missing_pages
merged_data = data_a.copy()
- merged_data['pages'] = list(data_a['pages'])
- initial_page_count = len(merged_data['pages'])
+ merged_data["pages"] = list(data_a["pages"])
+ initial_page_count = len(merged_data["pages"])
merge_manager._add_missing_pages(merged_data, data_b)
# 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"\n{'=' * 60}")
@@ -332,7 +330,7 @@ def test_merge_helper_is_element_in_conflict():
element_uuid="elem-456",
our_version={},
their_version={},
- description="Test conflict"
+ description="Test conflict",
)
merge_manager.conflicts.append(conflict)
@@ -365,50 +363,24 @@ def test_merge_helper_merge_by_timestamp():
older = (now - timedelta(hours=1)).isoformat()
newer = (now + timedelta(hours=1)).isoformat()
- our_page = {
- 'layout': {
- 'elements': [
- {
- 'uuid': 'elem-1',
- 'text_content': 'Older version',
- 'last_modified': older
- }
- ]
- }
- }
+ our_page = {"layout": {"elements": [{"uuid": "elem-1", "text_content": "Older version", "last_modified": older}]}}
- our_elem = our_page['layout']['elements'][0]
- their_elem = {
- 'uuid': 'elem-1',
- 'text_content': 'Newer version',
- 'last_modified': newer
- }
+ our_elem = our_page["layout"]["elements"][0]
+ their_elem = {"uuid": "elem-1", "text_content": "Newer version", "last_modified": newer}
# 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")
# Test: our version is newer, should not replace
- our_page['layout']['elements'][0] = {
- 'uuid': 'elem-2',
- 'text_content': 'Our newer version',
- 'last_modified': newer
- }
- their_elem_older = {
- 'uuid': 'elem-2',
- 'text_content': 'Their older version',
- 'last_modified': older
- }
+ our_page["layout"]["elements"][0] = {"uuid": "elem-2", "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(
- our_page, 'elem-2',
- their_elem_older,
- our_page['layout']['elements'][0]
- )
+ merge_manager._merge_by_timestamp(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"\n{'=' * 60}")
@@ -430,73 +402,49 @@ def test_merge_helper_merge_element():
# Setup: page with one element
our_page = {
- 'uuid': 'page-1',
- 'layout': {
- 'elements': [
- {
- 'uuid': 'elem-existing',
- 'text_content': 'Existing',
- 'last_modified': now
- }
- ]
- }
+ "uuid": "page-1",
+ "layout": {"elements": [{"uuid": "elem-existing", "text_content": "Existing", "last_modified": now}]},
}
- our_elements = {
- 'elem-existing': our_page['layout']['elements'][0]
- }
+ our_elements = {"elem-existing": our_page["layout"]["elements"][0]}
# Test 1: Adding new element
- their_new_elem = {
- 'uuid': 'elem-new',
- 'text_content': 'New element',
- 'last_modified': now
- }
+ their_new_elem = {"uuid": "elem-new", "text_content": "New element", "last_modified": now}
merge_manager._merge_element(
- our_page=our_page,
- page_uuid='page-1',
- their_elem=their_new_elem,
- our_elements=our_elements
+ our_page=our_page, page_uuid="page-1", their_elem=their_new_elem, our_elements=our_elements
)
- assert len(our_page['layout']['elements']) == 2
- assert our_page['layout']['elements'][1]['uuid'] == 'elem-new'
+ assert len(our_page["layout"]["elements"]) == 2
+ assert our_page["layout"]["elements"][1]["uuid"] == "elem-new"
print(f" ✓ Correctly added new element")
# Test 2: Element in conflict should be skipped
from pyPhotoAlbum.merge_manager import ConflictInfo, ConflictType
- conflict_elem = {
- 'uuid': 'elem-conflict',
- 'text_content': 'Conflict element',
- 'last_modified': now
- }
+ conflict_elem = {"uuid": "elem-conflict", "text_content": "Conflict element", "last_modified": now}
conflict = ConflictInfo(
conflict_type=ConflictType.ELEMENT_MODIFIED_BOTH,
- page_uuid='page-1',
- element_uuid='elem-conflict',
+ page_uuid="page-1",
+ element_uuid="elem-conflict",
our_version={},
their_version={},
- description="Test"
+ description="Test",
)
merge_manager.conflicts.append(conflict)
- our_elements['elem-conflict'] = {'uuid': 'elem-conflict', 'text_content': 'Ours'}
- our_page['layout']['elements'].append(our_elements['elem-conflict'])
+ our_elements["elem-conflict"] = {"uuid": "elem-conflict", "text_content": "Ours"}
+ 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(
- our_page=our_page,
- page_uuid='page-1',
- their_elem=conflict_elem,
- our_elements=our_elements
+ our_page=our_page, page_uuid="page-1", their_elem=conflict_elem, our_elements=our_elements
)
# 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"\n{'=' * 60}")
diff --git a/tests/test_migration.py b/tests/test_migration.py
index b771c6a..bc0c8c0 100755
--- a/tests/test_migration.py
+++ b/tests/test_migration.py
@@ -49,29 +49,16 @@ def create_v2_project_json():
"rotation": 0,
"z_index": 0,
"text_content": "Hello v2.0",
- "font_settings": {
- "family": "Arial",
- "size": 12,
- "color": [0, 0, 0]
- },
- "alignment": "left"
+ "font_settings": {"family": "Arial", "size": 12, "color": [0, 0, 0]},
+ "alignment": "left",
}
],
- "snapping_system": {
- "snap_threshold_mm": 5.0,
- "grid_size_mm": 10.0
- }
- }
+ "snapping_system": {"snap_threshold_mm": 5.0, "grid_size_mm": 10.0},
+ },
}
],
- "history": {
- "undo_stack": [],
- "redo_stack": [],
- "max_history": 100
- },
- "asset_manager": {
- "reference_counts": {}
- }
+ "history": {"undo_stack": [], "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}")
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)
- 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" ✓ Version: {v2_data['data_version']}")
@@ -107,9 +94,9 @@ def test_migration():
print(f"\n3. Verifying migration to v3.0...")
# Check project-level fields
- assert hasattr(project, 'project_id'), "Missing project_id"
- assert hasattr(project, 'created'), "Missing created timestamp"
- assert hasattr(project, 'last_modified'), "Missing last_modified timestamp"
+ assert hasattr(project, "project_id"), "Missing project_id"
+ assert hasattr(project, "created"), "Missing created timestamp"
+ assert hasattr(project, "last_modified"), "Missing last_modified timestamp"
print(f" ✓ Project has project_id: {project.project_id}")
print(f" ✓ Project has created: {project.created}")
print(f" ✓ Project has last_modified: {project.last_modified}")
@@ -117,20 +104,20 @@ def test_migration():
# Check page-level fields
assert len(project.pages) > 0, "No pages in project"
page = project.pages[0]
- assert hasattr(page, 'uuid'), "Page missing uuid"
- assert hasattr(page, 'created'), "Page missing created"
- assert hasattr(page, 'last_modified'), "Page missing last_modified"
- assert hasattr(page, 'deleted'), "Page missing deleted flag"
+ assert hasattr(page, "uuid"), "Page missing uuid"
+ assert hasattr(page, "created"), "Page missing created"
+ assert hasattr(page, "last_modified"), "Page missing last_modified"
+ assert hasattr(page, "deleted"), "Page missing deleted flag"
print(f" ✓ Page 1 has uuid: {page.uuid}")
print(f" ✓ Page 1 has timestamps and deletion tracking")
# Check element-level fields
assert len(page.layout.elements) > 0, "No elements in page"
element = page.layout.elements[0]
- assert hasattr(element, 'uuid'), "Element missing uuid"
- assert hasattr(element, 'created'), "Element missing created"
- assert hasattr(element, 'last_modified'), "Element missing last_modified"
- assert hasattr(element, 'deleted'), "Element missing deleted flag"
+ assert hasattr(element, "uuid"), "Element missing uuid"
+ assert hasattr(element, "created"), "Element missing created"
+ assert hasattr(element, "last_modified"), "Element missing last_modified"
+ assert hasattr(element, "deleted"), "Element missing deleted flag"
print(f" ✓ Element has uuid: {element.uuid}")
print(f" ✓ Element has timestamps and deletion tracking")
@@ -142,14 +129,14 @@ def test_migration():
print(f" ✓ Saved to: {v3_file}")
# Verify v3.0 file structure
- with zipfile.ZipFile(v3_file, 'r') as zipf:
- project_json = zipf.read('project.json').decode('utf-8')
+ with zipfile.ZipFile(v3_file, "r") as zipf:
+ project_json = zipf.read("project.json").decode("utf-8")
v3_data = json.loads(project_json)
- assert v3_data.get('data_version') == "3.0", "Wrong version"
- assert 'project_id' in v3_data, "Missing project_id 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 v3_data.get("data_version") == "3.0", "Wrong version"
+ assert "project_id" in v3_data, "Missing project_id 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"
print(f" ✓ Saved file version: {v3_data.get('data_version')}")
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"{'=' * 60}\n")
import traceback
+
traceback.print_exc()
return False
diff --git a/tests/test_models.py b/tests/test_models.py
index 8f878b0..264a02e 100755
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -30,15 +30,7 @@ class TestImageData:
def test_initialization_with_parameters(self, temp_image_file):
"""Test ImageData initialization with custom parameters"""
- 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
- )
+ 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)
assert img.image_path == temp_image_file
assert img.position == (10.0, 20.0)
assert img.size == (200.0, 150.0)
@@ -53,15 +45,7 @@ class TestImageData:
def test_serialization(self, temp_image_file):
"""Test ImageData serialization to dictionary"""
- 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
- )
+ 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)
data = img.serialize()
assert data["type"] == "image"
@@ -81,7 +65,7 @@ class TestImageData:
"rotation": 90.0,
"z_index": 7,
"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)
@@ -118,7 +102,7 @@ class TestImageData:
height=200.0,
rotation=0, # Visual rotation should be 0 for images
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
@@ -170,7 +154,7 @@ class TestPlaceholderData:
width=150.0,
height=100.0,
rotation=10.0,
- z_index=4
+ z_index=4,
)
assert placeholder.placeholder_type == "text"
assert placeholder.default_content == "Sample"
@@ -189,7 +173,7 @@ class TestPlaceholderData:
width=200.0,
height=150.0,
rotation=20.0,
- z_index=2
+ z_index=2,
)
data = placeholder.serialize()
@@ -210,7 +194,7 @@ class TestPlaceholderData:
"rotation": 45.0,
"z_index": 6,
"placeholder_type": "text",
- "default_content": "Default Text"
+ "default_content": "Default Text",
}
placeholder.deserialize(data)
@@ -243,7 +227,7 @@ class TestPlaceholderData:
width=300.0,
height=250.0,
rotation=60.0,
- z_index=8
+ z_index=8,
)
data = original.serialize()
restored = PlaceholderData()
@@ -283,7 +267,7 @@ class TestTextBoxData:
width=180.0,
height=60.0,
rotation=5.0,
- z_index=3
+ z_index=3,
)
assert textbox.text_content == "Hello World"
assert textbox.font_settings == font_settings
@@ -305,7 +289,7 @@ class TestTextBoxData:
width=220.0,
height=80.0,
rotation=15.0,
- z_index=5
+ z_index=5,
)
data = textbox.serialize()
@@ -329,7 +313,7 @@ class TestTextBoxData:
"z_index": 7,
"text_content": "Deserialized Text",
"font_settings": font_settings,
- "alignment": "justify"
+ "alignment": "justify",
}
textbox.deserialize(data)
@@ -366,7 +350,7 @@ class TestTextBoxData:
width=320.0,
height=120.0,
rotation=25.0,
- z_index=9
+ z_index=9,
)
data = original.serialize()
restored = TextBoxData()
diff --git a/tests/test_mouse_interaction_mixin.py b/tests/test_mouse_interaction_mixin.py
index dcea02a..de57774 100755
--- a/tests/test_mouse_interaction_mixin.py
+++ b/tests/test_mouse_interaction_mixin.py
@@ -28,7 +28,7 @@ class TestMouseInteractionWidget(
ElementSelectionMixin,
ViewportMixin,
UndoableInteractionMixin,
- QOpenGLWidget
+ QOpenGLWidget,
):
"""Test widget combining mouse interaction with other required mixins"""
@@ -49,9 +49,9 @@ class TestMouseInteractionInitialization:
qtbot.addWidget(widget)
# Should have initialized state
- assert hasattr(widget, 'drag_start_pos')
- assert hasattr(widget, 'is_dragging')
- assert hasattr(widget, 'is_panning')
+ assert hasattr(widget, "drag_start_pos")
+ assert hasattr(widget, "is_dragging")
+ assert hasattr(widget, "is_panning")
assert widget.drag_start_pos is None
assert widget.is_dragging is False
assert widget.is_panning is False
@@ -123,8 +123,11 @@ class TestMousePressEvent:
# Create image element with crop info
element = ImageData(
image_path="/test.jpg",
- x=50, 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)
+ x=50,
+ 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()
@@ -205,7 +208,7 @@ class TestMouseMoveEvent:
event.buttons = Mock(return_value=Qt.MouseButton.NoButton)
# 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.mouseMoveEvent(event)
@@ -295,9 +298,11 @@ class TestMouseMoveEvent:
# Create image element with crop info
element = ImageData(
image_path="/test.jpg",
- x=100, y=100,
- width=100, height=100,
- crop_info=(0.0, 0.0, 1.0, 1.0) # crop_info is a tuple (x, y, width, height)
+ x=100,
+ y=100,
+ 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)
@@ -377,9 +382,11 @@ class TestMouseDoubleClickEvent:
# Create text element with correct constructor
text_element = TextBoxData(
text_content="Test",
- x=100, y=100,
- width=100, height=50,
- font_settings={"family": "Arial", "size": 12, "color": (0, 0, 0)}
+ x=100,
+ y=100,
+ width=100,
+ height=50,
+ font_settings={"family": "Arial", "size": 12, "color": (0, 0, 0)},
)
# Mock _edit_text_element method
@@ -394,7 +401,7 @@ class TestMouseDoubleClickEvent:
QPointF(125, 125),
Qt.MouseButton.LeftButton,
Qt.MouseButton.LeftButton,
- Qt.KeyboardModifier.NoModifier
+ Qt.KeyboardModifier.NoModifier,
)
widget.mouseDoubleClickEvent(event)
@@ -419,7 +426,7 @@ class TestMouseDoubleClickEvent:
QPointF(125, 125),
Qt.MouseButton.LeftButton,
Qt.MouseButton.LeftButton,
- Qt.KeyboardModifier.NoModifier
+ Qt.KeyboardModifier.NoModifier,
)
widget.mouseDoubleClickEvent(event)
diff --git a/tests/test_multiselect.py b/tests/test_multiselect.py
index f24b7be..df92d26 100755
--- a/tests/test_multiselect.py
+++ b/tests/test_multiselect.py
@@ -16,6 +16,7 @@ from pyPhotoAlbum.page_layout import PageLayout
# Create a minimal test widget class that doesn't require full GLWidget initialization
class MultiSelectTestWidget(ElementSelectionMixin, RenderingMixin, QOpenGLWidget):
"""Widget combining necessary mixins for multiselect testing"""
+
def __init__(self):
super().__init__()
self._page_renderers = []
@@ -69,7 +70,7 @@ def test_multiselect_visual_feedback(qtbot):
print("\nTest 1: Single selection")
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)
for selected_elem in widget.selected_elements:
widget._draw_selection_handles(selected_elem)
@@ -82,7 +83,7 @@ def test_multiselect_visual_feedback(qtbot):
print("\nTest 2: Multiple selection (2 elements)")
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:
widget._draw_selection_handles(selected_elem)
@@ -95,7 +96,7 @@ def test_multiselect_visual_feedback(qtbot):
print("\nTest 3: Multiple selection (3 elements)")
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:
widget._draw_selection_handles(selected_elem)
@@ -108,7 +109,7 @@ def test_multiselect_visual_feedback(qtbot):
print("\nTest 4: No selection")
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:
widget._draw_selection_handles(selected_elem)
@@ -120,15 +121,17 @@ def test_multiselect_visual_feedback(qtbot):
widget.selected_elements = {element2}
# Mock OpenGL functions
- with patch('pyPhotoAlbum.gl_widget.glColor3f'), \
- patch('pyPhotoAlbum.gl_widget.glLineWidth'), \
- patch('pyPhotoAlbum.gl_widget.glBegin'), \
- patch('pyPhotoAlbum.gl_widget.glEnd'), \
- patch('pyPhotoAlbum.gl_widget.glVertex2f'), \
- patch('pyPhotoAlbum.gl_widget.glPushMatrix'), \
- patch('pyPhotoAlbum.gl_widget.glPopMatrix'), \
- patch('pyPhotoAlbum.gl_widget.glTranslatef'), \
- patch('pyPhotoAlbum.gl_widget.glRotatef'):
+ with (
+ patch("pyPhotoAlbum.gl_widget.glColor3f"),
+ patch("pyPhotoAlbum.gl_widget.glLineWidth"),
+ patch("pyPhotoAlbum.gl_widget.glBegin"),
+ patch("pyPhotoAlbum.gl_widget.glEnd"),
+ patch("pyPhotoAlbum.gl_widget.glVertex2f"),
+ patch("pyPhotoAlbum.gl_widget.glPushMatrix"),
+ patch("pyPhotoAlbum.gl_widget.glPopMatrix"),
+ patch("pyPhotoAlbum.gl_widget.glTranslatef"),
+ patch("pyPhotoAlbum.gl_widget.glRotatef"),
+ ):
# Call the actual method
widget._draw_selection_handles(element2)
diff --git a/tests/test_page_layout.py b/tests/test_page_layout.py
index bb58027..8283024 100755
--- a/tests/test_page_layout.py
+++ b/tests/test_page_layout.py
@@ -174,7 +174,7 @@ class TestPageLayoutDeserialization:
"elements": [],
"grid_layout": None,
"snapping_system": {},
- "show_snap_lines": False
+ "show_snap_lines": False,
}
layout.deserialize(data)
@@ -198,7 +198,7 @@ class TestPageLayoutDeserialization:
"size": (100, 100),
"rotation": 0,
"z_index": 0,
- "crop_info": (0, 0, 1, 1)
+ "crop_info": (0, 0, 1, 1),
},
{
"type": "placeholder",
@@ -207,7 +207,7 @@ class TestPageLayoutDeserialization:
"position": (30, 40),
"size": (80, 80),
"rotation": 0,
- "z_index": 1
+ "z_index": 1,
},
{
"type": "textbox",
@@ -217,9 +217,9 @@ class TestPageLayoutDeserialization:
"position": (50, 60),
"size": (120, 40),
"rotation": 0,
- "z_index": 2
- }
- ]
+ "z_index": 2,
+ },
+ ],
}
layout.deserialize(data)
@@ -242,7 +242,7 @@ class TestPageLayoutDeserialization:
"size": (100, 100),
"rotation": 0,
"z_index": 5, # Higher z_index
- "crop_info": (0, 0, 1, 1)
+ "crop_info": (0, 0, 1, 1),
},
{
"type": "placeholder",
@@ -251,9 +251,9 @@ class TestPageLayoutDeserialization:
"position": (30, 40),
"size": (80, 80),
"rotation": 0,
- "z_index": 1 # Lower z_index - should be first
- }
- ]
+ "z_index": 1, # Lower z_index - should be first
+ },
+ ],
}
layout.deserialize(data)
@@ -269,12 +269,7 @@ class TestPageLayoutDeserialization:
data = {
"size": (210, 297),
"elements": [],
- "grid_layout": {
- "rows": 2,
- "columns": 3,
- "spacing": 12.5,
- "merged_cells": [(0, 0), (1, 1)]
- }
+ "grid_layout": {"rows": 2, "columns": 3, "spacing": 12.5, "merged_cells": [(0, 0), (1, 1)]},
}
layout.deserialize(data)
@@ -291,10 +286,7 @@ class TestPageLayoutDeserialization:
data = {
"size": (210, 297),
"elements": [
- {
- "type": "unknown_type",
- "position": (10, 20)
- },
+ {"type": "unknown_type", "position": (10, 20)},
{
"type": "image",
"image_path": "test.jpg",
@@ -302,9 +294,9 @@ class TestPageLayoutDeserialization:
"size": (100, 100),
"rotation": 0,
"z_index": 0,
- "crop_info": (0, 0, 1, 1)
- }
- ]
+ "crop_info": (0, 0, 1, 1),
+ },
+ ],
}
layout.deserialize(data)
@@ -430,12 +422,7 @@ class TestGridLayoutClass:
def test_grid_deserialization(self):
"""Test GridLayout deserialization"""
grid = GridLayout()
- data = {
- "rows": 4,
- "columns": 5,
- "spacing": 8.5,
- "merged_cells": [(1, 2), (3, 3)]
- }
+ data = {"rows": 4, "columns": 5, "spacing": 8.5, "merged_cells": [(1, 2), (3, 3)]}
grid.deserialize(data)
diff --git a/tests/test_page_navigation_mixin.py b/tests/test_page_navigation_mixin.py
index 3314ef7..c4ad904 100755
--- a/tests/test_page_navigation_mixin.py
+++ b/tests/test_page_navigation_mixin.py
@@ -15,6 +15,7 @@ from pyPhotoAlbum.models import GhostPageData
# Create test widget combining necessary mixins
class TestPageNavWidget(PageNavigationMixin, ViewportMixin, QOpenGLWidget):
"""Test widget combining page navigation and viewport mixins"""
+
pass
@@ -149,9 +150,7 @@ class TestGetPagePositions:
mock_window.project.pages = [page]
# Mock calculate_page_layout_with_ghosts
- mock_window.project.calculate_page_layout_with_ghosts = Mock(return_value=[
- ('page', page, 0)
- ])
+ mock_window.project.calculate_page_layout_with_ghosts = Mock(return_value=[("page", page, 0)])
widget.window = Mock(return_value=mock_window)
@@ -159,7 +158,7 @@ class TestGetPagePositions:
# Should have one page entry
assert len(result) >= 1
- assert result[0][0] == 'page'
+ assert result[0][0] == "page"
assert result[0][1] is page
def test_get_page_positions_includes_ghosts(self, qtbot):
@@ -177,10 +176,9 @@ class TestGetPagePositions:
mock_window.project.pages = [page]
# Mock with ghost page
- mock_window.project.calculate_page_layout_with_ghosts = Mock(return_value=[
- ('page', page, 0),
- ('ghost', None, 1)
- ])
+ mock_window.project.calculate_page_layout_with_ghosts = Mock(
+ return_value=[("page", page, 0), ("ghost", None, 1)]
+ )
widget.window = Mock(return_value=mock_window)
@@ -189,8 +187,8 @@ class TestGetPagePositions:
# Should have page + ghost
assert len(result) >= 2
page_types = [r[0] for r in result]
- assert 'page' in page_types
- assert 'ghost' in page_types
+ assert "page" in page_types
+ assert "ghost" in page_types
class TestCheckGhostPageClick:
@@ -217,7 +215,7 @@ class TestCheckGhostPageClick:
result = widget._check_ghost_page_click(100, 100)
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):
"""Test clicking on ghost page creates new page"""
widget = TestPageNavWidget()
@@ -238,9 +236,7 @@ class TestCheckGhostPageClick:
# Mock _get_page_positions to return a ghost
ghost = GhostPageData(page_size=(210, 297))
- widget._get_page_positions = Mock(return_value=[
- ('ghost', ghost, 100)
- ])
+ widget._get_page_positions = Mock(return_value=[("ghost", ghost, 100)])
# Mock PageRenderer to say click is in page
mock_renderer_instance = Mock()
@@ -257,7 +253,7 @@ class TestCheckGhostPageClick:
assert len(mock_window.project.pages) == 1
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):
"""Test clicking outside ghost page returns False"""
widget = TestPageNavWidget()
@@ -273,9 +269,7 @@ class TestCheckGhostPageClick:
mock_window.project.pages = []
ghost = GhostPageData(page_size=(210, 297))
- widget._get_page_positions = Mock(return_value=[
- ('ghost', ghost, 100)
- ])
+ widget._get_page_positions = Mock(return_value=[("ghost", ghost, 100)])
# Mock renderer to say click is NOT in page
mock_renderer_instance = Mock()
diff --git a/tests/test_page_ops_mixin.py b/tests/test_page_ops_mixin.py
index 46ef0bb..e47331d 100755
--- a/tests/test_page_ops_mixin.py
+++ b/tests/test_page_ops_mixin.py
@@ -98,11 +98,7 @@ class TestGetMostVisiblePageIndex:
renderer3 = Mock()
renderer3.screen_y = 800
- window.gl_widget._page_renderers = [
- (renderer1, page1),
- (renderer2, page2),
- (renderer3, page3)
- ]
+ window.gl_widget._page_renderers = [(renderer1, page1), (renderer2, page2), (renderer3, page3)]
result = window._get_most_visible_page_index()
# Page 2 (index 1) should be closest to viewport center
@@ -122,10 +118,7 @@ class TestGetMostVisiblePageIndex:
renderer_orphan = Mock()
renderer_orphan.screen_y = 50 # Closer to center
- window.gl_widget._page_renderers = [
- (renderer1, page1),
- (renderer_orphan, orphan_page) # Not in project.pages
- ]
+ window.gl_widget._page_renderers = [(renderer1, page1), (renderer_orphan, orphan_page)] # Not in project.pages
window.gl_widget.current_page_index = 0
result = window._get_most_visible_page_index()
@@ -222,11 +215,7 @@ class TestToggleDoubleSpread:
renderer3 = Mock()
renderer3.screen_y = 800
- window.gl_widget._page_renderers = [
- (renderer1, page1),
- (renderer2, page2),
- (renderer3, page3)
- ]
+ window.gl_widget._page_renderers = [(renderer1, page1), (renderer2, page2), (renderer3, page3)]
window.toggle_double_spread()
@@ -340,11 +329,7 @@ class TestAddPage:
renderer3 = Mock()
renderer3.screen_y = 800
- window.gl_widget._page_renderers = [
- (renderer1, page1),
- (renderer2, page2),
- (renderer3, page3)
- ]
+ window.gl_widget._page_renderers = [(renderer1, page1), (renderer2, page2), (renderer3, page3)]
window.add_page()
@@ -387,11 +372,7 @@ class TestAddPage:
renderer3 = Mock()
renderer3.screen_y = 1500
- window.gl_widget._page_renderers = [
- (renderer1, page1),
- (renderer2, page2),
- (renderer3, page3)
- ]
+ window.gl_widget._page_renderers = [(renderer1, page1), (renderer2, page2), (renderer3, page3)]
window.add_page()
@@ -458,11 +439,7 @@ class TestRemovePage:
renderer3 = Mock()
renderer3.screen_y = -300 # Page 3 is most visible
- window.gl_widget._page_renderers = [
- (renderer1, page1),
- (renderer2, page2),
- (renderer3, page3)
- ]
+ window.gl_widget._page_renderers = [(renderer1, page1), (renderer2, page2), (renderer3, page3)]
window.remove_page()
diff --git a/tests/test_page_renderer.py b/tests/test_page_renderer.py
index 396737b..02c06fe 100755
--- a/tests/test_page_renderer.py
+++ b/tests/test_page_renderer.py
@@ -8,164 +8,124 @@ from pyPhotoAlbum.page_renderer import PageRenderer
class TestPageRendererCoordinates:
"""Test coordinate transformation methods"""
-
+
def test_page_to_screen_no_zoom_no_pan(self):
"""Test page_to_screen conversion with zoom=1.0 and no pan"""
renderer = PageRenderer(
- page_width_mm=210.0,
- page_height_mm=297.0,
- screen_x=100.0,
- screen_y=200.0,
- dpi=96,
- zoom=1.0
+ page_width_mm=210.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
screen_x, screen_y = renderer.page_to_screen(0, 0)
assert screen_x == 100.0
assert screen_y == 200.0
-
+
# Element at (50, 75) should be offset by that amount
screen_x, screen_y = renderer.page_to_screen(50, 75)
assert screen_x == 150.0
assert screen_y == 275.0
-
+
def test_page_to_screen_with_zoom(self):
"""Test page_to_screen conversion with zoom applied"""
renderer = PageRenderer(
- page_width_mm=210.0,
- page_height_mm=297.0,
- screen_x=100.0,
- screen_y=200.0,
- dpi=96,
- zoom=2.0
+ page_width_mm=210.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
screen_x, screen_y = renderer.page_to_screen(50, 75)
assert screen_x == 200.0 # 100 + 50*2
assert screen_y == 350.0 # 200 + 75*2
-
+
def test_page_to_screen_with_fractional_zoom(self):
"""Test page_to_screen conversion with fractional zoom"""
renderer = PageRenderer(
- page_width_mm=210.0,
- page_height_mm=297.0,
- screen_x=100.0,
- screen_y=200.0,
- dpi=96,
- zoom=0.5
+ page_width_mm=210.0, 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
screen_x, screen_y = renderer.page_to_screen(100, 150)
assert screen_x == 150.0 # 100 + 100*0.5
assert screen_y == 275.0 # 200 + 150*0.5
-
+
def test_screen_to_page_no_zoom_no_pan(self):
"""Test screen_to_page conversion with zoom=1.0 and no pan"""
renderer = PageRenderer(
- page_width_mm=210.0,
- page_height_mm=297.0,
- screen_x=100.0,
- screen_y=200.0,
- dpi=96,
- zoom=1.0
+ page_width_mm=210.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
page_x, page_y = renderer.screen_to_page(100.0, 200.0)
assert page_x == 0.0
assert page_y == 0.0
-
+
# Screen position offset should map to same offset in page coords
page_x, page_y = renderer.screen_to_page(150.0, 275.0)
assert page_x == 50.0
assert page_y == 75.0
-
+
def test_screen_to_page_with_zoom(self):
"""Test screen_to_page conversion with zoom applied"""
renderer = PageRenderer(
- page_width_mm=210.0,
- page_height_mm=297.0,
- screen_x=100.0,
- screen_y=200.0,
- dpi=96,
- zoom=2.0
+ page_width_mm=210.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
page_x, page_y = renderer.screen_to_page(200.0, 350.0)
assert page_x == 50.0 # (200-100)/2
assert page_y == 75.0 # (350-200)/2
-
+
def test_roundtrip_conversion_no_zoom(self):
"""Test that page->screen->page conversion is accurate with no zoom"""
renderer = PageRenderer(
- page_width_mm=210.0,
- page_height_mm=297.0,
- screen_x=100.0,
- screen_y=200.0,
- dpi=96,
- zoom=1.0
+ page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0
)
-
+
# Start with page coordinates
orig_page_x, orig_page_y = 123.45, 678.90
-
+
# Convert to screen and back
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)
-
+
# Should get back the original values
assert abs(page_x - orig_page_x) < 0.001
assert abs(page_y - orig_page_y) < 0.001
-
+
def test_roundtrip_conversion_with_zoom(self):
"""Test that page->screen->page conversion is accurate with zoom"""
renderer = PageRenderer(
- page_width_mm=210.0,
- page_height_mm=297.0,
- screen_x=100.0,
- screen_y=200.0,
- dpi=96,
- zoom=1.5
+ page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.5
)
-
+
# Start with page coordinates
orig_page_x, orig_page_y = 123.45, 678.90
-
+
# Convert to screen and back
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)
-
+
# Should get back the original values (with floating point tolerance)
assert abs(page_x - orig_page_x) < 0.001
assert abs(page_y - orig_page_y) < 0.001
-
+
def test_roundtrip_conversion_extreme_zoom(self):
"""Test coordinate conversion with extreme zoom levels"""
for zoom in [0.1, 0.5, 1.0, 2.0, 5.0]:
renderer = PageRenderer(
- page_width_mm=210.0,
- page_height_mm=297.0,
- screen_x=50.0,
- screen_y=100.0,
- dpi=96,
- zoom=zoom
+ page_width_mm=210.0, 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
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)
-
+
assert abs(page_x - orig_page_x) < 0.001
assert abs(page_y - orig_page_y) < 0.001
class TestPageRendererBounds:
"""Test page bounds and point detection"""
-
+
def test_is_point_in_page_inside(self):
"""Test is_point_in_page for points inside the page"""
renderer = PageRenderer(
@@ -174,108 +134,85 @@ class TestPageRendererBounds:
screen_x=100.0,
screen_y=200.0,
dpi=96,
- zoom=1.0
+ zoom=1.0,
)
-
+
# Calculate page dimensions in pixels
page_width_px = 210.0 * 96 / 25.4 # ~794 pixels
page_height_px = 297.0 * 96 / 25.4 # ~1123 pixels
-
+
# Point in center should be inside
center_x = 100.0 + page_width_px / 2
center_y = 200.0 + page_height_px / 2
assert renderer.is_point_in_page(center_x, center_y)
-
+
# Point at origin should be inside
assert renderer.is_point_in_page(100.0, 200.0)
-
+
# Point at bottom-right corner should be inside
- assert renderer.is_point_in_page(
- 100.0 + page_width_px,
- 200.0 + page_height_px
- )
-
+ assert renderer.is_point_in_page(100.0 + page_width_px, 200.0 + page_height_px)
+
def test_is_point_in_page_outside(self):
"""Test is_point_in_page for points outside the page"""
renderer = PageRenderer(
- page_width_mm=210.0,
- page_height_mm=297.0,
- screen_x=100.0,
- screen_y=200.0,
- dpi=96,
- zoom=1.0
+ page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0
)
-
+
# Point before page start
assert not renderer.is_point_in_page(50.0, 150.0)
-
+
# Point way beyond page
assert not renderer.is_point_in_page(2000.0, 2000.0)
-
+
# Point to the left of page
assert not renderer.is_point_in_page(50.0, 500.0)
-
+
# Point above page
assert not renderer.is_point_in_page(500.0, 150.0)
-
+
def test_is_point_in_page_with_zoom(self):
"""Test is_point_in_page with different zoom levels"""
for zoom in [0.5, 1.0, 2.0]:
renderer = PageRenderer(
- page_width_mm=210.0,
- page_height_mm=297.0,
- screen_x=100.0,
- screen_y=200.0,
- dpi=96,
- zoom=zoom
+ page_width_mm=210.0, 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
page_width_px = 210.0 * 96 / 25.4
page_height_px = 297.0 * 96 / 25.4
center_x = 100.0 + (page_width_px * zoom) / 2
center_y = 200.0 + (page_height_px * zoom) / 2
assert renderer.is_point_in_page(center_x, center_y)
-
+
def test_get_page_bounds_screen(self):
"""Test get_page_bounds_screen returns correct screen coordinates"""
renderer = PageRenderer(
- page_width_mm=210.0,
- page_height_mm=297.0,
- screen_x=100.0,
- screen_y=200.0,
- dpi=96,
- zoom=1.5
+ page_width_mm=210.0, 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()
-
+
assert x == 100.0
assert y == 200.0
-
+
# Width and height should be scaled by zoom
page_width_px = 210.0 * 96 / 25.4
page_height_px = 297.0 * 96 / 25.4
assert abs(w - page_width_px * 1.5) < 0.1
assert abs(h - page_height_px * 1.5) < 0.1
-
+
def test_get_page_bounds_page(self):
"""Test get_page_bounds_page returns correct page-local coordinates"""
renderer = PageRenderer(
- page_width_mm=210.0,
- page_height_mm=297.0,
- screen_x=100.0,
- screen_y=200.0,
- dpi=96,
- zoom=1.5
+ page_width_mm=210.0, 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()
-
+
# Origin should be at 0,0 in page-local coordinates
assert x == 0.0
assert y == 0.0
-
+
# Width and height should NOT be affected by zoom (page-local coords)
page_width_px = 210.0 * 96 / 25.4
page_height_px = 297.0 * 96 / 25.4
@@ -285,22 +222,17 @@ class TestPageRendererBounds:
class TestPageRendererSubPages:
"""Test sub-page detection for facing pages"""
-
+
def test_get_sub_page_at_single_page(self):
"""Test that get_sub_page_at returns None for single pages"""
renderer = PageRenderer(
- page_width_mm=210.0,
- page_height_mm=297.0,
- screen_x=100.0,
- screen_y=200.0,
- dpi=96,
- zoom=1.0
+ page_width_mm=210.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
result = renderer.get_sub_page_at(500.0, is_facing_page=False)
assert result is None
-
+
def test_get_sub_page_at_facing_page_left(self):
"""Test get_sub_page_at for left side of facing page"""
renderer = PageRenderer(
@@ -309,17 +241,17 @@ class TestPageRendererSubPages:
screen_x=100.0,
screen_y=200.0,
dpi=96,
- zoom=1.0
+ zoom=1.0,
)
-
+
# Calculate center line
page_width_px = 420.0 * 96 / 25.4
center_x = 100.0 + page_width_px / 2
-
+
# Point before center should be 'left'
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):
"""Test get_sub_page_at for right side of facing page"""
renderer = PageRenderer(
@@ -328,21 +260,21 @@ class TestPageRendererSubPages:
screen_x=100.0,
screen_y=200.0,
dpi=96,
- zoom=1.0
+ zoom=1.0,
)
-
+
# Calculate center line
page_width_px = 420.0 * 96 / 25.4
center_x = 100.0 + page_width_px / 2
-
+
# Point after center should be 'right'
result = renderer.get_sub_page_at(center_x + 10, is_facing_page=True)
- assert result == 'right'
+ assert result == "right"
class TestPageRendererDimensions:
"""Test page dimension calculations"""
-
+
def test_page_dimensions_calculated_correctly(self):
"""Test that page dimensions are calculated correctly from mm to pixels"""
renderer = PageRenderer(
@@ -351,113 +283,86 @@ class TestPageRendererDimensions:
screen_x=0.0,
screen_y=0.0,
dpi=96,
- zoom=1.0
+ zoom=1.0,
)
-
+
# A4 at 96 DPI
expected_width = 210.0 * 96 / 25.4 # ~794 pixels
expected_height = 297.0 * 96 / 25.4 # ~1123 pixels
-
+
assert abs(renderer.page_width_px - expected_width) < 0.1
assert abs(renderer.page_height_px - expected_height) < 0.1
-
+
def test_screen_dimensions_with_zoom(self):
"""Test that screen dimensions account for zoom"""
- 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
- )
-
+ 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)
+
# Screen dimensions should be doubled due to zoom
expected_width = (210.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_height - expected_height) < 0.1
-
+
def test_different_dpi_values(self):
"""Test page dimensions with different DPI values"""
dpi_values = [72, 96, 150, 300]
-
+
for dpi in dpi_values:
renderer = PageRenderer(
- page_width_mm=210.0,
- page_height_mm=297.0,
- screen_x=0.0,
- screen_y=0.0,
- dpi=dpi,
- zoom=1.0
+ page_width_mm=210.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_height = 297.0 * dpi / 25.4
-
+
assert abs(renderer.page_width_px - expected_width) < 0.1
assert abs(renderer.page_height_px - expected_height) < 0.1
class TestPageRendererEdgeCases:
"""Test edge cases and boundary conditions"""
-
+
def test_zero_coordinates(self):
"""Test handling of zero coordinates"""
renderer = PageRenderer(
- page_width_mm=210.0,
- page_height_mm=297.0,
- screen_x=100.0,
- screen_y=200.0,
- dpi=96,
- zoom=1.0
+ page_width_mm=210.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)
assert screen_x == 100.0
assert screen_y == 200.0
-
+
page_x, page_y = renderer.screen_to_page(100.0, 200.0)
assert page_x == 0.0
assert page_y == 0.0
-
+
def test_negative_page_coordinates(self):
"""Test handling of negative page coordinates"""
renderer = PageRenderer(
- page_width_mm=210.0,
- page_height_mm=297.0,
- screen_x=100.0,
- screen_y=200.0,
- dpi=96,
- zoom=1.0
+ page_width_mm=210.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
screen_x, screen_y = renderer.page_to_screen(-50, -75)
assert screen_x == 50.0
assert screen_y == 125.0
-
+
# And back again
page_x, page_y = renderer.screen_to_page(50.0, 125.0)
assert page_x == -50.0
assert page_y == -75.0
-
+
def test_very_large_coordinates(self):
"""Test handling of very large coordinates"""
renderer = PageRenderer(
- page_width_mm=210.0,
- page_height_mm=297.0,
- screen_x=100.0,
- screen_y=200.0,
- dpi=96,
- zoom=1.0
+ page_width_mm=210.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
-
+
screen_x, screen_y = renderer.page_to_screen(large_x, large_y)
page_x, page_y = renderer.screen_to_page(screen_x, screen_y)
-
+
assert abs(page_x - large_x) < 0.001
assert abs(page_y - large_y) < 0.001
diff --git a/tests/test_page_setup_dialog.py b/tests/test_page_setup_dialog.py
index 08e0ea6..d780715 100644
--- a/tests/test_page_setup_dialog.py
+++ b/tests/test_page_setup_dialog.py
@@ -190,16 +190,16 @@ class TestPageSetupDialog:
values = dialog.get_values()
# Check all values returned
- assert values['selected_index'] == 0
- assert values['selected_page'] == page
- assert values['is_cover'] is True
- assert values['paper_thickness_mm'] == 0.15
- assert values['cover_bleed_mm'] == 5.0
- assert values['width_mm'] == 200
- assert values['height_mm'] == 280
- assert values['working_dpi'] == 150
- assert values['export_dpi'] == 600
- assert values['set_as_default'] is True
+ assert values["selected_index"] == 0
+ assert values["selected_page"] == page
+ assert values["is_cover"] is True
+ assert values["paper_thickness_mm"] == 0.15
+ assert values["cover_bleed_mm"] == 5.0
+ assert values["width_mm"] == 200
+ assert values["height_mm"] == 280
+ assert values["working_dpi"] == 150
+ assert values["export_dpi"] == 600
+ assert values["set_as_default"] is True
def test_dialog_page_change_updates_values(self, qtbot):
"""Test changing selected page updates displayed values"""
@@ -240,14 +240,14 @@ class TestDialogMixin:
# Create mock dialog with get_values as a proper method
mock_dialog = MagicMock(spec=QDialog)
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(return_value=mock_dialog)
result = window.create_dialog(mock_dialog_class)
- assert result == {'test': 'value'}
+ assert result == {"test": "value"}
mock_dialog.exec.assert_called_once()
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
mock_dialog = MagicMock(spec=QDialog)
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(return_value=mock_dialog)
@@ -294,7 +294,7 @@ class TestDialogMixin:
result = window.show_dialog(mock_dialog_class, on_accept=callback)
assert result is True
- callback.assert_called_once_with({'test': 'value'})
+ callback.assert_called_once_with({"test": "value"})
class TestDialogActionDecorator:
@@ -362,7 +362,7 @@ class TestDialogMixinEdgeCases:
mock_dialog = MagicMock(spec=QDialog)
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)
@@ -370,7 +370,7 @@ class TestDialogMixinEdgeCases:
# Verify setWindowTitle was called
mock_dialog.setWindowTitle.assert_called_once_with("Custom Title")
- assert result == {'data': 'test'}
+ assert result == {"data": "test"}
def test_show_dialog_rejected(self, qtbot):
"""Test show_dialog when user rejects dialog"""
@@ -543,16 +543,16 @@ class TestPageSetupIntegration:
# Create mock values that would come from dialog
values = {
- 'selected_index': 0,
- 'selected_page': window.project.pages[0],
- 'is_cover': False,
- 'paper_thickness_mm': 0.15,
- 'cover_bleed_mm': 5.0,
- 'width_mm': 200,
- 'height_mm': 280,
- 'working_dpi': 150,
- 'export_dpi': 600,
- 'set_as_default': True
+ "selected_index": 0,
+ "selected_page": window.project.pages[0],
+ "is_cover": False,
+ "paper_thickness_mm": 0.15,
+ "cover_bleed_mm": 5.0,
+ "width_mm": 200,
+ "height_mm": 280,
+ "working_dpi": 150,
+ "export_dpi": 600,
+ "set_as_default": True,
}
# Access the unwrapped function to test business logic directly
@@ -564,7 +564,7 @@ class TestPageSetupIntegration:
original_func = window.page_setup
# Decorators return wrappers, but we can call them with values directly
# by accessing the innermost wrapped function
- while hasattr(original_func, '__wrapped__'):
+ while hasattr(original_func, "__wrapped__"):
original_func = original_func.__wrapped__
# If no __wrapped__, the decorator system is different
@@ -575,7 +575,7 @@ class TestPageSetupIntegration:
# Get the undecorated method from the class
undecorated_page_setup = page_ops.PageOperationsMixin.page_setup
# Find the innermost function
- while hasattr(undecorated_page_setup, '__wrapped__'):
+ while hasattr(undecorated_page_setup, "__wrapped__"):
undecorated_page_setup = undecorated_page_setup.__wrapped__
# Call the business logic directly
@@ -635,22 +635,23 @@ class TestPageSetupIntegration:
# Test designating first page as cover
values = {
- 'selected_index': 0,
- 'selected_page': window.project.pages[0],
- 'is_cover': True, # Designate as cover
- 'paper_thickness_mm': 0.1,
- 'cover_bleed_mm': 3.0,
- 'width_mm': 210,
- 'height_mm': 297,
- 'working_dpi': 96,
- 'export_dpi': 300,
- 'set_as_default': False
+ "selected_index": 0,
+ "selected_page": window.project.pages[0],
+ "is_cover": True, # Designate as cover
+ "paper_thickness_mm": 0.1,
+ "cover_bleed_mm": 3.0,
+ "width_mm": 210,
+ "height_mm": 297,
+ "working_dpi": 96,
+ "export_dpi": 300,
+ "set_as_default": False,
}
# Get the undecorated method
from pyPhotoAlbum.mixins.operations import page_ops
+
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__
# Mock update_cover_dimensions
@@ -706,21 +707,22 @@ class TestPageSetupIntegration:
# Test changing double spread page size
values = {
- 'selected_index': 0,
- 'selected_page': window.project.pages[0],
- 'is_cover': False,
- 'paper_thickness_mm': 0.1,
- 'cover_bleed_mm': 3.0,
- 'width_mm': 200, # New base width
- 'height_mm': 280, # New height
- 'working_dpi': 96,
- 'export_dpi': 300,
- 'set_as_default': False
+ "selected_index": 0,
+ "selected_page": window.project.pages[0],
+ "is_cover": False,
+ "paper_thickness_mm": 0.1,
+ "cover_bleed_mm": 3.0,
+ "width_mm": 200, # New base width
+ "height_mm": 280, # New height
+ "working_dpi": 96,
+ "export_dpi": 300,
+ "set_as_default": False,
}
from pyPhotoAlbum.mixins.operations import page_ops
+
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(window, values)
diff --git a/tests/test_page_setup_dialog_mocked.py b/tests/test_page_setup_dialog_mocked.py
index c924db4..fcc7982 100644
--- a/tests/test_page_setup_dialog_mocked.py
+++ b/tests/test_page_setup_dialog_mocked.py
@@ -26,7 +26,7 @@ class TestPageSetupDialogWithMocks:
# We can verify the class signature and that it would accept these params
# 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
# using qtbot which handles Qt properly
@@ -44,7 +44,7 @@ class TestPageSetupDialogWithMocks:
project.pages = [page1, page2]
# 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)
# Manually set required attributes
@@ -88,7 +88,7 @@ class TestPageSetupDialogWithMocks:
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
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.project = project
dialog._cover_group = Mock()
@@ -112,11 +112,11 @@ class TestPageSetupDialogWithMocks:
# Create 3 content pages (not covers)
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
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.project = project
dialog.cover_checkbox = Mock()
@@ -158,7 +158,7 @@ class TestPageSetupDialogWithMocks:
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
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.project = project
@@ -194,16 +194,16 @@ class TestPageSetupDialogWithMocks:
values = dialog.get_values()
# Verify all values were extracted
- assert values['selected_index'] == 0
- assert values['selected_page'] == page
- assert values['is_cover'] is True
- assert values['paper_thickness_mm'] == 0.15
- assert values['cover_bleed_mm'] == 5.0
- assert values['width_mm'] == 200.0
- assert values['height_mm'] == 280.0
- assert values['working_dpi'] == 150
- assert values['export_dpi'] == 600
- assert values['set_as_default'] is True
+ assert values["selected_index"] == 0
+ assert values["selected_page"] == page
+ assert values["is_cover"] is True
+ assert values["paper_thickness_mm"] == 0.15
+ assert values["cover_bleed_mm"] == 5.0
+ assert values["width_mm"] == 200.0
+ assert values["height_mm"] == 280.0
+ assert values["working_dpi"] == 150
+ assert values["export_dpi"] == 600
+ assert values["set_as_default"] is True
def test_cover_page_width_display(self):
"""Test cover page shows full width, not base width"""
@@ -217,7 +217,7 @@ class TestPageSetupDialogWithMocks:
page.is_cover = True
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.project = project
dialog._cover_group = Mock()
@@ -259,7 +259,7 @@ class TestDialogMixinMocked:
# Mock dialog class
mock_dialog_instance = Mock()
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)
@@ -279,7 +279,7 @@ class TestDialogMixinMocked:
mock_dialog_instance.get_values.assert_called_once()
# Verify result
- assert result == {'key': 'value'}
+ assert result == {"key": "value"}
def test_show_dialog_with_callback_flow(self):
"""Test show_dialog method with callback"""
@@ -293,7 +293,7 @@ class TestDialogMixinMocked:
# Mock dialog
mock_dialog_instance = Mock()
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)
@@ -304,7 +304,7 @@ class TestDialogMixinMocked:
result = window.show_dialog(mock_dialog_class, on_accept=callback, param="value")
# Verify callback was called with dialog values
- callback.assert_called_once_with({'data': 'test'})
+ callback.assert_called_once_with({"data": "test"})
# Verify result
assert result is True
@@ -346,7 +346,7 @@ class TestDialogActionDecoratorMocked:
# Mock dialog instance
mock_dialog = Mock()
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_cls = Mock(return_value=mock_dialog)
@@ -354,7 +354,7 @@ class TestDialogActionDecoratorMocked:
# Create decorated function
@dialog_action(dialog_class=mock_dialog_cls, requires_pages=True)
def test_function(self, values):
- return values['test']
+ return values["test"]
# Mock instance with required attributes
instance = Mock()
@@ -375,7 +375,7 @@ class TestDialogActionDecoratorMocked:
mock_dialog.get_values.assert_called_once()
# Verify original function received values
- assert result == 'data'
+ assert result == "data"
def test_decorator_returns_early_when_no_pages(self):
"""Test decorator returns early when pages required but not present"""
@@ -407,7 +407,7 @@ class TestDialogActionDecoratorMocked:
mock_dialog = Mock()
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)
@@ -427,8 +427,8 @@ class TestDialogActionDecoratorMocked:
mock_dialog_cls.assert_called_once()
# Verify result
- assert result == {'key': 'val'}
+ assert result == {"key": "val"}
-if __name__ == '__main__':
- pytest.main([__file__, '-v'])
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
diff --git a/tests/test_pdf_export.py b/tests/test_pdf_export.py
index cd729bb..228f86b 100755
--- a/tests/test_pdf_export.py
+++ b/tests/test_pdf_export.py
@@ -15,27 +15,27 @@ def test_pdf_exporter_basic():
# Create a simple project
project = Project("Test Project")
project.page_size_mm = (210, 297) # A4
-
+
# Add a single page
page = Page(page_number=1, is_double_spread=False)
project.add_page(page)
-
+
# 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
-
+
try:
exporter = PDFExporter(project)
success, warnings = exporter.export(tmp_path)
-
+
assert success, f"Export failed: {warnings}"
assert os.path.exists(tmp_path), "PDF file was not created"
assert os.path.getsize(tmp_path) > 0, "PDF file is empty"
-
+
print(f"✓ Basic PDF export successful: {tmp_path}")
if warnings:
print(f" Warnings: {warnings}")
-
+
finally:
if os.path.exists(tmp_path):
os.remove(tmp_path)
@@ -45,26 +45,26 @@ def test_pdf_exporter_double_spread():
"""Test PDF export with double-page spread"""
project = Project("Test Spread Project")
project.page_size_mm = (210, 297) # A4
-
+
# Add a double-page spread
spread_page = Page(page_number=1, is_double_spread=True)
project.add_page(spread_page)
-
+
# 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
-
+
try:
exporter = PDFExporter(project)
success, warnings = exporter.export(tmp_path)
-
+
assert success, f"Export failed: {warnings}"
assert os.path.exists(tmp_path), "PDF file was not created"
-
+
print(f"✓ Double-spread PDF export successful: {tmp_path}")
if warnings:
print(f" Warnings: {warnings}")
-
+
finally:
if os.path.exists(tmp_path):
os.remove(tmp_path)
@@ -83,14 +83,17 @@ def test_pdf_exporter_with_text():
text_content="Hello, World!",
font_settings={"family": "Helvetica", "size": 24, "color": (0, 0, 0)},
alignment="center",
- x=50, y=50, width=100, height=30
+ x=50,
+ y=50,
+ width=100,
+ height=30,
)
page.layout.add_element(text_box)
project.add_page(page)
# 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
try:
@@ -143,7 +146,7 @@ def test_pdf_text_position_and_size():
x=text_box_x_px,
y=text_box_y_px,
width=text_box_width_px,
- height=text_box_height_px
+ height=text_box_height_px,
)
page.layout.add_element(text_box)
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
# 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
try:
@@ -186,16 +189,20 @@ def test_pdf_text_position_and_size():
# Get the first character's position and font size
first_char = chars[0]
- text_x = first_char['x0']
- text_y_baseline = first_char['y0'] # This is the baseline y position
- actual_font_size = first_char['size']
+ text_x = first_char["x0"]
+ text_y_baseline = first_char["y0"] # This is the baseline y position
+ actual_font_size = first_char["size"]
print(f"\nText Position Analysis:")
- print(f" Text box (in pixels at {dpi} DPI): x={text_box_x_px}, y={text_box_y_px}, "
- f"w={text_box_width_px}, h={text_box_height_px}")
- 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" Text box (in pixels at {dpi} DPI): x={text_box_x_px}, y={text_box_y_px}, "
+ f"w={text_box_width_px}, h={text_box_height_px}"
+ )
+ 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" Expected font size (points): {expected_font_size_pt:.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
x_diff = abs(text_x - text_box_x_pt)
assert x_diff < 5.0, (
- f"Text X position mismatch: expected ~{text_box_x_pt:.1f}, "
- f"got {text_x:.1f} (diff: {x_diff:.1f}pt)"
+ f"Text X position mismatch: expected ~{text_box_x_pt:.1f}, " f"got {text_x:.1f} (diff: {x_diff:.1f}pt)"
)
# 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,
y=text_box_y_px,
width=text_box_width_px,
- height=text_box_height_px
+ height=text_box_height_px,
)
page.layout.add_element(text_box)
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
# 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
try:
@@ -313,7 +319,7 @@ def test_pdf_text_wrapping():
assert len(chars) > 0, "No text found in PDF"
# 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" 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
# Verify text wrapped to multiple lines
- assert len(y_positions) > 1, (
- f"Text should wrap to multiple lines but only found {len(y_positions)} line(s)"
- )
+ assert len(y_positions) > 1, 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)
tolerance = 5.0 # Small tolerance for rounding
for char in chars:
- char_x = char['x0']
- char_right = char['x1']
- assert 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, (
- f"Character '{char['text']}' ends at x={char_right:.1f} which exceeds box right {text_box_right_pt:.1f}"
- )
+ char_x = char["x0"]
+ char_right = char["x1"]
+ assert (
+ 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
+ ), 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"\n✓ Text wrapping test passed!")
@@ -354,28 +358,28 @@ def test_pdf_exporter_facing_pages_alignment():
# Add single page (page 1)
page1 = Page(page_number=1, is_double_spread=False)
project.add_page(page1)
-
+
# 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
spread = Page(page_number=2, is_double_spread=True)
project.add_page(spread)
-
+
# 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
-
+
try:
exporter = PDFExporter(project)
success, warnings = exporter.export(tmp_path)
-
+
assert success, f"Export failed: {warnings}"
assert os.path.exists(tmp_path), "PDF file was not created"
-
+
print(f"✓ Facing pages alignment successful: {tmp_path}")
print(f" Expected: Page 1 (single), blank page, Pages 2-3 (spread)")
if warnings:
print(f" Warnings: {warnings}")
-
+
finally:
if os.path.exists(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)"""
project = Project("Test Missing Image")
project.page_size_mm = (210, 297)
-
+
# Create page with image that doesn't exist
page = Page(page_number=1, is_double_spread=False)
-
+
# Add image with non-existent path
- image = ImageData(
- image_path="/nonexistent/path/to/image.jpg",
- x=50, y=50, width=100, height=100
- )
+ image = ImageData(image_path="/nonexistent/path/to/image.jpg", x=50, y=50, width=100, height=100)
page.layout.add_element(image)
-
+
project.add_page(page)
-
+
# 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
-
+
try:
exporter = PDFExporter(project)
success, warnings = exporter.export(tmp_path)
-
+
assert success, "Export should succeed even with missing images"
assert len(warnings) > 0, "Should have warnings for missing image"
assert "not found" in warnings[0].lower(), "Warning should mention missing image"
-
+
print(f"✓ Missing image handling successful: {tmp_path}")
print(f" Warnings: {warnings}")
-
+
finally:
if os.path.exists(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"""
import tempfile
from PIL import Image as PILImage
-
+
project = Project("Test Spanning Image")
project.page_size_mm = (210, 297) # A4
project.working_dpi = 96 # Standard DPI
-
+
# 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
- 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
test_img.save(img_path)
-
+
try:
# Create a double-page spread
spread_page = Page(page_number=1, is_double_spread=True)
-
+
# Calculate center position in pixels (for a 210mm page width at 96 DPI)
# Spread width is 2 * 210mm = 420mm
spread_width_px = 420 * 96 / 25.4 # ~1587 pixels
center_px = spread_width_px / 2 # ~794 pixels
-
+
# Add an image that spans across the center
# Position it so it overlaps the center line
image_width_px = 400
image_x_px = center_px - 200 # Start 200px before center, end 200px after
-
- spanning_image = ImageData(
- image_path=img_path,
- x=image_x_px,
- y=100,
- width=image_width_px,
- height=200
- )
+
+ spanning_image = ImageData(image_path=img_path, x=image_x_px, y=100, width=image_width_px, height=200)
spread_page.layout.add_element(spanning_image)
-
+
project.add_page(spread_page)
-
+
# 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
-
+
try:
exporter = PDFExporter(project)
success, warnings = exporter.export(pdf_path)
-
+
assert success, f"Export failed: {warnings}"
assert os.path.exists(pdf_path), "PDF file was not created"
-
+
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" Center line at {center_px:.1f}px")
if warnings:
print(f" Warnings: {warnings}")
-
+
finally:
if os.path.exists(pdf_path):
os.remove(pdf_path)
-
+
finally:
if os.path.exists(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"""
import tempfile
from PIL import Image as PILImage
-
+
project = Project("Test Multiple Spanning")
project.page_size_mm = (210, 297) # A4
project.working_dpi = 96
-
+
# Create test images
- test_img1 = PILImage.new('RGB', (300, 150), color='blue')
- test_img2 = PILImage.new('RGB', (250, 200), color='green')
-
- with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp1:
+ test_img1 = PILImage.new("RGB", (300, 150), color="blue")
+ test_img2 = PILImage.new("RGB", (250, 200), color="green")
+
+ with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp1:
img_path1 = img_tmp1.name
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
test_img2.save(img_path2)
-
+
try:
spread_page = Page(page_number=1, is_double_spread=True)
-
+
# Calculate positions
spread_width_px = 420 * 96 / 25.4
center_px = spread_width_px / 2
-
+
# First spanning image
image1 = ImageData(
- image_path=img_path1,
- x=center_px - 150, # Centered on split line
- y=50,
- width=300,
- height=150
+ image_path=img_path1, x=center_px - 150, y=50, width=300, height=150 # Centered on split line
)
-
+
# Second spanning image (different position)
- image2 = ImageData(
- image_path=img_path2,
- x=center_px - 100,
- y=250,
- width=250,
- height=200
- )
-
+ image2 = ImageData(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(image2)
-
+
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
-
+
try:
exporter = PDFExporter(project)
success, warnings = exporter.export(pdf_path)
-
+
assert success, f"Export failed: {warnings}"
assert os.path.exists(pdf_path), "PDF file was not created"
-
+
print(f"✓ Multiple spanning images export successful: {pdf_path}")
if warnings:
print(f" Warnings: {warnings}")
-
+
finally:
if os.path.exists(pdf_path):
os.remove(pdf_path)
-
+
finally:
if os.path.exists(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"""
import tempfile
from PIL import Image as PILImage
-
+
project = Project("Test Edge Case")
project.page_size_mm = (210, 297)
project.working_dpi = 96
-
- test_img = PILImage.new('RGB', (100, 100), color='yellow')
-
- with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp:
+
+ test_img = PILImage.new("RGB", (100, 100), color="yellow")
+
+ with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp:
img_path = img_tmp.name
test_img.save(img_path)
-
+
try:
spread_page = Page(page_number=1, is_double_spread=True)
-
+
spread_width_px = 420 * 96 / 25.4
center_px = spread_width_px / 2
-
+
# Image that just barely crosses the center line
- image = ImageData(
- image_path=img_path,
- x=center_px - 5, # Just 5px overlap
- y=100,
- width=100,
- height=100
- )
-
+ image = ImageData(image_path=img_path, x=center_px - 5, y=100, width=100, height=100) # Just 5px overlap
+
spread_page.layout.add_element(image)
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
-
+
try:
exporter = PDFExporter(project)
success, warnings = exporter.export(pdf_path)
-
+
assert success, f"Export failed: {warnings}"
-
+
print(f"✓ Edge case (barely spanning) export successful: {pdf_path}")
if warnings:
print(f" Warnings: {warnings}")
-
+
finally:
if os.path.exists(pdf_path):
os.remove(pdf_path)
-
+
finally:
if os.path.exists(img_path):
os.remove(img_path)
@@ -622,12 +601,12 @@ def test_pdf_exporter_text_spanning():
project = Project("Test Spanning Text")
project.page_size_mm = (210, 297)
project.working_dpi = 96
-
+
spread_page = Page(page_number=1, is_double_spread=True)
-
+
spread_width_px = 420 * 96 / 25.4
center_px = spread_width_px / 2
-
+
# Text box spanning the center
text_box = TextBoxData(
text_content="Spanning Text",
@@ -636,25 +615,25 @@ def test_pdf_exporter_text_spanning():
x=center_px - 100,
y=100,
width=200,
- height=50
+ height=50,
)
-
+
spread_page.layout.add_element(text_box)
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
-
+
try:
exporter = PDFExporter(project)
success, warnings = exporter.export(pdf_path)
-
+
assert success, f"Export failed: {warnings}"
-
+
print(f"✓ Spanning text box export successful: {pdf_path}")
if warnings:
print(f" Warnings: {warnings}")
-
+
finally:
if os.path.exists(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"""
import tempfile
from PIL import Image as PILImage, ImageDraw
-
+
project = Project("Test Aspect Ratio")
project.page_size_mm = (210, 297) # A4
project.working_dpi = 96
-
+
# Create a distinctive test image: red left half, blue right half, with a vertical line in center
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)
-
+
# Fill left half red
draw.rectangle([0, 0, test_width // 2, test_height], fill=(255, 0, 0))
-
+
# Fill right half blue
draw.rectangle([test_width // 2, 0, test_width, test_height], fill=(0, 0, 255))
-
+
# 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 horizontal reference lines for visual verification
for y in range(0, test_height, 50):
draw.line([0, y, test_width, y], fill=(255, 255, 255), width=2)
-
+
# 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
test_img.save(img_path)
-
+
try:
# Create a double-page spread
spread_page = Page(page_number=1, is_double_spread=True)
-
+
# Calculate positions
spread_width_px = 420 * 96 / 25.4 # ~1587 pixels
center_px = spread_width_px / 2 # ~794 pixels
-
+
# Create an image element that spans the center with a specific aspect ratio
# Make it 600px wide and 300px tall (2:1 aspect ratio)
image_width_px = 600
image_height_px = 300
image_x_px = center_px - 300 # Centered on the split line
-
+
spanning_image = ImageData(
- image_path=img_path,
- x=image_x_px,
- y=100,
- width=image_width_px,
- height=image_height_px
+ image_path=img_path, x=image_x_px, y=100, width=image_width_px, height=image_height_px
)
spread_page.layout.add_element(spanning_image)
-
+
project.add_page(spread_page)
-
+
# 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
-
+
try:
exporter = PDFExporter(project)
success, warnings = exporter.export(pdf_path)
-
+
assert success, f"Export failed: {warnings}"
assert os.path.exists(pdf_path), "PDF file was not created"
-
+
# Verify the PDF was created and has expected properties
# We can't easily extract and verify pixel-perfect image reconstruction without
# additional dependencies, but we can verify the export succeeded
file_size = os.path.getsize(pdf_path)
assert file_size > 1000, "PDF file seems too small"
-
+
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" 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" Right portion: {image_width_px - (center_px - image_x_px):.1f}px wide")
print(f" PDF size: {file_size} bytes")
-
+
if warnings:
print(f" Warnings: {warnings}")
-
+
finally:
if os.path.exists(pdf_path):
os.remove(pdf_path)
-
+
finally:
if os.path.exists(img_path):
os.remove(img_path)
@@ -758,74 +733,76 @@ def test_pdf_exporter_varying_aspect_ratios():
"""Test spanning images with various aspect ratios"""
import tempfile
from PIL import Image as PILImage, ImageDraw
-
+
project = Project("Test Varying Aspects")
project.page_size_mm = (210, 297)
project.working_dpi = 96
-
+
# Test different aspect ratios
test_configs = [
- ("Square", 400, 400), # 1:1
- ("Landscape", 800, 400), # 2:1
- ("Portrait", 400, 800), # 1:2
- ("Wide", 1200, 400), # 3:1
+ ("Square", 400, 400), # 1:1
+ ("Landscape", 800, 400), # 2:1
+ ("Portrait", 400, 800), # 1:2
+ ("Wide", 1200, 400), # 3:1
]
-
+
spread_width_px = 420 * 96 / 25.4
center_px = spread_width_px / 2
-
+
for idx, (name, img_w, img_h) in enumerate(test_configs):
# 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)
-
+
# Different colors for each test
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([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:
+ 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:
img_path = img_tmp.name
test_img.save(img_path)
-
+
try:
spread_page = Page(page_number=idx + 1, is_double_spread=True)
-
+
# Position spanning element
element_width_px = 500
element_height_px = int(500 * img_h / img_w) # Maintain aspect ratio
-
+
spanning_image = ImageData(
image_path=img_path,
x=center_px - 250,
y=100 + idx * 200,
width=element_width_px,
- height=element_height_px
+ height=element_height_px,
)
spread_page.layout.add_element(spanning_image)
-
+
project.add_page(spread_page)
-
+
finally:
if os.path.exists(img_path):
os.remove(img_path)
-
+
# 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
-
+
try:
exporter = PDFExporter(project)
success, warnings = exporter.export(pdf_path)
-
+
assert success, f"Export failed: {warnings}"
assert os.path.exists(pdf_path), "PDF file was not created"
-
+
print(f"✓ Varying aspect ratios test successful: {pdf_path}")
print(f" Tested {len(test_configs)} different aspect ratios")
if warnings:
print(f" Warnings: {warnings}")
-
+
finally:
if os.path.exists(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
# 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 a pattern that shows orientation
@@ -855,7 +832,7 @@ def test_pdf_exporter_rotated_image():
# Yellow vertical stripe on right
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
test_img.save(img_path)
@@ -865,11 +842,7 @@ def test_pdf_exporter_rotated_image():
# Add image with 90-degree PIL rotation
image = ImageData(
- image_path=img_path,
- x=50,
- y=50,
- width=200, # These dimensions are for the rotated version
- height=400
+ image_path=img_path, x=50, y=50, width=200, height=400 # These dimensions are for the rotated version
)
image.pil_rotation_90 = 1 # 90 degree rotation
image.image_dimensions = (400, 200) # Original dimensions before rotation
@@ -878,7 +851,7 @@ def test_pdf_exporter_rotated_image():
project.add_page(page)
# 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
try:
@@ -908,84 +881,85 @@ def test_pdf_exporter_image_downsampling():
"""Test that export DPI controls image downsampling and reduces file size"""
import tempfile
from PIL import Image as PILImage
-
+
project = Project("Test Downsampling")
project.page_size_mm = (210, 297) # A4
project.working_dpi = 96
-
+
# 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
import random
+
pixels = large_img.load()
for i in range(0, 4000, 10):
for j in range(0, 3000, 10):
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
large_img.save(img_path)
-
+
try:
# Create a page with the large image
page = Page(page_number=1, is_double_spread=False)
-
+
# Add image at reasonable size (100mm x 75mm)
image = ImageData(
image_path=img_path,
x=50,
y=50,
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)
project.add_page(page)
-
+
# 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
-
+
# 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
-
+
try:
# Export at 300 DPI
exporter_300 = PDFExporter(project, export_dpi=300)
success1, warnings1 = exporter_300.export(pdf_path_300dpi)
assert success1, f"300 DPI export failed: {warnings1}"
-
+
# Export at 150 DPI
exporter_150 = PDFExporter(project, export_dpi=150)
success2, warnings2 = exporter_150.export(pdf_path_150dpi)
assert success2, f"150 DPI export failed: {warnings2}"
-
+
# Check file sizes
size_300dpi = os.path.getsize(pdf_path_300dpi)
size_150dpi = os.path.getsize(pdf_path_150dpi)
-
+
print(f"✓ Image downsampling test successful:")
print(f" Original image: 4000x3000 pixels")
print(f" Element size: 100mm x 75mm")
print(f" PDF at 300 DPI: {size_300dpi:,} bytes")
print(f" PDF at 150 DPI: {size_150dpi:,} bytes")
print(f" Size reduction: {(1 - size_150dpi/size_300dpi)*100:.1f}%")
-
+
# 150 DPI should be smaller than 300 DPI
- assert size_150dpi < size_300dpi, \
- f"150 DPI file ({size_150dpi}) should be smaller than 300 DPI file ({size_300dpi})"
-
+ assert (
+ 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)
reduction_ratio = size_150dpi / size_300dpi
- assert reduction_ratio < 0.7, \
- f"150 DPI should be at least 30% smaller, got {(1-reduction_ratio)*100:.1f}%"
-
+ assert reduction_ratio < 0.7, f"150 DPI should be at least 30% smaller, got {(1-reduction_ratio)*100:.1f}%"
+
finally:
if os.path.exists(pdf_path_300dpi):
os.remove(pdf_path_300dpi)
if os.path.exists(pdf_path_150dpi):
os.remove(pdf_path_150dpi)
-
+
finally:
if os.path.exists(img_path):
os.remove(img_path)
diff --git a/tests/test_project.py b/tests/test_project.py
index a3ad56f..f51d295 100755
--- a/tests/test_project.py
+++ b/tests/test_project.py
@@ -15,7 +15,7 @@ class TestPage:
"""Test Page initialization with default values"""
layout = PageLayout()
page = Page(layout=layout, page_number=1)
-
+
assert page.layout is layout
assert page.page_number == 1
@@ -23,7 +23,7 @@ class TestPage:
"""Test Page initialization with custom parameters"""
layout = PageLayout()
page = Page(layout=layout, page_number=5)
-
+
assert page.layout is layout
assert page.page_number == 5
@@ -32,7 +32,7 @@ class TestPage:
layout = PageLayout()
page = Page(layout=layout, page_number=1)
page.page_number = 10
-
+
assert page.page_number == 10
@@ -42,7 +42,7 @@ class TestProject:
def test_initialization_default(self):
"""Test Project initialization with default values"""
project = Project()
-
+
assert project.name == "Untitled Project"
assert len(project.pages) == 0
assert project.working_dpi == 300
@@ -51,7 +51,7 @@ class TestProject:
def test_initialization_with_name(self):
"""Test Project initialization with custom name"""
project = Project(name="My Album")
-
+
assert project.name == "My Album"
def test_add_page(self):
@@ -59,24 +59,24 @@ class TestProject:
project = Project()
layout = PageLayout()
page = Page(layout=layout, page_number=1)
-
+
project.add_page(page)
-
+
assert len(project.pages) == 1
assert project.pages[0] is page
def test_add_multiple_pages(self):
"""Test adding multiple pages to the project"""
project = Project()
-
+
page1 = Page(layout=PageLayout(), page_number=1)
page2 = Page(layout=PageLayout(), page_number=2)
page3 = Page(layout=PageLayout(), page_number=3)
-
+
project.add_page(page1)
project.add_page(page2)
project.add_page(page3)
-
+
assert len(project.pages) == 3
assert project.pages[0] is page1
assert project.pages[1] is page2
@@ -85,15 +85,15 @@ class TestProject:
def test_remove_page(self):
"""Test removing a page from the project"""
project = Project()
-
+
page1 = Page(layout=PageLayout(), page_number=1)
page2 = Page(layout=PageLayout(), page_number=2)
-
+
project.add_page(page1)
project.add_page(page2)
-
+
project.remove_page(page1)
-
+
assert len(project.pages) == 1
assert project.pages[0] is page2
@@ -103,7 +103,7 @@ class TestProject:
page1 = Page(layout=PageLayout(), page_number=1)
page2 = Page(layout=PageLayout(), page_number=2)
project.add_page(page1)
-
+
# Try to remove a page that was never added
with pytest.raises(ValueError):
project.remove_page(page2)
@@ -112,51 +112,51 @@ class TestProject:
"""Test modifying working DPI"""
project = Project()
project.working_dpi = 300
-
+
assert project.working_dpi == 300
def test_page_size_modification(self):
"""Test modifying page size"""
project = Project()
project.page_size_mm = (300, 400)
-
+
assert project.page_size_mm == (300, 400)
def test_project_name_modification(self):
"""Test modifying project name"""
project = Project(name="Initial Name")
project.name = "New Name"
-
+
assert project.name == "New Name"
def test_asset_manager_exists(self):
"""Test that project has an asset manager"""
project = Project()
-
- assert hasattr(project, 'asset_manager')
+
+ assert hasattr(project, "asset_manager")
assert project.asset_manager is not None
def test_history_exists(self):
"""Test that project has a history manager"""
project = Project()
-
- assert hasattr(project, 'history')
+
+ assert hasattr(project, "history")
assert project.history is not None
def test_pages_list_is_mutable(self):
"""Test that pages list can be directly modified"""
project = Project()
page = Page(layout=PageLayout(), page_number=1)
-
+
project.pages.append(page)
-
+
assert len(project.pages) == 1
assert project.pages[0] is page
def test_empty_project_has_no_pages(self):
"""Test that a new project has no pages"""
project = Project()
-
+
assert len(project.pages) == 0
assert project.pages == []
@@ -167,23 +167,17 @@ class TestProjectWithPages:
def test_project_with_populated_pages(self, sample_image_data):
"""Test project with pages containing elements"""
project = Project(name="Photo Album")
-
+
# Create pages with elements
for i in range(3):
layout = PageLayout()
- img = ImageData(
- image_path=f"image_{i}.jpg",
- x=10 + i*10,
- y=20 + i*10,
- width=100,
- height=100
- )
+ img = ImageData(image_path=f"image_{i}.jpg", x=10 + i * 10, y=20 + i * 10, width=100, height=100)
layout.add_element(img)
- page = Page(layout=layout, page_number=i+1)
+ page = Page(layout=layout, page_number=i + 1)
project.add_page(page)
-
+
assert len(project.pages) == 3
-
+
# Check each page has elements
for i, page in enumerate(project.pages):
assert len(page.layout.elements) == 1
@@ -192,18 +186,18 @@ class TestProjectWithPages:
def test_reorder_pages(self):
"""Test reordering pages in project"""
project = Project()
-
+
page1 = Page(layout=PageLayout(), page_number=1)
page2 = Page(layout=PageLayout(), page_number=2)
page3 = Page(layout=PageLayout(), page_number=3)
-
+
project.add_page(page1)
project.add_page(page2)
project.add_page(page3)
-
+
# Swap page 1 and page 3
project.pages[0], project.pages[2] = project.pages[2], project.pages[0]
-
+
assert project.pages[0] is page3
assert project.pages[1] is page2
assert project.pages[2] is page1
@@ -211,43 +205,43 @@ class TestProjectWithPages:
def test_clear_all_pages(self):
"""Test clearing all pages from project"""
project = Project()
-
+
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)
-
+
# Clear all pages
project.pages.clear()
-
+
assert len(project.pages) == 0
def test_get_page_by_index(self):
"""Test accessing pages by index"""
project = Project()
-
+
page1 = Page(layout=PageLayout(), page_number=1)
page2 = Page(layout=PageLayout(), page_number=2)
-
+
project.add_page(page1)
project.add_page(page2)
-
+
assert project.pages[0] is page1
assert project.pages[1] is page2
def test_insert_page_at_position(self):
"""Test inserting a page at a specific position"""
project = Project()
-
+
page1 = Page(layout=PageLayout(), page_number=1)
page2 = Page(layout=PageLayout(), page_number=2)
page_new = Page(layout=PageLayout(), page_number=99)
-
+
project.add_page(page1)
project.add_page(page2)
-
+
# Insert new page in the middle
project.pages.insert(1, page_new)
-
+
assert len(project.pages) == 3
assert project.pages[0] is page1
assert project.pages[1] is page_new
diff --git a/tests/test_project_serialization.py b/tests/test_project_serialization.py
index 830982c..f31c6f6 100755
--- a/tests/test_project_serialization.py
+++ b/tests/test_project_serialization.py
@@ -39,9 +39,9 @@ def sample_project(temp_dir):
def sample_image(temp_dir):
"""Create a sample image file for testing"""
from PIL import 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")
img.save(image_path)
return image_path
@@ -49,42 +49,41 @@ def sample_image(temp_dir):
class TestBasicSerialization:
"""Tests for basic save/load functionality"""
-
+
def test_save_empty_project(self, sample_project, temp_dir):
"""Test saving an empty project to ZIP"""
zip_path = os.path.join(temp_dir, "empty_project.ppz")
-
+
success, error = save_to_zip(sample_project, zip_path)
-
+
assert success is True
assert error is None
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):
"""Test that .ppz extension is added automatically"""
zip_path = os.path.join(temp_dir, "project")
-
+
success, error = save_to_zip(sample_project, zip_path)
-
+
assert success is True
- expected_path = zip_path + '.ppz'
+ expected_path = zip_path + ".ppz"
assert os.path.exists(expected_path)
-
+
def test_load_empty_project(self, sample_project, temp_dir):
"""Test loading an empty project from ZIP"""
zip_path = os.path.join(temp_dir, "empty_project.ppz")
save_to_zip(sample_project, zip_path)
-
+
loaded_project = load_from_zip(zip_path)
-
assert loaded_project is not None
assert loaded_project.name == "Test Project"
assert loaded_project.page_size_mm == (210, 297)
assert loaded_project.working_dpi == 300
assert len(loaded_project.pages) == 0
-
+
def test_load_nonexistent_file(self, temp_dir):
"""Test loading from a non-existent file"""
zip_path = os.path.join(temp_dir, "nonexistent.ppz")
@@ -95,34 +94,34 @@ class TestBasicSerialization:
except Exception as error:
assert error is not None
assert "not found" in str(error).lower()
-
+
def test_save_project_with_pages(self, sample_project, temp_dir):
"""Test saving a project with multiple pages"""
# Add pages
for i in range(3):
layout = PageLayout()
- page = Page(layout=layout, page_number=i+1)
+ page = Page(layout=layout, page_number=i + 1)
sample_project.add_page(page)
-
+
zip_path = os.path.join(temp_dir, "project_with_pages.ppz")
success, error = save_to_zip(sample_project, zip_path)
-
+
assert success is True
assert os.path.exists(zip_path)
-
+
def test_load_project_with_pages(self, sample_project, temp_dir):
"""Test loading a project with multiple pages"""
# Add pages
for i in range(3):
layout = PageLayout()
- page = Page(layout=layout, page_number=i+1)
+ page = Page(layout=layout, page_number=i + 1)
sample_project.add_page(page)
-
+
# Save and load
zip_path = os.path.join(temp_dir, "project_with_pages.ppz")
save_to_zip(sample_project, zip_path)
loaded_project = load_from_zip(zip_path)
-
+
assert loaded_project is not None
assert len(loaded_project.pages) == 3
assert loaded_project.pages[0].page_number == 1
@@ -131,139 +130,139 @@ class TestBasicSerialization:
class TestZipStructure:
"""Tests for ZIP file structure and content"""
-
+
def test_zip_contains_project_json(self, sample_project, temp_dir):
"""Test that ZIP contains project.json"""
zip_path = os.path.join(temp_dir, "test.ppz")
save_to_zip(sample_project, zip_path)
-
- with zipfile.ZipFile(zip_path, 'r') as zipf:
- assert 'project.json' in zipf.namelist()
-
+
+ with zipfile.ZipFile(zip_path, "r") as zipf:
+ assert "project.json" in zipf.namelist()
+
def test_project_json_is_valid(self, sample_project, temp_dir):
"""Test that project.json contains valid JSON"""
zip_path = os.path.join(temp_dir, "test.ppz")
save_to_zip(sample_project, zip_path)
-
- with zipfile.ZipFile(zip_path, 'r') as zipf:
- project_json = zipf.read('project.json').decode('utf-8')
+
+ with zipfile.ZipFile(zip_path, "r") as zipf:
+ project_json = zipf.read("project.json").decode("utf-8")
data = json.loads(project_json)
-
- assert 'name' in data
- assert 'serialization_version' in data
- assert data['name'] == "Test Project"
-
+
+ assert "name" in data
+ assert "serialization_version" in data
+ assert data["name"] == "Test Project"
+
def test_version_in_serialized_data(self, sample_project, temp_dir):
"""Test that version information is included"""
zip_path = os.path.join(temp_dir, "test.ppz")
save_to_zip(sample_project, zip_path)
-
- with zipfile.ZipFile(zip_path, 'r') as zipf:
- project_json = zipf.read('project.json').decode('utf-8')
+
+ with zipfile.ZipFile(zip_path, "r") as zipf:
+ project_json = zipf.read("project.json").decode("utf-8")
data = json.loads(project_json)
-
- assert 'serialization_version' in data
- assert data['serialization_version'] == "3.0"
+
+ assert "serialization_version" in data
+ assert data["serialization_version"] == "3.0"
class TestAssetManagement:
"""Tests for asset bundling and management"""
-
+
def test_save_project_with_image(self, sample_project, sample_image, temp_dir):
"""Test saving a project with an image"""
# Import image to project
imported_path = sample_project.asset_manager.import_asset(sample_image)
-
+
# Create page with image
layout = PageLayout()
img_data = ImageData(image_path=imported_path, x=10, y=10, width=100, height=100)
layout.add_element(img_data)
page = Page(layout=layout, page_number=1)
sample_project.add_page(page)
-
+
# Save
zip_path = os.path.join(temp_dir, "project_with_image.ppz")
success, error = save_to_zip(sample_project, zip_path)
-
+
assert success is True
assert os.path.exists(zip_path)
-
+
def test_assets_folder_in_zip(self, sample_project, sample_image, temp_dir):
"""Test that assets folder is included in ZIP"""
# Import image
imported_path = sample_project.asset_manager.import_asset(sample_image)
-
+
# Create page with image
layout = PageLayout()
img_data = ImageData(image_path=imported_path, x=10, y=10, width=100, height=100)
layout.add_element(img_data)
page = Page(layout=layout, page_number=1)
sample_project.add_page(page)
-
+
# Save
zip_path = os.path.join(temp_dir, "project_with_image.ppz")
save_to_zip(sample_project, zip_path)
-
+
# Check ZIP contents
- with zipfile.ZipFile(zip_path, 'r') as zipf:
+ with zipfile.ZipFile(zip_path, "r") as zipf:
names = zipf.namelist()
# 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
-
+
def test_load_project_with_image(self, sample_project, sample_image, temp_dir):
"""Test loading a project with images"""
# Import image
imported_path = sample_project.asset_manager.import_asset(sample_image)
-
+
# Create page with image
layout = PageLayout()
img_data = ImageData(image_path=imported_path, x=10, y=10, width=100, height=100)
layout.add_element(img_data)
page = Page(layout=layout, page_number=1)
sample_project.add_page(page)
-
+
# Save and load
zip_path = os.path.join(temp_dir, "project_with_image.ppz")
save_to_zip(sample_project, zip_path)
loaded_project = load_from_zip(zip_path)
-
+
assert loaded_project is not None
assert len(loaded_project.pages) == 1
assert len(loaded_project.pages[0].layout.elements) == 1
-
+
# Verify image element
img_element = loaded_project.pages[0].layout.elements[0]
assert isinstance(img_element, ImageData)
assert img_element.image_path != ""
-
+
def test_asset_reference_counts_preserved(self, sample_project, sample_image, temp_dir):
"""Test that asset reference counts are preserved"""
# Import image
imported_path = sample_project.asset_manager.import_asset(sample_image)
-
+
# Use image twice
layout1 = PageLayout()
img1 = ImageData(image_path=imported_path, x=10, y=10, width=100, height=100)
layout1.add_element(img1)
page1 = Page(layout=layout1, page_number=1)
sample_project.add_page(page1)
-
+
layout2 = PageLayout()
img2 = ImageData(image_path=imported_path, x=20, y=20, width=100, height=100)
layout2.add_element(img2)
page2 = Page(layout=layout2, page_number=2)
sample_project.add_page(page2)
-
+
# Get relative path for reference count check
rel_path = os.path.relpath(imported_path, sample_project.folder_path)
original_ref_count = sample_project.asset_manager.get_reference_count(rel_path)
-
+
# Save and load
zip_path = os.path.join(temp_dir, "project_refs.ppz")
save_to_zip(sample_project, zip_path)
loaded_project = load_from_zip(zip_path)
-
+
assert loaded_project is not None
# Reference counts should be preserved
# Note: The actual reference counting behavior depends on deserialize implementation
@@ -271,7 +270,7 @@ class TestAssetManagement:
class TestPortability:
"""Tests for project portability across different locations"""
-
+
def test_load_to_different_directory(self, sample_project, sample_image, temp_dir):
"""Test loading project to a different directory"""
# Import image and create page
@@ -281,23 +280,23 @@ class TestPortability:
layout.add_element(img_data)
page = Page(layout=layout, page_number=1)
sample_project.add_page(page)
-
+
# Save
zip_path = os.path.join(temp_dir, "portable_project.ppz")
save_to_zip(sample_project, zip_path)
-
+
# Load to a different location
new_location = os.path.join(temp_dir, "different_location")
loaded_project = load_from_zip(zip_path, extract_to=new_location)
-
+
assert loaded_project is not None
assert loaded_project.folder_path == new_location
assert os.path.exists(new_location)
-
+
# Verify assets were extracted
assets_folder = os.path.join(new_location, "assets")
assert os.path.exists(assets_folder)
-
+
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"""
# Import image
@@ -307,19 +306,19 @@ class TestPortability:
layout.add_element(img_data)
page = Page(layout=layout, page_number=1)
sample_project.add_page(page)
-
+
# Save
zip_path = os.path.join(temp_dir, "portable_project.ppz")
save_to_zip(sample_project, zip_path)
-
+
# Load to different location
new_location = os.path.join(temp_dir, "new_location")
loaded_project = load_from_zip(zip_path, extract_to=new_location)
-
+
# Verify image path is accessible from new location
img_element = loaded_project.pages[0].layout.elements[0]
image_path = img_element.image_path
-
+
# Image path should exist
# Note: May be absolute or relative depending on implementation
if not os.path.isabs(image_path):
@@ -331,77 +330,73 @@ class TestPortability:
class TestProjectInfo:
"""Tests for get_project_info utility function"""
-
+
def test_get_project_info(self, sample_project, temp_dir):
"""Test getting project info without loading"""
# Add some pages
for i in range(5):
layout = PageLayout()
- page = Page(layout=layout, page_number=i+1)
+ page = Page(layout=layout, page_number=i + 1)
sample_project.add_page(page)
-
+
# Save
zip_path = os.path.join(temp_dir, "info_test.ppz")
save_to_zip(sample_project, zip_path)
-
+
# Get info
info = get_project_info(zip_path)
-
+
assert info is not None
- assert info['name'] == "Test Project"
- assert info['page_count'] == 5
- assert info['version'] == "3.0"
- assert info['working_dpi'] == 300
-
+ assert info["name"] == "Test Project"
+ assert info["page_count"] == 5
+ assert info["version"] == "3.0"
+ assert info["working_dpi"] == 300
+
def test_get_info_invalid_zip(self, temp_dir):
"""Test getting info from invalid ZIP"""
zip_path = os.path.join(temp_dir, "invalid.ppz")
-
+
info = get_project_info(zip_path)
-
+
assert info is None
class TestEdgeCases:
"""Tests for edge cases and error handling"""
-
+
def test_save_to_invalid_path(self, sample_project):
"""Test saving to an invalid path"""
invalid_path = "/nonexistent/directory/project.ppz"
-
+
success, error = save_to_zip(sample_project, invalid_path)
-
+
assert success is False
assert error is not None
-
+
def test_load_corrupted_zip(self, temp_dir):
"""Test loading a corrupted ZIP file"""
# Create a fake corrupted file
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")
-
+
try:
-
loaded_project = load_from_zip(corrupted_path)
-
assert False, "Should have raised an exception"
-
except Exception as error:
-
assert error is not None
-
+
def test_load_zip_without_project_json(self, temp_dir):
"""Test loading a ZIP without project.json"""
zip_path = os.path.join(temp_dir, "no_json.ppz")
# Create ZIP without project.json
- with zipfile.ZipFile(zip_path, 'w') as zipf:
- zipf.writestr('dummy.txt', 'dummy content')
+ with zipfile.ZipFile(zip_path, "w") as zipf:
+ zipf.writestr("dummy.txt", "dummy content")
try:
loaded_project = load_from_zip(zip_path)
@@ -409,27 +404,24 @@ class TestEdgeCases:
except Exception as error:
assert error is not None
assert "project.json not found" in str(error)
-
+
def test_project_with_text_elements(self, sample_project, temp_dir):
"""Test saving/loading project with text elements"""
# Create page with text
layout = PageLayout()
- text = TextBoxData(
- text_content="Hello World",
- x=10, y=10, width=200, height=50
- )
+ text = TextBoxData(text_content="Hello World", x=10, y=10, width=200, height=50)
layout.add_element(text)
page = Page(layout=layout, page_number=1)
sample_project.add_page(page)
-
+
# Save and load
zip_path = os.path.join(temp_dir, "with_text.ppz")
save_to_zip(sample_project, zip_path)
loaded_project = load_from_zip(zip_path)
-
+
assert loaded_project is not None
assert len(loaded_project.pages) == 1
-
+
text_element = loaded_project.pages[0].layout.elements[0]
assert isinstance(text_element, TextBoxData)
assert text_element.text_content == "Hello World"
diff --git a/tests/test_project_serializer_full.py b/tests/test_project_serializer_full.py
new file mode 100644
index 0000000..2f3bdec
--- /dev/null
+++ b/tests/test_project_serializer_full.py
@@ -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
diff --git a/tests/test_ribbon_builder.py b/tests/test_ribbon_builder.py
new file mode 100644
index 0000000..a945371
--- /dev/null
+++ b/tests/test_ribbon_builder.py
@@ -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
diff --git a/tests/test_ribbon_widget.py b/tests/test_ribbon_widget.py
new file mode 100644
index 0000000..cc53609
--- /dev/null
+++ b/tests/test_ribbon_widget.py
@@ -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
diff --git a/tests/test_rotation_serialization.py b/tests/test_rotation_serialization.py
index 83fd042..7088695 100755
--- a/tests/test_rotation_serialization.py
+++ b/tests/test_rotation_serialization.py
@@ -19,7 +19,7 @@ class TestRotationSerialization:
def sample_image(self):
"""Create a sample test image"""
# 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
def test_serialize_rotation_metadata(self):
@@ -47,7 +47,7 @@ class TestRotationSerialization:
"image_path": "test.jpg",
"crop_info": (0, 0, 1, 1),
"pil_rotation_90": 1,
- "image_dimensions": (400, 200)
+ "image_dimensions": (400, 200),
}
img_data = ImageData()
@@ -71,8 +71,10 @@ class TestRotationSerialization:
img_data._on_async_image_loaded(sample_image)
# Verify dimensions are updated to rotated dimensions
- assert img_data.image_dimensions == (200, 400), \
- f"Expected rotated dimensions (200, 400), got {img_data.image_dimensions}"
+ assert 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_height == 400
@@ -143,7 +145,7 @@ class TestRotationSerialization:
"image_path": "test.jpg",
"crop_info": (0, 0, 1, 1),
"pil_rotation_90": 0, # Not set in old format
- "image_dimensions": (400, 200)
+ "image_dimensions": (400, 200),
}
img_data = ImageData()
@@ -182,7 +184,6 @@ class TestRotationSerialization:
img2._on_async_image_loaded(sample_image)
# Verify dimensions are STILL correct after reload
- assert img2.image_dimensions == (200, 400), \
- "Dimensions should remain correct after reload"
+ assert img2.image_dimensions == (200, 400), "Dimensions should remain correct after reload"
assert img2._img_width == 200
assert img2._img_height == 400
diff --git a/tests/test_size_ops_mixin.py b/tests/test_size_ops_mixin.py
index 4913df8..58b25ae 100755
--- a/tests/test_size_ops_mixin.py
+++ b/tests/test_size_ops_mixin.py
@@ -30,7 +30,7 @@ class TestSizeWindow(SizeOperationsMixin, QMainWindow):
return len(self.gl_widget.selected_elements) >= min_count
def get_current_page(self):
- if hasattr(self, '_current_page'):
+ if hasattr(self, "_current_page"):
return self._current_page
return None
@@ -47,7 +47,7 @@ class TestSizeWindow(SizeOperationsMixin, QMainWindow):
class TestMakeSameSize:
"""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):
window = TestSizeWindow()
qtbot.addWidget(window)
@@ -57,10 +57,7 @@ class TestMakeSameSize:
window.gl_widget.selected_elements = {element1, element2}
- mock_manager.make_same_size.return_value = [
- (element1, (0, 0), (100, 100)),
- (element2, (150, 0), (200, 150))
- ]
+ mock_manager.make_same_size.return_value = [(element1, (0, 0), (100, 100)), (element2, (150, 0), (200, 150))]
window.make_same_size()
@@ -84,7 +81,7 @@ class TestMakeSameSize:
class TestMakeSameWidth:
"""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):
window = TestSizeWindow()
qtbot.addWidget(window)
@@ -94,10 +91,7 @@ class TestMakeSameWidth:
window.gl_widget.selected_elements = {element1, element2}
- mock_manager.make_same_width.return_value = [
- (element1, (0, 0), (100, 100)),
- (element2, (150, 0), (200, 150))
- ]
+ mock_manager.make_same_width.return_value = [(element1, (0, 0), (100, 100)), (element2, (150, 0), (200, 150))]
window.make_same_width()
@@ -109,7 +103,7 @@ class TestMakeSameWidth:
class TestMakeSameHeight:
"""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):
window = TestSizeWindow()
qtbot.addWidget(window)
@@ -119,10 +113,7 @@ class TestMakeSameHeight:
window.gl_widget.selected_elements = {element1, element2}
- mock_manager.make_same_height.return_value = [
- (element1, (0, 0), (100, 100)),
- (element2, (150, 0), (200, 150))
- ]
+ mock_manager.make_same_height.return_value = [(element1, (0, 0), (100, 100)), (element2, (150, 0), (200, 150))]
window.make_same_height()
@@ -134,7 +125,7 @@ class TestMakeSameHeight:
class TestFitToWidth:
"""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):
window = TestSizeWindow()
qtbot.addWidget(window)
@@ -174,7 +165,7 @@ class TestFitToWidth:
class TestFitToHeight:
"""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):
window = TestSizeWindow()
qtbot.addWidget(window)
@@ -199,7 +190,7 @@ class TestFitToHeight:
class TestFitToPage:
"""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):
window = TestSizeWindow()
qtbot.addWidget(window)
@@ -238,7 +229,7 @@ class TestFitToPage:
class TestSizeCommandPattern:
"""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):
window = TestSizeWindow()
qtbot.addWidget(window)
@@ -248,10 +239,7 @@ class TestSizeCommandPattern:
window.gl_widget.selected_elements = {element1, element2}
- mock_manager.make_same_size.return_value = [
- (element1, (0, 0), (100, 100)),
- (element2, (150, 0), (200, 150))
- ]
+ mock_manager.make_same_size.return_value = [(element1, (0, 0), (100, 100)), (element2, (150, 0), (200, 150))]
assert not window.project.history.can_undo()
@@ -259,7 +247,7 @@ class TestSizeCommandPattern:
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):
window = TestSizeWindow()
qtbot.addWidget(window)
@@ -284,7 +272,7 @@ class TestSizeCommandPattern:
class TestExpandImage:
"""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):
window = TestSizeWindow()
qtbot.addWidget(window)
diff --git a/tests/test_snapping.py b/tests/test_snapping.py
index 9f26e0e..bd02279 100755
--- a/tests/test_snapping.py
+++ b/tests/test_snapping.py
@@ -11,25 +11,25 @@ class TestGuide:
def test_guide_initialization(self):
"""Test Guide initialization"""
- guide = Guide(position=50.0, orientation='vertical')
+ guide = Guide(position=50.0, orientation="vertical")
assert guide.position == 50.0
- assert guide.orientation == 'vertical'
+ assert guide.orientation == "vertical"
def test_guide_serialization(self):
"""Test Guide serialization to dictionary"""
- guide = Guide(position=75.5, orientation='horizontal')
+ guide = Guide(position=75.5, orientation="horizontal")
data = guide.serialize()
- assert data['position'] == 75.5
- assert data['orientation'] == 'horizontal'
+ assert data["position"] == 75.5
+ assert data["orientation"] == "horizontal"
def test_guide_deserialization(self):
"""Test Guide deserialization from dictionary"""
- data = {'position': 100.0, 'orientation': 'vertical'}
+ data = {"position": 100.0, "orientation": "vertical"}
guide = Guide.deserialize(data)
assert guide.position == 100.0
- assert guide.orientation == 'vertical'
+ assert guide.orientation == "vertical"
def test_guide_deserialization_with_defaults(self):
"""Test Guide deserialization with missing fields uses defaults"""
@@ -37,7 +37,7 @@ class TestGuide:
guide = Guide.deserialize(data)
assert guide.position == 0
- assert guide.orientation == 'vertical'
+ assert guide.orientation == "vertical"
class TestSnappingSystem:
@@ -46,7 +46,7 @@ class TestSnappingSystem:
def test_initialization_default(self):
"""Test SnappingSystem initialization with default values"""
system = SnappingSystem()
-
+
assert system.snap_threshold_mm == 5.0
assert system.grid_size_mm == 10.0
assert system.snap_to_grid == False
@@ -62,19 +62,19 @@ class TestSnappingSystem:
def test_add_guide(self):
"""Test adding a guide"""
system = SnappingSystem()
- guide = system.add_guide(position=50.0, orientation='vertical')
+ guide = system.add_guide(position=50.0, orientation="vertical")
assert len(system.guides) == 1
assert guide.position == 50.0
- assert guide.orientation == 'vertical'
+ assert guide.orientation == "vertical"
assert guide in system.guides
def test_add_multiple_guides(self):
"""Test adding multiple guides"""
system = SnappingSystem()
- guide1 = system.add_guide(position=50.0, orientation='vertical')
- guide2 = system.add_guide(position=100.0, orientation='horizontal')
- guide3 = system.add_guide(position=150.0, orientation='vertical')
+ guide1 = system.add_guide(position=50.0, orientation="vertical")
+ guide2 = system.add_guide(position=100.0, orientation="horizontal")
+ guide3 = system.add_guide(position=150.0, orientation="vertical")
assert len(system.guides) == 3
assert guide1 in system.guides
@@ -84,8 +84,8 @@ class TestSnappingSystem:
def test_remove_guide(self):
"""Test removing a guide"""
system = SnappingSystem()
- guide = system.add_guide(position=50.0, orientation='vertical')
-
+ guide = system.add_guide(position=50.0, orientation="vertical")
+
system.remove_guide(guide)
assert len(system.guides) == 0
assert guide not in system.guides
@@ -93,9 +93,9 @@ class TestSnappingSystem:
def test_remove_guide_not_in_list(self):
"""Test removing a guide that's not in the list does nothing"""
system = SnappingSystem()
- guide1 = system.add_guide(position=50.0, orientation='vertical')
- guide2 = Guide(position=100.0, orientation='horizontal')
-
+ guide1 = system.add_guide(position=50.0, orientation="vertical")
+ guide2 = Guide(position=100.0, orientation="horizontal")
+
# Should not raise an error
system.remove_guide(guide2)
assert len(system.guides) == 1
@@ -104,9 +104,9 @@ class TestSnappingSystem:
def test_clear_guides(self):
"""Test clearing all guides"""
system = SnappingSystem()
- system.add_guide(position=50.0, orientation='vertical')
- system.add_guide(position=100.0, orientation='horizontal')
- system.add_guide(position=150.0, orientation='vertical')
+ system.add_guide(position=50.0, orientation="vertical")
+ system.add_guide(position=100.0, orientation="horizontal")
+ system.add_guide(position=150.0, orientation="vertical")
system.clear_guides()
assert len(system.guides) == 0
@@ -151,13 +151,13 @@ class TestSnappingSystem:
# Position near a grid line
dpi = 300
grid_size_px = 10.0 * dpi / 25.4 # ~118 pixels
-
+
position = (grid_size_px + 5, grid_size_px + 5) # Close to a grid point
size = (100.0, 100.0)
page_size = (210.0, 297.0)
snapped = system.snap_position(position, size, page_size, dpi=dpi)
-
+
# Should snap to nearest grid line
assert abs(snapped[0] - grid_size_px) < 1 # Allow small floating point error
assert abs(snapped[1] - grid_size_px) < 1
@@ -173,8 +173,8 @@ class TestSnappingSystem:
guide_pos_mm = 50.0
guide_pos_px = guide_pos_mm * dpi / 25.4
- system.add_guide(position=guide_pos_mm, orientation='vertical')
- system.add_guide(position=guide_pos_mm, orientation='horizontal')
+ system.add_guide(position=guide_pos_mm, orientation="vertical")
+ system.add_guide(position=guide_pos_mm, orientation="horizontal")
# Position near the guides
position = (guide_pos_px + 5, guide_pos_px + 5)
@@ -182,7 +182,7 @@ class TestSnappingSystem:
page_size = (210.0, 297.0)
snapped = system.snap_position(position, size, page_size, dpi=dpi)
-
+
# Should snap to guides
assert abs(snapped[0] - guide_pos_px) < 1
assert abs(snapped[1] - guide_pos_px) < 1
@@ -203,6 +203,7 @@ class TestSnappingSystem:
def test_snap_resize_bottom_right_handle(self):
"""Test snap_resize with bottom-right handle"""
from pyPhotoAlbum.snapping import SnapResizeParams
+
system = SnappingSystem(snap_threshold_mm=5.0)
system.snap_to_grid = True
system.grid_size_mm = 10.0
@@ -211,17 +212,11 @@ class TestSnappingSystem:
size = (200.0, 200.0)
dx = 10.0
dy = 10.0
- resize_handle = 'se'
+ resize_handle = "se"
page_size = (210.0, 297.0)
params = SnapResizeParams(
- position=position,
- size=size,
- dx=dx,
- dy=dy,
- resize_handle=resize_handle,
- page_size=page_size,
- dpi=300
+ position=position, size=size, dx=dx, dy=dy, resize_handle=resize_handle, page_size=page_size, dpi=300
)
new_pos, new_size = system.snap_resize(params)
@@ -234,6 +229,7 @@ class TestSnappingSystem:
def test_snap_resize_top_left_handle(self):
"""Test snap_resize with top-left handle"""
from pyPhotoAlbum.snapping import SnapResizeParams
+
system = SnappingSystem(snap_threshold_mm=5.0)
system.snap_to_edges = True
@@ -241,11 +237,12 @@ class TestSnappingSystem:
size = (200.0, 200.0)
dx = -10.0
dy = -10.0
- resize_handle = 'nw'
+ resize_handle = "nw"
page_size = (210.0, 297.0)
- params = SnapResizeParams(position=position, size=size, dx=dx, dy=dy,
- resize_handle=resize_handle, page_size=page_size, dpi=300)
+ params = SnapResizeParams(
+ position=position, size=size, dx=dx, dy=dy, resize_handle=resize_handle, page_size=page_size, dpi=300
+ )
new_pos, new_size = system.snap_resize(params)
# Both position and size should change for top-left handle
@@ -255,6 +252,7 @@ class TestSnappingSystem:
def test_snap_resize_top_handle(self):
"""Test snap_resize with top handle only"""
from pyPhotoAlbum.snapping import SnapResizeParams
+
system = SnappingSystem(snap_threshold_mm=5.0)
system.snap_to_edges = True
@@ -262,11 +260,12 @@ class TestSnappingSystem:
size = (200.0, 200.0)
dx = 0.0
dy = -10.0
- resize_handle = 'n'
+ resize_handle = "n"
page_size = (210.0, 297.0)
- params = SnapResizeParams(position=position, size=size, dx=dx, dy=dy,
- resize_handle=resize_handle, page_size=page_size, dpi=300)
+ params = SnapResizeParams(
+ position=position, size=size, dx=dx, dy=dy, resize_handle=resize_handle, page_size=page_size, dpi=300
+ )
new_pos, new_size = system.snap_resize(params)
# X position should stay same, Y should change
@@ -279,6 +278,7 @@ class TestSnappingSystem:
def test_snap_resize_right_handle(self):
"""Test snap_resize with right handle only"""
from pyPhotoAlbum.snapping import SnapResizeParams
+
system = SnappingSystem(snap_threshold_mm=5.0)
system.snap_to_edges = True
@@ -286,11 +286,12 @@ class TestSnappingSystem:
size = (200.0, 200.0)
dx = 10.0
dy = 0.0
- resize_handle = 'e'
+ resize_handle = "e"
page_size = (210.0, 297.0)
- params = SnapResizeParams(position=position, size=size, dx=dx, dy=dy,
- resize_handle=resize_handle, page_size=page_size, dpi=300)
+ params = SnapResizeParams(
+ position=position, size=size, dx=dx, dy=dy, resize_handle=resize_handle, page_size=page_size, dpi=300
+ )
new_pos, new_size = system.snap_resize(params)
# Position should stay same
@@ -302,6 +303,7 @@ class TestSnappingSystem:
def test_snap_resize_minimum_size(self):
"""Test snap_resize enforces minimum size"""
from pyPhotoAlbum.snapping import SnapResizeParams
+
system = SnappingSystem(snap_threshold_mm=5.0)
system.snap_to_edges = False
@@ -309,11 +311,12 @@ class TestSnappingSystem:
size = (50.0, 50.0)
dx = -100.0 # Try to make it very small
dy = -100.0
- resize_handle = 'se'
+ resize_handle = "se"
page_size = (210.0, 297.0)
- params = SnapResizeParams(position=position, size=size, dx=dx, dy=dy,
- resize_handle=resize_handle, page_size=page_size, dpi=300)
+ params = SnapResizeParams(
+ position=position, size=size, dx=dx, dy=dy, resize_handle=resize_handle, page_size=page_size, dpi=300
+ )
new_pos, new_size = system.snap_resize(params)
# Should enforce minimum size of 10 pixels
@@ -323,6 +326,7 @@ class TestSnappingSystem:
def test_snap_resize_all_handles(self):
"""Test snap_resize works with all handle types"""
from pyPhotoAlbum.snapping import SnapResizeParams
+
system = SnappingSystem(snap_threshold_mm=5.0)
system.snap_to_edges = False
@@ -332,11 +336,12 @@ class TestSnappingSystem:
dy = 10.0
page_size = (210.0, 297.0)
- handles = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']
+ handles = ["nw", "n", "ne", "e", "se", "s", "sw", "w"]
for handle in handles:
- params = SnapResizeParams(position=position, size=size, dx=dx, dy=dy,
- resize_handle=handle, page_size=page_size, dpi=300)
+ params = SnapResizeParams(
+ position=position, size=size, dx=dx, dy=dy, resize_handle=handle, page_size=page_size, dpi=300
+ )
new_pos, new_size = system.snap_resize(params)
# Should return valid position and size
assert isinstance(new_pos, tuple)
@@ -356,9 +361,9 @@ class TestSnappingSystem:
page_size = (210.0, 297.0)
lines = system.get_snap_lines(page_size, dpi=300)
- assert lines['grid'] == []
- assert lines['edges'] == []
- assert lines['guides'] == []
+ assert lines["grid"] == []
+ assert lines["edges"] == []
+ assert lines["guides"] == []
def test_get_snap_lines_with_grid(self):
"""Test get_snap_lines with grid enabled"""
@@ -370,11 +375,11 @@ class TestSnappingSystem:
lines = system.get_snap_lines(page_size, dpi=300)
# Should have grid lines
- assert len(lines['grid']) > 0
-
+ assert len(lines["grid"]) > 0
+
# Should have both vertical and horizontal grid lines
- vertical_lines = [line for line in lines['grid'] if line[0] == 'vertical']
- horizontal_lines = [line for line in lines['grid'] if line[0] == 'horizontal']
+ vertical_lines = [line for line in lines["grid"] if line[0] == "vertical"]
+ horizontal_lines = [line for line in lines["grid"] if line[0] == "horizontal"]
assert len(vertical_lines) > 0
assert len(horizontal_lines) > 0
@@ -387,34 +392,34 @@ class TestSnappingSystem:
lines = system.get_snap_lines(page_size, dpi=300)
# Should have exactly 4 edge lines (left, right, top, bottom)
- assert len(lines['edges']) == 4
-
+ assert len(lines["edges"]) == 4
+
# Check for vertical edges
- vertical_edges = [line for line in lines['edges'] if line[0] == 'vertical']
+ vertical_edges = [line for line in lines["edges"] if line[0] == "vertical"]
assert len(vertical_edges) == 2
-
+
# Check for horizontal edges
- horizontal_edges = [line for line in lines['edges'] if line[0] == 'horizontal']
+ horizontal_edges = [line for line in lines["edges"] if line[0] == "horizontal"]
assert len(horizontal_edges) == 2
def test_get_snap_lines_with_guides(self):
"""Test get_snap_lines with guides"""
system = SnappingSystem()
system.snap_to_guides = True
-
- system.add_guide(position=50.0, orientation='vertical')
- system.add_guide(position=100.0, orientation='horizontal')
- system.add_guide(position=150.0, orientation='vertical')
+
+ system.add_guide(position=50.0, orientation="vertical")
+ system.add_guide(position=100.0, orientation="horizontal")
+ system.add_guide(position=150.0, orientation="vertical")
page_size = (210.0, 297.0)
lines = system.get_snap_lines(page_size, dpi=300)
# Should have guide lines
- assert len(lines['guides']) == 3
-
+ assert len(lines["guides"]) == 3
+
# Check orientations
- vertical_guides = [line for line in lines['guides'] if line[0] == 'vertical']
- horizontal_guides = [line for line in lines['guides'] if line[0] == 'horizontal']
+ vertical_guides = [line for line in lines["guides"] if line[0] == "vertical"]
+ horizontal_guides = [line for line in lines["guides"] if line[0] == "horizontal"]
assert len(vertical_guides) == 2
assert len(horizontal_guides) == 1
@@ -425,35 +430,32 @@ class TestSnappingSystem:
system.snap_to_grid = True
system.snap_to_edges = False
system.snap_to_guides = True
-
- system.add_guide(position=50.0, orientation='vertical')
- system.add_guide(position=100.0, orientation='horizontal')
+
+ system.add_guide(position=50.0, orientation="vertical")
+ system.add_guide(position=100.0, orientation="horizontal")
data = system.serialize()
- assert data['snap_threshold_mm'] == 3.0
- assert data['grid_size_mm'] == 15.0
- assert data['snap_to_grid'] == True
- assert data['snap_to_edges'] == False
- assert data['snap_to_guides'] == True
- assert len(data['guides']) == 2
+ assert data["snap_threshold_mm"] == 3.0
+ assert data["grid_size_mm"] == 15.0
+ assert data["snap_to_grid"] == True
+ assert data["snap_to_edges"] == False
+ assert data["snap_to_guides"] == True
+ assert len(data["guides"]) == 2
def test_deserialization(self):
"""Test SnappingSystem deserialization from dictionary"""
system = SnappingSystem()
-
+
data = {
- 'snap_threshold_mm': 4.0,
- 'grid_size_mm': 20.0,
- 'snap_to_grid': True,
- 'snap_to_edges': False,
- 'snap_to_guides': True,
- 'guides': [
- {'position': 50.0, 'orientation': 'vertical'},
- {'position': 100.0, 'orientation': 'horizontal'}
- ]
+ "snap_threshold_mm": 4.0,
+ "grid_size_mm": 20.0,
+ "snap_to_grid": True,
+ "snap_to_edges": False,
+ "snap_to_guides": True,
+ "guides": [{"position": 50.0, "orientation": "vertical"}, {"position": 100.0, "orientation": "horizontal"}],
}
-
+
system.deserialize(data)
assert system.snap_threshold_mm == 4.0
@@ -463,15 +465,15 @@ class TestSnappingSystem:
assert system.snap_to_guides == True
assert len(system.guides) == 2
assert system.guides[0].position == 50.0
- assert system.guides[0].orientation == 'vertical'
+ assert system.guides[0].orientation == "vertical"
assert system.guides[1].position == 100.0
- assert system.guides[1].orientation == 'horizontal'
+ assert system.guides[1].orientation == "horizontal"
def test_deserialization_with_defaults(self):
"""Test SnappingSystem deserialization with missing fields uses defaults"""
system = SnappingSystem()
data = {}
-
+
system.deserialize(data)
assert system.snap_threshold_mm == 5.0
@@ -488,10 +490,10 @@ class TestSnappingSystem:
original.snap_to_grid = True
original.snap_to_edges = True
original.snap_to_guides = False
-
- original.add_guide(position=25.5, orientation='vertical')
- original.add_guide(position=75.5, orientation='horizontal')
- original.add_guide(position=125.5, orientation='vertical')
+
+ original.add_guide(position=25.5, orientation="vertical")
+ original.add_guide(position=75.5, orientation="horizontal")
+ original.add_guide(position=125.5, orientation="vertical")
data = original.serialize()
restored = SnappingSystem()
@@ -503,7 +505,7 @@ class TestSnappingSystem:
assert restored.snap_to_edges == original.snap_to_edges
assert restored.snap_to_guides == original.snap_to_guides
assert len(restored.guides) == len(original.guides)
-
+
for orig_guide, rest_guide in zip(original.guides, restored.guides):
assert rest_guide.position == orig_guide.position
assert rest_guide.orientation == orig_guide.orientation
diff --git a/tests/test_snapping_system.py b/tests/test_snapping_system.py
new file mode 100644
index 0000000..615a5bd
--- /dev/null
+++ b/tests/test_snapping_system.py
@@ -0,0 +1,630 @@
+"""
+Comprehensive tests for SnappingSystem
+"""
+
+import pytest
+import math
+from unittest.mock import Mock
+
+from pyPhotoAlbum.snapping import SnappingSystem, Guide, SnapResizeParams
+
+
+class TestGuide:
+ """Tests for Guide dataclass"""
+
+ def test_guide_creation(self):
+ """Test creating a Guide"""
+ guide = Guide(position=100.0, orientation="vertical")
+ assert guide.position == 100.0
+ assert guide.orientation == "vertical"
+
+ def test_guide_serialize(self):
+ """Test Guide serialization"""
+ guide = Guide(position=50.5, orientation="horizontal")
+ data = guide.serialize()
+
+ assert data["position"] == 50.5
+ assert data["orientation"] == "horizontal"
+
+ def test_guide_deserialize(self):
+ """Test Guide deserialization"""
+ data = {"position": 75.0, "orientation": "vertical"}
+ guide = Guide.deserialize(data)
+
+ assert guide.position == 75.0
+ assert guide.orientation == "vertical"
+
+ def test_guide_deserialize_defaults(self):
+ """Test Guide deserialization with missing fields"""
+ guide = Guide.deserialize({})
+
+ assert guide.position == 0
+ assert guide.orientation == "vertical"
+
+
+class TestSnappingSystemInit:
+ """Tests for SnappingSystem initialization"""
+
+ def test_default_init(self):
+ """Test default initialization"""
+ snap = SnappingSystem()
+
+ assert snap.snap_threshold_mm == 5.0
+ assert snap.grid_size_mm == 10.0
+ assert snap.snap_to_grid is False
+ assert snap.snap_to_edges is True
+ assert snap.snap_to_guides is True
+ assert snap.guides == []
+
+ def test_custom_threshold(self):
+ """Test initialization with custom threshold"""
+ snap = SnappingSystem(snap_threshold_mm=10.0)
+ assert snap.snap_threshold_mm == 10.0
+
+
+class TestGuideManagement:
+ """Tests for guide management methods"""
+
+ def test_add_guide(self):
+ """Test adding a guide"""
+ snap = SnappingSystem()
+ guide = snap.add_guide(100.0, "vertical")
+
+ assert len(snap.guides) == 1
+ assert snap.guides[0].position == 100.0
+ assert snap.guides[0].orientation == "vertical"
+
+ def test_add_multiple_guides(self):
+ """Test adding multiple guides"""
+ snap = SnappingSystem()
+ snap.add_guide(50.0, "horizontal")
+ snap.add_guide(100.0, "vertical")
+ snap.add_guide(150.0, "horizontal")
+
+ assert len(snap.guides) == 3
+
+ def test_remove_guide(self):
+ """Test removing a guide"""
+ snap = SnappingSystem()
+ guide = snap.add_guide(100.0, "vertical")
+
+ snap.remove_guide(guide)
+
+ assert len(snap.guides) == 0
+
+ def test_remove_nonexistent_guide(self):
+ """Test removing a guide that doesn't exist"""
+ snap = SnappingSystem()
+ guide = Guide(position=100.0, orientation="vertical")
+
+ # Should not raise exception
+ snap.remove_guide(guide)
+ assert len(snap.guides) == 0
+
+ def test_clear_guides(self):
+ """Test clearing all guides"""
+ snap = SnappingSystem()
+ snap.add_guide(50.0, "horizontal")
+ snap.add_guide(100.0, "vertical")
+ snap.add_guide(150.0, "horizontal")
+
+ snap.clear_guides()
+
+ assert len(snap.guides) == 0
+
+
+class TestSnapPosition:
+ """Tests for snap_position method"""
+
+ def test_no_snapping_when_disabled(self):
+ """Test that no snapping occurs when all snapping is disabled"""
+ snap = SnappingSystem()
+ snap.snap_to_grid = False
+ snap.snap_to_edges = False
+ snap.snap_to_guides = False
+
+ position = (100, 100)
+ size = (50, 50)
+ page_size = (210, 297) # A4 in mm
+
+ result = snap.snap_position(position, size, page_size)
+
+ assert result == position
+
+ def test_snap_to_left_edge(self):
+ """Test snapping to left page edge"""
+ snap = SnappingSystem(snap_threshold_mm=10.0)
+ snap.snap_to_edges = True
+ snap.snap_to_grid = False
+ snap.snap_to_guides = False
+
+ # Position close to left edge (within threshold)
+ # At 300 DPI, 10mm = ~118 pixels
+ position = (5, 100) # Very close to left edge (0)
+ size = (50, 50)
+ page_size = (210, 297)
+
+ result = snap.snap_position(position, size, page_size)
+
+ # Should snap to 0 (left edge)
+ assert result[0] == 0
+
+ def test_snap_to_right_edge(self):
+ """Test snapping to right page edge"""
+ snap = SnappingSystem(snap_threshold_mm=10.0)
+ snap.snap_to_edges = True
+ snap.snap_to_grid = False
+ snap.snap_to_guides = False
+
+ dpi = 300
+ page_width_mm = 210
+ page_width_px = page_width_mm * dpi / 25.4
+ element_width = 50
+
+ # Position close to right edge
+ position = (page_width_px - element_width - 5, 100)
+ size = (element_width, 50)
+ page_size = (page_width_mm, 297)
+
+ result = snap.snap_position(position, size, page_size, dpi)
+
+ # Should snap so element's right edge aligns with page right edge
+ expected_x = page_width_px - element_width
+ assert abs(result[0] - expected_x) < 1
+
+ def test_snap_to_grid(self):
+ """Test snapping to grid"""
+ snap = SnappingSystem(snap_threshold_mm=5.0)
+ snap.snap_to_grid = True
+ snap.snap_to_edges = False
+ snap.snap_to_guides = False
+ snap.grid_size_mm = 10.0
+
+ dpi = 300
+ grid_size_px = 10.0 * dpi / 25.4 # ~118 pixels
+
+ # Position slightly off grid
+ position = (grid_size_px + 10, grid_size_px + 10)
+ size = (50, 50)
+ page_size = (210, 297)
+
+ result = snap.snap_position(position, size, page_size, dpi)
+
+ # Should snap to nearest grid intersection
+ assert abs(result[0] - grid_size_px) < 1 or abs(result[0] - 2 * grid_size_px) < 1
+
+ def test_snap_to_guides(self):
+ """Test snapping to guides"""
+ snap = SnappingSystem(snap_threshold_mm=10.0)
+ snap.snap_to_edges = False
+ snap.snap_to_grid = False
+ snap.snap_to_guides = True
+
+ dpi = 300
+ guide_pos_mm = 50.0
+ guide_pos_px = guide_pos_mm * dpi / 25.4
+
+ snap.add_guide(guide_pos_mm, "vertical")
+ snap.add_guide(guide_pos_mm, "horizontal")
+
+ # Position close to guide intersection
+ position = (guide_pos_px + 5, guide_pos_px + 5)
+ size = (50, 50)
+ page_size = (210, 297)
+
+ result = snap.snap_position(position, size, page_size, dpi)
+
+ # Should snap to guide intersection
+ assert abs(result[0] - guide_pos_px) < 1 or abs(result[1] - guide_pos_px) < 1
+
+ def test_snap_uses_euclidean_distance(self):
+ """Test that snapping uses Euclidean distance for point selection"""
+ snap = SnappingSystem(snap_threshold_mm=20.0)
+ snap.snap_to_edges = True
+ snap.snap_to_grid = False
+ snap.snap_to_guides = False
+
+ # Position close to origin - should snap to (0, 0)
+ # At 300 DPI, 20mm threshold = ~236 pixels
+ # Position (50, 50) has euclidean distance ~70.7 from (0, 0)
+ # which is well within the threshold
+ position = (50, 50)
+ size = (50, 50)
+ page_size = (210, 297)
+ dpi = 300
+
+ result = snap.snap_position(position, size, page_size, dpi)
+
+ # Should snap to (0, 0) corner as it's closest and within threshold
+ # Note: snap_position considers multiple snap points; check we got one of them
+ assert result[0] == 0 or result[1] == 0, f"Expected at least one axis to snap to 0, got {result}"
+
+ def test_snap_with_project_settings(self):
+ """Test snapping with project settings override"""
+ snap = SnappingSystem()
+ snap.snap_to_grid = False # Local setting
+
+ mock_project = Mock()
+ mock_project.snap_to_grid = True
+ mock_project.snap_to_edges = False
+ mock_project.snap_to_guides = False
+ mock_project.grid_size_mm = 10.0
+ mock_project.snap_threshold_mm = 5.0
+
+ dpi = 300
+ grid_size_px = 10.0 * dpi / 25.4
+
+ # Position near grid line
+ position = (grid_size_px + 5, grid_size_px + 5)
+ size = (50, 50)
+ page_size = (210, 297)
+
+ result = snap.snap_position(position, size, page_size, dpi, mock_project)
+
+ # Should use project settings and snap to grid
+ # The result should be different from input (snapped)
+ assert result != position
+
+
+class TestSnapResize:
+ """Tests for snap_resize method"""
+
+ def test_resize_southeast_handle(self):
+ """Test resizing from SE corner"""
+ snap = SnappingSystem()
+ snap.snap_to_edges = False
+ snap.snap_to_grid = False
+ snap.snap_to_guides = False
+
+ params = SnapResizeParams(
+ position=(100, 100), size=(100, 100), dx=50, dy=50, resize_handle="se", page_size=(210, 297)
+ )
+
+ new_pos, new_size = snap.snap_resize(params)
+
+ # Position should stay same for SE resize
+ assert new_pos == (100, 100)
+ # Size should increase
+ assert new_size == (150, 150)
+
+ def test_resize_northwest_handle(self):
+ """Test resizing from NW corner"""
+ snap = SnappingSystem()
+ snap.snap_to_edges = False
+ snap.snap_to_grid = False
+ snap.snap_to_guides = False
+
+ params = SnapResizeParams(
+ position=(100, 100), size=(100, 100), dx=-20, dy=-20, resize_handle="nw", page_size=(210, 297)
+ )
+
+ new_pos, new_size = snap.snap_resize(params)
+
+ # Position should move for NW resize
+ assert new_pos == (80, 80)
+ # Size should increase
+ assert new_size == (120, 120)
+
+ def test_resize_minimum_size(self):
+ """Test that resize enforces minimum size"""
+ snap = SnappingSystem()
+ snap.snap_to_edges = False
+ snap.snap_to_grid = False
+ snap.snap_to_guides = False
+
+ params = SnapResizeParams(
+ position=(100, 100),
+ size=(50, 50),
+ dx=-100, # Would make width negative
+ dy=-100, # Would make height negative
+ resize_handle="se",
+ page_size=(210, 297),
+ )
+
+ new_pos, new_size = snap.snap_resize(params)
+
+ # Size should be clamped to minimum
+ assert new_size[0] >= 10
+ assert new_size[1] >= 10
+
+ def test_resize_snap_to_edge(self):
+ """Test that resize snaps edges to page boundaries"""
+ snap = SnappingSystem(snap_threshold_mm=10.0)
+ snap.snap_to_edges = True
+ snap.snap_to_grid = False
+ snap.snap_to_guides = False
+
+ dpi = 300
+ page_width_px = 210 * dpi / 25.4
+
+ params = SnapResizeParams(
+ position=(100, 100),
+ size=(100, 100),
+ dx=page_width_px - 200 - 5, # Almost to right edge
+ dy=0,
+ resize_handle="e",
+ page_size=(210, 297),
+ dpi=dpi,
+ )
+
+ new_pos, new_size = snap.snap_resize(params)
+
+ # Right edge should snap to page edge
+ right_edge = new_pos[0] + new_size[0]
+ assert abs(right_edge - page_width_px) < 20 # Within snap threshold
+
+
+class TestSnapEdgeToTargets:
+ """Tests for _snap_edge_to_targets method"""
+
+ def test_snap_to_page_start_edge(self):
+ """Test snapping to page start edge (0)"""
+ snap = SnappingSystem()
+ snap.snap_to_edges = True
+ snap.snap_to_grid = False
+ snap.snap_to_guides = False
+
+ dpi = 300
+ threshold_px = 50
+
+ result = snap._snap_edge_to_targets(
+ edge_position=10, page_size_mm=210, dpi=dpi, snap_threshold_px=threshold_px, orientation="vertical"
+ )
+
+ assert result == 0
+
+ def test_snap_to_page_end_edge(self):
+ """Test snapping to page end edge"""
+ snap = SnappingSystem()
+ snap.snap_to_edges = True
+ snap.snap_to_grid = False
+ snap.snap_to_guides = False
+
+ dpi = 300
+ page_size_mm = 210
+ page_size_px = page_size_mm * dpi / 25.4
+ threshold_px = 50
+
+ result = snap._snap_edge_to_targets(
+ edge_position=page_size_px - 10,
+ page_size_mm=page_size_mm,
+ dpi=dpi,
+ snap_threshold_px=threshold_px,
+ orientation="vertical",
+ )
+
+ assert result == page_size_px
+
+ def test_snap_to_grid_line(self):
+ """Test snapping to grid line"""
+ snap = SnappingSystem()
+ snap.snap_to_edges = False
+ snap.snap_to_grid = True
+ snap.snap_to_guides = False
+ snap.grid_size_mm = 10.0
+
+ dpi = 300
+ grid_size_px = 10.0 * dpi / 25.4
+ threshold_px = 50
+
+ result = snap._snap_edge_to_targets(
+ edge_position=grid_size_px + 5,
+ page_size_mm=210,
+ dpi=dpi,
+ snap_threshold_px=threshold_px,
+ orientation="vertical",
+ )
+
+ assert result == grid_size_px
+
+ def test_snap_to_guide(self):
+ """Test snapping to guide"""
+ snap = SnappingSystem()
+ snap.snap_to_edges = False
+ snap.snap_to_grid = False
+ snap.snap_to_guides = True
+
+ guide_pos_mm = 50.0
+ snap.add_guide(guide_pos_mm, "vertical")
+
+ dpi = 300
+ guide_pos_px = guide_pos_mm * dpi / 25.4
+ threshold_px = 50
+
+ result = snap._snap_edge_to_targets(
+ edge_position=guide_pos_px + 5,
+ page_size_mm=210,
+ dpi=dpi,
+ snap_threshold_px=threshold_px,
+ orientation="vertical",
+ )
+
+ assert result == guide_pos_px
+
+ def test_no_snap_when_out_of_threshold(self):
+ """Test no snap when edge is outside threshold"""
+ snap = SnappingSystem()
+ snap.snap_to_edges = True
+ snap.snap_to_grid = False
+ snap.snap_to_guides = False
+
+ dpi = 300
+ threshold_px = 10
+
+ result = snap._snap_edge_to_targets(
+ edge_position=500, # Far from any edge
+ page_size_mm=210,
+ dpi=dpi,
+ snap_threshold_px=threshold_px,
+ orientation="vertical",
+ )
+
+ assert result is None
+
+
+class TestSnapAxis:
+ """Tests for _snap_axis method"""
+
+ def test_snap_axis_to_start(self):
+ """Test snapping axis to start edge"""
+ snap = SnappingSystem()
+ snap.snap_to_edges = True
+ snap.snap_to_grid = False
+ snap.snap_to_guides = False
+
+ dpi = 300
+ threshold_px = 50
+
+ result = snap._snap_axis(
+ position=10, size=50, page_size_mm=210, dpi=dpi, snap_threshold_px=threshold_px, orientation="vertical"
+ )
+
+ assert result == 0
+
+ def test_snap_axis_to_end(self):
+ """Test snapping axis so element end aligns with page end"""
+ snap = SnappingSystem()
+ snap.snap_to_edges = True
+ snap.snap_to_grid = False
+ snap.snap_to_guides = False
+
+ dpi = 300
+ page_size_mm = 210
+ page_size_px = page_size_mm * dpi / 25.4
+ element_size = 50
+ threshold_px = 50
+
+ result = snap._snap_axis(
+ position=page_size_px - element_size - 10,
+ size=element_size,
+ page_size_mm=page_size_mm,
+ dpi=dpi,
+ snap_threshold_px=threshold_px,
+ orientation="vertical",
+ )
+
+ expected = page_size_px - element_size
+ assert abs(result - expected) < 1
+
+
+class TestGetSnapLines:
+ """Tests for get_snap_lines method"""
+
+ def test_get_snap_lines_edges_only(self):
+ """Test getting snap lines with edges only"""
+ snap = SnappingSystem()
+ snap.snap_to_edges = True
+ snap.snap_to_grid = False
+ snap.snap_to_guides = False
+
+ result = snap.get_snap_lines((210, 297))
+
+ assert len(result["edges"]) == 4 # 4 edges
+ assert len(result["grid"]) == 0
+ assert len(result["guides"]) == 0
+
+ def test_get_snap_lines_with_grid(self):
+ """Test getting snap lines with grid enabled"""
+ snap = SnappingSystem()
+ snap.snap_to_edges = False
+ snap.snap_to_grid = True
+ snap.snap_to_guides = False
+ snap.grid_size_mm = 10.0
+
+ result = snap.get_snap_lines((100, 100), dpi=300)
+
+ # Should have multiple grid lines
+ assert len(result["grid"]) > 0
+ assert len(result["edges"]) == 0
+
+ def test_get_snap_lines_with_guides(self):
+ """Test getting snap lines with guides"""
+ snap = SnappingSystem()
+ snap.snap_to_edges = False
+ snap.snap_to_grid = False
+ snap.snap_to_guides = True
+
+ snap.add_guide(50.0, "vertical")
+ snap.add_guide(100.0, "horizontal")
+
+ result = snap.get_snap_lines((210, 297))
+
+ assert len(result["guides"]) == 2
+
+
+class TestSerialization:
+ """Tests for serialize/deserialize methods"""
+
+ def test_serialize(self):
+ """Test serialization"""
+ snap = SnappingSystem(snap_threshold_mm=8.0)
+ snap.grid_size_mm = 15.0
+ snap.snap_to_grid = True
+ snap.snap_to_edges = False
+ snap.add_guide(50.0, "vertical")
+ snap.add_guide(100.0, "horizontal")
+
+ data = snap.serialize()
+
+ assert data["snap_threshold_mm"] == 8.0
+ assert data["grid_size_mm"] == 15.0
+ assert data["snap_to_grid"] is True
+ assert data["snap_to_edges"] is False
+ assert data["snap_to_guides"] is True
+ assert len(data["guides"]) == 2
+
+ def test_deserialize(self):
+ """Test deserialization"""
+ snap = SnappingSystem()
+
+ data = {
+ "snap_threshold_mm": 12.0,
+ "grid_size_mm": 20.0,
+ "snap_to_grid": True,
+ "snap_to_edges": False,
+ "snap_to_guides": False,
+ "guides": [{"position": 75.0, "orientation": "vertical"}, {"position": 125.0, "orientation": "horizontal"}],
+ }
+
+ snap.deserialize(data)
+
+ assert snap.snap_threshold_mm == 12.0
+ assert snap.grid_size_mm == 20.0
+ assert snap.snap_to_grid is True
+ assert snap.snap_to_edges is False
+ assert snap.snap_to_guides is False
+ assert len(snap.guides) == 2
+ assert snap.guides[0].position == 75.0
+ assert snap.guides[1].orientation == "horizontal"
+
+ def test_serialize_deserialize_roundtrip(self):
+ """Test serialize/deserialize roundtrip"""
+ original = SnappingSystem(snap_threshold_mm=7.5)
+ original.grid_size_mm = 12.5
+ original.snap_to_grid = True
+ original.add_guide(33.0, "vertical")
+ original.add_guide(66.0, "horizontal")
+
+ data = original.serialize()
+
+ restored = SnappingSystem()
+ restored.deserialize(data)
+
+ assert restored.snap_threshold_mm == original.snap_threshold_mm
+ assert restored.grid_size_mm == original.grid_size_mm
+ assert restored.snap_to_grid == original.snap_to_grid
+ assert restored.snap_to_edges == original.snap_to_edges
+ assert restored.snap_to_guides == original.snap_to_guides
+ assert len(restored.guides) == len(original.guides)
+
+ def test_deserialize_defaults(self):
+ """Test deserialization with missing fields uses defaults"""
+ snap = SnappingSystem()
+ snap.deserialize({})
+
+ assert snap.snap_threshold_mm == 5.0
+ assert snap.grid_size_mm == 10.0
+ assert snap.snap_to_grid is False
+ assert snap.snap_to_edges is True
+ assert snap.snap_to_guides is True
+ assert snap.guides == []
diff --git a/tests/test_template_manager.py b/tests/test_template_manager.py
index 5c015d4..d110522 100755
--- a/tests/test_template_manager.py
+++ b/tests/test_template_manager.py
@@ -25,11 +25,7 @@ class TestTemplate:
def test_initialization_with_parameters(self):
"""Test Template initialization with custom parameters"""
- template = Template(
- name="My Template",
- description="Test template",
- page_size_mm=(200, 280)
- )
+ template = Template(name="My Template", description="Test template", page_size_mm=(200, 280))
assert template.name == "My Template"
assert template.description == "Test template"
assert template.page_size_mm == (200, 280)
@@ -38,7 +34,7 @@ class TestTemplate:
"""Test adding elements to template"""
template = Template()
placeholder = PlaceholderData(x=10, y=20, width=100, height=50)
-
+
template.add_element(placeholder)
assert len(template.elements) == 1
assert template.elements[0] == placeholder
@@ -48,10 +44,10 @@ class TestTemplate:
template = Template()
elem1 = PlaceholderData(x=10, y=20, width=100, height=50)
elem2 = TextBoxData(text_content="Test", x=30, y=40, width=150, height=60)
-
+
template.add_element(elem1)
template.add_element(elem2)
-
+
assert len(template.elements) == 2
assert elem1 in template.elements
assert elem2 in template.elements
@@ -61,9 +57,9 @@ class TestTemplate:
template = Template(name="Test", description="Desc", page_size_mm=(200, 280))
placeholder = PlaceholderData(x=10, y=20, width=100, height=50)
template.add_element(placeholder)
-
+
data = template.to_dict()
-
+
assert data["name"] == "Test"
assert data["description"] == "Desc"
assert data["page_size_mm"] == (200, 280)
@@ -77,23 +73,13 @@ class TestTemplate:
"description": "Test description",
"page_size_mm": [220, 300],
"elements": [
- {
- "type": "placeholder",
- "position": (50, 60),
- "size": (120, 80),
- "placeholder_type": "image"
- },
- {
- "type": "textbox",
- "position": (70, 90),
- "size": (140, 100),
- "text_content": "Test text"
- }
- ]
+ {"type": "placeholder", "position": (50, 60), "size": (120, 80), "placeholder_type": "image"},
+ {"type": "textbox", "position": (70, 90), "size": (140, 100), "text_content": "Test text"},
+ ],
}
-
+
template = Template.from_dict(data)
-
+
assert template.name == "Loaded Template"
assert template.description == "Test description"
assert template.page_size_mm == (220, 300)
@@ -107,12 +93,12 @@ class TestTemplate:
"name": "Test",
"elements": [
{"type": "image", "position": (10, 20), "size": (100, 50)},
- {"type": "placeholder", "position": (30, 40), "size": (120, 60)}
- ]
+ {"type": "placeholder", "position": (30, 40), "size": (120, 60)},
+ ],
}
-
+
template = Template.from_dict(data)
-
+
# Should only have the placeholder, not the image
assert len(template.elements) == 1
assert isinstance(template.elements[0], PlaceholderData)
@@ -122,15 +108,15 @@ class TestTemplate:
template = Template(name="Save Test", description="Test save")
placeholder = PlaceholderData(x=10, y=20, width=100, height=50)
template.add_element(placeholder)
-
+
file_path = Path(temp_dir) / "test_template.json"
template.save_to_file(str(file_path))
-
+
# Verify file was created
assert file_path.exists()
-
+
# Verify content
- with open(file_path, 'r') as f:
+ with open(file_path, "r") as f:
data = json.load(f)
assert data["name"] == "Save Test"
assert data["description"] == "Test save"
@@ -142,23 +128,16 @@ class TestTemplate:
"name": "Load Test",
"description": "Test load",
"page_size_mm": [210, 297],
- "elements": [
- {
- "type": "placeholder",
- "position": (10, 20),
- "size": (100, 50),
- "placeholder_type": "image"
- }
- ]
+ "elements": [{"type": "placeholder", "position": (10, 20), "size": (100, 50), "placeholder_type": "image"}],
}
-
+
file_path = Path(temp_dir) / "load_test.json"
- with open(file_path, 'w') as f:
+ with open(file_path, "w") as f:
json.dump(data, f)
-
+
# Load template
template = Template.load_from_file(str(file_path))
-
+
assert template.name == "Load Test"
assert template.description == "Test load"
assert len(template.elements) == 1
@@ -177,7 +156,7 @@ class TestTemplateManager:
"""Test getting templates directory"""
manager = TemplateManager()
templates_dir = manager._get_templates_directory()
-
+
assert templates_dir.name == "templates"
assert ".pyphotoalbum" in str(templates_dir)
@@ -185,7 +164,7 @@ class TestTemplateManager:
"""Test getting built-in templates directory"""
manager = TemplateManager()
builtin_dir = manager._get_builtin_templates_directory()
-
+
assert builtin_dir.name == "templates"
assert "pyPhotoAlbum" in str(builtin_dir)
@@ -196,11 +175,11 @@ class TestTemplateManager:
builtin_dir = tmp_path / "builtin_templates"
user_dir.mkdir()
builtin_dir.mkdir()
-
+
manager = TemplateManager()
- monkeypatch.setattr(manager, 'templates_dir', user_dir)
- monkeypatch.setattr(manager, '_get_builtin_templates_directory', lambda: builtin_dir)
-
+ monkeypatch.setattr(manager, "templates_dir", user_dir)
+ monkeypatch.setattr(manager, "_get_builtin_templates_directory", lambda: builtin_dir)
+
templates = manager.list_templates()
assert templates == []
@@ -210,21 +189,21 @@ class TestTemplateManager:
builtin_dir = tmp_path / "builtin_templates"
user_dir.mkdir()
builtin_dir.mkdir()
-
+
# Create user template
user_template = user_dir / "My_Template.json"
user_template.write_text('{"name": "My Template"}')
-
+
# Create built-in template
builtin_template = builtin_dir / "Grid_2x2.json"
builtin_template.write_text('{"name": "Grid 2x2"}')
-
+
manager = TemplateManager()
- monkeypatch.setattr(manager, 'templates_dir', user_dir)
- monkeypatch.setattr(manager, '_get_builtin_templates_directory', lambda: builtin_dir)
-
+ monkeypatch.setattr(manager, "templates_dir", user_dir)
+ monkeypatch.setattr(manager, "_get_builtin_templates_directory", lambda: builtin_dir)
+
templates = manager.list_templates()
-
+
assert "[Built-in] Grid_2x2" in templates
assert "My_Template" in templates
assert len(templates) == 2
@@ -233,13 +212,13 @@ class TestTemplateManager:
"""Test saving a template"""
user_dir = tmp_path / "user_templates"
user_dir.mkdir()
-
+
manager = TemplateManager()
- monkeypatch.setattr(manager, 'templates_dir', user_dir)
-
+ monkeypatch.setattr(manager, "templates_dir", user_dir)
+
template = Template(name="Test Template")
manager.save_template(template)
-
+
# Verify file was created
template_file = user_dir / "Test Template.json"
assert template_file.exists()
@@ -248,16 +227,16 @@ class TestTemplateManager:
"""Test loading a user template"""
user_dir = tmp_path / "user_templates"
user_dir.mkdir()
-
+
# Create template file
data = {"name": "User Template", "description": "Test", "page_size_mm": [210, 297], "elements": []}
template_file = user_dir / "User Template.json"
- with open(template_file, 'w') as f:
+ with open(template_file, "w") as f:
json.dump(data, f)
-
+
manager = TemplateManager()
- monkeypatch.setattr(manager, 'templates_dir', user_dir)
-
+ monkeypatch.setattr(manager, "templates_dir", user_dir)
+
template = manager.load_template("User Template")
assert template.name == "User Template"
@@ -265,16 +244,16 @@ class TestTemplateManager:
"""Test loading a built-in template"""
builtin_dir = tmp_path / "builtin_templates"
builtin_dir.mkdir()
-
+
# Create built-in template file
data = {"name": "Grid 2x2", "description": "Built-in grid", "page_size_mm": [210, 297], "elements": []}
template_file = builtin_dir / "Grid 2x2.json"
- with open(template_file, 'w') as f:
+ with open(template_file, "w") as f:
json.dump(data, f)
-
+
manager = TemplateManager()
- monkeypatch.setattr(manager, '_get_builtin_templates_directory', lambda: builtin_dir)
-
+ monkeypatch.setattr(manager, "_get_builtin_templates_directory", lambda: builtin_dir)
+
template = manager.load_template("[Built-in] Grid 2x2")
assert template.name == "Grid 2x2"
@@ -282,10 +261,10 @@ class TestTemplateManager:
"""Test loading non-existent template raises error"""
user_dir = tmp_path / "user_templates"
user_dir.mkdir()
-
+
manager = TemplateManager()
- monkeypatch.setattr(manager, 'templates_dir', user_dir)
-
+ monkeypatch.setattr(manager, "templates_dir", user_dir)
+
with pytest.raises(FileNotFoundError):
manager.load_template("NonExistent")
@@ -293,21 +272,21 @@ class TestTemplateManager:
"""Test deleting a user template"""
user_dir = tmp_path / "user_templates"
user_dir.mkdir()
-
+
# Create template file
template_file = user_dir / "DeleteMe.json"
template_file.write_text('{"name": "DeleteMe"}')
-
+
manager = TemplateManager()
- monkeypatch.setattr(manager, 'templates_dir', user_dir)
-
+ monkeypatch.setattr(manager, "templates_dir", user_dir)
+
manager.delete_template("DeleteMe")
assert not template_file.exists()
def test_delete_builtin_template_raises_error(self):
"""Test deleting built-in template raises error"""
manager = TemplateManager()
-
+
with pytest.raises(PermissionError):
manager.delete_template("[Built-in] Grid_2x2")
@@ -318,25 +297,21 @@ class TestTemplateManager:
img = ImageData(image_path="test.jpg", x=10, y=20, width=100, height=50)
text = TextBoxData(text_content="Test", x=30, y=40, width=150, height=60)
placeholder = PlaceholderData(x=50, y=60, width=120, height=70)
-
+
layout.add_element(img)
layout.add_element(text)
layout.add_element(placeholder)
-
+
page = Page(layout=layout, page_number=1)
-
+
# Create template
manager = TemplateManager()
- template = manager.create_template_from_page(
- page,
- name="Test Template",
- description="Created from page"
- )
-
+ template = manager.create_template_from_page(page, name="Test Template", description="Created from page")
+
assert template.name == "Test Template"
assert template.description == "Created from page"
assert len(template.elements) == 3
-
+
# Image should be converted to placeholder
assert isinstance(template.elements[0], PlaceholderData)
assert isinstance(template.elements[1], TextBoxData)
@@ -352,10 +327,7 @@ class TestTemplateManager:
# Scale to 400x400 (2x scale) - results in pixels at 300 DPI
scaled = manager.scale_template_elements(
- elements,
- from_size=(200, 200),
- to_size=(400, 400),
- scale_mode="proportional"
+ elements, from_size=(200, 200), to_size=(400, 400), scale_mode="proportional"
)
assert len(scaled) == 1
@@ -379,10 +351,7 @@ class TestTemplateManager:
# Scale to 400x200 (2x width, 1x height) - results in pixels at 300 DPI
scaled = manager.scale_template_elements(
- elements,
- from_size=(200, 200),
- to_size=(400, 200),
- scale_mode="stretch"
+ elements, from_size=(200, 200), to_size=(400, 200), scale_mode="stretch"
)
assert len(scaled) == 1
@@ -403,10 +372,7 @@ class TestTemplateManager:
# Center in larger space without scaling - results in pixels at 300 DPI
scaled = manager.scale_template_elements(
- elements,
- from_size=(200, 200),
- to_size=(400, 400),
- scale_mode="center"
+ elements, from_size=(200, 200), to_size=(400, 400), scale_mode="center"
)
assert len(scaled) == 1
@@ -422,19 +388,16 @@ class TestTemplateManager:
def test_scale_template_preserves_properties(self):
"""Test that scaling preserves element properties"""
manager = TemplateManager()
-
+
elem = PlaceholderData(x=50, y=50, width=100, height=100)
elem.rotation = 45
elem.z_index = 5
elem.placeholder_type = "image"
-
+
scaled = manager.scale_template_elements(
- [elem],
- from_size=(200, 200),
- to_size=(400, 400),
- scale_mode="proportional"
+ [elem], from_size=(200, 200), to_size=(400, 400), scale_mode="proportional"
)
-
+
assert scaled[0].rotation == 45
assert scaled[0].z_index == 5
assert scaled[0].placeholder_type == "image"
@@ -442,19 +405,19 @@ class TestTemplateManager:
def test_apply_template_to_page_replace(self):
"""Test applying template with replace mode"""
manager = TemplateManager()
-
+
# Create template
template = Template(page_size_mm=(200, 200))
template.add_element(PlaceholderData(x=10, y=20, width=80, height=60))
-
+
# Create page with existing content
layout = PageLayout(width=200, height=200)
layout.add_element(ImageData(x=100, y=100, width=50, height=50))
page = Page(layout=layout, page_number=1)
-
+
# Apply template
manager.apply_template_to_page(template, page, mode="replace")
-
+
# Page should have only template elements
assert len(page.layout.elements) == 1
assert isinstance(page.layout.elements[0], PlaceholderData)
@@ -462,21 +425,21 @@ class TestTemplateManager:
def test_apply_template_to_page_reflow(self):
"""Test applying template with reflow mode"""
manager = TemplateManager()
-
+
# Create template with 2 placeholders
template = Template(page_size_mm=(200, 200))
template.add_element(PlaceholderData(x=10, y=20, width=80, height=60))
template.add_element(PlaceholderData(x=100, y=100, width=80, height=60))
-
+
# Create page with 1 image
layout = PageLayout(width=200, height=200)
img = ImageData(image_path="test.jpg", x=50, y=50, width=50, height=50)
layout.add_element(img)
page = Page(layout=layout, page_number=1)
-
+
# Apply template with reflow
manager.apply_template_to_page(template, page, mode="reflow")
-
+
# Should have 1 image (reflowed) + 1 placeholder
assert len(page.layout.elements) == 2
# First should be the reflowed image
@@ -487,14 +450,14 @@ class TestTemplateManager:
def test_create_page_from_template_default_size(self):
"""Test creating page from template with default size"""
manager = TemplateManager()
-
+
# Create template
template = Template(page_size_mm=(210, 297))
template.add_element(PlaceholderData(x=10, y=20, width=100, height=50))
-
+
# Create page
page = manager.create_page_from_template(template, page_number=5)
-
+
assert page.page_number == 5
assert page.layout.size == (210, 297)
assert len(page.layout.elements) == 1
@@ -510,11 +473,7 @@ class TestTemplateManager:
# Create page at 400x400 with 0% margin for exact 2x scaling
page = manager.create_page_from_template(
- template,
- page_number=1,
- target_size_mm=(400, 400),
- scale_mode="proportional",
- margin_percent=0.0
+ template, page_number=1, target_size_mm=(400, 400), scale_mode="proportional", margin_percent=0.0
)
assert page.layout.size == (400, 400)
@@ -529,24 +488,14 @@ class TestTemplateManager:
def test_scale_with_textbox_preserves_font_settings(self):
"""Test that scaling preserves text box font settings"""
manager = TemplateManager()
-
+
font_settings = {"family": "Arial", "size": 12, "color": (0, 0, 0)}
- text = TextBoxData(
- text_content="Test",
- font_settings=font_settings,
- x=50,
- y=50,
- width=100,
- height=50
- )
-
+ text = TextBoxData(text_content="Test", font_settings=font_settings, x=50, y=50, width=100, height=50)
+
scaled = manager.scale_template_elements(
- [text],
- from_size=(200, 200),
- to_size=(400, 400),
- scale_mode="proportional"
+ [text], from_size=(200, 200), to_size=(400, 400), scale_mode="proportional"
)
-
+
assert scaled[0].text_content == "Test"
assert scaled[0].font_settings == font_settings
assert scaled[0].alignment == text.alignment
@@ -567,12 +516,7 @@ class TestTemplateManager:
layout = PageLayout(width=210, height=210)
page = Page(layout=layout, page_number=1)
- manager.apply_template_to_page(
- template, page,
- mode="replace",
- scale_mode="stretch",
- margin_percent=2.5
- )
+ manager.apply_template_to_page(template, page, mode="replace", scale_mode="stretch", margin_percent=2.5)
# With 2.5% margin on 210mm page: margin = 5.25mm, content area = 199.5mm
# Template is 200mm, so scale = 199.5 / 200 = 0.9975
@@ -615,12 +559,7 @@ class TestTemplateManager:
layout = PageLayout(width=210, height=297)
page = Page(layout=layout, page_number=1)
- manager.apply_template_to_page(
- template, page,
- mode="replace",
- scale_mode="stretch",
- margin_percent=2.5
- )
+ manager.apply_template_to_page(template, page, mode="replace", scale_mode="stretch", margin_percent=2.5)
# With 2.5% margin: x_margin = 5.25mm, y_margin = 7.425mm
# Content area: 199.5 x 282.15mm
@@ -661,12 +600,7 @@ class TestTemplateManager:
layout = PageLayout(width=210, height=210)
page = Page(layout=layout, page_number=1)
- manager.apply_template_to_page(
- template, page,
- mode="replace",
- scale_mode="stretch",
- margin_percent=0.0
- )
+ manager.apply_template_to_page(template, page, mode="replace", scale_mode="stretch", margin_percent=0.0)
# With 0% margin: scale = 210/200 = 1.05, offset = 0
# Results are converted to pixels at 300 DPI
@@ -681,12 +615,7 @@ class TestTemplateManager:
layout2 = PageLayout(width=210, height=210)
page2 = Page(layout=layout2, page_number=1)
- manager.apply_template_to_page(
- template, page2,
- mode="replace",
- scale_mode="stretch",
- margin_percent=5.0
- )
+ manager.apply_template_to_page(template, page2, mode="replace", scale_mode="stretch", margin_percent=5.0)
# With 5% margin: margin = 10.5mm, content = 189mm, scale = 189/200 = 0.945
# Results are converted to pixels at 300 DPI
@@ -710,12 +639,7 @@ class TestTemplateManager:
layout = PageLayout(width=210, height=297)
page = Page(layout=layout, page_number=1)
- manager.apply_template_to_page(
- template, page,
- mode="replace",
- scale_mode="proportional",
- margin_percent=2.5
- )
+ manager.apply_template_to_page(template, page, mode="replace", scale_mode="proportional", margin_percent=2.5)
# With proportional mode on 210x297 page:
# Content area: 199.5 x 282.15mm
@@ -750,22 +674,22 @@ class TestTemplateManager:
# Add various elements with specific sizes (in pixels)
# Using pixel positions that correspond to reasonable mm values
- img1 = ImageData(image_path="test1.jpg", x=10*mm_to_px, y=20*mm_to_px, width=100*mm_to_px, height=75*mm_to_px)
- img2 = ImageData(image_path="test2.jpg", x=120*mm_to_px, y=30*mm_to_px, width=80*mm_to_px, height=60*mm_to_px)
+ img1 = ImageData(
+ image_path="test1.jpg", x=10 * mm_to_px, y=20 * mm_to_px, width=100 * mm_to_px, height=75 * mm_to_px
+ )
+ img2 = ImageData(
+ image_path="test2.jpg", x=120 * mm_to_px, y=30 * mm_to_px, width=80 * mm_to_px, height=60 * mm_to_px
+ )
text1 = TextBoxData(
text_content="Test Text",
- x=30*mm_to_px,
- y=150*mm_to_px,
- width=150*mm_to_px,
- height=40*mm_to_px,
- font_settings={"family": "Arial", "size": 12}
+ x=30 * mm_to_px,
+ y=150 * mm_to_px,
+ width=150 * mm_to_px,
+ height=40 * mm_to_px,
+ font_settings={"family": "Arial", "size": 12},
)
placeholder1 = PlaceholderData(
- placeholder_type="image",
- x=50*mm_to_px,
- y=220*mm_to_px,
- width=110*mm_to_px,
- height=60*mm_to_px
+ placeholder_type="image", x=50 * mm_to_px, y=220 * mm_to_px, width=110 * mm_to_px, height=60 * mm_to_px
)
layout.add_element(img1)
@@ -778,19 +702,19 @@ class TestTemplateManager:
# Store original element data
original_elements_data = []
for elem in original_page.layout.elements:
- original_elements_data.append({
- 'type': type(elem).__name__,
- 'position': elem.position,
- 'size': elem.size,
- 'rotation': elem.rotation,
- 'z_index': elem.z_index
- })
+ original_elements_data.append(
+ {
+ "type": type(elem).__name__,
+ "position": elem.position,
+ "size": elem.size,
+ "rotation": elem.rotation,
+ "z_index": elem.z_index,
+ }
+ )
# Create a template from the page
template = manager.create_template_from_page(
- original_page,
- name="Roundtrip Test Template",
- description="Testing size preservation"
+ original_page, name="Roundtrip Test Template", description="Testing size preservation"
)
# Create a new page with the same size
@@ -800,11 +724,7 @@ class TestTemplateManager:
# Apply the template to the new page with no margins and proportional scaling
# This should result in identical sizes since page sizes match
manager.apply_template_to_page(
- template,
- new_page,
- mode="replace",
- scale_mode="proportional",
- margin_percent=0.0
+ template, new_page, mode="replace", scale_mode="proportional", margin_percent=0.0
)
# Verify we have the same number of elements
diff --git a/tests/test_view_ops_mixin.py b/tests/test_view_ops_mixin.py
index afa3170..ec75a11 100755
--- a/tests/test_view_ops_mixin.py
+++ b/tests/test_view_ops_mixin.py
@@ -33,7 +33,7 @@ class TestViewWindow(ViewOperationsMixin, QMainWindow):
self._status_message = None
def get_current_page(self):
- if hasattr(self, '_current_page'):
+ if hasattr(self, "_current_page"):
return self._current_page
return None
@@ -289,8 +289,8 @@ class TestGuideOperations:
layout = PageLayout()
# Add some guides
- layout.snapping_system.add_guide(100, 'vertical')
- layout.snapping_system.add_guide(150, 'horizontal')
+ layout.snapping_system.add_guide(100, "vertical")
+ layout.snapping_system.add_guide(150, "horizontal")
page = Mock()
page.layout = layout
window._current_page = page
@@ -357,7 +357,7 @@ class TestGridSettingsDialog:
mock_dialog = Mock(spec=QDialog)
mock_dialog.exec.return_value = QDialog.DialogCode.Rejected
- with patch('PyQt6.QtWidgets.QDialog', return_value=mock_dialog):
+ with patch("PyQt6.QtWidgets.QDialog", return_value=mock_dialog):
window.set_grid_size()
# Dialog should have been created and exec called
@@ -426,7 +426,7 @@ class TestLayoutTabDelegation:
mock_dialog = Mock(spec=QDialog)
mock_dialog.exec.return_value = QDialog.DialogCode.Rejected
- with patch('PyQt6.QtWidgets.QDialog', return_value=mock_dialog):
+ with patch("PyQt6.QtWidgets.QDialog", return_value=mock_dialog):
window.layout_set_grid_size()
# Verify method was called (dialog creation attempted)
diff --git a/tests/test_viewport_mixin.py b/tests/test_viewport_mixin.py
index 1a8c1de..26dcc18 100755
--- a/tests/test_viewport_mixin.py
+++ b/tests/test_viewport_mixin.py
@@ -14,6 +14,7 @@ from pyPhotoAlbum.page_layout import PageLayout
# Create a minimal test widget class
class TestViewportWidget(ViewportMixin, QOpenGLWidget):
"""Test widget combining ViewportMixin with QOpenGLWidget"""
+
pass
@@ -90,10 +91,7 @@ class TestViewportCalculations:
mock_window.project.working_dpi = 96
# A4 page: 210mm x 297mm
- page = Page(
- layout=PageLayout(width=210, height=297),
- page_number=1
- )
+ page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
@@ -120,10 +118,7 @@ class TestViewportCalculations:
mock_window.project = Project(name="Test")
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)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
@@ -144,10 +139,7 @@ class TestViewportCalculations:
mock_window.project = Project(name="Test")
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)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
@@ -167,10 +159,7 @@ class TestViewportCalculations:
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 300 # High DPI
- page = Page(
- layout=PageLayout(width=210, height=297),
- page_number=1
- )
+ page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
@@ -226,10 +215,7 @@ class TestViewportCentering:
mock_window.project.working_dpi = 96
# A4 page: 210mm x 297mm
- page = Page(
- layout=PageLayout(width=210, height=297),
- page_number=1
- )
+ page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
@@ -262,10 +248,7 @@ class TestViewportCentering:
mock_window.project.working_dpi = 96
# A4 page: 210mm x 297mm
- page = Page(
- layout=PageLayout(width=210, height=297),
- page_number=1
- )
+ page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
@@ -298,10 +281,7 @@ class TestViewportCentering:
mock_window.project.working_dpi = 96
# 6x4 inch photo: 152.4mm x 101.6mm
- page = Page(
- layout=PageLayout(width=152.4, height=101.6),
- page_number=1
- )
+ page = Page(layout=PageLayout(width=152.4, height=101.6), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
@@ -324,10 +304,7 @@ class TestViewportCentering:
mock_window.project.working_dpi = 96
# A4 page: 210mm x 297mm
- page = Page(
- layout=PageLayout(width=210, height=297),
- page_number=1
- )
+ page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
@@ -337,7 +314,7 @@ class TestViewportCentering:
# Large window should have large positive offsets
assert offset[0] > 1000 # Lots of horizontal space
- assert offset[1] > 400 # Lots of vertical space
+ assert offset[1] > 400 # Lots of vertical space
def test_calculate_center_pan_offset_different_zoom_levels(self, qtbot):
"""Test that different zoom levels produce different offsets"""
@@ -349,10 +326,7 @@ class TestViewportCentering:
mock_window.project = Project(name="Test")
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)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
@@ -378,10 +352,7 @@ class TestViewportCentering:
mock_window = Mock()
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)
mock_window.project.pages = [page]
# Test at 96 DPI
@@ -412,10 +383,7 @@ class TestViewportResizing:
mock_window.project = Project(name="Test")
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)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
@@ -424,14 +392,14 @@ class TestViewportResizing:
widget.zoom_level = 0.5
# Get initial centered offset for 1000x800
- with patch.object(widget, 'width', return_value=1000):
- with patch.object(widget, 'height', return_value=800):
+ with patch.object(widget, "width", return_value=1000):
+ with patch.object(widget, "height", return_value=800):
initial_offset = list(widget._calculate_center_pan_offset(0.5))
# Trigger a resize to larger window (1200x900)
# Mock the widget's dimensions during resizeGL
- with patch.object(widget, 'width', return_value=1200):
- with patch.object(widget, 'height', return_value=900):
+ with patch.object(widget, "width", return_value=1200):
+ with patch.object(widget, "height", return_value=900):
widget.resizeGL(1200, 900)
new_offset = widget.pan_offset
@@ -466,10 +434,7 @@ class TestViewportResizing:
mock_window.project = Project(name="Test")
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)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
@@ -493,10 +458,7 @@ class TestViewportResizing:
mock_window.project = Project(name="Test")
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)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
@@ -527,7 +489,7 @@ class TestViewportOpenGL:
qtbot.addWidget(widget)
# Just verify the method exists and is callable
- assert hasattr(widget, 'initializeGL')
+ assert hasattr(widget, "initializeGL")
assert callable(widget.initializeGL)
def test_resizeGL_is_callable(self, qtbot):
@@ -535,5 +497,5 @@ class TestViewportOpenGL:
widget = TestViewportWidget()
qtbot.addWidget(widget)
- assert hasattr(widget, 'resizeGL')
+ assert hasattr(widget, "resizeGL")
assert callable(widget.resizeGL)
diff --git a/tests/test_zorder.py b/tests/test_zorder.py
index cb0b3b9..e4cc0b4 100755
--- a/tests/test_zorder.py
+++ b/tests/test_zorder.py
@@ -10,35 +10,35 @@ from pyPhotoAlbum.commands import ChangeZOrderCommand, CommandHistory
class TestZOrderBasics:
"""Tests for basic z-order functionality"""
-
+
def test_list_order_is_render_order(self):
"""Test that list order determines render order"""
layout = PageLayout(width=210, height=297)
-
+
# Add elements in order
elem1 = ImageData(x=10, y=10, width=50, height=50)
elem2 = TextBoxData(x=20, y=20, width=50, height=50)
elem3 = PlaceholderData(x=30, y=30, width=50, height=50)
-
+
layout.add_element(elem1)
layout.add_element(elem2)
layout.add_element(elem3)
-
+
# Verify order
assert layout.elements[0] is elem1
assert layout.elements[1] is elem2
assert layout.elements[2] is elem3
-
+
def test_element_at_end_renders_on_top(self):
"""Test that element at end of list renders on top"""
layout = PageLayout(width=210, height=297)
-
+
elem1 = ImageData(x=10, y=10)
elem2 = ImageData(x=20, y=20)
-
+
layout.add_element(elem1)
layout.add_element(elem2)
-
+
# elem2 should be last (on top)
assert layout.elements[-1] is elem2
assert layout.elements.index(elem2) > layout.elements.index(elem1)
@@ -46,133 +46,133 @@ class TestZOrderBasics:
class TestChangeZOrderCommand:
"""Tests for ChangeZOrderCommand"""
-
+
def test_move_element_forward(self):
"""Test moving an element forward one position"""
layout = PageLayout()
elem1 = ImageData(x=10, y=10)
elem2 = TextBoxData(x=20, y=20)
elem3 = PlaceholderData(x=30, y=30)
-
+
layout.add_element(elem1)
layout.add_element(elem2)
layout.add_element(elem3)
-
+
# Move elem1 forward (swap with elem2)
cmd = ChangeZOrderCommand(layout, elem1, old_index=0, new_index=1)
cmd.execute()
-
+
assert layout.elements.index(elem1) == 1
assert layout.elements.index(elem2) == 0
assert layout.elements.index(elem3) == 2
-
+
def test_move_element_backward(self):
"""Test moving an element backward one position"""
layout = PageLayout()
elem1 = ImageData(x=10, y=10)
elem2 = TextBoxData(x=20, y=20)
elem3 = PlaceholderData(x=30, y=30)
-
+
layout.add_element(elem1)
layout.add_element(elem2)
layout.add_element(elem3)
-
+
# Move elem2 backward (swap with elem1)
cmd = ChangeZOrderCommand(layout, elem2, old_index=1, new_index=0)
cmd.execute()
-
+
assert layout.elements.index(elem2) == 0
assert layout.elements.index(elem1) == 1
assert layout.elements.index(elem3) == 2
-
+
def test_move_to_front(self):
"""Test moving an element to the front (end of list)"""
layout = PageLayout()
elem1 = ImageData(x=10, y=10)
elem2 = TextBoxData(x=20, y=20)
elem3 = PlaceholderData(x=30, y=30)
-
+
layout.add_element(elem1)
layout.add_element(elem2)
layout.add_element(elem3)
-
+
# Move elem1 to front
cmd = ChangeZOrderCommand(layout, elem1, old_index=0, new_index=2)
cmd.execute()
-
+
assert layout.elements[-1] is elem1
assert layout.elements.index(elem1) == 2
-
+
def test_move_to_back(self):
"""Test moving an element to the back (start of list)"""
layout = PageLayout()
elem1 = ImageData(x=10, y=10)
elem2 = TextBoxData(x=20, y=20)
elem3 = PlaceholderData(x=30, y=30)
-
+
layout.add_element(elem1)
layout.add_element(elem2)
layout.add_element(elem3)
-
+
# Move elem3 to back
cmd = ChangeZOrderCommand(layout, elem3, old_index=2, new_index=0)
cmd.execute()
-
+
assert layout.elements[0] is elem3
assert layout.elements.index(elem3) == 0
-
+
def test_undo_redo(self):
"""Test undo/redo functionality"""
layout = PageLayout()
elem1 = ImageData(x=10, y=10)
elem2 = TextBoxData(x=20, y=20)
elem3 = PlaceholderData(x=30, y=30)
-
+
layout.add_element(elem1)
layout.add_element(elem2)
layout.add_element(elem3)
-
+
original_order = list(layout.elements)
-
+
# Move elem1 forward
cmd = ChangeZOrderCommand(layout, elem1, old_index=0, new_index=1)
cmd.execute()
-
+
assert layout.elements.index(elem1) == 1
-
+
# Undo
cmd.undo()
assert layout.elements == original_order
-
+
# Redo
cmd.redo()
assert layout.elements.index(elem1) == 1
-
+
def test_command_with_history(self):
"""Test ChangeZOrderCommand with CommandHistory"""
layout = PageLayout()
history = CommandHistory()
-
+
elem1 = ImageData(x=10, y=10)
elem2 = TextBoxData(x=20, y=20)
elem3 = PlaceholderData(x=30, y=30)
-
+
layout.add_element(elem1)
layout.add_element(elem2)
layout.add_element(elem3)
-
+
# Execute command through history
cmd = ChangeZOrderCommand(layout, elem1, old_index=0, new_index=2)
history.execute(cmd)
-
+
assert layout.elements.index(elem1) == 2
assert history.can_undo()
-
+
# Undo through history
history.undo()
assert layout.elements.index(elem1) == 0
assert history.can_redo()
-
+
# Redo through history
history.redo()
assert layout.elements.index(elem1) == 2
@@ -180,72 +180,93 @@ class TestChangeZOrderCommand:
class TestZOrderSerialization:
"""Tests for z-order serialization and deserialization"""
-
+
def test_serialize_preserves_order(self):
"""Test that serialization preserves element order"""
layout = PageLayout()
elem1 = ImageData(x=10, y=10, z_index=0)
elem2 = TextBoxData(x=20, y=20, z_index=1)
elem3 = PlaceholderData(x=30, y=30, z_index=2)
-
+
layout.add_element(elem1)
layout.add_element(elem2)
layout.add_element(elem3)
-
+
# Serialize
data = layout.serialize()
-
+
# Elements should be in order
- assert len(data['elements']) == 3
- assert data['elements'][0]['type'] == 'image'
- assert data['elements'][1]['type'] == 'textbox'
- assert data['elements'][2]['type'] == 'placeholder'
-
+ assert len(data["elements"]) == 3
+ assert data["elements"][0]["type"] == "image"
+ assert data["elements"][1]["type"] == "textbox"
+ assert data["elements"][2]["type"] == "placeholder"
+
def test_deserialize_sorts_by_zindex(self):
"""Test that deserialization sorts by z_index for backward compatibility"""
layout = PageLayout()
-
+
# Create data with z_index values out of order
data = {
- 'size': (210, 297),
- 'base_width': 210,
- 'is_facing_page': False,
- 'background_color': (1.0, 1.0, 1.0),
- 'elements': [
- {'type': 'image', 'position': (10, 10), 'size': (50, 50),
- 'rotation': 0, 'z_index': 2, 'image_path': '', 'crop_info': (0, 0, 1, 1)},
- {'type': 'textbox', 'position': (20, 20), 'size': (50, 50),
- 'rotation': 0, 'z_index': 0, 'text_content': '',
- 'font_settings': {}, 'alignment': 'left'},
- {'type': 'placeholder', 'position': (30, 30), 'size': (50, 50),
- 'rotation': 0, 'z_index': 1, 'placeholder_type': 'image', 'default_content': ''},
- ]
+ "size": (210, 297),
+ "base_width": 210,
+ "is_facing_page": False,
+ "background_color": (1.0, 1.0, 1.0),
+ "elements": [
+ {
+ "type": "image",
+ "position": (10, 10),
+ "size": (50, 50),
+ "rotation": 0,
+ "z_index": 2,
+ "image_path": "",
+ "crop_info": (0, 0, 1, 1),
+ },
+ {
+ "type": "textbox",
+ "position": (20, 20),
+ "size": (50, 50),
+ "rotation": 0,
+ "z_index": 0,
+ "text_content": "",
+ "font_settings": {},
+ "alignment": "left",
+ },
+ {
+ "type": "placeholder",
+ "position": (30, 30),
+ "size": (50, 50),
+ "rotation": 0,
+ "z_index": 1,
+ "placeholder_type": "image",
+ "default_content": "",
+ },
+ ],
}
-
+
layout.deserialize(data)
-
+
# Elements should be sorted by z_index
assert len(layout.elements) == 3
assert isinstance(layout.elements[0], TextBoxData) # z_index=0
assert isinstance(layout.elements[1], PlaceholderData) # z_index=1
assert isinstance(layout.elements[2], ImageData) # z_index=2
-
+
def test_roundtrip_maintains_order(self):
"""Test that serialize/deserialize maintains element order"""
layout1 = PageLayout()
elem1 = ImageData(x=10, y=10, z_index=0)
elem2 = TextBoxData(x=20, y=20, z_index=1)
elem3 = PlaceholderData(x=30, y=30, z_index=2)
-
+
layout1.add_element(elem1)
layout1.add_element(elem2)
layout1.add_element(elem3)
-
+
# Serialize and deserialize
data = layout1.serialize()
layout2 = PageLayout()
layout2.deserialize(data)
-
+
# Order should be maintained
assert len(layout2.elements) == 3
assert isinstance(layout2.elements[0], ImageData)
@@ -255,126 +276,126 @@ class TestZOrderSerialization:
class TestZOrderEdgeCases:
"""Tests for z-order edge cases"""
-
+
def test_single_element(self):
"""Test operations with single element"""
layout = PageLayout()
elem = ImageData(x=10, y=10)
layout.add_element(elem)
-
+
# Try to move forward (should stay at index 0)
cmd = ChangeZOrderCommand(layout, elem, old_index=0, new_index=0)
cmd.execute()
-
+
assert layout.elements.index(elem) == 0
-
+
def test_empty_list(self):
"""Test operations with empty list"""
layout = PageLayout()
assert len(layout.elements) == 0
-
+
def test_move_to_same_position(self):
"""Test moving element to its current position"""
layout = PageLayout()
elem1 = ImageData(x=10, y=10)
elem2 = TextBoxData(x=20, y=20)
-
+
layout.add_element(elem1)
layout.add_element(elem2)
-
+
# Move to same position
cmd = ChangeZOrderCommand(layout, elem1, old_index=0, new_index=0)
cmd.execute()
-
+
assert layout.elements.index(elem1) == 0
assert layout.elements.index(elem2) == 1
-
+
def test_swap_adjacent_elements(self):
"""Test swapping two adjacent elements"""
layout = PageLayout()
elem1 = ImageData(x=10, y=10)
elem2 = TextBoxData(x=20, y=20)
-
+
layout.add_element(elem1)
layout.add_element(elem2)
-
+
# Swap by moving elem1 forward
elements = layout.elements
index1 = elements.index(elem1)
index2 = elements.index(elem2)
elements[index1], elements[index2] = elements[index2], elements[index1]
-
+
assert layout.elements[0] is elem2
assert layout.elements[1] is elem1
-
+
def test_multiple_zorder_changes(self):
"""Test multiple z-order changes in sequence"""
layout = PageLayout()
history = CommandHistory()
-
+
elem1 = ImageData(x=10, y=10)
elem2 = TextBoxData(x=20, y=20)
elem3 = PlaceholderData(x=30, y=30)
-
+
layout.add_element(elem1)
layout.add_element(elem2)
layout.add_element(elem3)
-
+
# Move elem1 to front
cmd1 = ChangeZOrderCommand(layout, elem1, old_index=0, new_index=2)
history.execute(cmd1)
assert layout.elements.index(elem1) == 2
-
+
# Move elem2 to front
cmd2 = ChangeZOrderCommand(layout, elem2, old_index=0, new_index=2)
history.execute(cmd2)
assert layout.elements.index(elem2) == 2
-
+
# Undo both
history.undo()
assert layout.elements.index(elem2) == 0
-
+
history.undo()
assert layout.elements.index(elem1) == 0
class TestZOrderCommandSerialization:
"""Tests for ChangeZOrderCommand serialization"""
-
+
def test_serialize_command(self):
"""Test serializing a ChangeZOrderCommand"""
layout = PageLayout()
elem = ImageData(x=10, y=10)
layout.add_element(elem)
-
+
cmd = ChangeZOrderCommand(layout, elem, old_index=0, new_index=1)
-
+
data = cmd.serialize()
-
- assert data['type'] == 'change_zorder'
- assert data['old_index'] == 0
- assert data['new_index'] == 1
- assert 'element' in data
-
+
+ assert data["type"] == "change_zorder"
+ assert data["old_index"] == 0
+ assert data["new_index"] == 1
+ assert "element" in data
+
def test_deserialize_command(self):
"""Test deserializing a ChangeZOrderCommand"""
data = {
- 'type': 'change_zorder',
- 'element': {
- 'type': 'image',
- 'position': (10, 10),
- 'size': (50, 50),
- 'rotation': 0,
- 'z_index': 0,
- 'image_path': '',
- 'crop_info': (0, 0, 1, 1)
+ "type": "change_zorder",
+ "element": {
+ "type": "image",
+ "position": (10, 10),
+ "size": (50, 50),
+ "rotation": 0,
+ "z_index": 0,
+ "image_path": "",
+ "crop_info": (0, 0, 1, 1),
},
- 'old_index': 0,
- 'new_index': 1
+ "old_index": 0,
+ "new_index": 1,
}
-
+
cmd = ChangeZOrderCommand.deserialize(data, None)
-
+
assert isinstance(cmd, ChangeZOrderCommand)
assert cmd.old_index == 0
assert cmd.new_index == 1
diff --git a/tests/test_zorder_ops_mixin.py b/tests/test_zorder_ops_mixin.py
index 9e71505..8b99950 100755
--- a/tests/test_zorder_ops_mixin.py
+++ b/tests/test_zorder_ops_mixin.py
@@ -34,7 +34,7 @@ class TestZOrderWindow(ZOrderOperationsMixin, QMainWindow):
def get_current_page(self):
"""Return mock current page"""
- if hasattr(self, '_current_page'):
+ if hasattr(self, "_current_page"):
return self._current_page
return None