260 lines
10 KiB
Python
260 lines
10 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 import Alignment
|
|
|
|
|
|
class RenderableImage(Renderable, Queriable):
|
|
"""
|
|
A concrete implementation for rendering Image objects.
|
|
"""
|
|
|
|
def __init__(self, image: AbstractImage, canvas: PILImage.Image,
|
|
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
|
|
draw: The PIL ImageDraw object to draw on
|
|
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
|
|
"""
|
|
super().__init__()
|
|
self._abstract_image = image
|
|
self._canvas = canvas
|
|
self._pil_image = None
|
|
self._error_message = None
|
|
self._halign = halign
|
|
self._valign = valign
|
|
|
|
# Set origin as numpy array
|
|
self._origin = np.array(origin) if origin is not None else np.array([0, 0])
|
|
|
|
# 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
|
|
|
|
# Set size as numpy array
|
|
self._size = np.array(size)
|
|
|
|
@property
|
|
def origin(self) -> np.ndarray:
|
|
"""Get the origin of the image"""
|
|
return self._origin
|
|
|
|
@property
|
|
def size(self) -> np.ndarray:
|
|
"""Get the size of the image"""
|
|
return self._size
|
|
|
|
@property
|
|
def width(self) -> int:
|
|
"""Get the width of the image"""
|
|
return self._size[0]
|
|
|
|
def set_origin(self, origin: np.ndarray):
|
|
"""Set the origin of this image element"""
|
|
self._origin = origin
|
|
|
|
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):
|
|
"""
|
|
Render the image directly into the canvas using the provided draw object.
|
|
"""
|
|
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
|
|
|
|
# Calculate final position on canvas
|
|
final_x = int(self._origin[0] + x_offset)
|
|
final_y = int(self._origin[1] + y_offset)
|
|
|
|
# Get the underlying image from the draw object to paste onto
|
|
|
|
|
|
self._canvas.paste(resized_image, (final_x, final_y, final_x + img_width, final_y + img_height))
|
|
else:
|
|
# Draw error placeholder
|
|
self._draw_error_placeholder()
|
|
|
|
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):
|
|
"""
|
|
Draw a placeholder for when the image can't be loaded.
|
|
"""
|
|
# Calculate the rectangle coordinates with origin offset
|
|
x1 = int(self._origin[0])
|
|
y1 = int(self._origin[1])
|
|
x2 = int(self._origin[0] + self._size[0])
|
|
y2 = int(self._origin[1] + self._size[1])
|
|
|
|
self._draw = ImageDraw.Draw(self._canvas)
|
|
# Draw a gray box with a border
|
|
self._draw.rectangle([(x1, y1), (x2, y2)], fill=(240, 240, 240), outline=(180, 180, 180), width=2)
|
|
|
|
# Draw an X across the box
|
|
self._draw.line([(x1, y1), (x2, y2)], fill=(180, 180, 180), width=2)
|
|
self._draw.line([(x1, y2), (x2, y1)], 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 = self._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 = y1 + 10
|
|
for line in lines:
|
|
text_bbox = self._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 = x1 + (self._size[0] - text_width) // 2
|
|
|
|
# Draw the text
|
|
self._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])
|