Coverage for pyPhotoAlbum/project.py: 74%
234 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
1"""
2Project and page management for pyPhotoAlbum
3"""
5import os
6import math
7import uuid
8from datetime import datetime, timezone
9from tempfile import TemporaryDirectory
10from typing import List, Dict, Any, Optional, Tuple, Union
11from pyPhotoAlbum.page_layout import PageLayout
12from pyPhotoAlbum.commands import CommandHistory
13from pyPhotoAlbum.asset_manager import AssetManager
16class Page:
17 """Class representing a single page in the photo album"""
19 def __init__(self, layout: Optional[PageLayout] = None, page_number: int = 1, is_double_spread: bool = False):
20 """
21 Initialize a page.
23 Args:
24 layout: PageLayout instance (created automatically if None)
25 page_number: The page number (for spreads, this is the left page number)
26 is_double_spread: If True, this is a facing page spread (2x width)
27 """
28 self.page_number = page_number
29 self.is_cover = False
30 self.is_double_spread = is_double_spread
31 self.manually_sized = False # Track if user manually changed page size
33 # UUID for merge conflict resolution (v3.0+)
34 self.uuid = str(uuid.uuid4())
36 # Timestamps for merge conflict resolution (v3.0+)
37 now = datetime.now(timezone.utc).isoformat()
38 self.created = now
39 self.last_modified = now
41 # Deletion tracking for merge (v3.0+)
42 self.deleted = False
43 self.deleted_at: Optional[str] = None
45 # Create layout with appropriate width
46 if layout is None:
47 self.layout = PageLayout(is_facing_page=is_double_spread)
48 else:
49 self.layout = layout
50 # Ensure layout matches the is_double_spread setting
51 if is_double_spread != self.layout.is_facing_page:
52 # Need to update the layout for the new facing page state
53 self.layout.is_facing_page = is_double_spread
54 height = self.layout.size[1]
55 # Use the base_width if available, otherwise derive it
56 if hasattr(self.layout, "base_width"):
57 base_width = self.layout.base_width
58 else:
59 # If base_width not set, assume current width is correct
60 # and derive base_width from current state
61 base_width = self.layout.size[0] / 2 if not is_double_spread else self.layout.size[0]
62 self.layout.base_width = base_width
64 # Set the new width based on facing page state
65 self.layout.size = (base_width * 2 if is_double_spread else base_width, height)
67 def get_page_numbers(self) -> List[int]:
68 """
69 Get the page numbers this page represents.
71 Returns:
72 List of page numbers (2 numbers for spreads, 1 for single pages)
73 """
74 if self.is_double_spread:
75 return [self.page_number, self.page_number + 1]
76 else:
77 return [self.page_number]
79 def get_page_count(self) -> int:
80 """
81 Get the number of physical pages this represents.
83 Returns:
84 2 for spreads, 1 for single pages
85 """
86 return 2 if self.is_double_spread else 1
88 def mark_modified(self):
89 """Update the last_modified timestamp to now."""
90 self.last_modified = datetime.now(timezone.utc).isoformat()
92 def mark_deleted(self):
93 """Mark this page as deleted."""
94 self.deleted = True
95 self.deleted_at = datetime.now(timezone.utc).isoformat()
96 self.mark_modified()
98 def render(self):
99 """Render the entire page"""
100 print(f"Rendering page {self.page_number}")
101 self.layout.render()
103 def serialize(self) -> Dict[str, Any]:
104 """Serialize page to dictionary"""
105 return {
106 "page_number": self.page_number,
107 "is_cover": self.is_cover,
108 "is_double_spread": self.is_double_spread,
109 "manually_sized": self.manually_sized,
110 "layout": self.layout.serialize(),
111 # v3.0+ fields
112 "uuid": self.uuid,
113 "created": self.created,
114 "last_modified": self.last_modified,
115 "deleted": self.deleted,
116 "deleted_at": self.deleted_at,
117 }
119 def deserialize(self, data: Dict[str, Any]):
120 """Deserialize from dictionary"""
121 self.page_number = data.get("page_number", 1)
122 self.is_cover = data.get("is_cover", False)
123 self.is_double_spread = data.get("is_double_spread", False)
124 self.manually_sized = data.get("manually_sized", False)
126 # v3.0+ fields (with backwards compatibility)
127 self.uuid = data.get("uuid", str(uuid.uuid4()))
128 now = datetime.now(timezone.utc).isoformat()
129 self.created = data.get("created", now)
130 self.last_modified = data.get("last_modified", now)
131 self.deleted = data.get("deleted", False)
132 self.deleted_at = data.get("deleted_at", None)
134 layout_data = data.get("layout", {})
135 self.layout = PageLayout()
136 self.layout.deserialize(layout_data)
139class Project:
140 """Class representing the entire photo album project"""
142 def __init__(self, name: str = "Untitled Project", folder_path: Optional[str] = None):
143 self.name = name
144 self.folder_path = folder_path or os.path.join("./projects", name.replace(" ", "_"))
145 self.pages: List[Page] = []
146 self.default_min_distance = 10.0 # Default minimum distance between images
147 self.cover_size = (800, 600) # Default cover size in pixels
148 self.page_size = (800, 600) # Default page size in pixels
149 self.page_size_mm = (140, 140) # Default page size in mm (14cm x 14cm square)
150 self.working_dpi = 300 # Default working DPI
151 self.export_dpi = 300 # Default export DPI
152 self.page_spacing_mm = 10.0 # Default spacing between pages (1cm)
154 # Project ID for merge detection (v3.0+)
155 # Projects with same ID should be merged, different IDs should be concatenated
156 self.project_id = str(uuid.uuid4())
158 # Timestamps for project-level changes (v3.0+)
159 now = datetime.now(timezone.utc).isoformat()
160 self.created = now
161 self.last_modified = now
163 # Cover configuration
164 self.has_cover = False # Whether project has a cover
165 self.paper_thickness_mm = 0.2 # Paper thickness for spine calculation (default 0.2mm)
166 self.cover_bleed_mm = 0.0 # Bleed margin for cover (default 0mm)
167 self.binding_type = "saddle_stitch" # Binding type for spine calculation
169 # Print guide configuration
170 self.page_bleed_mm = 0.0 # Bleed margin for interior pages (default 0mm, e.g. 3mm for printing)
171 self.page_safe_area_mm = 5.0 # Safe area margin inside cut line (default 5mm)
172 self.show_print_guides = False # Show bleed/cut/safe-area guides in the editor
174 # Embedded templates - templates that travel with the project
175 self.embedded_templates: Dict[str, Dict[str, Any]] = {}
177 # Temporary directory management (if loaded from .ppz)
178 # Using TemporaryDirectory instance that auto-cleans on deletion
179 self._temp_dir: Optional[TemporaryDirectory[str]] = None
181 # Global snapping settings (apply to all pages)
182 self.snap_to_grid = False
183 self.snap_to_edges = True
184 self.snap_to_guides = True
185 self.grid_size_mm = 10.0
186 self.snap_threshold_mm = 5.0
187 self.show_grid = False # Show grid lines independently of snap_to_grid
188 self.show_snap_lines = True # Show snap lines (guides) during dragging
190 # Initialize asset manager
191 self.asset_manager = AssetManager(self.folder_path)
193 # Initialize command history with asset manager and project reference
194 self.history = CommandHistory(max_history=100, asset_manager=self.asset_manager, project=self)
196 # Track unsaved changes
197 self._dirty = False
198 self.file_path = None # Path to the saved .ppz file
200 def mark_dirty(self):
201 """Mark the project as having unsaved changes."""
202 self._dirty = True
203 self.mark_modified()
205 def mark_clean(self):
206 """Mark the project as saved (no unsaved changes)."""
207 self._dirty = False
209 def is_dirty(self) -> bool:
210 """Check if the project has unsaved changes."""
211 return self._dirty
213 def mark_modified(self):
214 """Update the last_modified timestamp to now."""
215 self.last_modified = datetime.now(timezone.utc).isoformat()
217 def add_page(self, page: Page, index: Optional[int] = None):
218 """
219 Add a page to the project.
221 Args:
222 page: The page to add
223 index: Optional index to insert at. If None, appends to end.
224 """
225 if index is None:
226 self.pages.append(page)
227 else:
228 self.pages.insert(index, page)
229 # Update cover dimensions if we have a cover
230 if self.has_cover and self.pages:
231 self.update_cover_dimensions()
232 self.mark_dirty()
234 def remove_page(self, page: Page):
235 """Remove a page from the project"""
236 self.pages.remove(page)
237 # Update cover dimensions if we have a cover
238 if self.has_cover and self.pages:
239 self.update_cover_dimensions()
240 self.mark_dirty()
242 def calculate_spine_width(self) -> float:
243 """
244 Calculate spine width based on page count and paper thickness.
246 For saddle stitch binding:
247 - Each sheet = 4 pages (2 pages per side when folded)
248 - Spine width = (Number of sheets × Paper thickness × 2)
250 Returns:
251 Spine width in mm
252 """
253 if not self.has_cover:
254 return 0.0
256 # Count content pages (excluding cover)
257 content_page_count = sum(page.get_page_count() for page in self.pages if not page.is_cover)
259 if self.binding_type == "saddle_stitch":
260 # Calculate number of sheets (each sheet = 4 pages)
261 sheets = math.ceil(content_page_count / 4)
262 # Spine width = sheets × paper thickness × 2 (folded)
263 spine_width = sheets * self.paper_thickness_mm * 2
264 return spine_width
266 return 0.0
268 def update_cover_dimensions(self):
269 """
270 Update cover page dimensions based on current page count and settings.
271 Calculates: Front width + Spine width + Back width + Bleed margins
272 """
273 if not self.has_cover or not self.pages:
274 return
276 # Find cover page (should be first page)
277 cover_page = None
278 for page in self.pages:
279 if page.is_cover:
280 cover_page = page
281 break
283 if not cover_page:
284 return
286 # Get standard page dimensions
287 page_width_mm, page_height_mm = self.page_size_mm
289 # Calculate spine width
290 spine_width = self.calculate_spine_width()
292 # Calculate cover dimensions
293 # Cover = Front + Spine + Back + Bleed on all sides
294 cover_width = (page_width_mm * 2) + spine_width + (self.cover_bleed_mm * 2)
295 cover_height = page_height_mm + (self.cover_bleed_mm * 2)
297 # Update cover page layout
298 cover_page.layout.size = (cover_width, cover_height)
299 cover_page.layout.base_width = page_width_mm # Store base width for reference
300 cover_page.manually_sized = True # Mark as manually sized
302 print(
303 f"Cover dimensions updated: {cover_width:.1f} × {cover_height:.1f} mm "
304 f"(Front: {page_width_mm}, Spine: {spine_width:.2f}, Back: {page_width_mm}, "
305 f"Bleed: {self.cover_bleed_mm})"
306 )
308 def get_page_display_name(self, page: Page) -> str:
309 """
310 Get display name for a page.
312 Args:
313 page: The page to get the display name for
315 Returns:
316 Display name like "Cover", "Page 1", "Pages 1-2", etc.
317 """
318 if page.is_cover:
319 return "Cover"
321 # Calculate adjusted page number (excluding cover from count)
322 adjusted_num = page.page_number
323 if self.has_cover:
324 # Subtract 1 to account for cover
325 adjusted_num = page.page_number - 1
327 if page.is_double_spread:
328 return f"Pages {adjusted_num}-{adjusted_num + 1}"
329 else:
330 return f"Page {adjusted_num}"
332 def calculate_page_layout_with_ghosts(self) -> List[Tuple[str, Optional["Page"], int]]:
333 """
334 Calculate page layout including ghost pages for alignment.
335 Excludes cover from spread calculations.
337 Returns:
338 List of tuples (page_type, page_or_ghost, logical_position)
339 where page_type is 'page' or 'ghost',
340 page_or_ghost is the Page object or None for ghost,
341 logical_position is the position in the album (1=right, 2=left, etc.)
342 """
343 from pyPhotoAlbum.models import GhostPageData
345 layout: list[tuple[str, Optional["Page"], int]] = []
346 current_position = 1 # Start at position 1 (right page)
348 for page in self.pages:
349 # Skip cover in spread calculations
350 if page.is_cover:
351 # Cover is rendered separately, doesn't participate in spreads
352 continue
353 # Check if we need a ghost page for alignment
354 # Ghost pages are needed when a single page would appear on the left
355 # but should be on the right (odd positions)
356 if not page.is_double_spread and current_position % 2 == 0:
357 # Current position is even (left page), but we have a single page
358 # This is fine - single page goes on left
359 pass
360 elif not page.is_double_spread and current_position % 2 == 1:
361 # Current position is odd (right page), single page is fine
362 pass
364 # Actually, let me reconsider the logic:
365 # In a photobook:
366 # - Position 1 is the right page (when opened, first content page)
367 # - Position 2 is the left page of the next spread
368 # - Position 3 is the right page of the next spread
369 # - etc.
370 #
371 # Double spreads occupy TWO positions (both left and right of a spread)
372 # They must start on an even position (left side) so they span across both pages
374 # Check if this is a double spread starting at an odd position
375 if page.is_double_spread and current_position % 2 == 1:
376 # Need to insert a ghost page to push the double spread to next position
377 layout.append(("ghost", None, current_position))
378 current_position += 1
380 # Add the actual page
381 layout.append(("page", page, current_position))
383 # Update position based on page type
384 if page.is_double_spread:
385 current_position += 2 # Double spread takes 2 positions
386 else:
387 current_position += 1 # Single page takes 1 position
389 return layout
391 def render_all_pages(self):
392 """Render all pages in the project"""
393 for page in self.pages:
394 page.render()
396 def serialize(self) -> Dict[str, Any]:
397 """Serialize entire project to dictionary"""
398 return {
399 "name": self.name,
400 "folder_path": self.folder_path,
401 "default_min_distance": self.default_min_distance,
402 "cover_size": self.cover_size,
403 "page_size": self.page_size,
404 "page_size_mm": self.page_size_mm,
405 "working_dpi": self.working_dpi,
406 "export_dpi": self.export_dpi,
407 "page_spacing_mm": self.page_spacing_mm,
408 "has_cover": self.has_cover,
409 "paper_thickness_mm": self.paper_thickness_mm,
410 "cover_bleed_mm": self.cover_bleed_mm,
411 "binding_type": self.binding_type,
412 "page_bleed_mm": self.page_bleed_mm,
413 "page_safe_area_mm": self.page_safe_area_mm,
414 "show_print_guides": self.show_print_guides,
415 "embedded_templates": self.embedded_templates,
416 "snap_to_grid": self.snap_to_grid,
417 "snap_to_edges": self.snap_to_edges,
418 "snap_to_guides": self.snap_to_guides,
419 "grid_size_mm": self.grid_size_mm,
420 "snap_threshold_mm": self.snap_threshold_mm,
421 "show_grid": self.show_grid,
422 "show_snap_lines": self.show_snap_lines,
423 "pages": [page.serialize() for page in self.pages],
424 "history": self.history.serialize(),
425 "asset_manager": self.asset_manager.serialize(),
426 # v3.0+ fields
427 "project_id": self.project_id,
428 "created": self.created,
429 "last_modified": self.last_modified,
430 }
432 def deserialize(self, data: Dict[str, Any]):
433 """Deserialize from dictionary"""
434 self.name = data.get("name", "Untitled Project")
435 self.folder_path = data.get("folder_path", os.path.join("./projects", self.name.replace(" ", "_")))
436 self.default_min_distance = data.get("default_min_distance", 10.0)
437 self.cover_size = tuple(data.get("cover_size", (800, 600)))
438 self.page_size = tuple(data.get("page_size", (800, 600)))
439 self.page_size_mm = tuple(data.get("page_size_mm", (210, 297)))
440 self.working_dpi = data.get("working_dpi", 300)
441 self.export_dpi = data.get("export_dpi", 300)
442 self.page_spacing_mm = data.get("page_spacing_mm", 10.0)
443 self.has_cover = data.get("has_cover", False)
444 self.paper_thickness_mm = data.get("paper_thickness_mm", 0.2)
445 self.cover_bleed_mm = data.get("cover_bleed_mm", 0.0)
446 self.binding_type = data.get("binding_type", "saddle_stitch")
447 self.page_bleed_mm = data.get("page_bleed_mm", 0.0)
448 self.page_safe_area_mm = data.get("page_safe_area_mm", 5.0)
449 self.show_print_guides = data.get("show_print_guides", False)
451 # Deserialize embedded templates
452 self.embedded_templates = data.get("embedded_templates", {})
454 # Deserialize global snapping settings
455 self.snap_to_grid = data.get("snap_to_grid", False)
456 self.snap_to_edges = data.get("snap_to_edges", True)
457 self.snap_to_guides = data.get("snap_to_guides", True)
458 self.grid_size_mm = data.get("grid_size_mm", 10.0)
459 self.snap_threshold_mm = data.get("snap_threshold_mm", 5.0)
460 self.show_grid = data.get("show_grid", False)
461 self.show_snap_lines = data.get("show_snap_lines", True)
463 # v3.0+ fields (with backwards compatibility)
464 self.project_id = data.get("project_id", str(uuid.uuid4()))
465 now = datetime.now(timezone.utc).isoformat()
466 self.created = data.get("created", now)
467 self.last_modified = data.get("last_modified", now)
469 self.pages = []
471 # Deserialize asset manager
472 self.asset_manager = AssetManager(self.folder_path)
473 asset_data = data.get("asset_manager")
474 if asset_data:
475 self.asset_manager.deserialize(asset_data)
477 # Deserialize pages
478 for page_data in data.get("pages", []):
479 page = Page()
480 page.deserialize(page_data)
481 self.pages.append(page)
483 # Deserialize command history with asset manager and project reference
484 history_data = data.get("history")
485 if history_data:
486 self.history = CommandHistory(max_history=100, asset_manager=self.asset_manager, project=self)
487 self.history.deserialize(history_data, self)
488 else:
489 self.history = CommandHistory(max_history=100, asset_manager=self.asset_manager, project=self)
491 def cleanup(self):
492 """
493 Cleanup project resources, including temporary directories.
494 Should be called when the project is closed or no longer needed.
495 """
496 if self._temp_dir is not None:
497 try:
498 # Let TemporaryDirectory clean itself up
499 temp_path = self._temp_dir.name
500 self._temp_dir.cleanup()
501 self._temp_dir = None
502 print(f"Cleaned up temporary project directory: {temp_path}")
503 except Exception as e:
504 print(f"Warning: Failed to cleanup temporary directory: {e}")
506 def __del__(self):
507 """Destructor to ensure cleanup happens when project is deleted."""
508 self.cleanup()