centralised image loading logic
All checks were successful
Python CI / test (push) Successful in 1m17s
Lint / lint (push) Successful in 1m32s
Tests / test (3.10) (push) Successful in 1m9s
Tests / test (3.11) (push) Successful in 1m8s
Tests / test (3.9) (push) Successful in 1m7s

This commit is contained in:
Duncan Tourolle 2025-11-27 21:33:53 +01:00
parent 45268cdfe4
commit d7786ede80
5 changed files with 91 additions and 78 deletions

View File

@ -32,6 +32,47 @@ class LoadPriority(Enum):
URGENT = 3 # User is actively interacting with
def get_image_dimensions(
image_path: str,
max_size: Optional[int] = None
) -> Optional[Tuple[int, int]]:
"""
Extract image dimensions without loading the full image.
Uses PIL's lazy loading to read only the header, making this a fast
operation suitable for UI code that needs dimensions before async loading.
Args:
image_path: Path to the image file (absolute or relative)
max_size: Optional maximum dimension - if provided, dimensions are
scaled down proportionally to fit within this limit
Returns:
Tuple of (width, height) or None if the image cannot be read
Example:
# Get raw dimensions
dims = get_image_dimensions("/path/to/image.jpg")
# Get dimensions scaled to fit within 300px
dims = get_image_dimensions("/path/to/image.jpg", max_size=300)
"""
try:
with Image.open(image_path) as img:
width, height = img.size
if max_size and (width > max_size or height > max_size):
scale = min(max_size / width, max_size / height)
width = int(width * scale)
height = int(height * scale)
return (width, height)
except Exception as e:
logger.warning(f"Could not extract dimensions for {image_path}: {e}")
return None
@dataclass(order=True)
class LoadRequest:
"""Request to load and process an image."""

View File

@ -128,22 +128,16 @@ class AssetDropMixin:
print(f"Error importing dropped image: {e}")
def _calculate_image_dimensions(self, image_path):
"""Calculate scaled image dimensions for new image"""
try:
from PIL import Image
img = Image.open(image_path)
img_width, img_height = img.size
"""Calculate scaled image dimensions for new image using centralized utility."""
from pyPhotoAlbum.async_backend import get_image_dimensions
max_size = 300
if img_width > max_size or img_height > max_size:
scale = min(max_size / img_width, max_size / img_height)
img_width = int(img_width * scale)
img_height = int(img_height * scale)
# Use centralized utility (max 300px for UI display)
dimensions = get_image_dimensions(image_path, max_size=300)
if dimensions:
return dimensions
return img_width, img_height
except Exception as e:
print(f"Error loading image dimensions: {e}")
return 200, 150
# Fallback dimensions if image cannot be read
return 200, 150
def _add_new_image_to_page(self, asset_path, target_page, page_index,
page_renderer, x, y, img_width, img_height, main_window):

View File

@ -3,10 +3,10 @@ Element operations mixin for pyPhotoAlbum
"""
from PyQt6.QtWidgets import QFileDialog
from PIL import Image
from pyPhotoAlbum.decorators import ribbon_action
from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
from pyPhotoAlbum.commands import AddElementCommand
from pyPhotoAlbum.async_backend import get_image_dimensions
class ElementOperationsMixin:
@ -43,18 +43,15 @@ class ElementOperationsMixin:
# Import asset to project
asset_path = self.project.asset_manager.import_asset(file_path)
# Load image from imported asset (not from original source)
# Get dimensions using centralized utility (max 300px for UI display)
full_asset_path = os.path.join(self.project.folder_path, asset_path)
img = Image.open(full_asset_path)
img_width, img_height = img.size
# Scale to reasonable size (max 300px)
max_size = 300
if img_width > max_size or img_height > max_size:
scale = min(max_size / img_width, max_size / img_height)
img_width = int(img_width * scale)
img_height = int(img_height * scale)
dimensions = get_image_dimensions(full_asset_path, max_size=300)
if dimensions:
img_width, img_height = dimensions
else:
# Fallback dimensions if image cannot be read
img_width, img_height = 200, 150
# Create image element at center of page
page_width_mm = current_page.layout.size[0]
page_height_mm = current_page.layout.size[1]

View File

@ -158,27 +158,16 @@ class ImageData(BaseLayoutElement):
def _extract_dimensions_metadata(self):
"""
Extract image dimensions without loading the full image.
Uses PIL's lazy loading to just read the header.
Uses the centralized get_image_dimensions() utility.
"""
try:
image_path = self.resolve_image_path()
if image_path:
# Use PIL to just read dimensions (fast, doesn't load pixel data)
with Image.open(image_path) as img:
width, height = img.width, img.height
from pyPhotoAlbum.async_backend import get_image_dimensions
# Apply same downsampling logic as the old sync code (max 2048px)
max_size = 2048
if width > max_size or height > max_size:
scale = min(max_size / width, max_size / height)
width = int(width * scale)
height = int(height * scale)
self.image_dimensions = (width, height)
print(f"ImageData: Extracted dimensions {self.image_dimensions} for {self.image_path}")
except Exception as e:
print(f"ImageData: Could not extract dimensions for {self.image_path}: {e}")
self.image_dimensions = None
image_path = self.resolve_image_path()
if image_path:
# Use centralized utility (max 2048px for texture loading)
self.image_dimensions = get_image_dimensions(image_path, max_size=2048)
if self.image_dimensions:
print(f"ImageData: Extracted dimensions {self.image_dimensions} for {self.image_path}")
def render(self):
"""Render the image using OpenGL"""

View File

@ -10,8 +10,6 @@ from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
from pyPhotoAlbum.commands import CommandHistory
from PIL import Image
import io
# Create test window with ElementOperationsMixin
@ -69,8 +67,8 @@ class TestAddImage:
"""Test add_image method"""
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
@patch('pyPhotoAlbum.mixins.operations.element_ops.Image.open')
def test_add_image_success(self, mock_image_open, mock_file_dialog, qtbot):
@patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions')
def test_add_image_success(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test successfully adding an image"""
window = TestElementWindow()
qtbot.addWidget(window)
@ -85,10 +83,8 @@ class TestAddImage:
# Mock file dialog
mock_file_dialog.return_value = ("/path/to/image.jpg", "Image Files (*.jpg)")
# Mock PIL Image
mock_img = Mock()
mock_img.size = (800, 600)
mock_image_open.return_value = mock_img
# Mock get_image_dimensions (returns scaled dimensions)
mock_get_dims.return_value = (300, 225) # 800x600 scaled to max 300
# Mock asset manager
window.project.asset_manager.import_asset.return_value = "assets/image.jpg"
@ -139,8 +135,8 @@ class TestAddImage:
assert not window._update_view_called
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
@patch('pyPhotoAlbum.mixins.operations.element_ops.Image.open')
def test_add_image_scales_large_image(self, mock_image_open, mock_file_dialog, qtbot):
@patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions')
def test_add_image_scales_large_image(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test that large images are scaled down"""
window = TestElementWindow()
qtbot.addWidget(window)
@ -153,22 +149,20 @@ class TestAddImage:
mock_file_dialog.return_value = ("/path/to/large.jpg", "Image Files (*.jpg)")
# Mock very large image
mock_img = Mock()
mock_img.size = (3000, 2000) # Much larger than max_size=300
mock_image_open.return_value = mock_img
# Mock get_image_dimensions returning scaled dimensions (3000x2000 -> 300x200)
mock_get_dims.return_value = (300, 200)
window.project.asset_manager.import_asset.return_value = "assets/large.jpg"
window.add_image()
# Image should be added (scaled down internally)
# Image should be added (scaled down by get_image_dimensions)
assert window._update_view_called
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
@patch('pyPhotoAlbum.mixins.operations.element_ops.Image.open')
def test_add_image_error_handling(self, mock_image_open, mock_file_dialog, qtbot):
"""Test error handling when adding image fails"""
@patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions')
def test_add_image_fallback_dimensions(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test fallback dimensions when get_image_dimensions returns None"""
window = TestElementWindow()
qtbot.addWidget(window)
@ -180,14 +174,16 @@ class TestAddImage:
mock_file_dialog.return_value = ("/path/to/broken.jpg", "Image Files (*.jpg)")
# Mock error
mock_image_open.side_effect = Exception("Cannot open image")
# Mock get_image_dimensions returning None (image unreadable)
mock_get_dims.return_value = None
window.project.asset_manager.import_asset.return_value = "assets/broken.jpg"
window.add_image()
# Should show error
assert window._error_message is not None
assert "failed to add image" in window._error_message.lower()
# Should still add image with fallback dimensions (200x150)
assert window._update_view_called
assert window.project.history.can_undo()
class TestAddText:
@ -294,8 +290,8 @@ class TestElementOperationsIntegration:
"""Test integration between element operations"""
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
@patch('pyPhotoAlbum.mixins.operations.element_ops.Image.open')
def test_add_multiple_elements(self, mock_image_open, mock_file_dialog, qtbot):
@patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions')
def test_add_multiple_elements(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test adding multiple different element types"""
window = TestElementWindow()
qtbot.addWidget(window)
@ -317,9 +313,7 @@ class TestElementOperationsIntegration:
# Add image
mock_file_dialog.return_value = ("/test.jpg", "Image Files")
mock_img = Mock()
mock_img.size = (100, 100)
mock_image_open.return_value = mock_img
mock_get_dims.return_value = (100, 100)
window.project.asset_manager.import_asset.return_value = "assets/test.jpg"
window.add_image()
@ -328,8 +322,8 @@ class TestElementOperationsIntegration:
assert window._update_view_called
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
@patch('pyPhotoAlbum.mixins.operations.element_ops.Image.open')
def test_add_image_with_undo(self, mock_image_open, mock_file_dialog, qtbot):
@patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions')
def test_add_image_with_undo(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test that adding image can be undone"""
window = TestElementWindow()
qtbot.addWidget(window)
@ -341,9 +335,7 @@ class TestElementOperationsIntegration:
window._current_page = page
mock_file_dialog.return_value = ("/test.jpg", "Image Files")
mock_img = Mock()
mock_img.size = (200, 150)
mock_image_open.return_value = mock_img
mock_get_dims.return_value = (200, 150)
window.project.asset_manager.import_asset.return_value = "assets/test.jpg"
# Should have no commands initially