240 lines
9.2 KiB
Python
240 lines
9.2 KiB
Python
import os
|
|
from typing import Optional, Tuple, Union, Dict, Any
|
|
import numpy as np
|
|
from PIL import Image as PILImage, ImageDraw, ImageFont
|
|
|
|
from pyWebLayout.core.base import Renderable, Queriable
|
|
from pyWebLayout.abstract.block import Image as AbstractImage
|
|
from .box import Box
|
|
from pyWebLayout.style.layout import Alignment
|
|
|
|
|
|
class RenderableImage(Box, Queriable):
|
|
"""
|
|
A concrete implementation for rendering Image objects.
|
|
"""
|
|
|
|
def __init__(self, image: AbstractImage,
|
|
max_width: Optional[int] = None, max_height: Optional[int] = None,
|
|
origin=None, size=None, callback=None, sheet=None, mode=None,
|
|
halign=Alignment.CENTER, valign=Alignment.CENTER):
|
|
"""
|
|
Initialize a renderable image.
|
|
|
|
Args:
|
|
image: The abstract Image object to render
|
|
max_width: Maximum width constraint for the image
|
|
max_height: Maximum height constraint for the image
|
|
origin: Optional origin coordinates
|
|
size: Optional size override
|
|
callback: Optional callback function
|
|
sheet: Optional sheet for rendering
|
|
mode: Optional image mode
|
|
halign: Horizontal alignment
|
|
valign: Vertical alignment
|
|
"""
|
|
self._abstract_image = image
|
|
self._pil_image = None
|
|
self._error_message = None
|
|
|
|
# Try to load the image
|
|
self._load_image()
|
|
|
|
# Calculate the size if not provided
|
|
if size is None:
|
|
size = image.calculate_scaled_dimensions(max_width, max_height)
|
|
# Ensure we have valid dimensions, fallback to defaults if None
|
|
if size[0] is None or size[1] is None:
|
|
size = (100, 100) # Default size when image dimensions are unavailable
|
|
|
|
# Initialize the box
|
|
super().__init__(origin or (0, 0), size, callback, sheet, mode, halign, valign)
|
|
|
|
def _load_image(self):
|
|
"""Load the image from the source path"""
|
|
try:
|
|
source = self._abstract_image.source
|
|
|
|
# Handle different types of sources
|
|
if os.path.isfile(source):
|
|
# Local file
|
|
self._pil_image = PILImage.open(source)
|
|
self._abstract_image._loaded_image = self._pil_image
|
|
elif source.startswith(('http://', 'https://')):
|
|
# URL - requires requests library
|
|
try:
|
|
import requests
|
|
from io import BytesIO
|
|
|
|
response = requests.get(source, stream=True)
|
|
if response.status_code == 200:
|
|
self._pil_image = PILImage.open(BytesIO(response.content))
|
|
self._abstract_image._loaded_image = self._pil_image
|
|
else:
|
|
self._error_message = f"Failed to load image: HTTP status {response.status_code}"
|
|
except ImportError:
|
|
self._error_message = "Requests library not available for URL loading"
|
|
else:
|
|
self._error_message = f"Unable to load image from source: {source}"
|
|
|
|
except Exception as e:
|
|
self._error_message = f"Error loading image: {str(e)}"
|
|
self._abstract_image._error = self._error_message
|
|
|
|
def render(self) -> PILImage.Image:
|
|
"""
|
|
Render the image.
|
|
|
|
Returns:
|
|
A PIL Image containing the rendered image
|
|
"""
|
|
# Create a base canvas
|
|
canvas = super().render()
|
|
|
|
if self._pil_image:
|
|
# Resize the image to fit the box while maintaining aspect ratio
|
|
resized_image = self._resize_image()
|
|
|
|
# Calculate position based on alignment
|
|
img_width, img_height = resized_image.size
|
|
box_width, box_height = self._size
|
|
|
|
# Horizontal alignment
|
|
if self._halign == Alignment.LEFT:
|
|
x_offset = 0
|
|
elif self._halign == Alignment.RIGHT:
|
|
x_offset = box_width - img_width
|
|
else: # CENTER is default
|
|
x_offset = (box_width - img_width) // 2
|
|
|
|
# Vertical alignment
|
|
if self._valign == Alignment.TOP:
|
|
y_offset = 0
|
|
elif self._valign == Alignment.BOTTOM:
|
|
y_offset = box_height - img_height
|
|
else: # CENTER is default
|
|
y_offset = (box_height - img_height) // 2
|
|
|
|
# Paste the image onto the canvas
|
|
if resized_image.mode == 'RGBA' and canvas.mode == 'RGBA':
|
|
canvas.paste(resized_image, (x_offset, y_offset), resized_image)
|
|
else:
|
|
canvas.paste(resized_image, (x_offset, y_offset))
|
|
else:
|
|
# Draw error placeholder
|
|
self._draw_error_placeholder(canvas)
|
|
|
|
return canvas
|
|
|
|
def _resize_image(self) -> PILImage.Image:
|
|
"""
|
|
Resize the image to fit within the box while maintaining aspect ratio.
|
|
|
|
Returns:
|
|
A resized PIL Image
|
|
"""
|
|
if not self._pil_image:
|
|
return PILImage.new('RGBA', tuple(self._size), (200, 200, 200, 100))
|
|
|
|
# Get the target dimensions
|
|
target_width, target_height = self._size
|
|
|
|
# Get the original dimensions
|
|
orig_width, orig_height = self._pil_image.size
|
|
|
|
# Calculate the scaling factor to maintain aspect ratio
|
|
width_ratio = target_width / orig_width
|
|
height_ratio = target_height / orig_height
|
|
|
|
# Use the smaller ratio to ensure the image fits within the box
|
|
ratio = min(width_ratio, height_ratio)
|
|
|
|
# Calculate new dimensions
|
|
new_width = int(orig_width * ratio)
|
|
new_height = int(orig_height * ratio)
|
|
|
|
# Resize the image
|
|
if self._pil_image.mode == 'RGBA':
|
|
resized = self._pil_image.resize((new_width, new_height), PILImage.LANCZOS)
|
|
else:
|
|
# Convert to RGBA if needed
|
|
resized = self._pil_image.convert('RGBA').resize((new_width, new_height), PILImage.LANCZOS)
|
|
|
|
return resized
|
|
|
|
def _draw_error_placeholder(self, canvas: PILImage.Image):
|
|
"""
|
|
Draw a placeholder for when the image can't be loaded.
|
|
|
|
Args:
|
|
canvas: The canvas to draw on
|
|
"""
|
|
draw = ImageDraw.Draw(canvas)
|
|
|
|
# Convert size to tuple for PIL compatibility
|
|
size_tuple = tuple(self._size)
|
|
|
|
# Draw a gray box with a border
|
|
draw.rectangle([(0, 0), size_tuple], fill=(240, 240, 240), outline=(180, 180, 180), width=2)
|
|
|
|
# Draw an X across the box
|
|
draw.line([(0, 0), size_tuple], fill=(180, 180, 180), width=2)
|
|
draw.line([(0, size_tuple[1]), (size_tuple[0], 0)], fill=(180, 180, 180), width=2)
|
|
|
|
# Add error text if available
|
|
if self._error_message:
|
|
try:
|
|
# Try to use a basic font
|
|
font = ImageFont.load_default()
|
|
|
|
# Draw the error message, wrapped to fit
|
|
error_text = "Error: " + self._error_message
|
|
|
|
# Simple text wrapping - split by words and add lines
|
|
words = error_text.split()
|
|
lines = []
|
|
current_line = ""
|
|
|
|
for word in words:
|
|
test_line = current_line + " " + word if current_line else word
|
|
text_bbox = draw.textbbox((0, 0), test_line, font=font)
|
|
text_width = text_bbox[2] - text_bbox[0]
|
|
|
|
if text_width <= self._size[0] - 20: # 10px padding on each side
|
|
current_line = test_line
|
|
else:
|
|
lines.append(current_line)
|
|
current_line = word
|
|
|
|
if current_line:
|
|
lines.append(current_line)
|
|
|
|
# Draw each line
|
|
y_pos = 10
|
|
for line in lines:
|
|
text_bbox = draw.textbbox((0, 0), line, font=font)
|
|
text_width = text_bbox[2] - text_bbox[0]
|
|
text_height = text_bbox[3] - text_bbox[1]
|
|
|
|
# Center the text horizontally
|
|
x_pos = (self._size[0] - text_width) // 2
|
|
|
|
# Draw the text
|
|
draw.text((x_pos, y_pos), line, fill=(80, 80, 80), font=font)
|
|
|
|
# Move to the next line
|
|
y_pos += text_height + 2
|
|
|
|
except Exception:
|
|
# If text rendering fails, just draw a generic error indicator
|
|
pass
|
|
|
|
def in_object(self, point):
|
|
"""Check if a point is within this image"""
|
|
point_array = np.array(point)
|
|
relative_point = point_array - self._origin
|
|
|
|
# Check if the point is within the image boundaries
|
|
return (0 <= relative_point[0] < self._size[0] and
|
|
0 <= relative_point[1] < self._size[1])
|