Improved snapping
All checks were successful
Python CI / test (push) Successful in 57s
Lint / lint (push) Successful in 1m6s
Tests / test (3.10) (push) Successful in 43s
Tests / test (3.11) (push) Successful in 45s
Tests / test (3.9) (push) Successful in 43s

This commit is contained in:
Duncan Tourolle 2025-10-28 21:37:39 +01:00
parent 5257c6654a
commit 795c0c531c
4 changed files with 1545 additions and 22 deletions

View File

@ -67,7 +67,7 @@ class SnappingSystem:
page_size: Tuple[float, float],
dpi: int = 300) -> Tuple[float, float]:
"""
Apply snapping to a position (per-axis)
Apply snapping to a position using combined distance threshold
Args:
position: Current position (x, y) in pixels
@ -78,6 +78,8 @@ class SnappingSystem:
Returns:
Snapped position (x, y) in pixels
"""
import math
x, y = position
width, height = size
page_width_mm, page_height_mm = page_size
@ -85,17 +87,84 @@ class SnappingSystem:
# Convert threshold from mm to pixels
snap_threshold_px = self.snap_threshold_mm * dpi / 25.4
# Snap X axis
snapped_x = self._snap_axis(
x, width, page_width_mm, dpi, snap_threshold_px, 'vertical'
)
# Collect all potential snap points for both edges of the element
snap_points = []
# Snap Y axis
snapped_y = self._snap_axis(
y, height, page_height_mm, dpi, snap_threshold_px, 'horizontal'
)
# 1. Page edge snap points
if self.snap_to_edges:
page_width_px = page_width_mm * dpi / 25.4
page_height_px = page_height_mm * dpi / 25.4
# Corners where element's top-left can snap
snap_points.extend([
(0, 0), # Top-left corner
(page_width_px - width, 0), # Top-right corner
(0, page_height_px - height), # Bottom-left corner
(page_width_px - width, page_height_px - height), # Bottom-right corner
])
# Edge positions (element aligned to edge on one axis)
snap_points.extend([
(0, y), # Left edge
(page_width_px - width, y), # Right edge
(x, 0), # Top edge
(x, page_height_px - height), # Bottom edge
])
return (snapped_x, snapped_y)
# 2. Grid snap points
if self.snap_to_grid:
grid_size_px = self.grid_size_mm * dpi / 25.4
page_width_px = page_width_mm * dpi / 25.4
page_height_px = page_height_mm * dpi / 25.4
# Calculate grid intersection points within range
x_start = max(0, int((x - snap_threshold_px) / grid_size_px)) * grid_size_px
x_end = min(page_width_px, int((x + snap_threshold_px) / grid_size_px + 1) * grid_size_px)
y_start = max(0, int((y - snap_threshold_px) / grid_size_px)) * grid_size_px
y_end = min(page_height_px, int((y + snap_threshold_px) / grid_size_px + 1) * grid_size_px)
grid_x = x_start
while grid_x <= x_end:
grid_y = y_start
while grid_y <= y_end:
snap_points.append((grid_x, grid_y))
# Also snap element's far edge to grid
if grid_x >= width:
snap_points.append((grid_x - width, grid_y))
if grid_y >= height:
snap_points.append((grid_x, grid_y - height))
grid_y += grid_size_px
grid_x += grid_size_px
# 3. Guide snap points
if self.snap_to_guides:
vertical_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == 'vertical']
horizontal_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == 'horizontal']
# Guide intersections (when both vertical and horizontal guides exist)
for vg in vertical_guides:
for hg in horizontal_guides:
snap_points.append((vg, hg))
# Also snap element's far edge to intersections
snap_points.append((vg - width, hg))
snap_points.append((vg, hg - height))
snap_points.append((vg - width, hg - height))
# Find the nearest snap point using Euclidean distance
best_snap_point = None
best_distance = snap_threshold_px
for snap_x, snap_y in snap_points:
distance = math.sqrt((x - snap_x) ** 2 + (y - snap_y) ** 2)
if distance < best_distance:
best_snap_point = (snap_x, snap_y)
best_distance = distance
# Return snapped position or original position
if best_snap_point:
return best_snap_point
else:
return (x, y)
def snap_resize(self,
position: Tuple[float, float],
@ -151,16 +220,19 @@ class SnappingSystem:
new_width = width + dx
# Now apply snapping to the edges that are being moved
# Use _snap_edge_to_targets consistently for all edges
# Snap left edge (for nw, w, sw handles)
if resize_handle in ['nw', 'w', 'sw']:
snapped_left = self._snap_axis(
new_x, new_width, page_width_mm, dpi, snap_threshold_px, 'vertical'
# Try to snap the left edge
snapped_left = self._snap_edge_to_targets(
new_x, page_width_mm, dpi, snap_threshold_px, 'vertical'
)
# Adjust width to compensate for position change
width_adjustment = new_x - snapped_left
new_x = snapped_left
new_width += width_adjustment
if snapped_left is not None:
# Adjust width to compensate for position change
width_adjustment = new_x - snapped_left
new_x = snapped_left
new_width += width_adjustment
# Snap right edge (for ne, e, se handles)
if resize_handle in ['ne', 'e', 'se']:
@ -175,13 +247,15 @@ class SnappingSystem:
# Snap top edge (for nw, n, ne handles)
if resize_handle in ['nw', 'n', 'ne']:
snapped_top = self._snap_axis(
new_y, new_height, page_height_mm, dpi, snap_threshold_px, 'horizontal'
# Try to snap the top edge
snapped_top = self._snap_edge_to_targets(
new_y, page_height_mm, dpi, snap_threshold_px, 'horizontal'
)
# Adjust height to compensate for position change
height_adjustment = new_y - snapped_top
new_y = snapped_top
new_height += height_adjustment
if snapped_top is not None:
# Adjust height to compensate for position change
height_adjustment = new_y - snapped_top
new_y = snapped_top
new_height += height_adjustment
# Snap bottom edge (for sw, s, se handles)
if resize_handle in ['sw', 's', 'se']:

421
tests/test_alignment.py Normal file
View File

@ -0,0 +1,421 @@
"""
Unit tests for pyPhotoAlbum alignment system
"""
import pytest
from pyPhotoAlbum.alignment import AlignmentManager
from pyPhotoAlbum.models import ImageData, PlaceholderData, TextBoxData
class TestAlignmentManager:
"""Tests for AlignmentManager class"""
def test_get_bounds_empty_list(self):
"""Test get_bounds with empty list"""
elements = []
bounds = AlignmentManager.get_bounds(elements)
assert bounds == (0, 0, 0, 0)
def test_get_bounds_single_element(self):
"""Test get_bounds with single element"""
elem = ImageData(x=10, y=20, width=100, height=50)
bounds = AlignmentManager.get_bounds([elem])
# min_x, min_y, max_x, max_y
assert bounds == (10, 20, 110, 70)
def test_get_bounds_multiple_elements(self):
"""Test get_bounds with multiple elements"""
elem1 = ImageData(x=10, y=20, width=100, height=50)
elem2 = ImageData(x=50, y=10, width=80, height=60)
elem3 = ImageData(x=5, y=30, width=90, height=40)
bounds = AlignmentManager.get_bounds([elem1, elem2, elem3])
# min_x = 5, min_y = 10, max_x = 130 (50+80), max_y = 70 (10+60 or 20+50)
assert bounds[0] == 5 # min_x
assert bounds[1] == 10 # min_y
assert bounds[2] == 130 # max_x
assert bounds[3] == 70 # max_y
def test_align_left_empty_list(self):
"""Test align_left with empty list"""
changes = AlignmentManager.align_left([])
assert changes == []
def test_align_left_single_element(self):
"""Test align_left with single element"""
elem = ImageData(x=50, y=50, width=100, height=100)
changes = AlignmentManager.align_left([elem])
assert changes == []
assert elem.position == (50, 50) # Should not change
def test_align_left_multiple_elements(self):
"""Test align_left with multiple elements"""
elem1 = ImageData(x=50, y=20, width=100, height=50)
elem2 = ImageData(x=30, y=40, width=80, height=60)
elem3 = ImageData(x=70, y=60, width=90, height=40)
changes = AlignmentManager.align_left([elem1, elem2, elem3])
# All should align to x=30 (leftmost)
assert elem1.position == (30, 20)
assert elem2.position == (30, 40)
assert elem3.position == (30, 60)
# Check undo information
assert len(changes) == 3
assert changes[0] == (elem1, (50, 20))
assert changes[1] == (elem2, (30, 40))
assert changes[2] == (elem3, (70, 60))
def test_align_right_multiple_elements(self):
"""Test align_right with multiple elements"""
elem1 = ImageData(x=50, y=20, width=100, height=50) # right edge at 150
elem2 = ImageData(x=30, y=40, width=80, height=60) # right edge at 110
elem3 = ImageData(x=70, y=60, width=90, height=40) # right edge at 160
changes = AlignmentManager.align_right([elem1, elem2, elem3])
# All right edges should align to x=160 (rightmost)
assert elem1.position[0] == 60 # 160 - 100
assert elem2.position[0] == 80 # 160 - 80
assert elem3.position[0] == 70 # 160 - 90
# Y positions should not change
assert elem1.position[1] == 20
assert elem2.position[1] == 40
assert elem3.position[1] == 60
def test_align_top_multiple_elements(self):
"""Test align_top with multiple elements"""
elem1 = ImageData(x=50, y=30, width=100, height=50)
elem2 = ImageData(x=30, y=20, width=80, height=60)
elem3 = ImageData(x=70, y=40, width=90, height=40)
changes = AlignmentManager.align_top([elem1, elem2, elem3])
# All should align to y=20 (topmost)
assert elem1.position[1] == 20
assert elem2.position[1] == 20
assert elem3.position[1] == 20
# X positions should not change
assert elem1.position[0] == 50
assert elem2.position[0] == 30
assert elem3.position[0] == 70
def test_align_bottom_multiple_elements(self):
"""Test align_bottom with multiple elements"""
elem1 = ImageData(x=50, y=30, width=100, height=50) # bottom at 80
elem2 = ImageData(x=30, y=20, width=80, height=60) # bottom at 80
elem3 = ImageData(x=70, y=40, width=90, height=50) # bottom at 90
changes = AlignmentManager.align_bottom([elem1, elem2, elem3])
# All bottom edges should align to y=90 (bottommost)
assert elem1.position[1] == 40 # 90 - 50
assert elem2.position[1] == 30 # 90 - 60
assert elem3.position[1] == 40 # 90 - 50
# X positions should not change
assert elem1.position[0] == 50
assert elem2.position[0] == 30
assert elem3.position[0] == 70
def test_align_horizontal_center_multiple_elements(self):
"""Test align_horizontal_center with multiple elements"""
elem1 = ImageData(x=50, y=20, width=100, height=50) # center at 100
elem2 = ImageData(x=30, y=40, width=80, height=60) # center at 70
elem3 = ImageData(x=70, y=60, width=60, height=40) # center at 100
changes = AlignmentManager.align_horizontal_center([elem1, elem2, elem3])
# Average center = (100 + 70 + 100) / 3 = 90
# All elements should center at x=90
assert abs(elem1.position[0] + elem1.size[0]/2 - 90) < 0.01
assert abs(elem2.position[0] + elem2.size[0]/2 - 90) < 0.01
assert abs(elem3.position[0] + elem3.size[0]/2 - 90) < 0.01
# Y positions should not change
assert elem1.position[1] == 20
assert elem2.position[1] == 40
assert elem3.position[1] == 60
def test_align_vertical_center_multiple_elements(self):
"""Test align_vertical_center with multiple elements"""
elem1 = ImageData(x=50, y=20, width=100, height=50) # center at 45
elem2 = ImageData(x=30, y=40, width=80, height=60) # center at 70
elem3 = ImageData(x=70, y=30, width=60, height=40) # center at 50
changes = AlignmentManager.align_vertical_center([elem1, elem2, elem3])
# Average center = (45 + 70 + 50) / 3 = 55
# All elements should center at y=55
assert abs(elem1.position[1] + elem1.size[1]/2 - 55) < 0.01
assert abs(elem2.position[1] + elem2.size[1]/2 - 55) < 0.01
assert abs(elem3.position[1] + elem3.size[1]/2 - 55) < 0.01
# X positions should not change
assert elem1.position[0] == 50
assert elem2.position[0] == 30
assert elem3.position[0] == 70
def test_make_same_size_empty_list(self):
"""Test make_same_size with empty list"""
changes = AlignmentManager.make_same_size([])
assert changes == []
def test_make_same_size_single_element(self):
"""Test make_same_size with single element"""
elem = ImageData(x=50, y=50, width=100, height=100)
changes = AlignmentManager.make_same_size([elem])
assert changes == []
assert elem.size == (100, 100) # Should not change
def test_make_same_size_multiple_elements(self):
"""Test make_same_size with multiple elements"""
elem1 = ImageData(x=50, y=20, width=100, height=50)
elem2 = ImageData(x=30, y=40, width=80, height=60)
elem3 = ImageData(x=70, y=60, width=90, height=40)
changes = AlignmentManager.make_same_size([elem1, elem2, elem3])
# All should match elem1's size
assert elem1.size == (100, 50)
assert elem2.size == (100, 50)
assert elem3.size == (100, 50)
# Check undo information (only elem2 and elem3 change)
assert len(changes) == 2
assert changes[0][0] == elem2
assert changes[0][2] == (80, 60) # old size
assert changes[1][0] == elem3
assert changes[1][2] == (90, 40) # old size
def test_make_same_width_multiple_elements(self):
"""Test make_same_width with multiple elements"""
elem1 = ImageData(x=50, y=20, width=100, height=50)
elem2 = ImageData(x=30, y=40, width=80, height=60)
elem3 = ImageData(x=70, y=60, width=90, height=40)
changes = AlignmentManager.make_same_width([elem1, elem2, elem3])
# All widths should match elem1
assert elem1.size[0] == 100
assert elem2.size[0] == 100
assert elem3.size[0] == 100
# Heights should not change
assert elem1.size[1] == 50
assert elem2.size[1] == 60
assert elem3.size[1] == 40
def test_make_same_height_multiple_elements(self):
"""Test make_same_height with multiple elements"""
elem1 = ImageData(x=50, y=20, width=100, height=50)
elem2 = ImageData(x=30, y=40, width=80, height=60)
elem3 = ImageData(x=70, y=60, width=90, height=40)
changes = AlignmentManager.make_same_height([elem1, elem2, elem3])
# All heights should match elem1
assert elem1.size[1] == 50
assert elem2.size[1] == 50
assert elem3.size[1] == 50
# Widths should not change
assert elem1.size[0] == 100
assert elem2.size[0] == 80
assert elem3.size[0] == 90
def test_distribute_horizontally_too_few_elements(self):
"""Test distribute_horizontally with less than 3 elements"""
elem1 = ImageData(x=50, y=20, width=100, height=50)
elem2 = ImageData(x=30, y=40, width=80, height=60)
changes = AlignmentManager.distribute_horizontally([elem1, elem2])
assert changes == []
def test_distribute_horizontally_multiple_elements(self):
"""Test distribute_horizontally with multiple elements"""
elem1 = ImageData(x=0, y=20, width=100, height=50)
elem2 = ImageData(x=50, y=40, width=80, height=60)
elem3 = ImageData(x=200, y=60, width=90, height=40)
changes = AlignmentManager.distribute_horizontally([elem1, elem2, elem3])
# Elements should be distributed evenly by their left edges
# min_x = 0, max_x = 200, span = 200
# spacing = 200 / (3-1) = 100
positions = [elem.position[0] for elem in [elem1, elem2, elem3]]
sorted_positions = sorted(positions)
assert sorted_positions[0] == 0
assert sorted_positions[1] == 100
assert sorted_positions[2] == 200
def test_distribute_vertically_multiple_elements(self):
"""Test distribute_vertically with multiple elements"""
elem1 = ImageData(x=20, y=0, width=100, height=50)
elem2 = ImageData(x=40, y=50, width=80, height=60)
elem3 = ImageData(x=60, y=300, width=90, height=40)
changes = AlignmentManager.distribute_vertically([elem1, elem2, elem3])
# Elements should be distributed evenly by their top edges
# min_y = 0, max_y = 300, span = 300
# spacing = 300 / (3-1) = 150
positions = [elem.position[1] for elem in [elem1, elem2, elem3]]
sorted_positions = sorted(positions)
assert sorted_positions[0] == 0
assert sorted_positions[1] == 150
assert sorted_positions[2] == 300
def test_space_horizontally_too_few_elements(self):
"""Test space_horizontally with less than 3 elements"""
elem1 = ImageData(x=50, y=20, width=100, height=50)
elem2 = ImageData(x=200, y=40, width=80, height=60)
changes = AlignmentManager.space_horizontally([elem1, elem2])
assert changes == []
def test_space_horizontally_multiple_elements(self):
"""Test space_horizontally with multiple elements"""
elem1 = ImageData(x=0, y=20, width=100, height=50)
elem2 = ImageData(x=150, y=40, width=50, height=60)
elem3 = ImageData(x=250, y=60, width=100, height=40)
changes = AlignmentManager.space_horizontally([elem1, elem2, elem3])
# Total width = 100 + 50 + 100 = 250
# Span = 0 to 350 (250 + 100 from elem3)
# Available space = 350 - 0 - 250 = 100
# Spacing = 100 / (3-1) = 50
# After sorting by x: elem1 at 0, elem2 after 100+50=150, elem3 after 150+50+50=250
sorted_elements = sorted([elem1, elem2, elem3], key=lambda e: e.position[0])
assert sorted_elements[0].position[0] == 0
assert sorted_elements[1].position[0] == 150 # 0 + 100 + 50
assert sorted_elements[2].position[0] == 250 # 150 + 50 + 50
def test_space_vertically_multiple_elements(self):
"""Test space_vertically with multiple elements"""
elem1 = ImageData(x=20, y=0, width=100, height=50)
elem2 = ImageData(x=40, y=100, width=80, height=30)
elem3 = ImageData(x=60, y=200, width=90, height=50)
changes = AlignmentManager.space_vertically([elem1, elem2, elem3])
# Total height = 50 + 30 + 50 = 130
# Span = 0 to 250 (200 + 50 from elem3)
# Available space = 250 - 0 - 130 = 120
# Spacing = 120 / (3-1) = 60
# After sorting by y: elem1 at 0, elem2 after 50+60=110, elem3 after 110+30+60=200
sorted_elements = sorted([elem1, elem2, elem3], key=lambda e: e.position[1])
assert sorted_elements[0].position[1] == 0
assert sorted_elements[1].position[1] == 110 # 0 + 50 + 60
assert sorted_elements[2].position[1] == 200 # 110 + 30 + 60
def test_alignment_with_different_element_types(self):
"""Test alignment works with different element types"""
elem1 = ImageData(x=50, y=20, width=100, height=50)
elem2 = PlaceholderData(placeholder_type="image", x=30, y=40, width=80, height=60)
elem3 = TextBoxData(text_content="Test", x=70, y=60, width=90, height=40)
# Test align_left
changes = AlignmentManager.align_left([elem1, elem2, elem3])
assert elem1.position[0] == 30
assert elem2.position[0] == 30
assert elem3.position[0] == 30
def test_undo_information_completeness(self):
"""Test that undo information contains all necessary data"""
elem1 = ImageData(x=50, y=20, width=100, height=50)
elem2 = ImageData(x=30, y=40, width=80, height=60)
elem3 = ImageData(x=70, y=60, width=90, height=40)
# Test position changes
changes = AlignmentManager.align_left([elem1, elem2, elem3])
for change in changes:
assert len(change) == 2 # (element, old_position)
assert isinstance(change[0], ImageData)
assert isinstance(change[1], tuple)
assert len(change[1]) == 2 # (x, y)
# Test size changes
elem1 = ImageData(x=50, y=20, width=100, height=50)
elem2 = ImageData(x=30, y=40, width=80, height=60)
elem3 = ImageData(x=70, y=60, width=90, height=40)
changes = AlignmentManager.make_same_size([elem1, elem2, elem3])
for change in changes:
assert len(change) == 3 # (element, old_position, old_size)
assert isinstance(change[0], ImageData)
assert isinstance(change[1], tuple)
assert len(change[1]) == 2 # (x, y)
assert isinstance(change[2], tuple)
assert len(change[2]) == 2 # (width, height)
def test_alignment_preserves_unaffected_properties(self):
"""Test that alignment operations only change intended properties"""
elem1 = ImageData(x=50, y=20, width=100, height=50, rotation=45, z_index=5)
elem2 = ImageData(x=30, y=40, width=80, height=60, rotation=90, z_index=3)
AlignmentManager.align_left([elem1, elem2])
# Rotation and z_index should not change
assert elem1.rotation == 45
assert elem1.z_index == 5
assert elem2.rotation == 90
assert elem2.z_index == 3
# Heights should not change
assert elem1.size[1] == 50
assert elem2.size[1] == 60
def test_distribute_with_unsorted_elements(self):
"""Test distribution works correctly with unsorted input"""
# Create elements in random order
elem3 = ImageData(x=200, y=60, width=90, height=40)
elem1 = ImageData(x=0, y=20, width=100, height=50)
elem2 = ImageData(x=100, y=40, width=80, height=60)
# Pass in random order
changes = AlignmentManager.distribute_horizontally([elem3, elem1, elem2])
# Should still distribute correctly
positions = sorted([elem1.position[0], elem2.position[0], elem3.position[0]])
assert positions[0] == 0
assert positions[1] == 100
assert positions[2] == 200
def test_space_with_varying_sizes(self):
"""Test spacing works correctly with elements of varying sizes"""
elem1 = ImageData(x=0, y=0, width=50, height=50)
elem2 = ImageData(x=100, y=0, width=100, height=50)
elem3 = ImageData(x=250, y=0, width=75, height=50)
changes = AlignmentManager.space_horizontally([elem1, elem2, elem3])
# Calculate expected spacing
# Total width = 50 + 100 + 75 = 225
# rightmost edge = 250 + 75 = 325
# Available space = 325 - 0 - 225 = 100
# Spacing = 100 / 2 = 50
sorted_elements = sorted([elem1, elem2, elem3], key=lambda e: e.position[0])
# Verify spacing between elements is equal
gap1 = sorted_elements[1].position[0] - (sorted_elements[0].position[0] + sorted_elements[0].size[0])
gap2 = sorted_elements[2].position[0] - (sorted_elements[1].position[0] + sorted_elements[1].size[0])
assert abs(gap1 - 50) < 0.01
assert abs(gap2 - 50) < 0.01

496
tests/test_snapping.py Normal file
View File

@ -0,0 +1,496 @@
"""
Unit tests for pyPhotoAlbum snapping system
"""
import pytest
from pyPhotoAlbum.snapping import SnappingSystem, Guide
class TestGuide:
"""Tests for Guide class"""
def test_guide_initialization(self):
"""Test Guide initialization"""
guide = Guide(position=50.0, orientation='vertical')
assert guide.position == 50.0
assert guide.orientation == 'vertical'
def test_guide_serialization(self):
"""Test Guide serialization to dictionary"""
guide = Guide(position=75.5, orientation='horizontal')
data = guide.serialize()
assert data['position'] == 75.5
assert data['orientation'] == 'horizontal'
def test_guide_deserialization(self):
"""Test Guide deserialization from dictionary"""
data = {'position': 100.0, 'orientation': 'vertical'}
guide = Guide.deserialize(data)
assert guide.position == 100.0
assert guide.orientation == 'vertical'
def test_guide_deserialization_with_defaults(self):
"""Test Guide deserialization with missing fields uses defaults"""
data = {}
guide = Guide.deserialize(data)
assert guide.position == 0
assert guide.orientation == 'vertical'
class TestSnappingSystem:
"""Tests for SnappingSystem class"""
def test_initialization_default(self):
"""Test SnappingSystem initialization with default values"""
system = SnappingSystem()
assert system.snap_threshold_mm == 5.0
assert system.grid_size_mm == 10.0
assert system.snap_to_grid == False
assert system.snap_to_edges == True
assert system.snap_to_guides == True
assert len(system.guides) == 0
def test_initialization_with_threshold(self):
"""Test SnappingSystem initialization with custom threshold"""
system = SnappingSystem(snap_threshold_mm=3.0)
assert system.snap_threshold_mm == 3.0
def test_add_guide(self):
"""Test adding a guide"""
system = SnappingSystem()
guide = system.add_guide(position=50.0, orientation='vertical')
assert len(system.guides) == 1
assert guide.position == 50.0
assert guide.orientation == 'vertical'
assert guide in system.guides
def test_add_multiple_guides(self):
"""Test adding multiple guides"""
system = SnappingSystem()
guide1 = system.add_guide(position=50.0, orientation='vertical')
guide2 = system.add_guide(position=100.0, orientation='horizontal')
guide3 = system.add_guide(position=150.0, orientation='vertical')
assert len(system.guides) == 3
assert guide1 in system.guides
assert guide2 in system.guides
assert guide3 in system.guides
def test_remove_guide(self):
"""Test removing a guide"""
system = SnappingSystem()
guide = system.add_guide(position=50.0, orientation='vertical')
system.remove_guide(guide)
assert len(system.guides) == 0
assert guide not in system.guides
def test_remove_guide_not_in_list(self):
"""Test removing a guide that's not in the list does nothing"""
system = SnappingSystem()
guide1 = system.add_guide(position=50.0, orientation='vertical')
guide2 = Guide(position=100.0, orientation='horizontal')
# Should not raise an error
system.remove_guide(guide2)
assert len(system.guides) == 1
assert guide1 in system.guides
def test_clear_guides(self):
"""Test clearing all guides"""
system = SnappingSystem()
system.add_guide(position=50.0, orientation='vertical')
system.add_guide(position=100.0, orientation='horizontal')
system.add_guide(position=150.0, orientation='vertical')
system.clear_guides()
assert len(system.guides) == 0
def test_snap_position_no_snapping_enabled(self):
"""Test snap_position with all snapping disabled"""
system = SnappingSystem()
system.snap_to_grid = False
system.snap_to_edges = False
system.snap_to_guides = False
position = (25.0, 35.0)
size = (100.0, 100.0)
page_size = (210.0, 297.0)
snapped = system.snap_position(position, size, page_size, dpi=300)
assert snapped == position # Should not snap
def test_snap_position_to_edges(self):
"""Test snap_position snapping to page edges"""
system = SnappingSystem(snap_threshold_mm=5.0)
system.snap_to_grid = False
system.snap_to_edges = True
system.snap_to_guides = False
# Position near left edge (should snap to 0)
position = (10.0, 50.0) # Close to 0 in pixels
size = (100.0, 100.0)
page_size = (210.0, 297.0) # A4 size in mm
snapped = system.snap_position(position, size, page_size, dpi=300)
assert snapped[0] == 0 # Should snap to left edge
def test_snap_position_to_grid(self):
"""Test snap_position snapping to grid"""
system = SnappingSystem(snap_threshold_mm=5.0)
system.snap_to_grid = True
system.snap_to_edges = False
system.snap_to_guides = False
system.grid_size_mm = 10.0
# Position near a grid line
dpi = 300
grid_size_px = 10.0 * dpi / 25.4 # ~118 pixels
position = (grid_size_px + 5, grid_size_px + 5) # Close to a grid point
size = (100.0, 100.0)
page_size = (210.0, 297.0)
snapped = system.snap_position(position, size, page_size, dpi=dpi)
# Should snap to nearest grid line
assert abs(snapped[0] - grid_size_px) < 1 # Allow small floating point error
assert abs(snapped[1] - grid_size_px) < 1
def test_snap_position_to_guides(self):
"""Test snap_position snapping to guides"""
system = SnappingSystem(snap_threshold_mm=5.0)
system.snap_to_grid = False
system.snap_to_edges = False
system.snap_to_guides = True
dpi = 300
guide_pos_mm = 50.0
guide_pos_px = guide_pos_mm * dpi / 25.4
system.add_guide(position=guide_pos_mm, orientation='vertical')
system.add_guide(position=guide_pos_mm, orientation='horizontal')
# Position near the guides
position = (guide_pos_px + 5, guide_pos_px + 5)
size = (100.0, 100.0)
page_size = (210.0, 297.0)
snapped = system.snap_position(position, size, page_size, dpi=dpi)
# Should snap to guides
assert abs(snapped[0] - guide_pos_px) < 1
assert abs(snapped[1] - guide_pos_px) < 1
def test_snap_position_outside_threshold(self):
"""Test snap_position when position is outside snap threshold"""
system = SnappingSystem(snap_threshold_mm=2.0) # Small threshold
system.snap_to_edges = True
# Position far from edges
position = (500.0, 600.0)
size = (100.0, 100.0)
page_size = (210.0, 297.0)
snapped = system.snap_position(position, size, page_size, dpi=300)
assert snapped == position # Should not snap when too far
def test_snap_resize_bottom_right_handle(self):
"""Test snap_resize with bottom-right handle"""
system = SnappingSystem(snap_threshold_mm=5.0)
system.snap_to_grid = True
system.grid_size_mm = 10.0
position = (100.0, 100.0)
size = (200.0, 200.0)
dx = 10.0
dy = 10.0
resize_handle = 'se'
page_size = (210.0, 297.0)
new_pos, new_size = system.snap_resize(
position, size, dx, dy, resize_handle, page_size, dpi=300
)
# Position shouldn't change for bottom-right handle
assert new_pos == position
# Size should change
assert new_size[0] > size[0]
assert new_size[1] > size[1]
def test_snap_resize_top_left_handle(self):
"""Test snap_resize with top-left handle"""
system = SnappingSystem(snap_threshold_mm=5.0)
system.snap_to_edges = True
position = (150.0, 150.0)
size = (200.0, 200.0)
dx = -10.0
dy = -10.0
resize_handle = 'nw'
page_size = (210.0, 297.0)
new_pos, new_size = system.snap_resize(
position, size, dx, dy, resize_handle, page_size, dpi=300
)
# Both position and size should change for top-left handle
assert new_pos != position
assert new_size != size
def test_snap_resize_top_handle(self):
"""Test snap_resize with top handle only"""
system = SnappingSystem(snap_threshold_mm=5.0)
system.snap_to_edges = True
position = (100.0, 100.0)
size = (200.0, 200.0)
dx = 0.0
dy = -10.0
resize_handle = 'n'
page_size = (210.0, 297.0)
new_pos, new_size = system.snap_resize(
position, size, dx, dy, resize_handle, page_size, dpi=300
)
# X position should stay same, Y should change
assert new_pos[0] == position[0]
assert new_pos[1] != position[1]
# Width should stay same, height should change
assert new_size[0] == size[0]
assert new_size[1] != size[1]
def test_snap_resize_right_handle(self):
"""Test snap_resize with right handle only"""
system = SnappingSystem(snap_threshold_mm=5.0)
system.snap_to_edges = True
position = (100.0, 100.0)
size = (200.0, 200.0)
dx = 10.0
dy = 0.0
resize_handle = 'e'
page_size = (210.0, 297.0)
new_pos, new_size = system.snap_resize(
position, size, dx, dy, resize_handle, page_size, dpi=300
)
# Position should stay same
assert new_pos == position
# Width should change, height should stay same
assert new_size[0] != size[0]
assert new_size[1] == size[1]
def test_snap_resize_minimum_size(self):
"""Test snap_resize enforces minimum size"""
system = SnappingSystem(snap_threshold_mm=5.0)
system.snap_to_edges = False
position = (100.0, 100.0)
size = (50.0, 50.0)
dx = -100.0 # Try to make it very small
dy = -100.0
resize_handle = 'se'
page_size = (210.0, 297.0)
new_pos, new_size = system.snap_resize(
position, size, dx, dy, resize_handle, page_size, dpi=300
)
# Should enforce minimum size of 10 pixels
assert new_size[0] >= 10
assert new_size[1] >= 10
def test_snap_resize_all_handles(self):
"""Test snap_resize works with all handle types"""
system = SnappingSystem(snap_threshold_mm=5.0)
system.snap_to_edges = False
position = (100.0, 100.0)
size = (200.0, 200.0)
dx = 10.0
dy = 10.0
page_size = (210.0, 297.0)
handles = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']
for handle in handles:
new_pos, new_size = system.snap_resize(
position, size, dx, dy, handle, page_size, dpi=300
)
# Should return valid position and size
assert isinstance(new_pos, tuple)
assert len(new_pos) == 2
assert isinstance(new_size, tuple)
assert len(new_size) == 2
assert new_size[0] >= 10 # Minimum size
assert new_size[1] >= 10
def test_get_snap_lines_empty(self):
"""Test get_snap_lines with no snapping enabled"""
system = SnappingSystem()
system.snap_to_grid = False
system.snap_to_edges = False
system.snap_to_guides = False
page_size = (210.0, 297.0)
lines = system.get_snap_lines(page_size, dpi=300)
assert lines['grid'] == []
assert lines['edges'] == []
assert lines['guides'] == []
def test_get_snap_lines_with_grid(self):
"""Test get_snap_lines with grid enabled"""
system = SnappingSystem()
system.snap_to_grid = True
system.grid_size_mm = 10.0
page_size = (30.0, 30.0) # Small page for easier testing
lines = system.get_snap_lines(page_size, dpi=300)
# Should have grid lines
assert len(lines['grid']) > 0
# Should have both vertical and horizontal grid lines
vertical_lines = [line for line in lines['grid'] if line[0] == 'vertical']
horizontal_lines = [line for line in lines['grid'] if line[0] == 'horizontal']
assert len(vertical_lines) > 0
assert len(horizontal_lines) > 0
def test_get_snap_lines_with_edges(self):
"""Test get_snap_lines with edge snapping enabled"""
system = SnappingSystem()
system.snap_to_edges = True
page_size = (210.0, 297.0)
lines = system.get_snap_lines(page_size, dpi=300)
# Should have exactly 4 edge lines (left, right, top, bottom)
assert len(lines['edges']) == 4
# Check for vertical edges
vertical_edges = [line for line in lines['edges'] if line[0] == 'vertical']
assert len(vertical_edges) == 2
# Check for horizontal edges
horizontal_edges = [line for line in lines['edges'] if line[0] == 'horizontal']
assert len(horizontal_edges) == 2
def test_get_snap_lines_with_guides(self):
"""Test get_snap_lines with guides"""
system = SnappingSystem()
system.snap_to_guides = True
system.add_guide(position=50.0, orientation='vertical')
system.add_guide(position=100.0, orientation='horizontal')
system.add_guide(position=150.0, orientation='vertical')
page_size = (210.0, 297.0)
lines = system.get_snap_lines(page_size, dpi=300)
# Should have guide lines
assert len(lines['guides']) == 3
# Check orientations
vertical_guides = [line for line in lines['guides'] if line[0] == 'vertical']
horizontal_guides = [line for line in lines['guides'] if line[0] == 'horizontal']
assert len(vertical_guides) == 2
assert len(horizontal_guides) == 1
def test_serialization(self):
"""Test SnappingSystem serialization to dictionary"""
system = SnappingSystem(snap_threshold_mm=3.0)
system.grid_size_mm = 15.0
system.snap_to_grid = True
system.snap_to_edges = False
system.snap_to_guides = True
system.add_guide(position=50.0, orientation='vertical')
system.add_guide(position=100.0, orientation='horizontal')
data = system.serialize()
assert data['snap_threshold_mm'] == 3.0
assert data['grid_size_mm'] == 15.0
assert data['snap_to_grid'] == True
assert data['snap_to_edges'] == False
assert data['snap_to_guides'] == True
assert len(data['guides']) == 2
def test_deserialization(self):
"""Test SnappingSystem deserialization from dictionary"""
system = SnappingSystem()
data = {
'snap_threshold_mm': 4.0,
'grid_size_mm': 20.0,
'snap_to_grid': True,
'snap_to_edges': False,
'snap_to_guides': True,
'guides': [
{'position': 50.0, 'orientation': 'vertical'},
{'position': 100.0, 'orientation': 'horizontal'}
]
}
system.deserialize(data)
assert system.snap_threshold_mm == 4.0
assert system.grid_size_mm == 20.0
assert system.snap_to_grid == True
assert system.snap_to_edges == False
assert system.snap_to_guides == True
assert len(system.guides) == 2
assert system.guides[0].position == 50.0
assert system.guides[0].orientation == 'vertical'
assert system.guides[1].position == 100.0
assert system.guides[1].orientation == 'horizontal'
def test_deserialization_with_defaults(self):
"""Test SnappingSystem deserialization with missing fields uses defaults"""
system = SnappingSystem()
data = {}
system.deserialize(data)
assert system.snap_threshold_mm == 5.0
assert system.grid_size_mm == 10.0
assert system.snap_to_grid == False
assert system.snap_to_edges == True
assert system.snap_to_guides == True
assert len(system.guides) == 0
def test_serialize_deserialize_roundtrip(self):
"""Test that serialize and deserialize are inverse operations"""
original = SnappingSystem(snap_threshold_mm=7.5)
original.grid_size_mm = 12.5
original.snap_to_grid = True
original.snap_to_edges = True
original.snap_to_guides = False
original.add_guide(position=25.5, orientation='vertical')
original.add_guide(position=75.5, orientation='horizontal')
original.add_guide(position=125.5, orientation='vertical')
data = original.serialize()
restored = SnappingSystem()
restored.deserialize(data)
assert restored.snap_threshold_mm == original.snap_threshold_mm
assert restored.grid_size_mm == original.grid_size_mm
assert restored.snap_to_grid == original.snap_to_grid
assert restored.snap_to_edges == original.snap_to_edges
assert restored.snap_to_guides == original.snap_to_guides
assert len(restored.guides) == len(original.guides)
for orig_guide, rest_guide in zip(original.guides, restored.guides):
assert rest_guide.position == orig_guide.position
assert rest_guide.orientation == orig_guide.orientation

View File

@ -0,0 +1,532 @@
"""
Unit tests for pyPhotoAlbum template management system
"""
import pytest
import tempfile
import json
from pathlib import Path
from pyPhotoAlbum.template_manager import Template, TemplateManager
from pyPhotoAlbum.models import ImageData, PlaceholderData, TextBoxData
from pyPhotoAlbum.page_layout import PageLayout
from pyPhotoAlbum.project import Page
class TestTemplate:
"""Tests for Template class"""
def test_initialization_default(self):
"""Test Template initialization with default values"""
template = Template()
assert template.name == "Untitled Template"
assert template.description == ""
assert template.page_size_mm == (210, 297)
assert len(template.elements) == 0
def test_initialization_with_parameters(self):
"""Test Template initialization with custom parameters"""
template = Template(
name="My Template",
description="Test template",
page_size_mm=(200, 280)
)
assert template.name == "My Template"
assert template.description == "Test template"
assert template.page_size_mm == (200, 280)
def test_add_element(self):
"""Test adding elements to template"""
template = Template()
placeholder = PlaceholderData(x=10, y=20, width=100, height=50)
template.add_element(placeholder)
assert len(template.elements) == 1
assert template.elements[0] == placeholder
def test_add_multiple_elements(self):
"""Test adding multiple elements"""
template = Template()
elem1 = PlaceholderData(x=10, y=20, width=100, height=50)
elem2 = TextBoxData(text_content="Test", x=30, y=40, width=150, height=60)
template.add_element(elem1)
template.add_element(elem2)
assert len(template.elements) == 2
assert elem1 in template.elements
assert elem2 in template.elements
def test_to_dict(self):
"""Test serialization to dictionary"""
template = Template(name="Test", description="Desc", page_size_mm=(200, 280))
placeholder = PlaceholderData(x=10, y=20, width=100, height=50)
template.add_element(placeholder)
data = template.to_dict()
assert data["name"] == "Test"
assert data["description"] == "Desc"
assert data["page_size_mm"] == (200, 280)
assert len(data["elements"]) == 1
assert data["elements"][0]["type"] == "placeholder"
def test_from_dict(self):
"""Test deserialization from dictionary"""
data = {
"name": "Loaded Template",
"description": "Test description",
"page_size_mm": [220, 300],
"elements": [
{
"type": "placeholder",
"position": (50, 60),
"size": (120, 80),
"placeholder_type": "image"
},
{
"type": "textbox",
"position": (70, 90),
"size": (140, 100),
"text_content": "Test text"
}
]
}
template = Template.from_dict(data)
assert template.name == "Loaded Template"
assert template.description == "Test description"
assert template.page_size_mm == (220, 300)
assert len(template.elements) == 2
assert isinstance(template.elements[0], PlaceholderData)
assert isinstance(template.elements[1], TextBoxData)
def test_from_dict_skips_image_elements(self):
"""Test that from_dict skips image elements"""
data = {
"name": "Test",
"elements": [
{"type": "image", "position": (10, 20), "size": (100, 50)},
{"type": "placeholder", "position": (30, 40), "size": (120, 60)}
]
}
template = Template.from_dict(data)
# Should only have the placeholder, not the image
assert len(template.elements) == 1
assert isinstance(template.elements[0], PlaceholderData)
def test_save_to_file(self, temp_dir):
"""Test saving template to file"""
template = Template(name="Save Test", description="Test save")
placeholder = PlaceholderData(x=10, y=20, width=100, height=50)
template.add_element(placeholder)
file_path = Path(temp_dir) / "test_template.json"
template.save_to_file(str(file_path))
# Verify file was created
assert file_path.exists()
# Verify content
with open(file_path, 'r') as f:
data = json.load(f)
assert data["name"] == "Save Test"
assert data["description"] == "Test save"
def test_load_from_file(self, temp_dir):
"""Test loading template from file"""
# Create a test file
data = {
"name": "Load Test",
"description": "Test load",
"page_size_mm": [210, 297],
"elements": [
{
"type": "placeholder",
"position": (10, 20),
"size": (100, 50),
"placeholder_type": "image"
}
]
}
file_path = Path(temp_dir) / "load_test.json"
with open(file_path, 'w') as f:
json.dump(data, f)
# Load template
template = Template.load_from_file(str(file_path))
assert template.name == "Load Test"
assert template.description == "Test load"
assert len(template.elements) == 1
class TestTemplateManager:
"""Tests for TemplateManager class"""
def test_initialization(self):
"""Test TemplateManager initialization"""
manager = TemplateManager()
assert manager.templates_dir is not None
assert isinstance(manager.templates_dir, Path)
def test_get_templates_directory(self):
"""Test getting templates directory"""
manager = TemplateManager()
templates_dir = manager._get_templates_directory()
assert templates_dir.name == "templates"
assert ".pyphotoalbum" in str(templates_dir)
def test_get_builtin_templates_directory(self):
"""Test getting built-in templates directory"""
manager = TemplateManager()
builtin_dir = manager._get_builtin_templates_directory()
assert builtin_dir.name == "templates"
assert "pyPhotoAlbum" in str(builtin_dir)
def test_list_templates_empty(self, tmp_path, monkeypatch):
"""Test listing templates when directory is empty"""
# Create temporary directories
user_dir = tmp_path / "user_templates"
builtin_dir = tmp_path / "builtin_templates"
user_dir.mkdir()
builtin_dir.mkdir()
manager = TemplateManager()
monkeypatch.setattr(manager, 'templates_dir', user_dir)
monkeypatch.setattr(manager, '_get_builtin_templates_directory', lambda: builtin_dir)
templates = manager.list_templates()
assert templates == []
def test_list_templates_with_files(self, tmp_path, monkeypatch):
"""Test listing templates with template files"""
user_dir = tmp_path / "user_templates"
builtin_dir = tmp_path / "builtin_templates"
user_dir.mkdir()
builtin_dir.mkdir()
# Create user template
user_template = user_dir / "My_Template.json"
user_template.write_text('{"name": "My Template"}')
# Create built-in template
builtin_template = builtin_dir / "Grid_2x2.json"
builtin_template.write_text('{"name": "Grid 2x2"}')
manager = TemplateManager()
monkeypatch.setattr(manager, 'templates_dir', user_dir)
monkeypatch.setattr(manager, '_get_builtin_templates_directory', lambda: builtin_dir)
templates = manager.list_templates()
assert "[Built-in] Grid_2x2" in templates
assert "My_Template" in templates
assert len(templates) == 2
def test_save_template(self, tmp_path, monkeypatch):
"""Test saving a template"""
user_dir = tmp_path / "user_templates"
user_dir.mkdir()
manager = TemplateManager()
monkeypatch.setattr(manager, 'templates_dir', user_dir)
template = Template(name="Test Template")
manager.save_template(template)
# Verify file was created
template_file = user_dir / "Test Template.json"
assert template_file.exists()
def test_load_template_user(self, tmp_path, monkeypatch):
"""Test loading a user template"""
user_dir = tmp_path / "user_templates"
user_dir.mkdir()
# Create template file
data = {"name": "User Template", "description": "Test", "page_size_mm": [210, 297], "elements": []}
template_file = user_dir / "User Template.json"
with open(template_file, 'w') as f:
json.dump(data, f)
manager = TemplateManager()
monkeypatch.setattr(manager, 'templates_dir', user_dir)
template = manager.load_template("User Template")
assert template.name == "User Template"
def test_load_template_builtin(self, tmp_path, monkeypatch):
"""Test loading a built-in template"""
builtin_dir = tmp_path / "builtin_templates"
builtin_dir.mkdir()
# Create built-in template file
data = {"name": "Grid 2x2", "description": "Built-in grid", "page_size_mm": [210, 297], "elements": []}
template_file = builtin_dir / "Grid 2x2.json"
with open(template_file, 'w') as f:
json.dump(data, f)
manager = TemplateManager()
monkeypatch.setattr(manager, '_get_builtin_templates_directory', lambda: builtin_dir)
template = manager.load_template("[Built-in] Grid 2x2")
assert template.name == "Grid 2x2"
def test_load_template_not_found(self, tmp_path, monkeypatch):
"""Test loading non-existent template raises error"""
user_dir = tmp_path / "user_templates"
user_dir.mkdir()
manager = TemplateManager()
monkeypatch.setattr(manager, 'templates_dir', user_dir)
with pytest.raises(FileNotFoundError):
manager.load_template("NonExistent")
def test_delete_template(self, tmp_path, monkeypatch):
"""Test deleting a user template"""
user_dir = tmp_path / "user_templates"
user_dir.mkdir()
# Create template file
template_file = user_dir / "DeleteMe.json"
template_file.write_text('{"name": "DeleteMe"}')
manager = TemplateManager()
monkeypatch.setattr(manager, 'templates_dir', user_dir)
manager.delete_template("DeleteMe")
assert not template_file.exists()
def test_delete_builtin_template_raises_error(self):
"""Test deleting built-in template raises error"""
manager = TemplateManager()
with pytest.raises(PermissionError):
manager.delete_template("[Built-in] Grid_2x2")
def test_create_template_from_page(self):
"""Test creating template from a page"""
# Create a page with various elements
layout = PageLayout(width=210, height=297)
img = ImageData(image_path="test.jpg", x=10, y=20, width=100, height=50)
text = TextBoxData(text_content="Test", x=30, y=40, width=150, height=60)
placeholder = PlaceholderData(x=50, y=60, width=120, height=70)
layout.add_element(img)
layout.add_element(text)
layout.add_element(placeholder)
page = Page(layout=layout, page_number=1)
# Create template
manager = TemplateManager()
template = manager.create_template_from_page(
page,
name="Test Template",
description="Created from page"
)
assert template.name == "Test Template"
assert template.description == "Created from page"
assert len(template.elements) == 3
# Image should be converted to placeholder
assert isinstance(template.elements[0], PlaceholderData)
assert isinstance(template.elements[1], TextBoxData)
assert isinstance(template.elements[2], PlaceholderData)
def test_scale_template_elements_proportional(self):
"""Test scaling template elements proportionally"""
manager = TemplateManager()
# Create elements at 200x200 size
elem = PlaceholderData(x=50, y=50, width=100, height=100)
elements = [elem]
# Scale to 400x400 (2x scale)
scaled = manager.scale_template_elements(
elements,
from_size=(200, 200),
to_size=(400, 400),
scale_mode="proportional"
)
assert len(scaled) == 1
# With proportional scaling and centering
# scale = min(400/200, 400/200) = 2.0
# offset = (400 - 200*2) / 2 = 0
assert scaled[0].position == (100, 100) # 50 * 2 + 0
assert scaled[0].size == (200, 200) # 100 * 2
def test_scale_template_elements_stretch(self):
"""Test scaling template elements with stretch mode"""
manager = TemplateManager()
elem = PlaceholderData(x=50, y=50, width=100, height=100)
elements = [elem]
# Scale to 400x200 (2x width, 1x height)
scaled = manager.scale_template_elements(
elements,
from_size=(200, 200),
to_size=(400, 200),
scale_mode="stretch"
)
assert len(scaled) == 1
assert scaled[0].position == (100, 50) # 50 * 2, 50 * 1
assert scaled[0].size == (200, 100) # 100 * 2, 100 * 1
def test_scale_template_elements_center(self):
"""Test scaling template elements with center mode"""
manager = TemplateManager()
elem = PlaceholderData(x=50, y=50, width=100, height=100)
elements = [elem]
# Center in larger space without scaling
scaled = manager.scale_template_elements(
elements,
from_size=(200, 200),
to_size=(400, 400),
scale_mode="center"
)
assert len(scaled) == 1
# offset = (400 - 200) / 2 = 100
assert scaled[0].position == (150, 150) # 50 + 100
assert scaled[0].size == (100, 100) # No scaling
def test_scale_template_preserves_properties(self):
"""Test that scaling preserves element properties"""
manager = TemplateManager()
elem = PlaceholderData(x=50, y=50, width=100, height=100)
elem.rotation = 45
elem.z_index = 5
elem.placeholder_type = "image"
scaled = manager.scale_template_elements(
[elem],
from_size=(200, 200),
to_size=(400, 400),
scale_mode="proportional"
)
assert scaled[0].rotation == 45
assert scaled[0].z_index == 5
assert scaled[0].placeholder_type == "image"
def test_apply_template_to_page_replace(self):
"""Test applying template with replace mode"""
manager = TemplateManager()
# Create template
template = Template(page_size_mm=(200, 200))
template.add_element(PlaceholderData(x=10, y=20, width=80, height=60))
# Create page with existing content
layout = PageLayout(width=200, height=200)
layout.add_element(ImageData(x=100, y=100, width=50, height=50))
page = Page(layout=layout, page_number=1)
# Apply template
manager.apply_template_to_page(template, page, mode="replace")
# Page should have only template elements
assert len(page.layout.elements) == 1
assert isinstance(page.layout.elements[0], PlaceholderData)
def test_apply_template_to_page_reflow(self):
"""Test applying template with reflow mode"""
manager = TemplateManager()
# Create template with 2 placeholders
template = Template(page_size_mm=(200, 200))
template.add_element(PlaceholderData(x=10, y=20, width=80, height=60))
template.add_element(PlaceholderData(x=100, y=100, width=80, height=60))
# Create page with 1 image
layout = PageLayout(width=200, height=200)
img = ImageData(image_path="test.jpg", x=50, y=50, width=50, height=50)
layout.add_element(img)
page = Page(layout=layout, page_number=1)
# Apply template with reflow
manager.apply_template_to_page(template, page, mode="reflow")
# Should have 1 image (reflowed) + 1 placeholder
assert len(page.layout.elements) == 2
# First should be the reflowed image
assert isinstance(page.layout.elements[0], ImageData)
# Second should be placeholder (no image to fill it)
assert isinstance(page.layout.elements[1], PlaceholderData)
def test_create_page_from_template_default_size(self):
"""Test creating page from template with default size"""
manager = TemplateManager()
# Create template
template = Template(page_size_mm=(210, 297))
template.add_element(PlaceholderData(x=10, y=20, width=100, height=50))
# Create page
page = manager.create_page_from_template(template, page_number=5)
assert page.page_number == 5
assert page.layout.size == (210, 297)
assert len(page.layout.elements) == 1
assert isinstance(page.layout.elements[0], PlaceholderData)
def test_create_page_from_template_custom_size(self):
"""Test creating page from template with custom size"""
manager = TemplateManager()
# Create template at 200x200
template = Template(page_size_mm=(200, 200))
template.add_element(PlaceholderData(x=50, y=50, width=100, height=100))
# Create page at 400x400
page = manager.create_page_from_template(
template,
page_number=1,
target_size_mm=(400, 400),
scale_mode="proportional"
)
assert page.layout.size == (400, 400)
assert len(page.layout.elements) == 1
# Element should be scaled
assert page.layout.elements[0].size == (200, 200) # 100 * 2
def test_scale_with_textbox_preserves_font_settings(self):
"""Test that scaling preserves text box font settings"""
manager = TemplateManager()
font_settings = {"family": "Arial", "size": 12, "color": (0, 0, 0)}
text = TextBoxData(
text_content="Test",
font_settings=font_settings,
x=50,
y=50,
width=100,
height=50
)
scaled = manager.scale_template_elements(
[text],
from_size=(200, 200),
to_size=(400, 400),
scale_mode="proportional"
)
assert scaled[0].text_content == "Test"
assert scaled[0].font_settings == font_settings
assert scaled[0].alignment == text.alignment