Coverage for pyWebLayout/concrete/image.py: 93%
134 statements
« prev ^ index » next coverage.py v7.11.2, created at 2025-11-12 12:02 +0000
« prev ^ index » next coverage.py v7.11.2, created at 2025-11-12 12:02 +0000
1import os
2from typing import Optional
3import numpy as np
4from PIL import Image as PILImage, ImageDraw, ImageFont
5from pyWebLayout.core.base import Renderable, Queriable
6from pyWebLayout.abstract.block import Image as AbstractImage
7from pyWebLayout.style import Alignment
10class RenderableImage(Renderable, Queriable):
11 """
12 A concrete implementation for rendering Image objects.
13 """
15 def __init__(self, image: AbstractImage, canvas: PILImage.Image,
16 max_width: Optional[int] = None, max_height: Optional[int] = None,
17 origin=None, size=None, callback=None, sheet=None, mode=None,
18 halign=Alignment.CENTER, valign=Alignment.CENTER):
19 """
20 Initialize a renderable image.
22 Args:
23 image: The abstract Image object to render
24 draw: The PIL ImageDraw object to draw on
25 max_width: Maximum width constraint for the image
26 max_height: Maximum height constraint for the image
27 origin: Optional origin coordinates
28 size: Optional size override
29 callback: Optional callback function
30 sheet: Optional sheet for rendering
31 mode: Optional image mode
32 halign: Horizontal alignment
33 valign: Vertical alignment
34 """
35 super().__init__()
36 self._abstract_image = image
37 self._canvas = canvas
38 self._pil_image = None
39 self._error_message = None
40 self._halign = halign
41 self._valign = valign
43 # Set origin as numpy array
44 self._origin = np.array(origin) if origin is not None else np.array([0, 0])
46 # Try to load the image
47 self._load_image()
49 # Calculate the size if not provided
50 if size is None:
51 size = image.calculate_scaled_dimensions(max_width, max_height)
52 # Ensure we have valid dimensions, fallback to defaults if None
53 if size[0] is None or size[1] is None:
54 size = (100, 100) # Default size when image dimensions are unavailable
56 # Ensure dimensions are positive (can be negative if calculated from insufficient space)
57 size = (max(1, size[0]), max(1, size[1]))
59 # Set size as numpy array
60 self._size = np.array(size)
62 @property
63 def origin(self) -> np.ndarray:
64 """Get the origin of the image"""
65 return self._origin
67 @property
68 def size(self) -> np.ndarray:
69 """Get the size of the image"""
70 return self._size
72 @property
73 def width(self) -> int:
74 """Get the width of the image"""
75 return self._size[0]
77 def set_origin(self, origin: np.ndarray):
78 """Set the origin of this image element"""
79 self._origin = origin
81 def _load_image(self):
82 """Load the image from the source path"""
83 try:
84 # Check if the image has already been loaded into memory
85 if hasattr( 85 ↛ 88line 85 didn't jump to line 88 because the condition on line 85 was never true
86 self._abstract_image,
87 '_loaded_image') and self._abstract_image._loaded_image is not None:
88 self._pil_image = self._abstract_image._loaded_image
89 return
91 source = self._abstract_image.source
93 # Handle different types of sources
94 if os.path.isfile(source):
95 # Local file
96 self._pil_image = PILImage.open(source)
97 self._abstract_image._loaded_image = self._pil_image
98 elif source.startswith(('http://', 'https://')):
99 # URL - requires requests library
100 try:
101 import requests
102 from io import BytesIO
104 response = requests.get(source, stream=True)
105 if response.status_code == 200:
106 self._pil_image = PILImage.open(BytesIO(response.content))
107 self._abstract_image._loaded_image = self._pil_image
108 else:
109 self._error_message = f"Failed to load image: HTTP status {response.status_code}"
110 except ImportError:
111 self._error_message = "Requests library not available for URL loading"
112 else:
113 self._error_message = f"Unable to load image from source: {source}"
115 except Exception as e:
116 self._error_message = f"Error loading image: {str(e)}"
117 self._abstract_image._error = self._error_message
119 def render(self):
120 """
121 Render the image directly into the canvas using the provided draw object.
122 """
123 if self._pil_image:
124 # Resize the image to fit the box while maintaining aspect ratio
125 resized_image = self._resize_image()
127 # Calculate position based on alignment
128 img_width, img_height = resized_image.size
129 box_width, box_height = self._size
131 # Horizontal alignment
132 if self._halign == Alignment.LEFT:
133 x_offset = 0
134 elif self._halign == Alignment.RIGHT:
135 x_offset = box_width - img_width
136 else: # CENTER is default
137 x_offset = (box_width - img_width) // 2
139 # Vertical alignment
140 if self._valign == Alignment.TOP:
141 y_offset = 0
142 elif self._valign == Alignment.BOTTOM:
143 y_offset = box_height - img_height
144 else: # CENTER is default
145 y_offset = (box_height - img_height) // 2
147 # Calculate final position on canvas
148 final_x = int(self._origin[0] + x_offset)
149 final_y = int(self._origin[1] + y_offset)
151 # Get the underlying image from the draw object to paste onto
153 self._canvas.paste(
154 resized_image,
155 (final_x,
156 final_y,
157 final_x +
158 img_width,
159 final_y +
160 img_height))
161 else:
162 # Draw error placeholder
163 self._draw_error_placeholder()
165 def _resize_image(self) -> PILImage.Image:
166 """
167 Resize the image to fit within the box while maintaining aspect ratio.
169 Returns:
170 A resized PIL Image
171 """
172 if not self._pil_image:
173 return PILImage.new('RGBA', tuple(self._size), (200, 200, 200, 100))
175 # Get the target dimensions
176 target_width, target_height = self._size
178 # Ensure target dimensions are positive
179 target_width = max(1, int(target_width))
180 target_height = max(1, int(target_height))
182 # Get the original dimensions
183 orig_width, orig_height = self._pil_image.size
185 # Calculate the scaling factor to maintain aspect ratio
186 width_ratio = target_width / orig_width
187 height_ratio = target_height / orig_height
189 # Use the smaller ratio to ensure the image fits within the box
190 ratio = min(width_ratio, height_ratio)
192 # Calculate new dimensions
193 new_width = max(1, int(orig_width * ratio))
194 new_height = max(1, int(orig_height * ratio))
196 # Resize the image
197 if self._pil_image.mode == 'RGBA': 197 ↛ 198line 197 didn't jump to line 198 because the condition on line 197 was never true
198 resized = self._pil_image.resize((new_width, new_height), PILImage.LANCZOS)
199 else:
200 # Convert to RGBA if needed
201 resized = self._pil_image.convert('RGBA').resize(
202 (new_width, new_height), PILImage.LANCZOS)
204 return resized
206 def _draw_error_placeholder(self):
207 """
208 Draw a placeholder for when the image can't be loaded.
209 """
210 # Calculate the rectangle coordinates with origin offset
211 x1 = int(self._origin[0])
212 y1 = int(self._origin[1])
213 x2 = int(self._origin[0] + self._size[0])
214 y2 = int(self._origin[1] + self._size[1])
216 self._draw = ImageDraw.Draw(self._canvas)
217 # Draw a gray box with a border
218 self._draw.rectangle([(x1, y1), (x2, y2)], fill=(
219 240, 240, 240), outline=(180, 180, 180), width=2)
221 # Draw an X across the box
222 self._draw.line([(x1, y1), (x2, y2)], fill=(180, 180, 180), width=2)
223 self._draw.line([(x1, y2), (x2, y1)], fill=(180, 180, 180), width=2)
225 # Add error text if available
226 if self._error_message: 226 ↛ exitline 226 didn't return from function '_draw_error_placeholder' because the condition on line 226 was always true
227 try:
228 # Try to use a basic font
229 font = ImageFont.load_default()
231 # Draw the error message, wrapped to fit
232 error_text = "Error: " + self._error_message
234 # Simple text wrapping - split by words and add lines
235 words = error_text.split()
236 lines = []
237 current_line = ""
239 for word in words:
240 test_line = current_line + " " + word if current_line else word
241 text_bbox = self._draw.textbbox((0, 0), test_line, font=font)
242 text_width = text_bbox[2] - text_bbox[0]
244 if text_width <= self._size[0] - 20: # 10px padding on each side
245 current_line = test_line
246 else:
247 lines.append(current_line)
248 current_line = word
250 if current_line: 250 ↛ 254line 250 didn't jump to line 254 because the condition on line 250 was always true
251 lines.append(current_line)
253 # Draw each line
254 y_pos = y1 + 10
255 for line in lines:
256 text_bbox = self._draw.textbbox((0, 0), line, font=font)
257 text_width = text_bbox[2] - text_bbox[0]
258 text_height = text_bbox[3] - text_bbox[1]
260 # Center the text horizontally
261 x_pos = x1 + (self._size[0] - text_width) // 2
263 # Draw the text
264 self._draw.text((x_pos, y_pos), line, fill=(80, 80, 80), font=font)
266 # Move to the next line
267 y_pos += text_height + 2
269 except Exception:
270 # If text rendering fails, just draw a generic error indicator
271 pass
273 def in_object(self, point):
274 """Check if a point is within this image"""
275 point_array = np.array(point)
276 relative_point = point_array - self._origin
278 # Check if the point is within the image boundaries
279 return (0 <= relative_point[0] < self._size[0] and
280 0 <= relative_point[1] < self._size[1])