Coverage for pyPhotoAlbum/project.py: 74%

234 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-20 12:55 +0000

1""" 

2Project and page management for pyPhotoAlbum 

3""" 

4 

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 

14 

15 

16class 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 

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