""" Project and page management for pyPhotoAlbum """ import os import math from typing import List, Dict, Any, Optional, Tuple 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""" def __init__(self, layout: Optional[PageLayout] = None, page_number: int = 1, is_double_spread: bool = False): """ Initialize a page. Args: layout: PageLayout instance (created automatically if None) page_number: The page number (for spreads, this is the left page number) is_double_spread: If True, this is a facing page spread (2x width) """ self.page_number = page_number self.is_cover = False self.is_double_spread = is_double_spread self.manually_sized = False # Track if user manually changed page size # Create layout with appropriate width if layout is None: self.layout = PageLayout(is_facing_page=is_double_spread) else: self.layout = layout # Ensure layout matches the is_double_spread setting if is_double_spread != self.layout.is_facing_page: # Need to update the layout for the new facing page state 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'): 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) """ if self.is_double_spread: 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. Returns: 2 for spreads, 1 for single pages """ return 2 if self.is_double_spread else 1 def render(self): """Render the entire page""" print(f"Rendering page {self.page_number}") self.layout.render() def serialize(self) -> Dict[str, Any]: """Serialize page to dictionary""" return { "page_number": self.page_number, "is_cover": self.is_cover, "is_double_spread": self.is_double_spread, "manually_sized": self.manually_sized, "layout": self.layout.serialize() } def deserialize(self, data: Dict[str, Any]): """Deserialize from dictionary""" self.page_number = data.get("page_number", 1) self.is_cover = data.get("is_cover", False) self.is_double_spread = data.get("is_double_spread", False) self.manually_sized = data.get("manually_sized", False) layout_data = data.get("layout", {}) self.layout = PageLayout() self.layout.deserialize(layout_data) class Project: """Class representing the entire photo album project""" def __init__(self, name: str = "Untitled Project", folder_path: Optional[str] = None): self.name = name self.folder_path = folder_path or os.path.join("./projects", name.replace(" ", "_")) self.pages: List[Page] = [] self.default_min_distance = 10.0 # Default minimum distance between images self.cover_size = (800, 600) # Default cover size in pixels self.page_size = (800, 600) # Default page size in pixels self.page_size_mm = (140, 140) # Default page size in mm (14cm x 14cm) self.working_dpi = 300 # Default working DPI self.export_dpi = 300 # Default export DPI self.page_spacing_mm = 10.0 # Default spacing between pages (1cm) # Cover configuration self.has_cover = False # Whether project has a cover self.paper_thickness_mm = 0.2 # Paper thickness for spine calculation (default 0.2mm) self.cover_bleed_mm = 0.0 # Bleed margin for cover (default 0mm) self.binding_type = "saddle_stitch" # Binding type for spine calculation # Embedded templates - templates that travel with the project self.embedded_templates: Dict[str, Dict[str, Any]] = {} # Track if this project was loaded from a temp directory and should be cleaned up self._is_temp_project = False self._temp_cleanup_path = None # Initialize asset manager self.asset_manager = AssetManager(self.folder_path) # Initialize command history with asset manager self.history = CommandHistory(max_history=100) self.history.asset_manager = self.asset_manager def add_page(self, page: Page): """Add a page to the project""" self.pages.append(page) # Update cover dimensions if we have a cover if self.has_cover and self.pages: self.update_cover_dimensions() def remove_page(self, page: Page): """Remove a page from the project""" self.pages.remove(page) # Update cover dimensions if we have a cover if self.has_cover and self.pages: self.update_cover_dimensions() 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 ) 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. Calculates: Front width + Spine width + Back width + Bleed margins """ 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})") 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', page_or_ghost is the Page object or None for ghost, 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: # Cover is rendered separately, doesn't participate in spreads continue # Check if we need a ghost page for alignment # Ghost pages are needed when a single page would appear on the left # but should be on the right (odd positions) if not page.is_double_spread and current_position % 2 == 0: # Current position is even (left page), but we have a single page # This is fine - single page goes on left pass 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)) current_position += 1 # Add the actual page 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): """Render all pages in the project""" for page in self.pages: page.render() def serialize(self) -> Dict[str, Any]: """Serialize entire project to dictionary""" return { "name": self.name, "folder_path": self.folder_path, "default_min_distance": self.default_min_distance, "cover_size": self.cover_size, "page_size": self.page_size, "page_size_mm": self.page_size_mm, "working_dpi": self.working_dpi, "export_dpi": self.export_dpi, "page_spacing_mm": self.page_spacing_mm, "has_cover": self.has_cover, "paper_thickness_mm": self.paper_thickness_mm, "cover_bleed_mm": self.cover_bleed_mm, "binding_type": self.binding_type, "embedded_templates": self.embedded_templates, "pages": [page.serialize() for page in self.pages], "history": self.history.serialize(), "asset_manager": self.asset_manager.serialize() } def deserialize(self, data: Dict[str, Any]): """Deserialize from dictionary""" self.name = data.get("name", "Untitled Project") self.folder_path = data.get("folder_path", os.path.join("./projects", self.name.replace(" ", "_"))) self.default_min_distance = data.get("default_min_distance", 10.0) self.cover_size = tuple(data.get("cover_size", (800, 600))) self.page_size = tuple(data.get("page_size", (800, 600))) self.page_size_mm = tuple(data.get("page_size_mm", (210, 297))) self.working_dpi = data.get("working_dpi", 300) self.export_dpi = data.get("export_dpi", 300) self.page_spacing_mm = data.get("page_spacing_mm", 10.0) self.has_cover = data.get("has_cover", False) self.paper_thickness_mm = data.get("paper_thickness_mm", 0.2) self.cover_bleed_mm = data.get("cover_bleed_mm", 0.0) self.binding_type = data.get("binding_type", "saddle_stitch") # Deserialize embedded templates self.embedded_templates = data.get("embedded_templates", {}) self.pages = [] # Deserialize asset manager self.asset_manager = AssetManager(self.folder_path) 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 history_data = data.get("history") if history_data: self.history = CommandHistory(max_history=100) self.history.asset_manager = self.asset_manager self.history.deserialize(history_data, self) else: self.history = CommandHistory(max_history=100) self.history.asset_manager = self.asset_manager def cleanup(self): """ Cleanup project resources, including temporary directories. Should be called when the project is closed or no longer needed. """ if self._is_temp_project and self._temp_cleanup_path: try: import shutil if os.path.exists(self._temp_cleanup_path): shutil.rmtree(self._temp_cleanup_path) print(f"Cleaned up temporary project directory: {self._temp_cleanup_path}") except Exception as e: print(f"Warning: Failed to cleanup temporary directory {self._temp_cleanup_path}: {e}") def __del__(self): """Destructor to ensure cleanup happens when project is deleted.""" self.cleanup()