From 795c0c531c5e63e10da07d230c1333282d2762f3 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Tue, 28 Oct 2025 21:37:39 +0100 Subject: [PATCH] Improved snapping --- pyPhotoAlbum/snapping.py | 118 ++++++-- tests/test_alignment.py | 421 ++++++++++++++++++++++++++ tests/test_snapping.py | 496 ++++++++++++++++++++++++++++++ tests/test_template_manager.py | 532 +++++++++++++++++++++++++++++++++ 4 files changed, 1545 insertions(+), 22 deletions(-) create mode 100644 tests/test_alignment.py create mode 100644 tests/test_snapping.py create mode 100644 tests/test_template_manager.py diff --git a/pyPhotoAlbum/snapping.py b/pyPhotoAlbum/snapping.py index d0c760f..6cecbee 100644 --- a/pyPhotoAlbum/snapping.py +++ b/pyPhotoAlbum/snapping.py @@ -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']: diff --git a/tests/test_alignment.py b/tests/test_alignment.py new file mode 100644 index 0000000..35b9479 --- /dev/null +++ b/tests/test_alignment.py @@ -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 diff --git a/tests/test_snapping.py b/tests/test_snapping.py new file mode 100644 index 0000000..3e94b8c --- /dev/null +++ b/tests/test_snapping.py @@ -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 diff --git a/tests/test_template_manager.py b/tests/test_template_manager.py new file mode 100644 index 0000000..0123875 --- /dev/null +++ b/tests/test_template_manager.py @@ -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