Coverage for pyPhotoAlbum/models.py: 89%

450 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-20 12:55 +0000

1""" 

2Data model classes for pyPhotoAlbum 

3""" 

4 

5from abc import ABC, abstractmethod 

6from typing import Tuple, Optional, Dict, Any, List 

7import json 

8import logging 

9import os 

10import uuid 

11from datetime import datetime, timezone 

12from PIL import Image 

13 

14from pyPhotoAlbum.image_utils import apply_pil_rotation, calculate_center_crop_coords 

15from pyPhotoAlbum.gl_imports import ( 

16 GL_AVAILABLE, 

17 glBegin, 

18 glEnd, 

19 glVertex2f, 

20 glColor3f, 

21 glColor4f, 

22 GL_QUADS, 

23 GL_LINE_LOOP, 

24 glEnable, 

25 glDisable, 

26 GL_TEXTURE_2D, 

27 glBindTexture, 

28 glTexCoord2f, 

29 glTexParameteri, 

30 GL_TEXTURE_MIN_FILTER, 

31 GL_TEXTURE_MAG_FILTER, 

32 GL_LINEAR, 

33 glGenTextures, 

34 glTexImage2D, 

35 GL_RGBA, 

36 GL_UNSIGNED_BYTE, 

37 glDeleteTextures, 

38 glGetString, 

39 GL_VERSION, 

40 glLineStipple, 

41 GL_LINE_STIPPLE, 

42 glPushMatrix, 

43 glPopMatrix, 

44 glTranslatef, 

45 glRotatef, 

46 GL_BLEND, 

47 glBlendFunc, 

48 GL_SRC_ALPHA, 

49 GL_ONE_MINUS_SRC_ALPHA, 

50) 

51 

52logger = logging.getLogger(__name__) 

53 

54 

55# ============================================================================= 

56# Image Styling 

57# ============================================================================= 

58 

59 

60class ImageStyle: 

61 """ 

62 Styling properties for images and placeholders. 

63 

64 This class encapsulates all visual styling that can be applied to images: 

65 - Rounded corners 

66 - Borders (width, color) 

67 - Drop shadows 

68 - Decorative frames 

69 

70 Styles are attached to both ImageData and PlaceholderData. When an image 

71 is dropped onto a placeholder, it inherits the placeholder's style. 

72 """ 

73 

74 def __init__( 

75 self, 

76 corner_radius: float = 0.0, 

77 border_width: float = 0.0, 

78 border_color: Tuple[int, int, int] = (0, 0, 0), 

79 shadow_enabled: bool = False, 

80 shadow_offset: Tuple[float, float] = (2.0, 2.0), 

81 shadow_blur: float = 3.0, 

82 shadow_color: Tuple[int, int, int, int] = (0, 0, 0, 128), 

83 frame_style: Optional[str] = None, 

84 frame_color: Tuple[int, int, int] = (0, 0, 0), 

85 frame_corners: Optional[Tuple[bool, bool, bool, bool]] = None, 

86 ): 

87 """ 

88 Initialize image style. 

89 

90 Args: 

91 corner_radius: Corner radius as percentage of shorter side (0-50) 

92 border_width: Border width in mm (0 = no border) 

93 border_color: Border color as RGB tuple (0-255) 

94 shadow_enabled: Whether drop shadow is enabled 

95 shadow_offset: Shadow offset in mm (x, y) 

96 shadow_blur: Shadow blur radius in mm 

97 shadow_color: Shadow color as RGBA tuple (0-255) 

98 frame_style: Name of decorative frame style (None = no frame) 

99 frame_color: Frame tint color as RGB tuple (0-255) 

100 frame_corners: Which corners get frame decoration (TL, TR, BR, BL). 

101 None means all corners, (True, True, True, True) means all, 

102 (True, False, False, True) means only left corners, etc. 

103 """ 

104 self.corner_radius = corner_radius 

105 self.border_width = border_width 

106 self.border_color: Tuple[int, int, int] = border_color 

107 self.shadow_enabled = shadow_enabled 

108 self.shadow_offset: Tuple[float, float] = shadow_offset 

109 self.shadow_blur = shadow_blur 

110 self.shadow_color: Tuple[int, int, int, int] = shadow_color 

111 self.frame_style = frame_style 

112 self.frame_color: Tuple[int, int, int] = frame_color 

113 # frame_corners: (top_left, top_right, bottom_right, bottom_left) 

114 self.frame_corners: Tuple[bool, bool, bool, bool] = frame_corners if frame_corners else (True, True, True, True) 

115 

116 def copy(self) -> "ImageStyle": 

117 """Create a copy of this style.""" 

118 return ImageStyle( 

119 corner_radius=self.corner_radius, 

120 border_width=self.border_width, 

121 border_color=self.border_color, 

122 shadow_enabled=self.shadow_enabled, 

123 shadow_offset=self.shadow_offset, 

124 shadow_blur=self.shadow_blur, 

125 shadow_color=self.shadow_color, 

126 frame_style=self.frame_style, 

127 frame_color=self.frame_color, 

128 frame_corners=self.frame_corners, 

129 ) 

130 

131 def has_styling(self) -> bool: 

132 """Check if any styling is applied (non-default values).""" 

133 return self.corner_radius > 0 or self.border_width > 0 or self.shadow_enabled or self.frame_style is not None 

134 

135 def serialize(self) -> Dict[str, Any]: 

136 """Serialize style to dictionary.""" 

137 return { 

138 "corner_radius": self.corner_radius, 

139 "border_width": self.border_width, 

140 "border_color": list(self.border_color), 

141 "shadow_enabled": self.shadow_enabled, 

142 "shadow_offset": list(self.shadow_offset), 

143 "shadow_blur": self.shadow_blur, 

144 "shadow_color": list(self.shadow_color), 

145 "frame_style": self.frame_style, 

146 "frame_color": list(self.frame_color), 

147 "frame_corners": list(self.frame_corners), 

148 } 

149 

150 @classmethod 

151 def deserialize(cls, data: Optional[Dict[str, Any]]) -> "ImageStyle": 

152 """Deserialize style from dictionary.""" 

153 if data is None: 

154 return cls() 

155 frame_corners_data = data.get("frame_corners") 

156 frame_corners = tuple(frame_corners_data) if frame_corners_data else None 

157 return cls( 

158 corner_radius=data.get("corner_radius", 0.0), 

159 border_width=data.get("border_width", 0.0), 

160 border_color=tuple(data.get("border_color", (0, 0, 0))), 

161 shadow_enabled=data.get("shadow_enabled", False), 

162 shadow_offset=tuple(data.get("shadow_offset", (2.0, 2.0))), 

163 shadow_blur=data.get("shadow_blur", 3.0), 

164 shadow_color=tuple(data.get("shadow_color", (0, 0, 0, 128))), 

165 frame_style=data.get("frame_style"), 

166 frame_color=tuple(data.get("frame_color", (0, 0, 0))), 

167 frame_corners=frame_corners, 

168 ) 

169 

170 def __eq__(self, other): 

171 if not isinstance(other, ImageStyle): 

172 return False 

173 return ( 

174 self.corner_radius == other.corner_radius 

175 and self.border_width == other.border_width 

176 and self.border_color == other.border_color 

177 and self.shadow_enabled == other.shadow_enabled 

178 and self.shadow_offset == other.shadow_offset 

179 and self.shadow_blur == other.shadow_blur 

180 and self.shadow_color == other.shadow_color 

181 and self.frame_style == other.frame_style 

182 and self.frame_color == other.frame_color 

183 and self.frame_corners == other.frame_corners 

184 ) 

185 

186 def __repr__(self): 

187 if not self.has_styling(): 

188 return "ImageStyle()" 

189 parts = [] 

190 if self.corner_radius > 0: 

191 parts.append(f"corner_radius={self.corner_radius}") 

192 if self.border_width > 0: 

193 parts.append(f"border_width={self.border_width}") 

194 if self.shadow_enabled: 

195 parts.append("shadow_enabled=True") 

196 if self.frame_style: 

197 parts.append(f"frame_style='{self.frame_style}'") 

198 return f"ImageStyle({', '.join(parts)})" 

199 

200 

201# Global configuration for asset path resolution 

202_asset_search_paths: List[str] = [] 

203_primary_project_folder: Optional[str] = None 

204 

205 

206def set_asset_resolution_context(project_folder: str, additional_search_paths: Optional[List[str]] = None): 

207 """ 

208 Set the context for resolving asset paths. 

209 

210 Args: 

211 project_folder: Primary project folder path 

212 additional_search_paths: Optional list of additional paths to search for assets 

213 """ 

214 global _primary_project_folder, _asset_search_paths 

215 _primary_project_folder = project_folder 

216 _asset_search_paths = additional_search_paths or [] 

217 print(f"Asset resolution context set: project={project_folder}, search_paths={_asset_search_paths}") 

218 

219 

220def get_asset_search_paths() -> Tuple[Optional[str], List[str]]: 

221 """Get the current asset resolution context.""" 

222 return _primary_project_folder, _asset_search_paths 

223 

224 

225class BaseLayoutElement(ABC): 

226 """Abstract base class for all layout elements""" 

227 

228 def __init__( 

229 self, x: float = 0, y: float = 0, width: float = 100, height: float = 100, rotation: float = 0, z_index: int = 0 

230 ): 

231 self.position = (x, y) 

232 self.size = (width, height) 

233 self.rotation = rotation 

234 self.z_index = z_index 

235 

236 # UUID for merge conflict resolution (v3.0+) 

237 self.uuid = str(uuid.uuid4()) 

238 

239 # Timestamps for merge conflict resolution (v3.0+) 

240 now = datetime.now(timezone.utc).isoformat() 

241 self.created = now 

242 self.last_modified = now 

243 

244 # Deletion tracking for merge (v3.0+) 

245 self.deleted = False 

246 self.deleted_at: Optional[str] = None 

247 

248 def mark_modified(self): 

249 """Update the last_modified timestamp to now.""" 

250 self.last_modified = datetime.now(timezone.utc).isoformat() 

251 

252 def mark_deleted(self): 

253 """Mark this element as deleted.""" 

254 self.deleted = True 

255 self.deleted_at = datetime.now(timezone.utc).isoformat() 

256 self.mark_modified() 

257 

258 def _serialize_base_fields(self) -> Dict[str, Any]: 

259 """Serialize base fields common to all elements (v3.0+).""" 

260 return { 

261 "uuid": self.uuid, 

262 "created": self.created, 

263 "last_modified": self.last_modified, 

264 "deleted": self.deleted, 

265 "deleted_at": self.deleted_at, 

266 } 

267 

268 def _deserialize_base_fields(self, data: Dict[str, Any]): 

269 """Deserialize base fields common to all elements (v3.0+).""" 

270 # UUID (required in v3.0+, generate if missing for backwards compatibility) 

271 self.uuid = data.get("uuid", str(uuid.uuid4())) 

272 

273 # Timestamps (required in v3.0+, use current time if missing) 

274 now = datetime.now(timezone.utc).isoformat() 

275 self.created = data.get("created", now) 

276 self.last_modified = data.get("last_modified", now) 

277 

278 # Deletion tracking (default to not deleted) 

279 self.deleted = data.get("deleted", False) 

280 self.deleted_at = data.get("deleted_at", None) 

281 

282 @abstractmethod 

283 def render(self): 

284 """Render the element using OpenGL""" 

285 pass 

286 

287 @abstractmethod 

288 def serialize(self) -> Dict[str, Any]: 

289 """Serialize the element to a dictionary""" 

290 pass 

291 

292 @abstractmethod 

293 def deserialize(self, data: Dict[str, Any]): 

294 """Deserialize from a dictionary""" 

295 pass 

296 

297 

298class ImageData(BaseLayoutElement): 

299 """Class to store image data and properties""" 

300 

301 def __init__( 

302 self, 

303 image_path: str = "", 

304 crop_info: Optional[Tuple] = None, 

305 image_dimensions: Optional[Tuple[int, int]] = None, 

306 style: Optional["ImageStyle"] = None, 

307 **kwargs, 

308 ): 

309 super().__init__(**kwargs) 

310 self.image_path = image_path 

311 self.crop_info = crop_info or (0, 0, 1, 1) # Default: no crop 

312 

313 # Metadata: Store image dimensions for aspect ratio calculations before full load 

314 # This allows correct rendering even while async loading is in progress 

315 self.image_dimensions = image_dimensions # (width, height) or None 

316 

317 # PIL-level rotation: number of 90° rotations to apply to the loaded image 

318 # This is separate from the visual rotation field (which should stay at 0) 

319 self.pil_rotation_90 = 0 # 0, 1, 2, or 3 (for 0°, 90°, 180°, 270°) 

320 

321 # Styling properties (rounded corners, borders, shadows, frames) 

322 self.style = style if style is not None else ImageStyle() 

323 

324 # If dimensions not provided and we have a path, try to extract them quickly 

325 if not self.image_dimensions and self.image_path: 

326 self._extract_dimensions_metadata() 

327 

328 # Async loading state 

329 self._async_loading = False 

330 self._async_load_requested = False 

331 

332 def resolve_image_path(self) -> Optional[str]: 

333 """ 

334 Resolve the image path to an absolute path. 

335 

336 Returns the absolute path if the image exists, None otherwise. 

337 """ 

338 if not self.image_path: 

339 return None 

340 

341 # Already absolute 

342 if os.path.isabs(self.image_path): 

343 if os.path.exists(self.image_path): 

344 return self.image_path 

345 return None 

346 

347 # Relative path - look in project folder 

348 project_folder, _ = get_asset_search_paths() 

349 if project_folder: 

350 full_path = os.path.join(project_folder, self.image_path) 

351 if os.path.exists(full_path): 

352 return full_path 

353 

354 return None 

355 

356 def _extract_dimensions_metadata(self): 

357 """ 

358 Extract image dimensions without loading the full image. 

359 Uses the centralized get_image_dimensions() utility. 

360 """ 

361 from pyPhotoAlbum.async_backend import get_image_dimensions 

362 

363 image_path = self.resolve_image_path() 

364 if image_path: 

365 # Use centralized utility (max 2048px for texture loading) 

366 self.image_dimensions = get_image_dimensions(image_path, max_size=2048) 

367 if self.image_dimensions: 

368 print(f"ImageData: Extracted dimensions {self.image_dimensions} for {self.image_path}") 

369 

370 def render(self): 

371 """Render the image using OpenGL""" 

372 

373 x, y = self.position 

374 w, h = self.size 

375 texture_id = None 

376 

377 # Create texture from pending image if one exists (deferred from async load) 

378 # Texture creation must happen during render when GL context is active 

379 if hasattr(self, "_pending_pil_image") and self._pending_pil_image is not None: 

380 self._create_texture_from_pending_image() 

381 

382 # Check if style changed and texture needs regeneration 

383 if hasattr(self, "_texture_id") and self._texture_id: 

384 current_hash = self._get_style_hash() 

385 cached_hash = getattr(self, "_texture_style_hash", None) 

386 if cached_hash is None: 

387 # First time check - assume texture was loaded without styling 

388 # Set hash to 0 (no corner radius) to match legacy behavior 

389 self._texture_style_hash = hash((0.0,)) 

390 cached_hash = self._texture_style_hash 

391 if cached_hash != current_hash: 

392 # Style changed - mark for reload 

393 self._async_load_requested = False 

394 glDeleteTextures([self._texture_id]) 

395 delattr(self, "_texture_id") # Remove attribute so async loader will re-trigger 

396 

397 # Draw drop shadow first (behind everything) 

398 if self.style.shadow_enabled: 

399 self._render_shadow(x, y, w, h) 

400 

401 # Use cached texture if available 

402 if hasattr(self, "_texture_id") and self._texture_id: 

403 texture_id = self._texture_id 

404 

405 # Check if texture was pre-cropped (for styled images with rounded corners) 

406 if getattr(self, "_texture_precropped", False): 

407 # Texture is already cropped to visible region - use full texture 

408 tx_min, ty_min, tx_max, ty_max = 0.0, 0.0, 1.0, 1.0 

409 else: 

410 # Get image dimensions (from loaded texture or metadata) 

411 if hasattr(self, "_img_width") and hasattr(self, "_img_height"): 

412 img_width, img_height = self._img_width, self._img_height 

413 elif self.image_dimensions: 

414 img_width, img_height = self.image_dimensions 

415 else: 

416 # No dimensions available, render without aspect ratio correction 

417 img_width, img_height = int(w), int(h) 

418 

419 # Calculate texture coordinates for center crop with element's crop_info 

420 tx_min, ty_min, tx_max, ty_max = calculate_center_crop_coords( 

421 img_width, img_height, w, h, self.crop_info 

422 ) 

423 

424 # Enable blending for transparency (rounded corners) 

425 glEnable(GL_BLEND) 

426 glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) 

427 

428 # Enable texturing and draw with crop 

429 glEnable(GL_TEXTURE_2D) 

430 glBindTexture(GL_TEXTURE_2D, texture_id) 

431 glColor4f(1.0, 1.0, 1.0, 1.0) # White color to show texture as-is 

432 

433 glBegin(GL_QUADS) 

434 glTexCoord2f(tx_min, ty_min) 

435 glVertex2f(x, y) 

436 glTexCoord2f(tx_max, ty_min) 

437 glVertex2f(x + w, y) 

438 glTexCoord2f(tx_max, ty_max) 

439 glVertex2f(x + w, y + h) 

440 glTexCoord2f(tx_min, ty_max) 

441 glVertex2f(x, y + h) 

442 glEnd() 

443 

444 glDisable(GL_TEXTURE_2D) 

445 glDisable(GL_BLEND) 

446 

447 # If no image or loading failed, draw placeholder 

448 if not texture_id: 

449 glColor3f(0.7, 0.85, 1.0) # Light blue 

450 glBegin(GL_QUADS) 

451 glVertex2f(x, y) 

452 glVertex2f(x + w, y) 

453 glVertex2f(x + w, y + h) 

454 glVertex2f(x, y + h) 

455 glEnd() 

456 

457 # Draw styled border if specified, otherwise default thin black border 

458 if self.style.border_width > 0: 

459 self._render_border(x, y, w, h) 

460 else: 

461 # Default thin border for visibility 

462 glColor3f(0.0, 0.0, 0.0) # Black border 

463 glBegin(GL_LINE_LOOP) 

464 glVertex2f(x, y) 

465 glVertex2f(x + w, y) 

466 glVertex2f(x + w, y + h) 

467 glVertex2f(x, y + h) 

468 glEnd() 

469 

470 # Draw decorative frame if specified 

471 if self.style.frame_style: 

472 self._render_frame(x, y, w, h) 

473 

474 def _render_shadow(self, x: float, y: float, w: float, h: float): 

475 """Render drop shadow behind the image.""" 

476 # Convert shadow offset from mm to pixels (approximate, assuming 96 DPI for screen) 

477 dpi = 96.0 

478 mm_to_px = dpi / 25.4 

479 offset_x = self.style.shadow_offset[0] * mm_to_px 

480 offset_y = self.style.shadow_offset[1] * mm_to_px 

481 

482 # Shadow color with alpha 

483 r, g, b, a = self.style.shadow_color 

484 glEnable(GL_BLEND) 

485 glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) 

486 glColor4f(r / 255.0, g / 255.0, b / 255.0, a / 255.0) 

487 

488 # Draw shadow quad (slightly offset) 

489 shadow_x = x + offset_x 

490 shadow_y = y + offset_y 

491 glBegin(GL_QUADS) 

492 glVertex2f(shadow_x, shadow_y) 

493 glVertex2f(shadow_x + w, shadow_y) 

494 glVertex2f(shadow_x + w, shadow_y + h) 

495 glVertex2f(shadow_x, shadow_y + h) 

496 glEnd() 

497 

498 glDisable(GL_BLEND) 

499 

500 def _render_border(self, x: float, y: float, w: float, h: float): 

501 """Render styled border around the image.""" 

502 # Convert border width from mm to pixels 

503 dpi = 96.0 

504 mm_to_px = dpi / 25.4 

505 border_px = self.style.border_width * mm_to_px 

506 

507 # Border color 

508 r, g, b = self.style.border_color 

509 glColor3f(r / 255.0, g / 255.0, b / 255.0) 

510 

511 # Draw border as thick line (OpenGL line width) 

512 from OpenGL.GL import glLineWidth 

513 

514 glLineWidth(max(1.0, border_px)) 

515 glBegin(GL_LINE_LOOP) 

516 glVertex2f(x, y) 

517 glVertex2f(x + w, y) 

518 glVertex2f(x + w, y + h) 

519 glVertex2f(x, y + h) 

520 glEnd() 

521 glLineWidth(1.0) # Reset to default 

522 

523 def _render_frame(self, x: float, y: float, w: float, h: float): 

524 """Render decorative frame around the image.""" 

525 from pyPhotoAlbum.frame_manager import get_frame_manager 

526 

527 frame_manager = get_frame_manager() 

528 frame_manager.render_frame_opengl( 

529 frame_name=self.style.frame_style, # type: ignore[arg-type] 

530 x=x, 

531 y=y, 

532 width=w, 

533 height=h, 

534 color=self.style.frame_color, 

535 corners=self.style.frame_corners, 

536 ) 

537 

538 def serialize(self) -> Dict[str, Any]: 

539 """Serialize image data to dictionary""" 

540 data = { 

541 "type": "image", 

542 "position": self.position, 

543 "size": self.size, 

544 "rotation": self.rotation, 

545 "z_index": self.z_index, 

546 "image_path": self.image_path, 

547 "crop_info": self.crop_info, 

548 "pil_rotation_90": getattr(self, "pil_rotation_90", 0), 

549 } 

550 # Include image dimensions metadata if available 

551 if self.image_dimensions: 

552 data["image_dimensions"] = self.image_dimensions 

553 

554 # Include style if non-default (v3.1+) 

555 if self.style.has_styling(): 

556 data["style"] = self.style.serialize() 

557 

558 # Add base fields (v3.0+) 

559 data.update(self._serialize_base_fields()) 

560 

561 return data 

562 

563 def deserialize(self, data: Dict[str, Any]): 

564 """Deserialize from dictionary""" 

565 # Deserialize base fields first (v3.0+) 

566 self._deserialize_base_fields(data) 

567 

568 self.position = tuple(data.get("position", (0, 0))) 

569 self.size = tuple(data.get("size", (100, 100))) 

570 self.rotation = data.get("rotation", 0) 

571 self.z_index = data.get("z_index", 0) 

572 self.image_path = data.get("image_path", "") 

573 self.crop_info = tuple(data.get("crop_info", (0, 0, 1, 1))) 

574 self.pil_rotation_90 = data.get("pil_rotation_90", 0) 

575 

576 # Backwards compatibility: convert old visual rotation to PIL rotation 

577 if self.pil_rotation_90 == 0 and self.rotation != 0: 

578 # Old project with visual rotation - convert to PIL rotation 

579 # Round to nearest 90 degrees 

580 normalized_rotation = round(self.rotation / 90) * 90 

581 if normalized_rotation == 90: 

582 self.pil_rotation_90 = 1 

583 elif normalized_rotation == 180: 

584 self.pil_rotation_90 = 2 

585 elif normalized_rotation == 270: 

586 self.pil_rotation_90 = 3 

587 # Reset visual rotation 

588 self.rotation = 0 

589 print(f"ImageData: Converted old visual rotation to pil_rotation_90={self.pil_rotation_90}") 

590 

591 # Load image dimensions metadata if available 

592 self.image_dimensions = data.get("image_dimensions", None) 

593 if self.image_dimensions: 

594 self.image_dimensions = tuple(self.image_dimensions) 

595 

596 # Load style (v3.1+, backwards compatible - defaults to no styling) 

597 self.style = ImageStyle.deserialize(data.get("style")) 

598 

599 def _on_async_image_loaded(self, pil_image): 

600 """ 

601 Callback when async image loading completes. 

602 

603 NOTE: This is called from a signal, potentially before GL context is ready. 

604 We store the image and create the texture during the next render() call 

605 when the GL context is guaranteed to be active. 

606 

607 Args: 

608 pil_image: Loaded PIL Image (already RGBA, already resized) 

609 """ 

610 try: 

611 logger.debug(f"ImageData: Async load completed for {self.image_path}, size: {pil_image.size}") 

612 

613 # Apply PIL-level rotation if needed 

614 if hasattr(self, "pil_rotation_90") and self.pil_rotation_90 > 0: 

615 pil_image = apply_pil_rotation(pil_image, self.pil_rotation_90) 

616 logger.debug(f"ImageData: Applied PIL rotation {self.pil_rotation_90 * 90}° to {self.image_path}") 

617 

618 # For rounded corners, we need to pre-crop the image to the visible region 

619 # so that the corners are applied to what will actually be displayed. 

620 # Calculate the crop region based on element aspect ratio and crop_info. 

621 if self.style.corner_radius > 0: 

622 from pyPhotoAlbum.image_utils import apply_rounded_corners, crop_image_to_coords 

623 

624 # Get element dimensions for aspect ratio calculation 

625 element_width, element_height = self.size 

626 

627 # Calculate crop coordinates (same logic as render-time) 

628 crop_coords = calculate_center_crop_coords( 

629 pil_image.width, pil_image.height, element_width, element_height, self.crop_info 

630 ) 

631 

632 # Pre-crop the image to the visible region 

633 pil_image = crop_image_to_coords(pil_image, crop_coords) 

634 logger.debug(f"ImageData: Pre-cropped to {pil_image.size} for styling") 

635 

636 # Now apply rounded corners to the cropped image 

637 pil_image = apply_rounded_corners(pil_image, self.style.corner_radius) 

638 logger.debug(f"ImageData: Applied {self.style.corner_radius}% corner radius to {self.image_path}") 

639 

640 # Mark that texture is pre-cropped (no further crop needed at render time) 

641 self._texture_precropped = True 

642 else: 

643 self._texture_precropped = False 

644 

645 # Store the image for texture creation during next render() 

646 # This avoids GL context issues when callback runs on wrong thread/timing 

647 self._pending_pil_image = pil_image 

648 self._img_width = pil_image.width 

649 self._img_height = pil_image.height 

650 self._async_loading = False 

651 

652 # Track which style was applied to this texture (for cache invalidation) 

653 self._texture_style_hash = self._get_style_hash() 

654 

655 # Update metadata for future renders - always update to reflect dimensions 

656 self.image_dimensions = (pil_image.width, pil_image.height) 

657 

658 logger.debug(f"ImageData: Queued for texture creation: {self.image_path}") 

659 

660 except Exception as e: 

661 logger.error(f"ImageData: Error processing async loaded image {self.image_path}: {e}") 

662 self._pending_pil_image = None 

663 self._async_loading = False 

664 

665 def _get_style_hash(self) -> int: 

666 """Get a hash of the current style settings that affect texture rendering.""" 

667 # Corner radius affects the texture, and when styled, crop_info and size also matter 

668 # because we pre-crop the image before applying rounded corners 

669 if self.style.corner_radius > 0: 

670 return hash((self.style.corner_radius, self.crop_info, self.size)) 

671 return hash((self.style.corner_radius,)) 

672 

673 def _create_texture_from_pending_image(self): 

674 """ 

675 Create OpenGL texture from pending PIL image. 

676 Called during render() when GL context is active. 

677 """ 

678 if not hasattr(self, "_pending_pil_image") or self._pending_pil_image is None: 

679 return False 

680 

681 try: 

682 # Verify GL context is actually current before creating textures 

683 # glGetString returns None if no context is active 

684 gl_version = glGetString(GL_VERSION) 

685 if gl_version is None: 

686 # No GL context - keep pending image and try again next render 

687 logger.debug(f"ImageData: No GL context for texture creation, deferring: {self.image_path}") 

688 return False 

689 

690 logger.debug(f"ImageData: Creating texture for {self.image_path} (GL version: {gl_version})") 

691 pil_image = self._pending_pil_image 

692 

693 # Ensure RGBA format for GL_RGBA texture (defensive check) 

694 if pil_image.mode != "RGBA": 

695 pil_image = pil_image.convert("RGBA") 

696 

697 # Delete old texture if it exists 

698 if hasattr(self, "_texture_id") and self._texture_id: 

699 glDeleteTextures([self._texture_id]) 

700 

701 # Create GPU texture from pre-processed PIL image 

702 img_data = pil_image.tobytes() 

703 

704 texture_id = glGenTextures(1) 

705 glBindTexture(GL_TEXTURE_2D, texture_id) 

706 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR) 

707 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR) 

708 glTexImage2D( 

709 GL_TEXTURE_2D, 0, GL_RGBA, pil_image.width, pil_image.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, img_data 

710 ) 

711 

712 # Cache texture 

713 self._texture_id = texture_id 

714 self._texture_path = self.image_path 

715 

716 # Clear pending image to free memory 

717 self._pending_pil_image = None 

718 

719 # Clear the warning flag if we successfully created the texture 

720 if hasattr(self, "_gl_context_warned"): 

721 delattr(self, "_gl_context_warned") 

722 

723 logger.info(f"ImageData: Successfully created texture for {self.image_path}") 

724 return True 

725 

726 except Exception as e: 

727 error_str = str(e) 

728 # Check if this is a GL context error (err 1282 = GL_INVALID_OPERATION) 

729 # These are typically caused by no GL context being current 

730 if "GLError" in error_str and "1282" in error_str: 

731 # GL context not ready - keep pending image and try again next render 

732 # Don't spam the console with repeated messages 

733 if not hasattr(self, "_gl_context_warned"): 

734 logger.warning( 

735 f"ImageData: GL context error (1282) for {self.image_path}, will retry on next render" 

736 ) 

737 self._gl_context_warned = True 

738 return False 

739 else: 

740 # Other error - give up on this image 

741 logger.error(f"ImageData: Error creating texture for {self.image_path}: {e}") 

742 self._texture_id = None 

743 self._pending_pil_image = None 

744 return False 

745 

746 def _on_async_image_load_failed(self, error_msg: str): 

747 """ 

748 Callback when async image loading fails. 

749 

750 Args: 

751 error_msg: Error message 

752 """ 

753 logger.error(f"ImageData: Async load failed for {self.image_path}: {error_msg}") 

754 self._async_loading = False 

755 self._async_load_requested = False 

756 

757 

758class PlaceholderData(BaseLayoutElement): 

759 """Class to store placeholder data""" 

760 

761 def __init__( 

762 self, 

763 placeholder_type: str = "image", 

764 default_content: str = "", 

765 style: Optional["ImageStyle"] = None, 

766 **kwargs, 

767 ): 

768 super().__init__(**kwargs) 

769 self.placeholder_type = placeholder_type 

770 self.default_content = default_content 

771 # Style to apply when an image is dropped onto this placeholder 

772 self.style = style if style is not None else ImageStyle() 

773 

774 def render(self): 

775 """Render the placeholder using OpenGL""" 

776 

777 x, y = self.position 

778 w, h = self.size 

779 

780 # Apply rotation if needed 

781 if self.rotation != 0: 

782 glPushMatrix() 

783 # Translate to center of element 

784 center_x = x + w / 2 

785 center_y = y + h / 2 

786 glTranslatef(center_x, center_y, 0) 

787 glRotatef(self.rotation, 0, 0, 1) 

788 glTranslatef(-w / 2, -h / 2, 0) 

789 # Now render at origin (rotation pivot is at element center) 

790 x, y = 0, 0 

791 

792 # Draw a light gray rectangle as placeholder background 

793 glColor3f(0.9, 0.9, 0.9) # Light gray 

794 glBegin(GL_QUADS) 

795 glVertex2f(x, y) 

796 glVertex2f(x + w, y) 

797 glVertex2f(x + w, y + h) 

798 glVertex2f(x, y + h) 

799 glEnd() 

800 

801 # Draw dashed border for placeholder 

802 glEnable(GL_LINE_STIPPLE) 

803 glLineStipple(1, 0x00FF) # Dashed pattern 

804 glColor3f(0.5, 0.5, 0.5) # Gray border 

805 glBegin(GL_LINE_LOOP) 

806 glVertex2f(x, y) 

807 glVertex2f(x + w, y) 

808 glVertex2f(x + w, y + h) 

809 glVertex2f(x, y + h) 

810 glEnd() 

811 glDisable(GL_LINE_STIPPLE) 

812 

813 # Pop matrix if we pushed for rotation 

814 if self.rotation != 0: 

815 glPopMatrix() 

816 

817 def serialize(self) -> Dict[str, Any]: 

818 """Serialize placeholder data to dictionary""" 

819 data = { 

820 "type": "placeholder", 

821 "position": self.position, 

822 "size": self.size, 

823 "rotation": self.rotation, 

824 "z_index": self.z_index, 

825 "placeholder_type": self.placeholder_type, 

826 "default_content": self.default_content, 

827 } 

828 # Include style if non-default (v3.1+) - for templatable styling 

829 if self.style.has_styling(): 

830 data["style"] = self.style.serialize() 

831 # Add base fields (v3.0+) 

832 data.update(self._serialize_base_fields()) 

833 return data 

834 

835 def deserialize(self, data: Dict[str, Any]): 

836 """Deserialize from dictionary""" 

837 # Deserialize base fields first (v3.0+) 

838 self._deserialize_base_fields(data) 

839 

840 self.position = tuple(data.get("position", (0, 0))) 

841 self.size = tuple(data.get("size", (100, 100))) 

842 self.rotation = data.get("rotation", 0) 

843 self.z_index = data.get("z_index", 0) 

844 self.placeholder_type = data.get("placeholder_type", "image") 

845 self.default_content = data.get("default_content", "") 

846 # Load style (v3.1+, backwards compatible) 

847 self.style = ImageStyle.deserialize(data.get("style")) 

848 

849 

850class TextBoxData(BaseLayoutElement): 

851 """Class to store text box data""" 

852 

853 def __init__(self, text_content: str = "", font_settings: Optional[Dict] = None, alignment: str = "left", **kwargs): 

854 super().__init__(**kwargs) 

855 self.text_content = text_content 

856 self.font_settings = font_settings or {"family": "Arial", "size": 12, "color": (0, 0, 0)} 

857 self.alignment = alignment 

858 

859 def render(self): 

860 """Render the text box using OpenGL""" 

861 x, y = self.position 

862 w, h = self.size 

863 

864 # Apply rotation if needed 

865 if self.rotation != 0: 

866 glPushMatrix() 

867 # Translate to center of element 

868 center_x = x + w / 2 

869 center_y = y + h / 2 

870 glTranslatef(center_x, center_y, 0) 

871 glRotatef(self.rotation, 0, 0, 1) 

872 glTranslatef(-w / 2, -h / 2, 0) 

873 # Now render at origin (rotation pivot is at element center) 

874 x, y = 0, 0 

875 

876 # No background fill - text boxes are transparent in final output 

877 # Just draw a light dashed border for editing visibility 

878 glEnable(GL_LINE_STIPPLE) 

879 glLineStipple(2, 0xAAAA) # Dashed line pattern 

880 glColor3f(0.7, 0.7, 0.7) # Light gray border 

881 glBegin(GL_LINE_LOOP) 

882 glVertex2f(x, y) 

883 glVertex2f(x + w, y) 

884 glVertex2f(x + w, y + h) 

885 glVertex2f(x, y + h) 

886 glEnd() 

887 glDisable(GL_LINE_STIPPLE) 

888 

889 # Pop matrix if we pushed for rotation 

890 if self.rotation != 0: 

891 glPopMatrix() 

892 

893 # Note: Text content is rendered using QPainter overlay in GLWidget.paintGL() 

894 

895 def serialize(self) -> Dict[str, Any]: 

896 """Serialize text box data to dictionary""" 

897 data = { 

898 "type": "textbox", 

899 "position": self.position, 

900 "size": self.size, 

901 "rotation": self.rotation, 

902 "z_index": self.z_index, 

903 "text_content": self.text_content, 

904 "font_settings": self.font_settings, 

905 "alignment": self.alignment, 

906 } 

907 # Add base fields (v3.0+) 

908 data.update(self._serialize_base_fields()) 

909 return data 

910 

911 def deserialize(self, data: Dict[str, Any]): 

912 """Deserialize from dictionary""" 

913 # Deserialize base fields first (v3.0+) 

914 self._deserialize_base_fields(data) 

915 

916 self.position = tuple(data.get("position", (0, 0))) 

917 self.size = tuple(data.get("size", (100, 100))) 

918 self.rotation = data.get("rotation", 0) 

919 self.z_index = data.get("z_index", 0) 

920 self.text_content = data.get("text_content", "") 

921 self.font_settings = data.get("font_settings", {"family": "Arial", "size": 12, "color": (0, 0, 0)}) 

922 self.alignment = data.get("alignment", "left") 

923 

924 

925class GhostPageData(BaseLayoutElement): 

926 """Class to represent a ghost page placeholder for alignment in double-page spreads""" 

927 

928 def __init__(self, page_size: Tuple[float, float] = (210, 297), **kwargs): 

929 super().__init__(**kwargs) 

930 self.page_size = page_size # Size in mm 

931 self.is_ghost = True 

932 

933 def render(self): 

934 """Render the ghost page with 'Add Page' button in page-local coordinates""" 

935 

936 # Render at page origin (0,0) in page-local coordinates 

937 # PageRenderer will handle transformation to screen coordinates 

938 x, y = 0, 0 

939 

940 # Calculate dimensions from page_size (in mm) - assume 300 DPI for now 

941 # This will be overridden by proper size calculation in PageRenderer 

942 dpi = 300 # Default DPI for rendering 

943 w = self.page_size[0] * dpi / 25.4 

944 h = self.page_size[1] * dpi / 25.4 

945 

946 # Enable alpha blending for transparency 

947 glEnable(GL_BLEND) 

948 glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) 

949 

950 # Draw a light grey semi-transparent rectangle as ghost page background 

951 glColor4f(0.8, 0.8, 0.8, 0.5) # Light grey with 50% opacity 

952 glBegin(GL_QUADS) 

953 glVertex2f(x, y) 

954 glVertex2f(x + w, y) 

955 glVertex2f(x + w, y + h) 

956 glVertex2f(x, y + h) 

957 glEnd() 

958 

959 glDisable(GL_BLEND) 

960 

961 # Draw dashed border 

962 glEnable(GL_LINE_STIPPLE) 

963 glLineStipple(2, 0x0F0F) # Dashed pattern 

964 glColor3f(0.5, 0.5, 0.5) # Grey border 

965 glBegin(GL_LINE_LOOP) 

966 glVertex2f(x, y) 

967 glVertex2f(x + w, y) 

968 glVertex2f(x + w, y + h) 

969 glVertex2f(x, y + h) 

970 glEnd() 

971 glDisable(GL_LINE_STIPPLE) 

972 

973 # Note: "Click to Add Page" text is rendered using QPainter overlay in GLWidget 

974 # The entire page is clickable 

975 

976 def get_page_rect(self) -> Tuple[float, float, float, float]: 

977 """Get the bounding box of the entire ghost page in page-local coordinates (x, y, width, height)""" 

978 # Return in page-local coordinates (matching render method) 

979 x, y = 0, 0 

980 dpi = 300 # Default DPI 

981 w = self.page_size[0] * dpi / 25.4 

982 h = self.page_size[1] * dpi / 25.4 

983 return (x, y, w, h) 

984 

985 def serialize(self) -> Dict[str, Any]: 

986 """Serialize ghost page data to dictionary""" 

987 data = {"type": "ghostpage", "position": self.position, "size": self.size, "page_size": self.page_size} 

988 # Add base fields (v3.0+) 

989 data.update(self._serialize_base_fields()) 

990 return data 

991 

992 def deserialize(self, data: Dict[str, Any]): 

993 """Deserialize from dictionary""" 

994 # Deserialize base fields first (v3.0+) 

995 self._deserialize_base_fields(data) 

996 

997 self.position = tuple(data.get("position", (0, 0))) 

998 self.size = tuple(data.get("size", (100, 100))) 

999 self.page_size = tuple(data.get("page_size", (210, 297)))