1 """

2 Data model classes for pyPhotoAlbum

3 """

4

5 from abc import ABC, abstractmethod

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

7 import json

8 import logging

9 import os

10 import uuid

11 from datetime import datetime, timezone

12 from PIL import Image

13

14 from pyPhotoAlbum.image_utils import apply_pil_rotation, calculate_center_crop_coords

15 from 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

52 logger = logging.getLogger(__name__)

53

54

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

56 # Image Styling

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

58

59

60 class 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

206 def 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

220 def 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

225 class 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

298 class 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

758 class 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

850 class 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

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