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

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 

8 

9 

10class RenderableImage(Renderable, Queriable): 

11 """ 

12 A concrete implementation for rendering Image objects. 

13 """ 

14 

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. 

21 

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 

42 

43 # Set origin as numpy array 

44 self._origin = np.array(origin) if origin is not None else np.array([0, 0]) 

45 

46 # Try to load the image 

47 self._load_image() 

48 

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 

55 

56 # Ensure dimensions are positive (can be negative if calculated from insufficient space) 

57 size = (max(1, size[0]), max(1, size[1])) 

58 

59 # Set size as numpy array 

60 self._size = np.array(size) 

61 

62 @property 

63 def origin(self) -> np.ndarray: 

64 """Get the origin of the image""" 

65 return self._origin 

66 

67 @property 

68 def size(self) -> np.ndarray: 

69 """Get the size of the image""" 

70 return self._size 

71 

72 @property 

73 def width(self) -> int: 

74 """Get the width of the image""" 

75 return self._size[0] 

76 

77 def set_origin(self, origin: np.ndarray): 

78 """Set the origin of this image element""" 

79 self._origin = origin 

80 

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 

90 

91 source = self._abstract_image.source 

92 

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 

103 

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}" 

114 

115 except Exception as e: 

116 self._error_message = f"Error loading image: {str(e)}" 

117 self._abstract_image._error = self._error_message 

118 

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

126 

127 # Calculate position based on alignment 

128 img_width, img_height = resized_image.size 

129 box_width, box_height = self._size 

130 

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 

138 

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 

146 

147 # Calculate final position on canvas 

148 final_x = int(self._origin[0] + x_offset) 

149 final_y = int(self._origin[1] + y_offset) 

150 

151 # Get the underlying image from the draw object to paste onto 

152 

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

164 

165 def _resize_image(self) -> PILImage.Image: 

166 """ 

167 Resize the image to fit within the box while maintaining aspect ratio. 

168 

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

174 

175 # Get the target dimensions 

176 target_width, target_height = self._size 

177 

178 # Ensure target dimensions are positive 

179 target_width = max(1, int(target_width)) 

180 target_height = max(1, int(target_height)) 

181 

182 # Get the original dimensions 

183 orig_width, orig_height = self._pil_image.size 

184 

185 # Calculate the scaling factor to maintain aspect ratio 

186 width_ratio = target_width / orig_width 

187 height_ratio = target_height / orig_height 

188 

189 # Use the smaller ratio to ensure the image fits within the box 

190 ratio = min(width_ratio, height_ratio) 

191 

192 # Calculate new dimensions 

193 new_width = max(1, int(orig_width * ratio)) 

194 new_height = max(1, int(orig_height * ratio)) 

195 

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) 

203 

204 return resized 

205 

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

215 

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) 

220 

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) 

224 

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

230 

231 # Draw the error message, wrapped to fit 

232 error_text = "Error: " + self._error_message 

233 

234 # Simple text wrapping - split by words and add lines 

235 words = error_text.split() 

236 lines = [] 

237 current_line = "" 

238 

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] 

243 

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 

249 

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) 

252 

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] 

259 

260 # Center the text horizontally 

261 x_pos = x1 + (self._size[0] - text_width) // 2 

262 

263 # Draw the text 

264 self._draw.text((x_pos, y_pos), line, fill=(80, 80, 80), font=font) 

265 

266 # Move to the next line 

267 y_pos += text_height + 2 

268 

269 except Exception: 

270 # If text rendering fails, just draw a generic error indicator 

271 pass 

272 

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 

277 

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