Coverage for pyPhotoAlbum/alignment.py: 91%

388 statements  

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

1""" 

2Alignment and distribution manager for pyPhotoAlbum 

3""" 

4 

5from typing import List, Tuple 

6from pyPhotoAlbum.models import BaseLayoutElement 

7 

8 

9class ElementMaximizer: 

10 """ 

11 Handles element maximization using a crystal growth algorithm. 

12 Breaks down the complex maximize_pattern logic into atomic, testable methods. 

13 """ 

14 

15 def __init__(self, elements: List[BaseLayoutElement], page_size: Tuple[float, float], min_gap: float): 

16 """ 

17 Initialize the maximizer with elements and constraints. 

18 

19 Args: 

20 elements: List of elements to maximize 

21 page_size: (width, height) of the page in mm 

22 min_gap: Minimum gap to maintain between elements and borders (in mm) 

23 """ 

24 self.elements = elements 

25 self.page_width, self.page_height = page_size 

26 self.min_gap = min_gap 

27 self.changes: List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]] = [] 

28 self._record_initial_states() 

29 

30 def _record_initial_states(self) -> None: 

31 """Record initial positions and sizes for undo functionality.""" 

32 for elem in self.elements: 

33 self.changes.append((elem, elem.position, elem.size)) 

34 

35 def check_collision(self, elem_idx: int, new_size: Tuple[float, float]) -> bool: 

36 """ 

37 Check if element with new_size would collide with boundaries or other elements. 

38 

39 Args: 

40 elem_idx: Index of the element to check 

41 new_size: Proposed new size (width, height) 

42 

43 Returns: 

44 True if collision detected, False otherwise 

45 """ 

46 elem = self.elements[elem_idx] 

47 x, y = elem.position 

48 w, h = new_size 

49 

50 # Check page boundaries 

51 if x < self.min_gap or y < self.min_gap: 

52 return True 

53 if x + w > self.page_width - self.min_gap: 

54 return True 

55 if y + h > self.page_height - self.min_gap: 

56 return True 

57 

58 # Check collision with other elements 

59 for i, other in enumerate(self.elements): 

60 if i == elem_idx: 

61 continue 

62 

63 other_x, other_y = other.position 

64 other_w, other_h = other.size 

65 

66 # Calculate distances between rectangles 

67 horizontal_gap = max( 

68 other_x - (x + w), x - (other_x + other_w) # Other is to the right # Other is to the left 

69 ) 

70 

71 vertical_gap = max(other_y - (y + h), y - (other_y + other_h)) # Other is below # Other is above 

72 

73 # If rectangles overlap or are too close in both dimensions 

74 if horizontal_gap < self.min_gap and vertical_gap < self.min_gap: 

75 return True 

76 

77 return False 

78 

79 def find_max_scale( 

80 self, 

81 elem_idx: int, 

82 current_scale: float, 

83 max_search_scale: float = 3.0, 

84 tolerance: float = 0.001, 

85 max_iterations: int = 20, 

86 ) -> float: 

87 """ 

88 Use binary search to find the maximum scale factor for an element. 

89 

90 Args: 

91 elem_idx: Index of the element 

92 current_scale: Current scale factor 

93 max_search_scale: Maximum scale to search up to (relative to current_scale) 

94 tolerance: Convergence tolerance for binary search 

95 max_iterations: Maximum binary search iterations 

96 

97 Returns: 

98 Maximum scale factor that doesn't cause collision 

99 """ 

100 old_size = self.changes[elem_idx][2] 

101 

102 # Binary search for maximum scale 

103 low, high = current_scale, current_scale * max_search_scale 

104 best_scale = current_scale 

105 

106 for _ in range(max_iterations): 

107 mid = (low + high) / 2.0 

108 test_size = (old_size[0] * mid, old_size[1] * mid) 

109 

110 if self.check_collision(elem_idx, test_size): 

111 high = mid 

112 else: 

113 best_scale = mid 

114 low = mid 

115 

116 if high - low < tolerance: 

117 break 

118 

119 return best_scale 

120 

121 def grow_iteration(self, scales: List[float], growth_rate: float) -> bool: 

122 """ 

123 Perform one iteration of the growth algorithm. 

124 

125 Args: 

126 scales: Current scale factors for each element 

127 growth_rate: Percentage to grow each iteration (0.05 = 5%) 

128 

129 Returns: 

130 True if any element grew, False otherwise 

131 """ 

132 any_growth = False 

133 

134 for i, elem in enumerate(self.elements): 

135 old_size = self.changes[i][2] 

136 

137 # Try to grow this element 

138 new_scale = scales[i] * (1.0 + growth_rate) 

139 new_size = (old_size[0] * new_scale, old_size[1] * new_scale) 

140 

141 if not self.check_collision(i, new_size): 

142 scales[i] = new_scale 

143 elem.size = new_size 

144 any_growth = True 

145 else: 

146 # Can't grow uniformly, try to find maximum possible scale 

147 max_scale = self.find_max_scale(i, scales[i]) 

148 if max_scale > scales[i]: 

149 scales[i] = max_scale 

150 elem.size = (old_size[0] * max_scale, old_size[1] * max_scale) 

151 any_growth = True 

152 

153 return any_growth 

154 

155 def check_element_collision(self, elem: BaseLayoutElement, new_pos: Tuple[float, float]) -> bool: 

156 """ 

157 Check if moving an element to new_pos would cause collision with other elements. 

158 

159 Args: 

160 elem: The element to check 

161 new_pos: Proposed new position (x, y) 

162 

163 Returns: 

164 True if collision detected, False otherwise 

165 """ 

166 x, y = new_pos 

167 w, h = elem.size 

168 

169 for other in self.elements: 

170 if other is elem: 

171 continue 

172 ox, oy = other.position 

173 ow, oh = other.size 

174 

175 # Check if rectangles overlap (with min_gap consideration) 

176 if ( 

177 abs((x + w / 2) - (ox + ow / 2)) < (w + ow) / 2 + self.min_gap 

178 and abs((y + h / 2) - (oy + oh / 2)) < (h + oh) / 2 + self.min_gap 

179 ): 

180 return True 

181 

182 return False 

183 

184 def center_element_horizontally(self, elem: BaseLayoutElement) -> None: 

185 """ 

186 Micro-adjust element position to center horizontally in available space. 

187 

188 Args: 

189 elem: Element to center 

190 """ 

191 x, y = elem.position 

192 w, h = elem.size 

193 

194 # Calculate available space on each side 

195 space_left = x - self.min_gap 

196 space_right = (self.page_width - self.min_gap) - (x + w) 

197 

198 if space_left >= 0 and space_right >= 0: 

199 adjust_x = (space_right - space_left) / 4.0 # Gentle centering 

200 new_x = max(self.min_gap, min(self.page_width - w - self.min_gap, x + adjust_x)) 

201 

202 # Verify this doesn't cause collision 

203 old_pos = elem.position 

204 new_pos = (new_x, y) 

205 

206 if not self.check_element_collision(elem, new_pos): 

207 elem.position = new_pos 

208 

209 def center_element_vertically(self, elem: BaseLayoutElement) -> None: 

210 """ 

211 Micro-adjust element position to center vertically in available space. 

212 

213 Args: 

214 elem: Element to center 

215 """ 

216 x, y = elem.position 

217 w, h = elem.size 

218 

219 # Calculate available space on each side 

220 space_top = y - self.min_gap 

221 space_bottom = (self.page_height - self.min_gap) - (y + h) 

222 

223 if space_top >= 0 and space_bottom >= 0: 

224 adjust_y = (space_bottom - space_top) / 4.0 

225 new_y = max(self.min_gap, min(self.page_height - h - self.min_gap, y + adjust_y)) 

226 

227 # Verify this doesn't cause collision 

228 old_pos = elem.position 

229 new_pos = (x, new_y) 

230 

231 if not self.check_element_collision(elem, new_pos): 

232 elem.position = new_pos 

233 

234 def center_elements(self) -> None: 

235 """Center all elements slightly within their constrained space.""" 

236 for elem in self.elements: 

237 self.center_element_horizontally(elem) 

238 self.center_element_vertically(elem) 

239 

240 def maximize( 

241 self, max_iterations: int = 100, growth_rate: float = 0.05 

242 ) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]: 

243 """ 

244 Execute the maximization algorithm. 

245 

246 Args: 

247 max_iterations: Maximum number of growth iterations 

248 growth_rate: Percentage to grow each iteration (0.05 = 5%) 

249 

250 Returns: 

251 List of (element, old_position, old_size) tuples for undo 

252 """ 

253 scales = [1.0] * len(self.elements) 

254 

255 # Growth algorithm - iterative expansion 

256 for _ in range(max_iterations): 

257 if not self.grow_iteration(scales, growth_rate): 

258 break 

259 

260 # Center elements slightly within their constrained space 

261 self.center_elements() 

262 

263 return self.changes 

264 

265 

266class AlignmentManager: 

267 """Manages alignment and distribution operations on multiple elements""" 

268 

269 @staticmethod 

270 def get_bounds(elements: List[BaseLayoutElement]) -> Tuple[float, float, float, float]: 

271 """ 

272 Get the bounding box of multiple elements. 

273 

274 Returns: 

275 (min_x, min_y, max_x, max_y) 

276 """ 

277 if not elements: 

278 return (0, 0, 0, 0) 

279 

280 min_x = min(elem.position[0] for elem in elements) 

281 min_y = min(elem.position[1] for elem in elements) 

282 max_x = max(elem.position[0] + elem.size[0] for elem in elements) 

283 max_y = max(elem.position[1] + elem.size[1] for elem in elements) 

284 

285 return (min_x, min_y, max_x, max_y) 

286 

287 @staticmethod 

288 def align_left(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]: 

289 """ 

290 Align all elements to the leftmost element. 

291 

292 Returns: 

293 List of (element, old_position) tuples for undo 

294 """ 

295 if len(elements) < 2: 

296 return [] 

297 

298 min_x = min(elem.position[0] for elem in elements) 

299 changes = [] 

300 

301 for elem in elements: 

302 old_pos = elem.position 

303 elem.position = (min_x, elem.position[1]) 

304 changes.append((elem, old_pos)) 

305 

306 return changes 

307 

308 @staticmethod 

309 def align_right(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]: 

310 """ 

311 Align all elements to the rightmost element. 

312 

313 Returns: 

314 List of (element, old_position) tuples for undo 

315 """ 

316 if len(elements) < 2: 

317 return [] 

318 

319 max_right = max(elem.position[0] + elem.size[0] for elem in elements) 

320 changes = [] 

321 

322 for elem in elements: 

323 old_pos = elem.position 

324 new_x = max_right - elem.size[0] 

325 elem.position = (new_x, elem.position[1]) 

326 changes.append((elem, old_pos)) 

327 

328 return changes 

329 

330 @staticmethod 

331 def align_top(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]: 

332 """ 

333 Align all elements to the topmost element. 

334 

335 Returns: 

336 List of (element, old_position) tuples for undo 

337 """ 

338 if len(elements) < 2: 

339 return [] 

340 

341 min_y = min(elem.position[1] for elem in elements) 

342 changes = [] 

343 

344 for elem in elements: 

345 old_pos = elem.position 

346 elem.position = (elem.position[0], min_y) 

347 changes.append((elem, old_pos)) 

348 

349 return changes 

350 

351 @staticmethod 

352 def align_bottom(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]: 

353 """ 

354 Align all elements to the bottommost element. 

355 

356 Returns: 

357 List of (element, old_position) tuples for undo 

358 """ 

359 if len(elements) < 2: 

360 return [] 

361 

362 max_bottom = max(elem.position[1] + elem.size[1] for elem in elements) 

363 changes = [] 

364 

365 for elem in elements: 

366 old_pos = elem.position 

367 new_y = max_bottom - elem.size[1] 

368 elem.position = (elem.position[0], new_y) 

369 changes.append((elem, old_pos)) 

370 

371 return changes 

372 

373 @staticmethod 

374 def align_horizontal_center( 

375 elements: List[BaseLayoutElement], 

376 ) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]: 

377 """ 

378 Align all elements to horizontal center. 

379 

380 Returns: 

381 List of (element, old_position) tuples for undo 

382 """ 

383 if len(elements) < 2: 

384 return [] 

385 

386 # Calculate average center 

387 centers = [elem.position[0] + elem.size[0] / 2 for elem in elements] 

388 avg_center = sum(centers) / len(centers) 

389 

390 changes = [] 

391 for elem in elements: 

392 old_pos = elem.position 

393 new_x = avg_center - elem.size[0] / 2 

394 elem.position = (new_x, elem.position[1]) 

395 changes.append((elem, old_pos)) 

396 

397 return changes 

398 

399 @staticmethod 

400 def align_vertical_center(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]: 

401 """ 

402 Align all elements to vertical center. 

403 

404 Returns: 

405 List of (element, old_position) tuples for undo 

406 """ 

407 if len(elements) < 2: 

408 return [] 

409 

410 # Calculate average center 

411 centers = [elem.position[1] + elem.size[1] / 2 for elem in elements] 

412 avg_center = sum(centers) / len(centers) 

413 

414 changes = [] 

415 for elem in elements: 

416 old_pos = elem.position 

417 new_y = avg_center - elem.size[1] / 2 

418 elem.position = (elem.position[0], new_y) 

419 changes.append((elem, old_pos)) 

420 

421 return changes 

422 

423 @staticmethod 

424 def make_same_size( 

425 elements: List[BaseLayoutElement], 

426 ) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]: 

427 """ 

428 Make all elements the same size as the first element. 

429 

430 Returns: 

431 List of (element, old_position, old_size) tuples for undo 

432 """ 

433 if len(elements) < 2: 

434 return [] 

435 

436 target_size = elements[0].size 

437 changes = [] 

438 

439 for elem in elements[1:]: 

440 old_pos = elem.position 

441 old_size = elem.size 

442 elem.size = target_size 

443 changes.append((elem, old_pos, old_size)) 

444 

445 return changes 

446 

447 @staticmethod 

448 def make_same_width( 

449 elements: List[BaseLayoutElement], 

450 ) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]: 

451 """ 

452 Make all elements the same width as the first element. 

453 

454 Returns: 

455 List of (element, old_position, old_size) tuples for undo 

456 """ 

457 if len(elements) < 2: 

458 return [] 

459 

460 target_width = elements[0].size[0] 

461 changes = [] 

462 

463 for elem in elements[1:]: 

464 old_pos = elem.position 

465 old_size = elem.size 

466 elem.size = (target_width, elem.size[1]) 

467 changes.append((elem, old_pos, old_size)) 

468 

469 return changes 

470 

471 @staticmethod 

472 def make_same_height( 

473 elements: List[BaseLayoutElement], 

474 ) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]: 

475 """ 

476 Make all elements the same height as the first element. 

477 

478 Returns: 

479 List of (element, old_position, old_size) tuples for undo 

480 """ 

481 if len(elements) < 2: 

482 return [] 

483 

484 target_height = elements[0].size[1] 

485 changes = [] 

486 

487 for elem in elements[1:]: 

488 old_pos = elem.position 

489 old_size = elem.size 

490 elem.size = (elem.size[0], target_height) 

491 changes.append((elem, old_pos, old_size)) 

492 

493 return changes 

494 

495 @staticmethod 

496 def distribute_horizontally( 

497 elements: List[BaseLayoutElement], 

498 ) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]: 

499 """ 

500 Distribute elements evenly across horizontal span. 

501 

502 Returns: 

503 List of (element, old_position) tuples for undo 

504 """ 

505 if len(elements) < 3: 

506 return [] 

507 

508 # Sort by x position 

509 sorted_elements = sorted(elements, key=lambda e: e.position[0]) 

510 

511 # Get leftmost and rightmost positions 

512 min_x = sorted_elements[0].position[0] 

513 max_x = sorted_elements[-1].position[0] 

514 

515 # Calculate spacing between centers 

516 total_span = max_x - min_x 

517 spacing = total_span / (len(sorted_elements) - 1) 

518 

519 changes = [] 

520 for i, elem in enumerate(sorted_elements): 

521 old_pos = elem.position 

522 new_x = min_x + (i * spacing) 

523 elem.position = (new_x, elem.position[1]) 

524 changes.append((elem, old_pos)) 

525 

526 return changes 

527 

528 @staticmethod 

529 def distribute_vertically(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]: 

530 """ 

531 Distribute elements evenly across vertical span. 

532 

533 Returns: 

534 List of (element, old_position) tuples for undo 

535 """ 

536 if len(elements) < 3: 

537 return [] 

538 

539 # Sort by y position 

540 sorted_elements = sorted(elements, key=lambda e: e.position[1]) 

541 

542 # Get topmost and bottommost positions 

543 min_y = sorted_elements[0].position[1] 

544 max_y = sorted_elements[-1].position[1] 

545 

546 # Calculate spacing between centers 

547 total_span = max_y - min_y 

548 spacing = total_span / (len(sorted_elements) - 1) 

549 

550 changes = [] 

551 for i, elem in enumerate(sorted_elements): 

552 old_pos = elem.position 

553 new_y = min_y + (i * spacing) 

554 elem.position = (elem.position[0], new_y) 

555 changes.append((elem, old_pos)) 

556 

557 return changes 

558 

559 @staticmethod 

560 def space_horizontally(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]: 

561 """ 

562 Distribute elements with equal spacing between them horizontally. 

563 

564 Returns: 

565 List of (element, old_position) tuples for undo 

566 """ 

567 if len(elements) < 3: 

568 return [] 

569 

570 # Sort by x position 

571 sorted_elements = sorted(elements, key=lambda e: e.position[0]) 

572 

573 # Get leftmost and rightmost boundaries 

574 min_x = sorted_elements[0].position[0] 

575 max_right = sorted_elements[-1].position[0] + sorted_elements[-1].size[0] 

576 

577 # Calculate total width of all elements 

578 total_width = sum(elem.size[0] for elem in sorted_elements) 

579 

580 # Calculate available space and spacing 

581 available_space = max_right - min_x - total_width 

582 spacing = available_space / (len(sorted_elements) - 1) 

583 

584 changes = [] 

585 current_x = min_x 

586 

587 for elem in sorted_elements: 

588 old_pos = elem.position 

589 elem.position = (current_x, elem.position[1]) 

590 changes.append((elem, old_pos)) 

591 current_x += elem.size[0] + spacing 

592 

593 return changes 

594 

595 @staticmethod 

596 def space_vertically(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]: 

597 """ 

598 Distribute elements with equal spacing between them vertically. 

599 

600 Returns: 

601 List of (element, old_position) tuples for undo 

602 """ 

603 if len(elements) < 3: 

604 return [] 

605 

606 # Sort by y position 

607 sorted_elements = sorted(elements, key=lambda e: e.position[1]) 

608 

609 # Get topmost and bottommost boundaries 

610 min_y = sorted_elements[0].position[1] 

611 max_bottom = sorted_elements[-1].position[1] + sorted_elements[-1].size[1] 

612 

613 # Calculate total height of all elements 

614 total_height = sum(elem.size[1] for elem in sorted_elements) 

615 

616 # Calculate available space and spacing 

617 available_space = max_bottom - min_y - total_height 

618 spacing = available_space / (len(sorted_elements) - 1) 

619 

620 changes = [] 

621 current_y = min_y 

622 

623 for elem in sorted_elements: 

624 old_pos = elem.position 

625 elem.position = (elem.position[0], current_y) 

626 changes.append((elem, old_pos)) 

627 current_y += elem.size[1] + spacing 

628 

629 return changes 

630 

631 @staticmethod 

632 def fit_to_page_width( 

633 element: BaseLayoutElement, page_width: float 

634 ) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]: 

635 """ 

636 Resize element to fit page width while maintaining aspect ratio. 

637 

638 Args: 

639 element: The element to resize 

640 page_width: The page width in mm 

641 

642 Returns: 

643 Tuple of (element, old_position, old_size) for undo 

644 """ 

645 old_pos = element.position 

646 old_size = element.size 

647 

648 # Calculate aspect ratio 

649 aspect_ratio = old_size[1] / old_size[0] 

650 

651 # Set new size 

652 new_width = page_width 

653 new_height = page_width * aspect_ratio 

654 element.size = (new_width, new_height) 

655 

656 return (element, old_pos, old_size) 

657 

658 @staticmethod 

659 def fit_to_page_height( 

660 element: BaseLayoutElement, page_height: float 

661 ) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]: 

662 """ 

663 Resize element to fit page height while maintaining aspect ratio. 

664 

665 Args: 

666 element: The element to resize 

667 page_height: The page height in mm 

668 

669 Returns: 

670 Tuple of (element, old_position, old_size) for undo 

671 """ 

672 old_pos = element.position 

673 old_size = element.size 

674 

675 # Calculate aspect ratio 

676 aspect_ratio = old_size[0] / old_size[1] 

677 

678 # Set new size 

679 new_height = page_height 

680 new_width = page_height * aspect_ratio 

681 element.size = (new_width, new_height) 

682 

683 return (element, old_pos, old_size) 

684 

685 @staticmethod 

686 def fit_to_page( 

687 element: BaseLayoutElement, page_width: float, page_height: float 

688 ) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]: 

689 """ 

690 Resize element to fit within page dimensions while maintaining aspect ratio. 

691 

692 Args: 

693 element: The element to resize 

694 page_width: The page width in mm 

695 page_height: The page height in mm 

696 

697 Returns: 

698 Tuple of (element, old_position, old_size) for undo 

699 """ 

700 old_pos = element.position 

701 old_size = element.size 

702 

703 # Calculate aspect ratios 

704 element_aspect = old_size[0] / old_size[1] 

705 page_aspect = page_width / page_height 

706 

707 # Determine which dimension to fit to 

708 if element_aspect > page_aspect: 

709 # Element is wider than page - fit to width 

710 new_width = page_width 

711 new_height = page_width / element_aspect 

712 else: 

713 # Element is taller than page - fit to height 

714 new_height = page_height 

715 new_width = page_height * element_aspect 

716 

717 element.size = (new_width, new_height) 

718 

719 return (element, old_pos, old_size) 

720 

721 @staticmethod 

722 def maximize_pattern( 

723 elements: List[BaseLayoutElement], 

724 page_size: Tuple[float, float], 

725 min_gap: float = 2.0, 

726 max_iterations: int = 100, 

727 growth_rate: float = 0.05, 

728 ) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]: 

729 """ 

730 Maximize element sizes using a crystal growth algorithm. 

731 Elements grow until they are close to borders or each other. 

732 

733 Args: 

734 elements: List of elements to maximize 

735 page_size: (width, height) of the page in mm 

736 min_gap: Minimum gap to maintain between elements and borders (in mm) 

737 max_iterations: Maximum number of growth iterations 

738 growth_rate: Percentage to grow each iteration (0.05 = 5%) 

739 

740 Returns: 

741 List of (element, old_position, old_size) tuples for undo 

742 """ 

743 if not elements: 

744 return [] 

745 

746 maximizer = ElementMaximizer(elements, page_size, min_gap) 

747 return maximizer.maximize(max_iterations, growth_rate) 

748 

749 @staticmethod 

750 def expand_to_bounds( 

751 element: BaseLayoutElement, 

752 page_size: Tuple[float, float], 

753 other_elements: List[BaseLayoutElement], 

754 min_gap: float = 10.0, 

755 ) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]: 

756 """ 

757 Expand a single element until it is min_gap away from page edges or other elements. 

758 

759 This function expands an element from its current position and size, growing it 

760 in all directions (up, down, left, right) until it reaches: 

761 - The page boundaries (with min_gap margin) 

762 - Another element on the same page (with min_gap spacing) 

763 

764 The element expands independently in width and height to fill all available space. 

765 

766 Args: 

767 element: The element to expand 

768 page_size: (width, height) of the page in mm 

769 other_elements: List of other elements on the same page (excluding the target element) 

770 min_gap: Minimum gap to maintain between element and boundaries/other elements (in mm) 

771 

772 Returns: 

773 Tuple of (element, old_position, old_size) for undo 

774 """ 

775 page_width, page_height = page_size 

776 old_pos = element.position 

777 old_size = element.size 

778 

779 x, y = element.position 

780 w, h = element.size 

781 

782 # Calculate maximum expansion in each direction 

783 # Start with page boundaries 

784 max_left = x - min_gap # How much we can expand left 

785 max_right = (page_width - min_gap) - (x + w) # How much we can expand right 

786 max_top = y - min_gap # How much we can expand up 

787 max_bottom = (page_height - min_gap) - (y + h) # How much we can expand down 

788 

789 # Check constraints from other elements 

790 # We need to be conservative and check ALL elements against ALL expansion directions 

791 for other in other_elements: 

792 ox, oy = other.position 

793 ow, oh = other.size 

794 

795 # Calculate the other element's bounds 

796 other_left = ox 

797 other_right = ox + ow 

798 other_top = oy 

799 other_bottom = oy + oh 

800 

801 # Calculate current element's bounds 

802 elem_left = x 

803 elem_right = x + w 

804 elem_top = y 

805 elem_bottom = y + h 

806 

807 # Check leftward expansion 

808 # An element blocks leftward expansion if: 

809 # 1. It's to the left of our left edge (other_right <= elem_left) 

810 # 2. Its vertical range would overlap with ANY part of our vertical extent 

811 if other_right <= elem_left: 

812 # Check if vertical ranges overlap (current OR after any vertical expansion) 

813 # Conservative: assume we might expand vertically to page bounds 

814 if not (other_bottom <= elem_top - min_gap or other_top >= elem_bottom + min_gap): 

815 # This element blocks leftward expansion 

816 available_left = elem_left - other_right - min_gap 

817 max_left = min(max_left, available_left) 

818 

819 # Check rightward expansion 

820 if other_left >= elem_right: 

821 # Check if vertical ranges overlap 

822 if not (other_bottom <= elem_top - min_gap or other_top >= elem_bottom + min_gap): 

823 # This element blocks rightward expansion 

824 available_right = other_left - elem_right - min_gap 

825 max_right = min(max_right, available_right) 

826 

827 # Check upward expansion 

828 if other_bottom <= elem_top: 

829 # Check if horizontal ranges overlap 

830 if not (other_right <= elem_left - min_gap or other_left >= elem_right + min_gap): 

831 # This element blocks upward expansion 

832 available_top = elem_top - other_bottom - min_gap 

833 max_top = min(max_top, available_top) 

834 

835 # Check downward expansion 

836 if other_top >= elem_bottom: 

837 # Check if horizontal ranges overlap 

838 if not (other_right <= elem_left - min_gap or other_left >= elem_right + min_gap): 

839 # This element blocks downward expansion 

840 available_bottom = other_top - elem_bottom - min_gap 

841 max_bottom = min(max_bottom, available_bottom) 

842 

843 # Ensure non-negative expansion 

844 max_left = max(0, max_left) 

845 max_right = max(0, max_right) 

846 max_top = max(0, max_top) 

847 max_bottom = max(0, max_bottom) 

848 

849 # Expand to fill all available space (no aspect ratio constraint) 

850 width_increase = max_left + max_right 

851 height_increase = max_top + max_bottom 

852 

853 # Calculate new size 

854 new_width = w + width_increase 

855 new_height = h + height_increase 

856 

857 # Calculate new position (expand from center to maintain relative position) 

858 # Distribute the expansion proportionally to available space on each side 

859 if max_left + max_right > 0: 

860 left_ratio = max_left / (max_left + max_right) 

861 new_x = x - (width_increase * left_ratio) 

862 else: 

863 new_x = x 

864 

865 if max_top + max_bottom > 0: 

866 top_ratio = max_top / (max_top + max_bottom) 

867 new_y = y - (height_increase * top_ratio) 

868 else: 

869 new_y = y 

870 

871 # Apply the new position and size 

872 element.position = (new_x, new_y) 

873 element.size = (new_width, new_height) 

874 

875 return (element, old_pos, old_size)