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])