Coverage for pyPhotoAlbum/models.py: 89%
450 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
1"""
2Data model classes for pyPhotoAlbum
3"""
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
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)
52logger = logging.getLogger(__name__)
55# =============================================================================
56# Image Styling
57# =============================================================================
60class ImageStyle:
61 """
62 Styling properties for images and placeholders.
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
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 """
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.
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)
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 )
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
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 }
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 )
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 )
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)})"
201# Global configuration for asset path resolution
202_asset_search_paths: List[str] = []
203_primary_project_folder: Optional[str] = None
206def set_asset_resolution_context(project_folder: str, additional_search_paths: Optional[List[str]] = None):
207 """
208 Set the context for resolving asset paths.
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}")
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
225class BaseLayoutElement(ABC):
226 """Abstract base class for all layout elements"""
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
236 # UUID for merge conflict resolution (v3.0+)
237 self.uuid = str(uuid.uuid4())
239 # Timestamps for merge conflict resolution (v3.0+)
240 now = datetime.now(timezone.utc).isoformat()
241 self.created = now
242 self.last_modified = now
244 # Deletion tracking for merge (v3.0+)
245 self.deleted = False
246 self.deleted_at: Optional[str] = None
248 def mark_modified(self):
249 """Update the last_modified timestamp to now."""
250 self.last_modified = datetime.now(timezone.utc).isoformat()
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()
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 }
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()))
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)
278 # Deletion tracking (default to not deleted)
279 self.deleted = data.get("deleted", False)
280 self.deleted_at = data.get("deleted_at", None)
282 @abstractmethod
283 def render(self):
284 """Render the element using OpenGL"""
285 pass
287 @abstractmethod
288 def serialize(self) -> Dict[str, Any]:
289 """Serialize the element to a dictionary"""
290 pass
292 @abstractmethod
293 def deserialize(self, data: Dict[str, Any]):
294 """Deserialize from a dictionary"""
295 pass
298class ImageData(BaseLayoutElement):
299 """Class to store image data and properties"""
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
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
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°)
321 # Styling properties (rounded corners, borders, shadows, frames)
322 self.style = style if style is not None else ImageStyle()
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()
328 # Async loading state
329 self._async_loading = False
330 self._async_load_requested = False
332 def resolve_image_path(self) -> Optional[str]:
333 """
334 Resolve the image path to an absolute path.
336 Returns the absolute path if the image exists, None otherwise.
337 """
338 if not self.image_path:
339 return None
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
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
354 return None
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
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}")
370 def render(self):
371 """Render the image using OpenGL"""
373 x, y = self.position
374 w, h = self.size
375 texture_id = None
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()
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
397 # Draw drop shadow first (behind everything)
398 if self.style.shadow_enabled:
399 self._render_shadow(x, y, w, h)
401 # Use cached texture if available
402 if hasattr(self, "_texture_id") and self._texture_id:
403 texture_id = self._texture_id
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)
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 )
424 # Enable blending for transparency (rounded corners)
425 glEnable(GL_BLEND)
426 glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
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
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()
444 glDisable(GL_TEXTURE_2D)
445 glDisable(GL_BLEND)
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()
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()
470 # Draw decorative frame if specified
471 if self.style.frame_style:
472 self._render_frame(x, y, w, h)
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
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)
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()
498 glDisable(GL_BLEND)
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
507 # Border color
508 r, g, b = self.style.border_color
509 glColor3f(r / 255.0, g / 255.0, b / 255.0)
511 # Draw border as thick line (OpenGL line width)
512 from OpenGL.GL import glLineWidth
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
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
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 )
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
554 # Include style if non-default (v3.1+)
555 if self.style.has_styling():
556 data["style"] = self.style.serialize()
558 # Add base fields (v3.0+)
559 data.update(self._serialize_base_fields())
561 return data
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)
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)
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}")
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)
596 # Load style (v3.1+, backwards compatible - defaults to no styling)
597 self.style = ImageStyle.deserialize(data.get("style"))
599 def _on_async_image_loaded(self, pil_image):
600 """
601 Callback when async image loading completes.
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.
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}")
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}")
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
624 # Get element dimensions for aspect ratio calculation
625 element_width, element_height = self.size
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 )
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")
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}")
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
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
652 # Track which style was applied to this texture (for cache invalidation)
653 self._texture_style_hash = self._get_style_hash()
655 # Update metadata for future renders - always update to reflect dimensions
656 self.image_dimensions = (pil_image.width, pil_image.height)
658 logger.debug(f"ImageData: Queued for texture creation: {self.image_path}")
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
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,))
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
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
690 logger.debug(f"ImageData: Creating texture for {self.image_path} (GL version: {gl_version})")
691 pil_image = self._pending_pil_image
693 # Ensure RGBA format for GL_RGBA texture (defensive check)
694 if pil_image.mode != "RGBA":
695 pil_image = pil_image.convert("RGBA")
697 # Delete old texture if it exists
698 if hasattr(self, "_texture_id") and self._texture_id:
699 glDeleteTextures([self._texture_id])
701 # Create GPU texture from pre-processed PIL image
702 img_data = pil_image.tobytes()
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 )
712 # Cache texture
713 self._texture_id = texture_id
714 self._texture_path = self.image_path
716 # Clear pending image to free memory
717 self._pending_pil_image = None
719 # Clear the warning flag if we successfully created the texture
720 if hasattr(self, "_gl_context_warned"):
721 delattr(self, "_gl_context_warned")
723 logger.info(f"ImageData: Successfully created texture for {self.image_path}")
724 return True
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
746 def _on_async_image_load_failed(self, error_msg: str):
747 """
748 Callback when async image loading fails.
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
758class PlaceholderData(BaseLayoutElement):
759 """Class to store placeholder data"""
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()
774 def render(self):
775 """Render the placeholder using OpenGL"""
777 x, y = self.position
778 w, h = self.size
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
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()
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)
813 # Pop matrix if we pushed for rotation
814 if self.rotation != 0:
815 glPopMatrix()
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
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)
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"))
850class TextBoxData(BaseLayoutElement):
851 """Class to store text box data"""
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
859 def render(self):
860 """Render the text box using OpenGL"""
861 x, y = self.position
862 w, h = self.size
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
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)
889 # Pop matrix if we pushed for rotation
890 if self.rotation != 0:
891 glPopMatrix()
893 # Note: Text content is rendered using QPainter overlay in GLWidget.paintGL()
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
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)
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")
925class GhostPageData(BaseLayoutElement):
926 """Class to represent a ghost page placeholder for alignment in double-page spreads"""
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
933 def render(self):
934 """Render the ghost page with 'Add Page' button in page-local coordinates"""
936 # Render at page origin (0,0) in page-local coordinates
937 # PageRenderer will handle transformation to screen coordinates
938 x, y = 0, 0
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
946 # Enable alpha blending for transparency
947 glEnable(GL_BLEND)
948 glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
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()
959 glDisable(GL_BLEND)
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)
973 # Note: "Click to Add Page" text is rendered using QPainter overlay in GLWidget
974 # The entire page is clickable
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)
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
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)
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)))