1 """

2 Project and page management for pyPhotoAlbum

3 """

4

5 import os

6 import math

7 import uuid

8 from datetime import datetime, timezone

9 from tempfile import TemporaryDirectory

10 from typing import List, Dict, Any, Optional, Tuple, Union

11 from pyPhotoAlbum.page_layout import PageLayout

12 from pyPhotoAlbum.commands import CommandHistory

13 from pyPhotoAlbum.asset_manager import AssetManager

14

15

16 class Page:

17 """Class representing a single page in the photo album"""

18

19 def __init__(self, layout: Optional[PageLayout] = None, page_number: int = 1, is_double_spread: bool = False):

20 """

21 Initialize a page.

22

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

32

33 # UUID for merge conflict resolution (v3.0+)

34 self.uuid = str(uuid.uuid4())

35

36 # Timestamps for merge conflict resolution (v3.0+)

37 now = datetime.now(timezone.utc).isoformat()

38 self.created = now

39 self.last_modified = now

40

41 # Deletion tracking for merge (v3.0+)

42 self.deleted = False

43 self.deleted_at: Optional[str] = None

44

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

63

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)

66

67 def get_page_numbers(self) -> List[int]:

68 """

69 Get the page numbers this page represents.

70

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]

78

79 def get_page_count(self) -> int:

80 """

81 Get the number of physical pages this represents.

82

83 Returns:

84 2 for spreads, 1 for single pages

85 """

86 return 2 if self.is_double_spread else 1

87

88 def mark_modified(self):

89 """Update the last_modified timestamp to now."""

90 self.last_modified = datetime.now(timezone.utc).isoformat()

91

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

97

98 def render(self):

99 """Render the entire page"""

100 print(f"Rendering page {self.page_number}")

101 self.layout.render()

102

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 }

118

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)

125

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)

133

134 layout_data = data.get("layout", {})

135 self.layout = PageLayout()

136 self.layout.deserialize(layout_data)

137

138

139 class Project:

140 """Class representing the entire photo album project"""

141

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)

153

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

157

158 # Timestamps for project-level changes (v3.0+)

159 now = datetime.now(timezone.utc).isoformat()

160 self.created = now

161 self.last_modified = now

162

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

168

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

173

174 # Embedded templates - templates that travel with the project

175 self.embedded_templates: Dict[str, Dict[str, Any]] = {}

176

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

180

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

189

190 # Initialize asset manager

191 self.asset_manager = AssetManager(self.folder_path)

192

193 # Initialize command history with asset manager and project reference

194 self.history = CommandHistory(max_history=100, asset_manager=self.asset_manager, project=self)

195

196 # Track unsaved changes

197 self._dirty = False

198 self.file_path = None # Path to the saved .ppz file

199

200 def mark_dirty(self):

201 """Mark the project as having unsaved changes."""

202 self._dirty = True

203 self.mark_modified()

204

205 def mark_clean(self):

206 """Mark the project as saved (no unsaved changes)."""

207 self._dirty = False

208

209 def is_dirty(self) -> bool:

210 """Check if the project has unsaved changes."""

211 return self._dirty

212

213 def mark_modified(self):

214 """Update the last_modified timestamp to now."""

215 self.last_modified = datetime.now(timezone.utc).isoformat()

216

217 def add_page(self, page: Page, index: Optional[int] = None):

218 """

219 Add a page to the project.

220

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

233

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

241

242 def calculate_spine_width(self) -> float:

243 """

244 Calculate spine width based on page count and paper thickness.

245

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)

249

250 Returns:

251 Spine width in mm

252 """

253 if not self.has_cover:

254 return 0.0

255

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)

258

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

265

266 return 0.0

267

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

275

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

282

283 if not cover_page:

284 return

285

286 # Get standard page dimensions

287 page_width_mm, page_height_mm = self.page_size_mm

288

289 # Calculate spine width

290 spine_width = self.calculate_spine_width()

291

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)

296

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

301

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 )

307

308 def get_page_display_name(self, page: Page) -> str:

309 """

310 Get display name for a page.

311

312 Args:

313 page: The page to get the display name for

314

315 Returns:

316 Display name like "Cover", "Page 1", "Pages 1-2", etc.

317 """

318 if page.is_cover:

319 return "Cover"

320

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

326

327 if page.is_double_spread:

328 return f"Pages {adjusted_num}-{adjusted_num + 1}"

329 else:

330 return f"Page {adjusted_num}"

331

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.

336

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

344

345 layout: list[tuple[str, Optional["Page"], int]] = []

346 current_position = 1 # Start at position 1 (right page)

347

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

363

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

373

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

379

380 # Add the actual page

381 layout.append(("page", page, current_position))

382

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

388

389 return layout

390

391 def render_all_pages(self):

392 """Render all pages in the project"""

393 for page in self.pages:

394 page.render()

395

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 }

431

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)

450

451 # Deserialize embedded templates

452 self.embedded_templates = data.get("embedded_templates", {})

453

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)

462

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)

468

469 self.pages = []

470

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)

476

477 # Deserialize pages

478 for page_data in data.get("pages", []):

479 page = Page()

480 page.deserialize(page_data)

481 self.pages.append(page)

482

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)

490

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

505

506 def __del__(self):

507 """Destructor to ensure cleanup happens when project is deleted."""

508 self.cleanup()