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
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:
parent
3b2301b547
commit
f6ed11b0bc
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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", {})
|
||||
|
||||
@ -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()}
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -7,4 +7,4 @@ UI presentation logic separately from business logic.
|
||||
|
||||
from .page_setup_dialog import PageSetupDialog
|
||||
|
||||
__all__ = ['PageSetupDialog']
|
||||
__all__ = ["PageSetupDialog"]
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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())
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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))}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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"""
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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°")
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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)"""
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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)}%")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"""
|
||||
|
||||
@ -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)
|
||||
"""
|
||||
|
||||
@ -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('&', '&')
|
||||
text_content = text_content.replace('<', '<')
|
||||
text_content = text_content.replace('>', '>')
|
||||
text_content = text_content.replace('\n', '<br/>')
|
||||
text_content = text_content.replace("&", "&")
|
||||
text_content = text_content.replace("<", "<")
|
||||
text_content = text_content.replace(">", ">")
|
||||
text_content = text_content.replace("\n", "<br/>")
|
||||
|
||||
# Create paragraph with the text
|
||||
para = Paragraph(text_content, style)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
183
tests/test_asset_path_mixin.py
Normal file
183
tests/test_asset_path_mixin.py
Normal 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
|
||||
635
tests/test_async_loading_mixin.py
Normal file
635
tests/test_async_loading_mixin.py
Normal 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
|
||||
511
tests/test_autosave_manager.py
Normal file
511
tests/test_autosave_manager.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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 == []
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"])
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
432
tests/test_project_serializer_full.py
Normal file
432
tests/test_project_serializer_full.py
Normal 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
|
||||
634
tests/test_ribbon_builder.py
Normal file
634
tests/test_ribbon_builder.py
Normal 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
402
tests/test_ribbon_widget.py
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user