385 lines
15 KiB
Python
385 lines
15 KiB
Python
"""
|
||
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()
|