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

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

View File

@ -65,14 +65,10 @@ class ElementMaximizer:
# Calculate distances between rectangles
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.

View File

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

View File

@ -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", {})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(),
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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",
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)}%")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 <br/> tags
text_content = text_element.text_content
text_content = text_content.replace('&', '&amp;')
text_content = text_content.replace('<', '&lt;')
text_content = text_content.replace('>', '&gt;')
text_content = text_content.replace('\n', '<br/>')
text_content = text_content.replace("&", "&amp;")
text_content = text_content.replace("<", "&lt;")
text_content = text_content.replace(">", "&gt;")
text_content = text_content.replace("\n", "<br/>")
# Create paragraph with the text
para = Paragraph(text_content, style)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(),
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -249,7 +249,7 @@ class TestDialogMethods:
qtbot.addWidget(window)
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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,6 +16,7 @@ from pyPhotoAlbum.page_layout import PageLayout
# Create a minimal test widget class that doesn't require full GLWidget initialization
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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

402
tests/test_ribbon_widget.py Normal file
View File

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

View File

@ -19,7 +19,7 @@ class TestRotationSerialization:
def sample_image(self):
"""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

View File

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

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