Duncan Tourolle 65ab46556f
Some checks failed
Python CI / test (push) Failing after 3m55s
big update with ok rendering
2025-08-27 22:22:54 +02:00

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