2025-11-11 15:34:04 +01:00

385 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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