Coverage for pyPhotoAlbum/commands.py: 90%

384 statements  

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

1""" 

2Command pattern implementation for undo/redo functionality 

3""" 

4 

5import os 

6from abc import ABC, abstractmethod 

7from typing import Dict, Any, List, Optional 

8from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData 

9 

10 

11def _normalize_asset_path(image_path: str, asset_manager) -> str: 

12 """ 

13 Convert absolute path to relative for asset manager. 

14 

15 Args: 

16 image_path: Image path (absolute or relative) 

17 asset_manager: AssetManager instance 

18 

19 Returns: 

20 Relative path suitable for asset manager 

21 """ 

22 if not asset_manager or not image_path: 

23 return image_path 

24 

25 if os.path.isabs(image_path): 

26 return os.path.relpath(image_path, asset_manager.project_folder) 

27 return image_path 

28 

29 

30def _deserialize_element(elem_data: Dict[str, Any]) -> BaseLayoutElement: 

31 """ 

32 Deserialize element data into the appropriate element type. 

33 

34 Args: 

35 elem_data: Dictionary containing serialized element data with 'type' key 

36 

37 Returns: 

38 Deserialized element instance (ImageData, PlaceholderData, or TextBoxData) 

39 

40 Raises: 

41 ValueError: If element type is unknown 

42 """ 

43 elem_type = elem_data.get("type") 

44 

45 element: BaseLayoutElement 

46 if elem_type == "image": 

47 element = ImageData() 

48 elif elem_type == "placeholder": 

49 element = PlaceholderData() 

50 elif elem_type == "textbox": 

51 element = TextBoxData() 

52 else: 

53 raise ValueError(f"Unknown element type: {elem_type}") 

54 

55 element.deserialize(elem_data) 

56 return element 

57 

58 

59class Command(ABC): 

60 """Abstract base class for all commands""" 

61 

62 @abstractmethod 

63 def execute(self): 

64 """Execute the command""" 

65 pass 

66 

67 @abstractmethod 

68 def undo(self): 

69 """Undo the command""" 

70 pass 

71 

72 @abstractmethod 

73 def redo(self): 

74 """Redo the command (default implementation calls execute)""" 

75 self.execute() 

76 

77 @abstractmethod 

78 def serialize(self) -> Dict[str, Any]: 

79 """Serialize command to dictionary for saving""" 

80 pass 

81 

82 @staticmethod 

83 @abstractmethod 

84 def deserialize(data: Dict[str, Any], project) -> "Command": 

85 """Deserialize command from dictionary""" 

86 pass 

87 

88 

89class AddElementCommand(Command): 

90 """Command for adding an element to a page""" 

91 

92 def __init__(self, page_layout, element: BaseLayoutElement, asset_manager=None): 

93 self.page_layout = page_layout 

94 self.element = element 

95 self.executed = False 

96 self.asset_manager = asset_manager 

97 

98 # Acquire reference to asset when command is created 

99 if self.asset_manager and isinstance(self.element, ImageData) and self.element.image_path: 

100 rel_path = _normalize_asset_path(self.element.image_path, self.asset_manager) 

101 self.asset_manager.acquire_reference(rel_path) 

102 

103 def execute(self): 

104 """Add the element to the page""" 

105 if not self.executed: 

106 self.page_layout.add_element(self.element) 

107 self.executed = True 

108 

109 def undo(self): 

110 """Remove the element from the page""" 

111 if self.executed: 

112 self.page_layout.remove_element(self.element) 

113 self.executed = False 

114 

115 def redo(self): 

116 """Re-add the element""" 

117 self.execute() 

118 

119 def serialize(self) -> Dict[str, Any]: 

120 """Serialize to dictionary""" 

121 return {"type": "add_element", "element": self.element.serialize(), "executed": self.executed} 

122 

123 @staticmethod 

124 def deserialize(data: Dict[str, Any], project) -> "AddElementCommand": 

125 """Deserialize from dictionary""" 

126 element = _deserialize_element(data["element"]) 

127 # Note: page_layout will be handled by the CommandHistory deserializer 

128 cmd = AddElementCommand(None, element) 

129 cmd.executed = data.get("executed", False) 

130 return cmd 

131 

132 

133class DeleteElementCommand(Command): 

134 """Command for deleting an element from a page""" 

135 

136 def __init__(self, page_layout, element: BaseLayoutElement, asset_manager=None): 

137 self.page_layout = page_layout 

138 self.element = element 

139 self.executed = False 

140 self.asset_manager = asset_manager 

141 

142 # Acquire reference to asset to keep it alive while in undo history 

143 if self.asset_manager and isinstance(self.element, ImageData) and self.element.image_path: 

144 rel_path = _normalize_asset_path(self.element.image_path, self.asset_manager) 

145 self.asset_manager.acquire_reference(rel_path) 

146 

147 def execute(self): 

148 """Remove the element from the page""" 

149 if not self.executed: 

150 self.page_layout.remove_element(self.element) 

151 self.executed = True 

152 

153 def undo(self): 

154 """Re-add the element to the page""" 

155 if self.executed: 

156 self.page_layout.add_element(self.element) 

157 self.executed = False 

158 

159 def redo(self): 

160 """Re-remove the element""" 

161 self.execute() 

162 

163 def serialize(self) -> Dict[str, Any]: 

164 """Serialize to dictionary""" 

165 return {"type": "delete_element", "element": self.element.serialize(), "executed": self.executed} 

166 

167 @staticmethod 

168 def deserialize(data: Dict[str, Any], project) -> "DeleteElementCommand": 

169 """Deserialize from dictionary""" 

170 element = _deserialize_element(data["element"]) 

171 cmd = DeleteElementCommand(None, element) 

172 cmd.executed = data.get("executed", False) 

173 return cmd 

174 

175 

176class MoveElementCommand(Command): 

177 """Command for moving an element""" 

178 

179 def __init__(self, element: BaseLayoutElement, old_position: tuple, new_position: tuple): 

180 self.element = element 

181 self.old_position = old_position 

182 self.new_position = new_position 

183 

184 def execute(self): 

185 """Move element to new position""" 

186 self.element.position = self.new_position 

187 

188 def undo(self): 

189 """Move element back to old position""" 

190 self.element.position = self.old_position 

191 

192 def redo(self): 

193 """Move element to new position again""" 

194 self.execute() 

195 

196 def serialize(self) -> Dict[str, Any]: 

197 """Serialize to dictionary""" 

198 return { 

199 "type": "move_element", 

200 "element": self.element.serialize(), 

201 "old_position": self.old_position, 

202 "new_position": self.new_position, 

203 } 

204 

205 @staticmethod 

206 def deserialize(data: Dict[str, Any], project) -> "MoveElementCommand": 

207 """Deserialize from dictionary""" 

208 element = _deserialize_element(data["element"]) 

209 return MoveElementCommand(element, tuple(data["old_position"]), tuple(data["new_position"])) 

210 

211 

212class ResizeElementCommand(Command): 

213 """Command for resizing an element""" 

214 

215 def __init__( 

216 self, element: BaseLayoutElement, old_position: tuple, old_size: tuple, new_position: tuple, new_size: tuple 

217 ): 

218 self.element = element 

219 self.old_position = old_position 

220 self.old_size = old_size 

221 self.new_position = new_position 

222 self.new_size = new_size 

223 

224 def execute(self): 

225 """Resize element to new size""" 

226 self.element.position = self.new_position 

227 self.element.size = self.new_size 

228 

229 def undo(self): 

230 """Resize element back to old size""" 

231 self.element.position = self.old_position 

232 self.element.size = self.old_size 

233 

234 def redo(self): 

235 """Resize element to new size again""" 

236 self.execute() 

237 

238 def serialize(self) -> Dict[str, Any]: 

239 """Serialize to dictionary""" 

240 return { 

241 "type": "resize_element", 

242 "element": self.element.serialize(), 

243 "old_position": self.old_position, 

244 "old_size": self.old_size, 

245 "new_position": self.new_position, 

246 "new_size": self.new_size, 

247 } 

248 

249 @staticmethod 

250 def deserialize(data: Dict[str, Any], project) -> "ResizeElementCommand": 

251 """Deserialize from dictionary""" 

252 element = _deserialize_element(data["element"]) 

253 return ResizeElementCommand( 

254 element, 

255 tuple(data["old_position"]), 

256 tuple(data["old_size"]), 

257 tuple(data["new_position"]), 

258 tuple(data["new_size"]), 

259 ) 

260 

261 

262class RotateElementCommand(Command): 

263 """Command for rotating an element""" 

264 

265 def __init__(self, element: BaseLayoutElement, old_rotation: float, new_rotation: float): 

266 self.element = element 

267 self.old_rotation = old_rotation 

268 self.new_rotation = new_rotation 

269 

270 # Store old position, size, and PIL rotation state 

271 self.old_position = element.position 

272 self.old_size = element.size 

273 

274 # For ImageData, store the old PIL rotation state 

275 if hasattr(element, "pil_rotation_90"): 

276 self.old_pil_rotation = element.pil_rotation_90 

277 else: 

278 self.old_pil_rotation = None 

279 

280 def execute(self): 

281 """Rotate element by physically rotating the PIL image data""" 

282 from pyPhotoAlbum.models import ImageData 

283 

284 # Calculate rotation delta 

285 delta = (self.new_rotation - self.old_rotation) % 360 

286 

287 # For ImageData, rotate the actual PIL image 

288 if isinstance(self.element, ImageData): 

289 # Update PIL rotation counter 

290 if delta == 90: 

291 self.element.pil_rotation_90 = (self.element.pil_rotation_90 + 1) % 4 

292 elif delta == 270: 

293 self.element.pil_rotation_90 = (self.element.pil_rotation_90 + 3) % 4 

294 elif delta == 180: 

295 self.element.pil_rotation_90 = (self.element.pil_rotation_90 + 2) % 4 

296 

297 # For 90° or 270° rotations, swap dimensions 

298 if delta == 90 or delta == 270: 

299 w, h = self.element.size 

300 x, y = self.element.position 

301 

302 # Swap dimensions 

303 self.element.size = (h, w) 

304 

305 # Adjust position to keep center in same place 

306 center_x = x + w / 2 

307 center_y = y + h / 2 

308 self.element.position = (center_x - h / 2, center_y - w / 2) 

309 

310 # Clear the texture so it will be reloaded with the new rotation 

311 if hasattr(self.element, "_texture_id"): 

312 del self.element._texture_id 

313 if hasattr(self.element, "_async_load_requested"): 

314 self.element._async_load_requested = False 

315 

316 # Keep visual rotation at 0 

317 self.element.rotation = 0 

318 else: 

319 # For non-image elements, use old visual rotation 

320 if delta == 90 or delta == 270: 

321 w, h = self.element.size 

322 x, y = self.element.position 

323 self.element.size = (h, w) 

324 center_x = x + w / 2 

325 center_y = y + h / 2 

326 self.element.position = (center_x - h / 2, center_y - w / 2) 

327 self.element.rotation = 0 

328 else: 

329 self.element.rotation = self.new_rotation 

330 

331 def undo(self): 

332 """Restore element back to old state""" 

333 from pyPhotoAlbum.models import ImageData 

334 

335 # Restore original rotation, position, and size 

336 self.element.rotation = self.old_rotation 

337 self.element.position = self.old_position 

338 self.element.size = self.old_size 

339 

340 # For ImageData, restore PIL rotation and clear texture 

341 if isinstance(self.element, ImageData) and self.old_pil_rotation is not None: 

342 self.element.pil_rotation_90 = self.old_pil_rotation 

343 if hasattr(self.element, "_texture_id"): 

344 self.element._texture_id = None 

345 self.element._async_load_requested = False 

346 

347 def redo(self): 

348 """Rotate element to new angle again""" 

349 self.execute() 

350 

351 def serialize(self) -> Dict[str, Any]: 

352 """Serialize to dictionary""" 

353 return { 

354 "type": "rotate_element", 

355 "element": self.element.serialize(), 

356 "old_rotation": self.old_rotation, 

357 "new_rotation": self.new_rotation, 

358 } 

359 

360 @staticmethod 

361 def deserialize(data: Dict[str, Any], project) -> "RotateElementCommand": 

362 """Deserialize from dictionary""" 

363 element = _deserialize_element(data["element"]) 

364 return RotateElementCommand(element, data["old_rotation"], data["new_rotation"]) 

365 

366 

367class AdjustImageCropCommand(Command): 

368 """Command for adjusting image crop/pan within frame""" 

369 

370 def __init__(self, element: ImageData, old_crop_info: tuple, new_crop_info: tuple): 

371 self.element = element 

372 self.old_crop_info = old_crop_info 

373 self.new_crop_info = new_crop_info 

374 

375 def execute(self): 

376 """Apply new crop info""" 

377 self.element.crop_info = self.new_crop_info 

378 

379 def undo(self): 

380 """Restore old crop info""" 

381 self.element.crop_info = self.old_crop_info 

382 

383 def redo(self): 

384 """Apply new crop info again""" 

385 self.execute() 

386 

387 def serialize(self) -> Dict[str, Any]: 

388 """Serialize to dictionary""" 

389 return { 

390 "type": "adjust_image_crop", 

391 "element": self.element.serialize(), 

392 "old_crop_info": self.old_crop_info, 

393 "new_crop_info": self.new_crop_info, 

394 } 

395 

396 @staticmethod 

397 def deserialize(data: Dict[str, Any], project) -> "AdjustImageCropCommand": 

398 """Deserialize from dictionary""" 

399 elem_data = data["element"] 

400 element = ImageData() 

401 element.deserialize(elem_data) 

402 

403 return AdjustImageCropCommand(element, tuple(data["old_crop_info"]), tuple(data["new_crop_info"])) 

404 

405 

406class AlignElementsCommand(Command): 

407 """Command for aligning multiple elements""" 

408 

409 def __init__(self, changes: List[tuple]): 

410 """ 

411 Args: 

412 changes: List of (element, old_position) tuples 

413 """ 

414 self.changes = changes 

415 

416 def execute(self): 

417 """Positions have already been set by AlignmentManager""" 

418 pass 

419 

420 def undo(self): 

421 """Restore old positions""" 

422 for element, old_position in self.changes: 

423 element.position = old_position 

424 

425 def redo(self): 

426 """Re-apply alignment (positions are stored in current state)""" 

427 # Store current positions and restore them 

428 new_positions = [(elem, elem.position) for elem, _ in self.changes] 

429 for element, old_position in self.changes: 

430 element.position = old_position 

431 # Then re-apply new positions 

432 for element, new_position in new_positions: 

433 element.position = new_position 

434 

435 def serialize(self) -> Dict[str, Any]: 

436 """Serialize to dictionary""" 

437 return { 

438 "type": "align_elements", 

439 "changes": [{"element": elem.serialize(), "old_position": old_pos} for elem, old_pos in self.changes], 

440 } 

441 

442 @staticmethod 

443 def deserialize(data: Dict[str, Any], project) -> "AlignElementsCommand": 

444 """Deserialize from dictionary""" 

445 changes = [] 

446 for change_data in data.get("changes", []): 

447 try: 

448 element = _deserialize_element(change_data["element"]) 

449 old_position = tuple(change_data["old_position"]) 

450 changes.append((element, old_position)) 

451 except ValueError: 

452 continue 

453 return AlignElementsCommand(changes) 

454 

455 

456class ResizeElementsCommand(Command): 

457 """Command for resizing multiple elements""" 

458 

459 def __init__(self, changes: List[tuple]): 

460 """ 

461 Args: 

462 changes: List of (element, old_position, old_size) tuples 

463 """ 

464 self.changes = changes 

465 self.new_states = [(elem, elem.position, elem.size) for elem, _, _ in changes] 

466 

467 def execute(self): 

468 """Sizes have already been set by AlignmentManager""" 

469 pass 

470 

471 def undo(self): 

472 """Restore old positions and sizes""" 

473 for element, old_position, old_size in self.changes: 

474 element.position = old_position 

475 element.size = old_size 

476 

477 def redo(self): 

478 """Re-apply new sizes""" 

479 for element, new_position, new_size in self.new_states: 

480 element.position = new_position 

481 element.size = new_size 

482 

483 def serialize(self) -> Dict[str, Any]: 

484 """Serialize to dictionary""" 

485 return { 

486 "type": "resize_elements", 

487 "changes": [ 

488 {"element": elem.serialize(), "old_position": old_pos, "old_size": old_size} 

489 for elem, old_pos, old_size in self.changes 

490 ], 

491 } 

492 

493 @staticmethod 

494 def deserialize(data: Dict[str, Any], project) -> "ResizeElementsCommand": 

495 """Deserialize from dictionary""" 

496 changes = [] 

497 for change_data in data.get("changes", []): 

498 try: 

499 element = _deserialize_element(change_data["element"]) 

500 old_position = tuple(change_data["old_position"]) 

501 old_size = tuple(change_data["old_size"]) 

502 changes.append((element, old_position, old_size)) 

503 except ValueError: 

504 continue 

505 return ResizeElementsCommand(changes) 

506 

507 

508class ChangeZOrderCommand(Command): 

509 """Command for changing element z-order (list position)""" 

510 

511 def __init__(self, page_layout, element: BaseLayoutElement, old_index: int, new_index: int): 

512 self.page_layout = page_layout 

513 self.element = element 

514 self.old_index = old_index 

515 self.new_index = new_index 

516 

517 def execute(self): 

518 """Move element to new position in list""" 

519 elements = self.page_layout.elements 

520 if self.element in elements: 

521 elements.remove(self.element) 

522 elements.insert(self.new_index, self.element) 

523 

524 def undo(self): 

525 """Move element back to old position in list""" 

526 elements = self.page_layout.elements 

527 if self.element in elements: 

528 elements.remove(self.element) 

529 elements.insert(self.old_index, self.element) 

530 

531 def redo(self): 

532 """Move element to new position again""" 

533 self.execute() 

534 

535 def serialize(self) -> Dict[str, Any]: 

536 """Serialize to dictionary""" 

537 return { 

538 "type": "change_zorder", 

539 "element": self.element.serialize(), 

540 "old_index": self.old_index, 

541 "new_index": self.new_index, 

542 } 

543 

544 @staticmethod 

545 def deserialize(data: Dict[str, Any], project) -> "ChangeZOrderCommand": 

546 """Deserialize from dictionary""" 

547 element = _deserialize_element(data["element"]) 

548 return ChangeZOrderCommand( 

549 None, element, data["old_index"], data["new_index"] # page_layout will be set by CommandHistory 

550 ) 

551 

552 

553class StateChangeCommand(Command): 

554 """ 

555 Generic command for operations that change state. 

556 

557 This command captures before/after snapshots of state and can restore them. 

558 Used by the @undoable_operation decorator. 

559 """ 

560 

561 def __init__(self, description: str, restore_func, before_state: Any, after_state: Any = None): 

562 """ 

563 Args: 

564 description: Human-readable description of the operation 

565 restore_func: Function to restore state: restore_func(state) 

566 before_state: State before the operation 

567 after_state: State after the operation (captured during execute) 

568 """ 

569 self.description = description 

570 self.restore_func = restore_func 

571 self.before_state = before_state 

572 self.after_state = after_state 

573 

574 def execute(self): 

575 """State is already applied, just store after_state if not set""" 

576 # After state is captured by decorator after operation runs 

577 pass 

578 

579 def undo(self): 

580 """Restore to before state""" 

581 self.restore_func(self.before_state) 

582 

583 def redo(self): 

584 """Restore to after state""" 

585 self.restore_func(self.after_state) 

586 

587 def serialize(self) -> Dict[str, Any]: 

588 """Serialize to dictionary""" 

589 # For now, state change commands are not serialized 

590 # This could be enhanced later if needed 

591 return {"type": "state_change", "description": self.description} 

592 

593 @staticmethod 

594 def deserialize(data: Dict[str, Any], project) -> "StateChangeCommand": 

595 """Deserialize from dictionary""" 

596 # Not implemented - would need to serialize state 

597 raise NotImplementedError("StateChangeCommand deserialization not yet supported") 

598 

599 

600class CommandHistory: 

601 """Manages undo/redo command history""" 

602 

603 def __init__(self, max_history: int = 100, asset_manager=None, project=None): 

604 self.undo_stack: List[Command] = [] 

605 self.redo_stack: List[Command] = [] 

606 self.max_history = max_history 

607 self.asset_manager = asset_manager 

608 self.project = project # Reference to project for dirty flag tracking 

609 

610 def execute(self, command: Command): 

611 """Execute a command and add it to history""" 

612 command.execute() 

613 

614 # When clearing redo stack, release asset references 

615 for cmd in self.redo_stack: 

616 self._release_command_assets(cmd) 

617 self.redo_stack.clear() 

618 

619 self.undo_stack.append(command) 

620 

621 # Mark project as dirty 

622 if self.project: 

623 self.project.mark_dirty() 

624 

625 # Limit history size - release assets from old commands 

626 if len(self.undo_stack) > self.max_history: 

627 old_cmd = self.undo_stack.pop(0) 

628 self._release_command_assets(old_cmd) 

629 

630 def _release_command_assets(self, command: Command): 

631 """Release asset references held by a command""" 

632 if not self.asset_manager: 

633 return 

634 

635 # Release asset references for commands that hold them 

636 if isinstance(command, (AddElementCommand, DeleteElementCommand)): 

637 if isinstance(command.element, ImageData) and command.element.image_path: 

638 # Convert absolute path to relative for asset manager 

639 asset_path = command.element.image_path 

640 if os.path.isabs(asset_path): 

641 asset_path = os.path.relpath(asset_path, self.asset_manager.project_folder) 

642 self.asset_manager.release_reference(asset_path) 

643 

644 def undo(self) -> bool: 

645 """Undo the last command""" 

646 if not self.can_undo(): 

647 return False 

648 

649 command = self.undo_stack.pop() 

650 command.undo() 

651 self.redo_stack.append(command) 

652 

653 # Mark project as dirty 

654 if self.project: 

655 self.project.mark_dirty() 

656 

657 return True 

658 

659 def redo(self) -> bool: 

660 """Redo the last undone command""" 

661 if not self.can_redo(): 

662 return False 

663 

664 command = self.redo_stack.pop() 

665 command.redo() 

666 self.undo_stack.append(command) 

667 

668 # Mark project as dirty 

669 if self.project: 

670 self.project.mark_dirty() 

671 

672 return True 

673 

674 def can_undo(self) -> bool: 

675 """Check if undo is available""" 

676 return len(self.undo_stack) > 0 

677 

678 def can_redo(self) -> bool: 

679 """Check if redo is available""" 

680 return len(self.redo_stack) > 0 

681 

682 def clear(self): 

683 """Clear all history and release asset references""" 

684 # Release all asset references 

685 for cmd in self.undo_stack: 

686 self._release_command_assets(cmd) 

687 for cmd in self.redo_stack: 

688 self._release_command_assets(cmd) 

689 

690 self.undo_stack.clear() 

691 self.redo_stack.clear() 

692 

693 def serialize(self) -> Dict[str, Any]: 

694 """Serialize history to dictionary""" 

695 return { 

696 "undo_stack": [cmd.serialize() for cmd in self.undo_stack], 

697 "redo_stack": [cmd.serialize() for cmd in self.redo_stack], 

698 "max_history": self.max_history, 

699 } 

700 

701 def deserialize(self, data: Dict[str, Any], project): 

702 """Deserialize history from dictionary""" 

703 self.max_history = data.get("max_history", 100) 

704 

705 # Deserialize undo stack 

706 self.undo_stack = [] 

707 for cmd_data in data.get("undo_stack", []): 

708 cmd = self._deserialize_command(cmd_data, project) 

709 if cmd: 

710 # Fix up page_layout references for commands that need them 

711 self._fixup_page_layout(cmd, project) 

712 self.undo_stack.append(cmd) 

713 

714 # Deserialize redo stack 

715 self.redo_stack = [] 

716 for cmd_data in data.get("redo_stack", []): 

717 cmd = self._deserialize_command(cmd_data, project) 

718 if cmd: 

719 # Fix up page_layout references for commands that need them 

720 self._fixup_page_layout(cmd, project) 

721 self.redo_stack.append(cmd) 

722 

723 def _fixup_page_layout(self, cmd: Command, project): 

724 """ 

725 Fix up page_layout references after deserialization. 

726 

727 Commands like AddElementCommand store page_layout as None during 

728 deserialization because the page_layout object doesn't exist yet. 

729 This method finds the correct page_layout based on the element. 

730 """ 

731 # Check if command has a page_layout attribute that's None 

732 if not hasattr(cmd, "page_layout") or cmd.page_layout is not None: 

733 return 

734 

735 # Try to find the page containing this element 

736 if hasattr(cmd, "element") and cmd.element: 

737 element = cmd.element 

738 for page in project.pages: 

739 if element in page.layout.elements: 

740 cmd.page_layout = page.layout 

741 return 

742 # Element not found in any page - use first page as fallback 

743 # This can happen for newly added elements not yet in a page 

744 if project.pages: 

745 cmd.page_layout = project.pages[0].layout 

746 

747 # Command type registry for deserialization 

748 _COMMAND_DESERIALIZERS = { 

749 "add_element": AddElementCommand.deserialize, 

750 "delete_element": DeleteElementCommand.deserialize, 

751 "move_element": MoveElementCommand.deserialize, 

752 "resize_element": ResizeElementCommand.deserialize, 

753 "rotate_element": RotateElementCommand.deserialize, 

754 "align_elements": AlignElementsCommand.deserialize, 

755 "resize_elements": ResizeElementsCommand.deserialize, 

756 "change_zorder": ChangeZOrderCommand.deserialize, 

757 "adjust_image_crop": AdjustImageCropCommand.deserialize, 

758 } 

759 

760 def _deserialize_command(self, data: Dict[str, Any], project) -> Optional[Command]: 

761 """Deserialize a single command using registry pattern""" 

762 cmd_type = data.get("type") 

763 if cmd_type is None: 

764 return None 

765 

766 deserializer = self._COMMAND_DESERIALIZERS.get(cmd_type) 

767 if not deserializer: 

768 print(f"Warning: Unknown command type: {cmd_type}") 

769 return None 

770 

771 try: 

772 return deserializer(data, project) 

773 except Exception as e: 

774 print(f"Error deserializing command: {e}") 

775 return None