Files
pyPhotoAlbum/pyPhotoAlbum/alignment.py
T
dtourolle 6755549dfd
Python CI / test (push) Successful in 1m37s
Lint / lint (push) Successful in 1m32s
Tests / test (3.10) (push) Successful in 1m11s
Tests / test (3.11) (push) Successful in 1m11s
Tests / test (3.9) (push) Successful in 1m8s
Refactor to allow indepth testing
2025-11-23 11:05:46 +01:00

854 lines
29 KiB
Python

"""
Alignment and distribution manager for pyPhotoAlbum
"""
from typing import List, Tuple
from pyPhotoAlbum.models import BaseLayoutElement
class ElementMaximizer:
"""
Handles element maximization using a crystal growth algorithm.
Breaks down the complex maximize_pattern logic into atomic, testable methods.
"""
def __init__(self, elements: List[BaseLayoutElement], page_size: Tuple[float, float], min_gap: float):
"""
Initialize the maximizer with elements and constraints.
Args:
elements: List of elements to maximize
page_size: (width, height) of the page in mm
min_gap: Minimum gap to maintain between elements and borders (in mm)
"""
self.elements = elements
self.page_width, self.page_height = page_size
self.min_gap = min_gap
self.changes: List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]] = []
self._record_initial_states()
def _record_initial_states(self) -> None:
"""Record initial positions and sizes for undo functionality."""
for elem in self.elements:
self.changes.append((elem, elem.position, elem.size))
def check_collision(self, elem_idx: int, new_size: Tuple[float, float]) -> bool:
"""
Check if element with new_size would collide with boundaries or other elements.
Args:
elem_idx: Index of the element to check
new_size: Proposed new size (width, height)
Returns:
True if collision detected, False otherwise
"""
elem = self.elements[elem_idx]
x, y = elem.position
w, h = new_size
# Check page boundaries
if x < self.min_gap or y < self.min_gap:
return True
if x + w > self.page_width - self.min_gap:
return True
if y + h > self.page_height - self.min_gap:
return True
# Check collision with other elements
for i, other in enumerate(self.elements):
if i == elem_idx:
continue
other_x, other_y = other.position
other_w, other_h = other.size
# Calculate distances between rectangles
horizontal_gap = max(
other_x - (x + w), # Other is to the right
x - (other_x + other_w) # Other is to the left
)
vertical_gap = max(
other_y - (y + h), # Other is below
y - (other_y + other_h) # Other is above
)
# If rectangles overlap or are too close in both dimensions
if horizontal_gap < self.min_gap and vertical_gap < self.min_gap:
return True
return False
def find_max_scale(self, elem_idx: int, current_scale: float, max_search_scale: float = 3.0,
tolerance: float = 0.001, max_iterations: int = 20) -> float:
"""
Use binary search to find the maximum scale factor for an element.
Args:
elem_idx: Index of the element
current_scale: Current scale factor
max_search_scale: Maximum scale to search up to (relative to current_scale)
tolerance: Convergence tolerance for binary search
max_iterations: Maximum binary search iterations
Returns:
Maximum scale factor that doesn't cause collision
"""
old_size = self.changes[elem_idx][2]
# Binary search for maximum scale
low, high = current_scale, current_scale * max_search_scale
best_scale = current_scale
for _ in range(max_iterations):
mid = (low + high) / 2.0
test_size = (old_size[0] * mid, old_size[1] * mid)
if self.check_collision(elem_idx, test_size):
high = mid
else:
best_scale = mid
low = mid
if high - low < tolerance:
break
return best_scale
def grow_iteration(self, scales: List[float], growth_rate: float) -> bool:
"""
Perform one iteration of the growth algorithm.
Args:
scales: Current scale factors for each element
growth_rate: Percentage to grow each iteration (0.05 = 5%)
Returns:
True if any element grew, False otherwise
"""
any_growth = False
for i, elem in enumerate(self.elements):
old_size = self.changes[i][2]
# Try to grow this element
new_scale = scales[i] * (1.0 + growth_rate)
new_size = (old_size[0] * new_scale, old_size[1] * new_scale)
if not self.check_collision(i, new_size):
scales[i] = new_scale
elem.size = new_size
any_growth = True
else:
# Can't grow uniformly, try to find maximum possible scale
max_scale = self.find_max_scale(i, scales[i])
if max_scale > scales[i]:
scales[i] = max_scale
elem.size = (old_size[0] * max_scale, old_size[1] * max_scale)
any_growth = True
return any_growth
def check_element_collision(self, elem: BaseLayoutElement, new_pos: Tuple[float, float]) -> bool:
"""
Check if moving an element to new_pos would cause collision with other elements.
Args:
elem: The element to check
new_pos: Proposed new position (x, y)
Returns:
True if collision detected, False otherwise
"""
x, y = new_pos
w, h = elem.size
for other in self.elements:
if other is elem:
continue
ox, oy = other.position
ow, oh = other.size
# Check if rectangles overlap (with min_gap consideration)
if (abs((x + w/2) - (ox + ow/2)) < (w + ow)/2 + self.min_gap and
abs((y + h/2) - (oy + oh/2)) < (h + oh)/2 + self.min_gap):
return True
return False
def center_element_horizontally(self, elem: BaseLayoutElement) -> None:
"""
Micro-adjust element position to center horizontally in available space.
Args:
elem: Element to center
"""
x, y = elem.position
w, h = elem.size
# Calculate available space on each side
space_left = x - self.min_gap
space_right = (self.page_width - self.min_gap) - (x + w)
if space_left >= 0 and space_right >= 0:
adjust_x = (space_right - space_left) / 4.0 # Gentle centering
new_x = max(self.min_gap, min(self.page_width - w - self.min_gap, x + adjust_x))
# Verify this doesn't cause collision
old_pos = elem.position
new_pos = (new_x, y)
if not self.check_element_collision(elem, new_pos):
elem.position = new_pos
def center_element_vertically(self, elem: BaseLayoutElement) -> None:
"""
Micro-adjust element position to center vertically in available space.
Args:
elem: Element to center
"""
x, y = elem.position
w, h = elem.size
# Calculate available space on each side
space_top = y - self.min_gap
space_bottom = (self.page_height - self.min_gap) - (y + h)
if space_top >= 0 and space_bottom >= 0:
adjust_y = (space_bottom - space_top) / 4.0
new_y = max(self.min_gap, min(self.page_height - h - self.min_gap, y + adjust_y))
# Verify this doesn't cause collision
old_pos = elem.position
new_pos = (x, new_y)
if not self.check_element_collision(elem, new_pos):
elem.position = new_pos
def center_elements(self) -> None:
"""Center all elements slightly within their constrained space."""
for elem in self.elements:
self.center_element_horizontally(elem)
self.center_element_vertically(elem)
def maximize(self, max_iterations: int = 100, growth_rate: float = 0.05) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
"""
Execute the maximization algorithm.
Args:
max_iterations: Maximum number of growth iterations
growth_rate: Percentage to grow each iteration (0.05 = 5%)
Returns:
List of (element, old_position, old_size) tuples for undo
"""
scales = [1.0] * len(self.elements)
# Growth algorithm - iterative expansion
for _ in range(max_iterations):
if not self.grow_iteration(scales, growth_rate):
break
# Center elements slightly within their constrained space
self.center_elements()
return self.changes
class AlignmentManager:
"""Manages alignment and distribution operations on multiple elements"""
@staticmethod
def get_bounds(elements: List[BaseLayoutElement]) -> Tuple[float, float, float, float]:
"""
Get the bounding box of multiple elements.
Returns:
(min_x, min_y, max_x, max_y)
"""
if not elements:
return (0, 0, 0, 0)
min_x = min(elem.position[0] for elem in elements)
min_y = min(elem.position[1] for elem in elements)
max_x = max(elem.position[0] + elem.size[0] for elem in elements)
max_y = max(elem.position[1] + elem.size[1] for elem in elements)
return (min_x, min_y, max_x, max_y)
@staticmethod
def align_left(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
"""
Align all elements to the leftmost element.
Returns:
List of (element, old_position) tuples for undo
"""
if len(elements) < 2:
return []
min_x = min(elem.position[0] for elem in elements)
changes = []
for elem in elements:
old_pos = elem.position
elem.position = (min_x, elem.position[1])
changes.append((elem, old_pos))
return changes
@staticmethod
def align_right(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
"""
Align all elements to the rightmost element.
Returns:
List of (element, old_position) tuples for undo
"""
if len(elements) < 2:
return []
max_right = max(elem.position[0] + elem.size[0] for elem in elements)
changes = []
for elem in elements:
old_pos = elem.position
new_x = max_right - elem.size[0]
elem.position = (new_x, elem.position[1])
changes.append((elem, old_pos))
return changes
@staticmethod
def align_top(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
"""
Align all elements to the topmost element.
Returns:
List of (element, old_position) tuples for undo
"""
if len(elements) < 2:
return []
min_y = min(elem.position[1] for elem in elements)
changes = []
for elem in elements:
old_pos = elem.position
elem.position = (elem.position[0], min_y)
changes.append((elem, old_pos))
return changes
@staticmethod
def align_bottom(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
"""
Align all elements to the bottommost element.
Returns:
List of (element, old_position) tuples for undo
"""
if len(elements) < 2:
return []
max_bottom = max(elem.position[1] + elem.size[1] for elem in elements)
changes = []
for elem in elements:
old_pos = elem.position
new_y = max_bottom - elem.size[1]
elem.position = (elem.position[0], new_y)
changes.append((elem, old_pos))
return changes
@staticmethod
def align_horizontal_center(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
"""
Align all elements to horizontal center.
Returns:
List of (element, old_position) tuples for undo
"""
if len(elements) < 2:
return []
# Calculate average center
centers = [elem.position[0] + elem.size[0] / 2 for elem in elements]
avg_center = sum(centers) / len(centers)
changes = []
for elem in elements:
old_pos = elem.position
new_x = avg_center - elem.size[0] / 2
elem.position = (new_x, elem.position[1])
changes.append((elem, old_pos))
return changes
@staticmethod
def align_vertical_center(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
"""
Align all elements to vertical center.
Returns:
List of (element, old_position) tuples for undo
"""
if len(elements) < 2:
return []
# Calculate average center
centers = [elem.position[1] + elem.size[1] / 2 for elem in elements]
avg_center = sum(centers) / len(centers)
changes = []
for elem in elements:
old_pos = elem.position
new_y = avg_center - elem.size[1] / 2
elem.position = (elem.position[0], new_y)
changes.append((elem, old_pos))
return changes
@staticmethod
def make_same_size(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
"""
Make all elements the same size as the first element.
Returns:
List of (element, old_position, old_size) tuples for undo
"""
if len(elements) < 2:
return []
target_size = elements[0].size
changes = []
for elem in elements[1:]:
old_pos = elem.position
old_size = elem.size
elem.size = target_size
changes.append((elem, old_pos, old_size))
return changes
@staticmethod
def make_same_width(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
"""
Make all elements the same width as the first element.
Returns:
List of (element, old_position, old_size) tuples for undo
"""
if len(elements) < 2:
return []
target_width = elements[0].size[0]
changes = []
for elem in elements[1:]:
old_pos = elem.position
old_size = elem.size
elem.size = (target_width, elem.size[1])
changes.append((elem, old_pos, old_size))
return changes
@staticmethod
def make_same_height(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
"""
Make all elements the same height as the first element.
Returns:
List of (element, old_position, old_size) tuples for undo
"""
if len(elements) < 2:
return []
target_height = elements[0].size[1]
changes = []
for elem in elements[1:]:
old_pos = elem.position
old_size = elem.size
elem.size = (elem.size[0], target_height)
changes.append((elem, old_pos, old_size))
return changes
@staticmethod
def distribute_horizontally(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
"""
Distribute elements evenly across horizontal span.
Returns:
List of (element, old_position) tuples for undo
"""
if len(elements) < 3:
return []
# Sort by x position
sorted_elements = sorted(elements, key=lambda e: e.position[0])
# Get leftmost and rightmost positions
min_x = sorted_elements[0].position[0]
max_x = sorted_elements[-1].position[0]
# Calculate spacing between centers
total_span = max_x - min_x
spacing = total_span / (len(sorted_elements) - 1)
changes = []
for i, elem in enumerate(sorted_elements):
old_pos = elem.position
new_x = min_x + (i * spacing)
elem.position = (new_x, elem.position[1])
changes.append((elem, old_pos))
return changes
@staticmethod
def distribute_vertically(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
"""
Distribute elements evenly across vertical span.
Returns:
List of (element, old_position) tuples for undo
"""
if len(elements) < 3:
return []
# Sort by y position
sorted_elements = sorted(elements, key=lambda e: e.position[1])
# Get topmost and bottommost positions
min_y = sorted_elements[0].position[1]
max_y = sorted_elements[-1].position[1]
# Calculate spacing between centers
total_span = max_y - min_y
spacing = total_span / (len(sorted_elements) - 1)
changes = []
for i, elem in enumerate(sorted_elements):
old_pos = elem.position
new_y = min_y + (i * spacing)
elem.position = (elem.position[0], new_y)
changes.append((elem, old_pos))
return changes
@staticmethod
def space_horizontally(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
"""
Distribute elements with equal spacing between them horizontally.
Returns:
List of (element, old_position) tuples for undo
"""
if len(elements) < 3:
return []
# Sort by x position
sorted_elements = sorted(elements, key=lambda e: e.position[0])
# Get leftmost and rightmost boundaries
min_x = sorted_elements[0].position[0]
max_right = sorted_elements[-1].position[0] + sorted_elements[-1].size[0]
# Calculate total width of all elements
total_width = sum(elem.size[0] for elem in sorted_elements)
# Calculate available space and spacing
available_space = max_right - min_x - total_width
spacing = available_space / (len(sorted_elements) - 1)
changes = []
current_x = min_x
for elem in sorted_elements:
old_pos = elem.position
elem.position = (current_x, elem.position[1])
changes.append((elem, old_pos))
current_x += elem.size[0] + spacing
return changes
@staticmethod
def space_vertically(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
"""
Distribute elements with equal spacing between them vertically.
Returns:
List of (element, old_position) tuples for undo
"""
if len(elements) < 3:
return []
# Sort by y position
sorted_elements = sorted(elements, key=lambda e: e.position[1])
# Get topmost and bottommost boundaries
min_y = sorted_elements[0].position[1]
max_bottom = sorted_elements[-1].position[1] + sorted_elements[-1].size[1]
# Calculate total height of all elements
total_height = sum(elem.size[1] for elem in sorted_elements)
# Calculate available space and spacing
available_space = max_bottom - min_y - total_height
spacing = available_space / (len(sorted_elements) - 1)
changes = []
current_y = min_y
for elem in sorted_elements:
old_pos = elem.position
elem.position = (elem.position[0], current_y)
changes.append((elem, old_pos))
current_y += elem.size[1] + spacing
return changes
@staticmethod
def fit_to_page_width(element: BaseLayoutElement, page_width: float) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
"""
Resize element to fit page width while maintaining aspect ratio.
Args:
element: The element to resize
page_width: The page width in mm
Returns:
Tuple of (element, old_position, old_size) for undo
"""
old_pos = element.position
old_size = element.size
# Calculate aspect ratio
aspect_ratio = old_size[1] / old_size[0]
# Set new size
new_width = page_width
new_height = page_width * aspect_ratio
element.size = (new_width, new_height)
return (element, old_pos, old_size)
@staticmethod
def fit_to_page_height(element: BaseLayoutElement, page_height: float) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
"""
Resize element to fit page height while maintaining aspect ratio.
Args:
element: The element to resize
page_height: The page height in mm
Returns:
Tuple of (element, old_position, old_size) for undo
"""
old_pos = element.position
old_size = element.size
# Calculate aspect ratio
aspect_ratio = old_size[0] / old_size[1]
# Set new size
new_height = page_height
new_width = page_height * aspect_ratio
element.size = (new_width, new_height)
return (element, old_pos, old_size)
@staticmethod
def fit_to_page(element: BaseLayoutElement, page_width: float, page_height: float) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
"""
Resize element to fit within page dimensions while maintaining aspect ratio.
Args:
element: The element to resize
page_width: The page width in mm
page_height: The page height in mm
Returns:
Tuple of (element, old_position, old_size) for undo
"""
old_pos = element.position
old_size = element.size
# Calculate aspect ratios
element_aspect = old_size[0] / old_size[1]
page_aspect = page_width / page_height
# Determine which dimension to fit to
if element_aspect > page_aspect:
# Element is wider than page - fit to width
new_width = page_width
new_height = page_width / element_aspect
else:
# Element is taller than page - fit to height
new_height = page_height
new_width = page_height * element_aspect
element.size = (new_width, new_height)
return (element, old_pos, old_size)
@staticmethod
def maximize_pattern(
elements: List[BaseLayoutElement],
page_size: Tuple[float, float],
min_gap: float = 2.0,
max_iterations: int = 100,
growth_rate: float = 0.05
) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
"""
Maximize element sizes using a crystal growth algorithm.
Elements grow until they are close to borders or each other.
Args:
elements: List of elements to maximize
page_size: (width, height) of the page in mm
min_gap: Minimum gap to maintain between elements and borders (in mm)
max_iterations: Maximum number of growth iterations
growth_rate: Percentage to grow each iteration (0.05 = 5%)
Returns:
List of (element, old_position, old_size) tuples for undo
"""
if not elements:
return []
maximizer = ElementMaximizer(elements, page_size, min_gap)
return maximizer.maximize(max_iterations, growth_rate)
@staticmethod
def expand_to_bounds(
element: BaseLayoutElement,
page_size: Tuple[float, float],
other_elements: List[BaseLayoutElement],
min_gap: float = 10.0
) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
"""
Expand a single element until it is min_gap away from page edges or other elements.
This function expands an element from its current position and size, growing it
in all directions (up, down, left, right) until it reaches:
- The page boundaries (with min_gap margin)
- Another element on the same page (with min_gap spacing)
The element expands independently in width and height to fill all available space.
Args:
element: The element to expand
page_size: (width, height) of the page in mm
other_elements: List of other elements on the same page (excluding the target element)
min_gap: Minimum gap to maintain between element and boundaries/other elements (in mm)
Returns:
Tuple of (element, old_position, old_size) for undo
"""
page_width, page_height = page_size
old_pos = element.position
old_size = element.size
x, y = element.position
w, h = element.size
# Calculate maximum expansion in each direction
# Start with page boundaries
max_left = x - min_gap # How much we can expand left
max_right = (page_width - min_gap) - (x + w) # How much we can expand right
max_top = y - min_gap # How much we can expand up
max_bottom = (page_height - min_gap) - (y + h) # How much we can expand down
# Check constraints from other elements
# We need to be conservative and check ALL elements against ALL expansion directions
for other in other_elements:
ox, oy = other.position
ow, oh = other.size
# Calculate the other element's bounds
other_left = ox
other_right = ox + ow
other_top = oy
other_bottom = oy + oh
# Calculate current element's bounds
elem_left = x
elem_right = x + w
elem_top = y
elem_bottom = y + h
# Check leftward expansion
# An element blocks leftward expansion if:
# 1. It's to the left of our left edge (other_right <= elem_left)
# 2. Its vertical range would overlap with ANY part of our vertical extent
if other_right <= elem_left:
# Check if vertical ranges overlap (current OR after any vertical expansion)
# Conservative: assume we might expand vertically to page bounds
if not (other_bottom <= elem_top - min_gap or other_top >= elem_bottom + min_gap):
# This element blocks leftward expansion
available_left = elem_left - other_right - min_gap
max_left = min(max_left, available_left)
# Check rightward expansion
if other_left >= elem_right:
# Check if vertical ranges overlap
if not (other_bottom <= elem_top - min_gap or other_top >= elem_bottom + min_gap):
# This element blocks rightward expansion
available_right = other_left - elem_right - min_gap
max_right = min(max_right, available_right)
# Check upward expansion
if other_bottom <= elem_top:
# Check if horizontal ranges overlap
if not (other_right <= elem_left - min_gap or other_left >= elem_right + min_gap):
# This element blocks upward expansion
available_top = elem_top - other_bottom - min_gap
max_top = min(max_top, available_top)
# Check downward expansion
if other_top >= elem_bottom:
# Check if horizontal ranges overlap
if not (other_right <= elem_left - min_gap or other_left >= elem_right + min_gap):
# This element blocks downward expansion
available_bottom = other_top - elem_bottom - min_gap
max_bottom = min(max_bottom, available_bottom)
# Ensure non-negative expansion
max_left = max(0, max_left)
max_right = max(0, max_right)
max_top = max(0, max_top)
max_bottom = max(0, max_bottom)
# Expand to fill all available space (no aspect ratio constraint)
width_increase = max_left + max_right
height_increase = max_top + max_bottom
# Calculate new size
new_width = w + width_increase
new_height = h + height_increase
# Calculate new position (expand from center to maintain relative position)
# Distribute the expansion proportionally to available space on each side
if max_left + max_right > 0:
left_ratio = max_left / (max_left + max_right)
new_x = x - (width_increase * left_ratio)
else:
new_x = x
if max_top + max_bottom > 0:
top_ratio = max_top / (max_top + max_bottom)
new_y = y - (height_increase * top_ratio)
else:
new_y = y
# Apply the new position and size
element.position = (new_x, new_y)
element.size = (new_width, new_height)
return (element, old_pos, old_size)