Coverage for pyPhotoAlbum/mixins/operations/file_ops.py: 73%

521 statements  

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

1""" 

2File operations mixin for pyPhotoAlbum 

3""" 

4 

5import os 

6from typing import TYPE_CHECKING, Optional, cast 

7 

8from PyQt6.QtCore import QObject, pyqtSignal 

9from PyQt6.QtWidgets import ( 

10 QFileDialog, 

11 QDialog, 

12 QVBoxLayout, 

13 QHBoxLayout, 

14 QLabel, 

15 QDoubleSpinBox, 

16 QSpinBox, 

17 QPushButton, 

18 QGroupBox, 

19 QRadioButton, 

20 QButtonGroup, 

21 QLineEdit, 

22 QTextEdit, 

23 QWidget, 

24 QMessageBox, 

25) 

26from pyPhotoAlbum.decorators import ribbon_action, numerical_input 

27from pyPhotoAlbum.project import Project, Page 

28from pyPhotoAlbum.async_project_loader import AsyncProjectLoader 

29from pyPhotoAlbum.loading_widget import LoadingWidget 

30from pyPhotoAlbum.project_serializer import save_to_zip, save_to_zip_async 

31from pyPhotoAlbum.models import set_asset_resolution_context 

32from pyPhotoAlbum.version_manager import format_version_info, CURRENT_DATA_VERSION 

33from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog 

34 

35 

36class _SaveBridge(QObject): 

37 """Thread-safe signal bridge for async save callbacks. 

38 

39 Signals can be safely emitted from any thread; connected slots run on 

40 the main (GUI) thread via Qt's queued connection. 

41 """ 

42 

43 progress = pyqtSignal(int, str) 

44 finished = pyqtSignal(bool, str) 

45 

46 

47class FileOperationsMixin: 

48 """Mixin providing file-related operations""" 

49 

50 # Type hints for expected attributes from mixing class 

51 def show_status(self, message: str, timeout: int = 0) -> None: 

52 """Expected from ApplicationStateMixin""" 

53 ... 

54 

55 def show_error(self, title: str, message: str) -> None: 

56 """Expected from ApplicationStateMixin""" 

57 ... 

58 

59 def resolve_asset_path(self, path: str) -> Optional[str]: 

60 """Expected from asset path mixin""" 

61 ... 

62 

63 @ribbon_action(label="New", tooltip="Create a new project", tab="Home", group="File", shortcut="Ctrl+N") 

64 def new_project(self): 

65 """Create a new project with initial setup dialog""" 

66 # Create new project setup dialog 

67 dialog = QDialog(self) 

68 dialog.setWindowTitle("New Project Setup") 

69 dialog.setMinimumWidth(450) 

70 

71 layout = QVBoxLayout() 

72 

73 # Project name group 

74 name_group = QGroupBox("Project Name") 

75 name_layout = QVBoxLayout() 

76 name_input = QLineEdit() 

77 name_input.setText("New Project") 

78 name_input.selectAll() 

79 name_layout.addWidget(name_input) 

80 name_group.setLayout(name_layout) 

81 layout.addWidget(name_group) 

82 

83 # Default page size group 

84 size_group = QGroupBox("Default Page Size") 

85 size_layout = QVBoxLayout() 

86 

87 info_label = QLabel("This will be the default size for all new pages in this project.") 

88 info_label.setWordWrap(True) 

89 info_label.setStyleSheet("font-size: 9pt; color: gray;") 

90 size_layout.addWidget(info_label) 

91 

92 # Width 

93 width_layout = QHBoxLayout() 

94 width_layout.addWidget(QLabel("Width:")) 

95 width_spinbox = QDoubleSpinBox() 

96 width_spinbox.setRange(10, 1000) 

97 width_spinbox.setValue(140) # Default 14cm 

98 width_spinbox.setSuffix(" mm") 

99 width_layout.addWidget(width_spinbox) 

100 size_layout.addLayout(width_layout) 

101 

102 # Height 

103 height_layout = QHBoxLayout() 

104 height_layout.addWidget(QLabel("Height:")) 

105 height_spinbox = QDoubleSpinBox() 

106 height_spinbox.setRange(10, 1000) 

107 height_spinbox.setValue(140) # Default 14cm 

108 height_spinbox.setSuffix(" mm") 

109 height_layout.addWidget(height_spinbox) 

110 size_layout.addLayout(height_layout) 

111 

112 # Add common size presets 

113 presets_layout = QHBoxLayout() 

114 presets_layout.addWidget(QLabel("Presets:")) 

115 

116 def set_preset(w, h): 

117 width_spinbox.setValue(w) 

118 height_spinbox.setValue(h) 

119 

120 preset_a4 = QPushButton("A4 (210×297)") 

121 preset_a4.clicked.connect(lambda: set_preset(210, 297)) 

122 presets_layout.addWidget(preset_a4) 

123 

124 preset_a5 = QPushButton("A5 (148×210)") 

125 preset_a5.clicked.connect(lambda: set_preset(148, 210)) 

126 presets_layout.addWidget(preset_a5) 

127 

128 preset_square = QPushButton("Square (200×200)") 

129 preset_square.clicked.connect(lambda: set_preset(200, 200)) 

130 presets_layout.addWidget(preset_square) 

131 

132 presets_layout.addStretch() 

133 size_layout.addLayout(presets_layout) 

134 

135 size_group.setLayout(size_layout) 

136 layout.addWidget(size_group) 

137 

138 # DPI settings group 

139 dpi_group = QGroupBox("DPI Settings") 

140 dpi_layout = QVBoxLayout() 

141 

142 # Working DPI 

143 working_dpi_layout = QHBoxLayout() 

144 working_dpi_layout.addWidget(QLabel("Working DPI:")) 

145 working_dpi_spinbox = QSpinBox() 

146 working_dpi_spinbox.setRange(72, 1200) 

147 working_dpi_spinbox.setValue(300) 

148 working_dpi_layout.addWidget(working_dpi_spinbox) 

149 dpi_layout.addLayout(working_dpi_layout) 

150 

151 # Export DPI 

152 export_dpi_layout = QHBoxLayout() 

153 export_dpi_layout.addWidget(QLabel("Export DPI:")) 

154 export_dpi_spinbox = QSpinBox() 

155 export_dpi_spinbox.setRange(72, 1200) 

156 export_dpi_spinbox.setValue(300) 

157 export_dpi_layout.addWidget(export_dpi_spinbox) 

158 dpi_layout.addLayout(export_dpi_layout) 

159 

160 dpi_group.setLayout(dpi_layout) 

161 layout.addWidget(dpi_group) 

162 

163 # Buttons 

164 button_layout = QHBoxLayout() 

165 cancel_btn = QPushButton("Cancel") 

166 cancel_btn.clicked.connect(dialog.reject) 

167 create_btn = QPushButton("Create Project") 

168 create_btn.clicked.connect(dialog.accept) 

169 create_btn.setDefault(True) 

170 

171 button_layout.addStretch() 

172 button_layout.addWidget(cancel_btn) 

173 button_layout.addWidget(create_btn) 

174 layout.addLayout(button_layout) 

175 

176 dialog.setLayout(layout) 

177 

178 # Show dialog 

179 if dialog.exec() == QDialog.DialogCode.Accepted: 

180 # Get values 

181 project_name = name_input.text().strip() or "New Project" 

182 width_mm = width_spinbox.value() 

183 height_mm = height_spinbox.value() 

184 working_dpi = working_dpi_spinbox.value() 

185 export_dpi = export_dpi_spinbox.value() 

186 

187 # Cleanup old project if it exists 

188 if hasattr(self, "project") and self.project: 

189 self.project.cleanup() 

190 

191 # Create project with custom settings 

192 self.project = Project(project_name) 

193 self.project.page_size_mm = (width_mm, height_mm) 

194 self.project.working_dpi = working_dpi 

195 self.project.export_dpi = export_dpi 

196 

197 # Set asset resolution context 

198 set_asset_resolution_context(self.project.folder_path) 

199 

200 # Update view 

201 self.update_view() 

202 

203 self.show_status(f"New project created: {project_name} ({width_mm}×{height_mm} mm)") 

204 print(f"New project created: {project_name}, default page size: {width_mm}×{height_mm} mm") 

205 else: 

206 # User cancelled - keep current project 

207 print("New project creation cancelled") 

208 

209 @ribbon_action(label="Open", tooltip="Open an existing project", tab="Home", group="File", shortcut="Ctrl+O") 

210 def open_project(self): 

211 """Open an existing project with async loading and progress bar""" 

212 file_path, _ = QFileDialog.getOpenFileName( 

213 self, "Open Project", "", "pyPhotoAlbum Projects (*.ppz);;All Files (*)" 

214 ) 

215 

216 if file_path: 

217 print(f"Opening project: {file_path}") 

218 

219 # Create loading widget if not exists 

220 if not hasattr(self, "_loading_widget"): 

221 self._loading_widget = LoadingWidget(self) 

222 

223 # Show loading widget 

224 self._loading_widget.show_loading("Opening project...") 

225 

226 # Create and configure async loader 

227 self._project_loader = AsyncProjectLoader(file_path) 

228 self._opening_file_path = file_path # Store for later 

229 

230 # Connect signals 

231 self._project_loader.progress_updated.connect(self._on_load_progress) 

232 self._project_loader.load_complete.connect(self._on_load_complete) 

233 self._project_loader.load_failed.connect(self._on_load_failed) 

234 

235 # Start async loading 

236 self._project_loader.start() 

237 

238 def _on_load_progress(self, current: int, total: int, message: str): 

239 """Handle loading progress updates""" 

240 if hasattr(self, "_loading_widget"): 

241 self._loading_widget.set_progress(current, total) 

242 self._loading_widget.set_status(message) 

243 

244 def _on_load_complete(self, project): 

245 """Handle successful project load""" 

246 # Cleanup old project if it exists 

247 if hasattr(self, "project") and self.project: 

248 self.project.cleanup() 

249 

250 # Set new project 

251 self.project = project 

252 

253 # Set file path and mark as clean 

254 if hasattr(self, "_opening_file_path"): 

255 self.project.file_path = self._opening_file_path 

256 delattr(self, "_opening_file_path") 

257 self.project.mark_clean() 

258 

259 self.gl_widget.current_page_index = 0 # Reset to first page 

260 

261 # Hide loading widget 

262 if hasattr(self, "_loading_widget"): 

263 self._loading_widget.hide_loading() 

264 

265 # Update view (this will trigger progressive image loading) 

266 self.update_view() 

267 

268 # Check for missing assets and inform user 

269 missing_assets = self._check_missing_assets() 

270 if missing_assets: 

271 self._show_missing_assets_warning(missing_assets) 

272 self.show_status(f"Project opened: {project.name} ({len(missing_assets)} missing images)") 

273 else: 

274 self.show_status(f"Project opened: {project.name}") 

275 print(f"Successfully loaded project: {project.name}") 

276 

277 def _on_load_failed(self, error_msg: str): 

278 """Handle project load failure""" 

279 # Hide loading widget 

280 if hasattr(self, "_loading_widget"): 

281 self._loading_widget.hide_loading() 

282 

283 error_msg = f"Failed to open project: {error_msg}" 

284 self.show_status(error_msg) 

285 self.show_error("Load Failed", error_msg) 

286 print(error_msg) 

287 

288 @ribbon_action(label="Save", tooltip="Save the current project", tab="Home", group="File", shortcut="Ctrl+S") 

289 def save_project(self) -> bool: 

290 """Save the current project asynchronously with progress feedback. 

291 

292 Returns True if an async save was started, False if the user cancelled. 

293 """ 

294 # Prevent concurrent saves — only one background save at a time 

295 if getattr(self, "_save_in_progress", False): 

296 self.show_status("Save already in progress...") 

297 return False 

298 

299 # If project has a file path, use it; otherwise prompt for location 

300 file_path = self.project.file_path if hasattr(self.project, "file_path") and self.project.file_path else None 

301 

302 if not file_path: 

303 file_path, _ = QFileDialog.getSaveFileName( 

304 self, "Save Project", "", "pyPhotoAlbum Projects (*.ppz);;All Files (*)" # type: ignore[arg-type] 

305 ) 

306 

307 if not file_path: 

308 return False 

309 

310 self._save_in_progress = True 

311 print(f"Saving project to: {file_path}") 

312 

313 # Create loading widget if not exists 

314 if not hasattr(self, "_loading_widget"): 

315 self._loading_widget = LoadingWidget(self) 

316 

317 # Show loading widget 

318 self._loading_widget.show_loading("Saving project...") 

319 

320 # Bridge object: signals are thread-safe so background thread can 

321 # emit them and slots always run on the main (GUI) thread. 

322 bridge = _SaveBridge(parent=self) # type: ignore[arg-type] 

323 

324 def _on_progress(progress: int, message: str): 

325 if hasattr(self, "_loading_widget"): 

326 try: 

327 self._loading_widget.set_progress(progress, 100) 

328 self._loading_widget.set_status(message) 

329 except RuntimeError: 

330 pass 

331 

332 def _on_finished(success: bool, error: str): 

333 self._save_in_progress = False 

334 try: 

335 if hasattr(self, "_loading_widget"): 

336 self._loading_widget.hide_loading() 

337 except RuntimeError: 

338 pass 

339 

340 if success: 

341 self.project.file_path = file_path 

342 self.project.mark_clean() 

343 self.show_status(f"Project saved: {file_path}") 

344 print(f"Successfully saved project to: {file_path}") 

345 if getattr(self, "_pending_close", False): 

346 self._pending_close = False 

347 self.close() # type: ignore[attr-defined] 

348 else: 

349 self._pending_close = False 

350 error_msg = f"Failed to save project: {error}" 

351 self.show_status(error_msg) 

352 self.show_error("Save Failed", error_msg) 

353 print(error_msg) 

354 

355 bridge.progress.connect(_on_progress) 

356 bridge.finished.connect(_on_finished) 

357 

358 # Start async save — callbacks emit signals (thread-safe) 

359 save_to_zip_async( 

360 self.project, 

361 file_path, 

362 on_complete=lambda ok, err: bridge.finished.emit(ok, err or ""), 

363 on_progress=lambda p, m: bridge.progress.emit(p, m), 

364 ) 

365 

366 # Show immediate feedback 

367 self.show_status("Saving project in background...", 2000) 

368 return True 

369 

370 @ribbon_action(label="Heal Assets", tooltip="Reconnect missing image assets", tab="Home", group="File") 

371 def heal_assets(self): 

372 """Open the asset healing dialog to reconnect missing images""" 

373 dialog = AssetHealDialog(self.project, self) 

374 dialog.exec() 

375 

376 # Update the view to reflect any changes 

377 self.update_view() 

378 

379 def _check_missing_assets(self) -> list: 

380 """Check for missing assets in the project - returns list of missing paths""" 

381 from pyPhotoAlbum.models import ImageData 

382 

383 missing = [] 

384 for page in self.project.pages: 

385 for element in page.layout.elements: 

386 if isinstance(element, ImageData) and element.image_path: 

387 # Absolute paths need healing 

388 if os.path.isabs(element.image_path): 

389 missing.append(element.image_path) 

390 # Paths not in assets/ need healing 

391 elif not element.image_path.startswith("assets/"): 

392 missing.append(element.image_path) 

393 else: 

394 # Check if file exists in assets using mixin 

395 if not self.resolve_asset_path(element.image_path): 

396 missing.append(element.image_path) 

397 return list(set(missing)) # Remove duplicates 

398 

399 def _show_missing_assets_warning(self, missing_assets: list): 

400 """Show a warning about missing assets""" 

401 from PyQt6.QtWidgets import QMessageBox 

402 

403 # Build message with list of missing images 

404 if len(missing_assets) <= 5: 

405 asset_list = "\n".join(f"{path}" for path in missing_assets) 

406 else: 

407 asset_list = "\n".join(f"{path}" for path in missing_assets[:5]) 

408 asset_list += f"\n ... and {len(missing_assets) - 5} more" 

409 

410 msg = QMessageBox(cast(QWidget, self)) 

411 msg.setIcon(QMessageBox.Icon.Warning) 

412 msg.setWindowTitle("Missing Assets") 

413 msg.setText(f"{len(missing_assets)} image(s) could not be found in the assets folder:") 

414 msg.setInformativeText(asset_list) 

415 msg.setDetailedText( 

416 "These images need to be reconnected using the 'Heal Assets' feature.\n\n" 

417 "Go to: Home → Heal Assets\n\n" 

418 "Add search paths where the original images might be located, " 

419 "then click 'Attempt Healing' to find and import them." 

420 ) 

421 msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Open) 

422 btn = msg.button(QMessageBox.StandardButton.Open) 

423 if btn is not None: 

424 btn.setText("Open Heal Assets") 

425 

426 result = msg.exec() 

427 if result == QMessageBox.StandardButton.Open: 

428 self.heal_assets() 

429 

430 @ribbon_action( 

431 label="Project Settings", tooltip="Configure project-wide page size and defaults", tab="Home", group="File" 

432 ) 

433 @numerical_input(fields=[("width", "Width", "mm", 10, 1000), ("height", "Height", "mm", 10, 1000)]) 

434 def project_settings(self): 

435 """Configure project-wide settings including default page size""" 

436 # Create dialog 

437 dialog = QDialog(self) 

438 dialog.setWindowTitle("Project Settings") 

439 dialog.setMinimumWidth(500) 

440 

441 layout = QVBoxLayout() 

442 

443 # Page size group 

444 size_group = QGroupBox("Default Page Size") 

445 size_layout = QVBoxLayout() 

446 

447 # Width 

448 width_layout = QHBoxLayout() 

449 width_layout.addWidget(QLabel("Width:")) 

450 width_spinbox = QDoubleSpinBox() 

451 width_spinbox.setRange(10, 1000) 

452 width_spinbox.setValue(self.project.page_size_mm[0]) 

453 width_spinbox.setSuffix(" mm") 

454 width_layout.addWidget(width_spinbox) 

455 size_layout.addLayout(width_layout) 

456 

457 # Height 

458 height_layout = QHBoxLayout() 

459 height_layout.addWidget(QLabel("Height:")) 

460 height_spinbox = QDoubleSpinBox() 

461 height_spinbox.setRange(10, 1000) 

462 height_spinbox.setValue(self.project.page_size_mm[1]) 

463 height_spinbox.setSuffix(" mm") 

464 height_layout.addWidget(height_spinbox) 

465 size_layout.addLayout(height_layout) 

466 

467 size_group.setLayout(size_layout) 

468 layout.addWidget(size_group) 

469 

470 # DPI settings group 

471 dpi_group = QGroupBox("DPI Settings") 

472 dpi_layout = QVBoxLayout() 

473 

474 # Working DPI 

475 working_dpi_layout = QHBoxLayout() 

476 working_dpi_layout.addWidget(QLabel("Working DPI:")) 

477 working_dpi_spinbox = QSpinBox() 

478 working_dpi_spinbox.setRange(72, 1200) 

479 working_dpi_spinbox.setValue(self.project.working_dpi) 

480 working_dpi_layout.addWidget(working_dpi_spinbox) 

481 dpi_layout.addLayout(working_dpi_layout) 

482 

483 # Export DPI 

484 export_dpi_layout = QHBoxLayout() 

485 export_dpi_layout.addWidget(QLabel("Export DPI:")) 

486 export_dpi_spinbox = QSpinBox() 

487 export_dpi_spinbox.setRange(72, 1200) 

488 export_dpi_spinbox.setValue(self.project.export_dpi) 

489 export_dpi_layout.addWidget(export_dpi_spinbox) 

490 dpi_layout.addLayout(export_dpi_layout) 

491 

492 dpi_group.setLayout(dpi_layout) 

493 layout.addWidget(dpi_group) 

494 

495 # Content scaling options (only if pages exist and size is changing) 

496 scaling_group = None 

497 scaling_buttons = None 

498 

499 scope_buttons = None 

500 if self.project.pages: 

501 scaling_group = QGroupBox("Apply to Existing Pages") 

502 scaling_layout = QVBoxLayout() 

503 

504 # Scope: which pages to update 

505 scaling_layout.addWidget(QLabel("Pages to update:")) 

506 scope_buttons = QButtonGroup() 

507 scope_non_manual = QRadioButton("Non-manual pages only") 

508 scope_non_manual.setChecked(True) 

509 scope_all = QRadioButton("All pages (override manual sizing)") 

510 scope_buttons.addButton(scope_non_manual, 0) 

511 scope_buttons.addButton(scope_all, 1) 

512 scaling_layout.addWidget(scope_non_manual) 

513 scaling_layout.addWidget(scope_all) 

514 

515 # Content scaling 

516 scaling_layout.addWidget(QLabel("Content adjustment:")) 

517 scaling_buttons = QButtonGroup() 

518 

519 proportional_radio = QRadioButton("Resize proportionally (fit to smallest axis)") 

520 proportional_radio.setToolTip("Scale content uniformly to fit the new page size") 

521 scaling_buttons.addButton(proportional_radio, 0) 

522 scaling_layout.addWidget(proportional_radio) 

523 

524 stretch_radio = QRadioButton("Resize on both axes (stretch)") 

525 stretch_radio.setToolTip("Scale width and height independently") 

526 scaling_buttons.addButton(stretch_radio, 1) 

527 scaling_layout.addWidget(stretch_radio) 

528 

529 reposition_radio = QRadioButton("Keep content size, reposition to center") 

530 reposition_radio.setToolTip("Maintain element sizes but center them on new page") 

531 scaling_buttons.addButton(reposition_radio, 2) 

532 scaling_layout.addWidget(reposition_radio) 

533 

534 none_radio = QRadioButton("Don't adjust content (page size only)") 

535 none_radio.setToolTip("Only change page size, leave content as-is") 

536 none_radio.setChecked(True) # Default 

537 scaling_buttons.addButton(none_radio, 3) 

538 scaling_layout.addWidget(none_radio) 

539 

540 scaling_group.setLayout(scaling_layout) 

541 layout.addWidget(scaling_group) 

542 

543 # Buttons 

544 button_layout = QHBoxLayout() 

545 cancel_btn = QPushButton("Cancel") 

546 cancel_btn.clicked.connect(dialog.reject) 

547 ok_btn = QPushButton("OK") 

548 ok_btn.clicked.connect(dialog.accept) 

549 ok_btn.setDefault(True) 

550 

551 button_layout.addStretch() 

552 button_layout.addWidget(cancel_btn) 

553 button_layout.addWidget(ok_btn) 

554 layout.addLayout(button_layout) 

555 

556 dialog.setLayout(layout) 

557 

558 # Show dialog 

559 if dialog.exec() == QDialog.DialogCode.Accepted: 

560 # Get new values 

561 new_width = width_spinbox.value() 

562 new_height = height_spinbox.value() 

563 new_working_dpi = working_dpi_spinbox.value() 

564 new_export_dpi = export_dpi_spinbox.value() 

565 

566 # Determine scaling mode and scope 

567 scaling_mode = "none" 

568 include_manual = False 

569 if scaling_buttons: 

570 selected_id = scaling_buttons.checkedId() 

571 modes = {0: "proportional", 1: "stretch", 2: "reposition", 3: "none"} 

572 scaling_mode = modes.get(selected_id, "none") 

573 if scope_buttons: 

574 include_manual = scope_buttons.checkedId() == 1 

575 

576 # Apply settings 

577 old_size = self.project.page_size_mm 

578 self.project.page_size_mm = (new_width, new_height) 

579 self.project.working_dpi = new_working_dpi 

580 self.project.export_dpi = new_export_dpi 

581 

582 # Update existing pages 

583 if self.project.pages and old_size != (new_width, new_height): 

584 self._apply_page_size_to_project(old_size, (new_width, new_height), scaling_mode, include_manual) 

585 

586 self.update_view() 

587 self.show_status(f"Project settings updated: {new_width}×{new_height} mm", 2000) 

588 print(f"Project settings updated: {new_width}×{new_height} mm, scaling mode: {scaling_mode}") 

589 

590 def _apply_page_size_to_project(self, old_size, new_size, scaling_mode, include_manual=False): 

591 """ 

592 Apply new page size to existing pages. 

593 

594 Args: 

595 old_size: Old page size (width, height) in mm 

596 new_size: New page size (width, height) in mm 

597 scaling_mode: 'proportional', 'stretch', 'reposition', or 'none' 

598 include_manual: If True, also resize manually-sized pages 

599 """ 

600 old_width, old_height = old_size 

601 new_width, new_height = new_size 

602 

603 width_ratio = new_width / old_width if old_width > 0 else 1.0 

604 height_ratio = new_height / old_height if old_height > 0 else 1.0 

605 

606 for page in self.project.pages: 

607 if page.is_cover: 

608 continue 

609 if page.manually_sized and not include_manual: 

610 continue 

611 

612 # Update page size 

613 old_page_width, old_page_height = page.layout.size 

614 

615 # For double spreads, maintain the 2x multiplier 

616 if page.is_double_spread: 

617 page.layout.size = (new_width * 2, new_height) 

618 else: 

619 page.layout.size = (new_width, new_height) 

620 

621 # Apply content scaling based on mode 

622 if scaling_mode == "proportional": 

623 # Use smallest ratio to fit content 

624 scale = min(width_ratio, height_ratio) 

625 self._scale_page_elements(page, scale, scale) 

626 elif scaling_mode == "stretch": 

627 # Scale independently on each axis 

628 self._scale_page_elements(page, width_ratio, height_ratio) 

629 elif scaling_mode == "reposition": 

630 # Keep size, center content 

631 self._reposition_page_elements(page, old_size, new_size) 

632 # 'none' - do nothing to elements 

633 

634 def _scale_page_elements(self, page, x_scale, y_scale): 

635 """ 

636 Scale all elements on a page 

637 

638 Args: 

639 page: Page object 

640 x_scale: Horizontal scale factor 

641 y_scale: Vertical scale factor 

642 """ 

643 for element in page.layout.elements: 

644 # Scale position 

645 x, y = element.position 

646 element.position = (x * x_scale, y * y_scale) 

647 

648 # Scale size 

649 width, height = element.size 

650 element.size = (width * x_scale, height * y_scale) 

651 

652 def _reposition_page_elements(self, page, old_size, new_size): 

653 """ 

654 Reposition elements to center them on the new page size 

655 

656 Args: 

657 page: Page object 

658 old_size: Old page size (width, height) in mm 

659 new_size: New page size (width, height) in mm 

660 """ 

661 old_width, old_height = old_size 

662 new_width, new_height = new_size 

663 

664 x_offset = (new_width - old_width) / 2.0 

665 y_offset = (new_height - old_height) / 2.0 

666 

667 for element in page.layout.elements: 

668 x, y = element.position 

669 element.position = (x + x_offset, y + y_offset) 

670 

671 @ribbon_action(label="Export PDF", tooltip="Export project to PDF", tab="Home", group="File") 

672 def export_pdf(self): 

673 """Export project to PDF using async backend (non-blocking)""" 

674 # Check if we have pages to export 

675 if not self.project or not self.project.pages: 

676 self.show_status("No pages to export") 

677 return 

678 

679 # Show file save dialog 

680 file_path, _ = QFileDialog.getSaveFileName(self, "Export to PDF", "", "PDF Files (*.pdf);;All Files (*)") 

681 

682 if not file_path: 

683 return 

684 

685 # Ensure .pdf extension 

686 if not file_path.lower().endswith(".pdf"): 

687 file_path += ".pdf" 

688 

689 # Use async PDF export (non-blocking, UI stays responsive) 

690 success = self.gl_widget.export_pdf_async(self.project, file_path, export_dpi=self.project.export_dpi) 

691 if success: 

692 self.show_status("PDF export started...", 2000) 

693 else: 

694 self.show_status("PDF export failed to start", 3000) 

695 

696 @ribbon_action( 

697 label="Clean Assets", tooltip="Find and remove duplicate or unused image files", tab="Home", group="File" 

698 ) 

699 def clean_assets(self): 

700 """Find and remove duplicate and unused asset files to save space""" 

701 from PyQt6.QtWidgets import QProgressDialog, QCheckBox 

702 from PyQt6.QtCore import Qt 

703 

704 # Helper to format bytes 

705 def format_bytes(num_bytes): 

706 if num_bytes >= 1024 * 1024: 

707 return f"{num_bytes / (1024 * 1024):.1f} MB" 

708 elif num_bytes >= 1024: 

709 return f"{num_bytes / 1024:.1f} KB" 

710 else: 

711 return f"{num_bytes} bytes" 

712 

713 # Scan for issues with progress dialog 

714 progress = QProgressDialog("Scanning assets...", "Cancel", 0, 100, self) 

715 progress.setWindowTitle("Clean Assets") 

716 progress.setWindowModality(Qt.WindowModality.WindowModal) 

717 progress.setValue(10) 

718 

719 # Compute hashes for duplicate detection 

720 self.project.asset_manager.compute_all_hashes() 

721 progress.setValue(40) 

722 

723 if progress.wasCanceled(): 

724 return 

725 

726 # Get duplicate stats 

727 dup_groups, dup_files, dup_bytes = self.project.asset_manager.get_duplicate_stats() 

728 progress.setValue(60) 

729 

730 # Get unused stats 

731 unused_files, unused_bytes = self.project.asset_manager.get_unused_stats() 

732 progress.setValue(80) 

733 

734 progress.close() 

735 

736 # Check if there's anything to clean 

737 if dup_files == 0 and unused_files == 0: 

738 QMessageBox.information( 

739 self, "Assets Clean", "No duplicate or unused files were found in your project assets." 

740 ) 

741 return 

742 

743 # Build dialog with checkboxes for each cleanup type 

744 dialog = QDialog(self) 

745 dialog.setWindowTitle("Clean Assets") 

746 dialog.setMinimumWidth(450) 

747 

748 layout = QVBoxLayout() 

749 

750 # Info label 

751 info_label = QLabel("Select which cleanup operations to perform:") 

752 layout.addWidget(info_label) 

753 

754 # Duplicates checkbox 

755 dup_checkbox = None 

756 if dup_files > 0: 

757 dup_checkbox = QCheckBox( 

758 f"Remove {dup_files} duplicate file(s) in {dup_groups} group(s) " f"(saves {format_bytes(dup_bytes)})" 

759 ) 

760 dup_checkbox.setChecked(True) 

761 dup_checkbox.setToolTip( 

762 "Duplicate files have identical content but different names.\n" 

763 "Image references will be automatically updated to use the kept file." 

764 ) 

765 layout.addWidget(dup_checkbox) 

766 

767 # Unused checkbox 

768 unused_checkbox = None 

769 if unused_files > 0: 

770 unused_checkbox = QCheckBox(f"Remove {unused_files} unused file(s) (saves {format_bytes(unused_bytes)})") 

771 unused_checkbox.setChecked(True) 

772 unused_checkbox.setToolTip( 

773 "Unused files exist in the assets folder but are not referenced\n" 

774 "by any image element in your project." 

775 ) 

776 layout.addWidget(unused_checkbox) 

777 

778 # Summary 

779 total_files = dup_files + unused_files 

780 total_bytes = dup_bytes + unused_bytes 

781 summary_label = QLabel(f"\nTotal potential savings: {format_bytes(total_bytes)} from {total_files} file(s)") 

782 summary_label.setStyleSheet("font-weight: bold;") 

783 layout.addWidget(summary_label) 

784 

785 # Buttons 

786 button_layout = QHBoxLayout() 

787 cancel_btn = QPushButton("Cancel") 

788 cancel_btn.clicked.connect(dialog.reject) 

789 clean_btn = QPushButton("Clean Selected") 

790 clean_btn.clicked.connect(dialog.accept) 

791 clean_btn.setDefault(True) 

792 

793 button_layout.addStretch() 

794 button_layout.addWidget(cancel_btn) 

795 button_layout.addWidget(clean_btn) 

796 layout.addLayout(button_layout) 

797 

798 dialog.setLayout(layout) 

799 

800 if dialog.exec() != QDialog.DialogCode.Accepted: 

801 return 

802 

803 # Perform selected cleanups 

804 total_removed = 0 

805 total_saved = 0 

806 

807 # Remove duplicates if selected 

808 if dup_checkbox and dup_checkbox.isChecked(): 

809 

810 def update_image_references(old_path: str, new_path: str): 

811 """Update all ImageData elements that reference the old path""" 

812 from pyPhotoAlbum.models import ImageData 

813 

814 for page in self.project.pages: 

815 for element in page.layout.elements: 

816 if isinstance(element, ImageData) and element.image_path == old_path: 

817 element.image_path = new_path 

818 element.mark_modified() 

819 print(f"Updated image reference: {old_path} -> {new_path}") 

820 

821 removed, saved = self.project.asset_manager.deduplicate_assets( 

822 update_references_callback=update_image_references 

823 ) 

824 total_removed += removed 

825 total_saved += saved 

826 

827 # Remove unused if selected 

828 if unused_checkbox and unused_checkbox.isChecked(): 

829 removed, saved = self.project.asset_manager.remove_unused_assets() 

830 total_removed += removed 

831 total_saved += saved 

832 

833 if total_removed > 0: 

834 # Mark project as dirty since we modified it 

835 self.project.mark_dirty() 

836 

837 # Update view 

838 self.update_view() 

839 

840 # Show result 

841 QMessageBox.information( 

842 self, 

843 "Cleanup Complete", 

844 f"Removed {total_removed} file(s).\n\n" 

845 f"Saved {format_bytes(total_saved)} of disk space.\n\n" 

846 f"Remember to save your project to preserve these changes.", 

847 ) 

848 

849 self.show_status( 

850 f"Asset cleanup complete: removed {total_removed} files, saved {format_bytes(total_saved)}" 

851 ) 

852 else: 

853 self.show_status("No files were removed") 

854 

855 @ribbon_action(label="About", tooltip="About pyPhotoAlbum and data format version", tab="Home", group="File") 

856 def show_about(self): 

857 """Show about dialog with version information""" 

858 dialog = QDialog(self) 

859 dialog.setWindowTitle("About pyPhotoAlbum") 

860 dialog.setMinimumWidth(600) 

861 dialog.setMinimumHeight(400) 

862 

863 layout = QVBoxLayout() 

864 

865 # Application info 

866 app_info = QLabel("<h2>pyPhotoAlbum</h2>") 

867 app_info.setWordWrap(True) 

868 layout.addWidget(app_info) 

869 

870 description = QLabel( 

871 "A photo album layout and design application with advanced " 

872 "page composition features and PDF export capabilities." 

873 ) 

874 description.setWordWrap(True) 

875 layout.addWidget(description) 

876 

877 # Version information 

878 version_text = QTextEdit() 

879 version_text.setReadOnly(True) 

880 version_text.setPlainText(format_version_info()) 

881 layout.addWidget(version_text) 

882 

883 # Close button 

884 close_button = QPushButton("Close") 

885 close_button.clicked.connect(dialog.accept) 

886 layout.addWidget(close_button) 

887 

888 dialog.setLayout(layout) 

889 dialog.exec()