Duncan Tourolle 54cc78783a
All checks were successful
Python CI / test (push) Successful in 1m44s
Lint / lint (push) Successful in 1m29s
Tests / test (3.11) (push) Successful in 1m49s
Tests / test (3.12) (push) Successful in 1m52s
Tests / test (3.13) (push) Successful in 1m45s
Tests / test (3.14) (push) Successful in 1m28s
Added styling
Improved pdf generation speed
2026-01-01 13:37:14 +01:00

1003 lines
38 KiB
Python

"""
Data model classes for pyPhotoAlbum
"""
from abc import ABC, abstractmethod
from typing import Tuple, Optional, Dict, Any, List
import json
import logging
import os
import uuid
from datetime import datetime, timezone
from PIL import Image
from pyPhotoAlbum.image_utils import apply_pil_rotation, calculate_center_crop_coords
from pyPhotoAlbum.gl_imports import (
GL_AVAILABLE,
glBegin,
glEnd,
glVertex2f,
glColor3f,
glColor4f,
GL_QUADS,
GL_LINE_LOOP,
glEnable,
glDisable,
GL_TEXTURE_2D,
glBindTexture,
glTexCoord2f,
glTexParameteri,
GL_TEXTURE_MIN_FILTER,
GL_TEXTURE_MAG_FILTER,
GL_LINEAR,
glGenTextures,
glTexImage2D,
GL_RGBA,
GL_UNSIGNED_BYTE,
glDeleteTextures,
glGetString,
GL_VERSION,
glLineStipple,
GL_LINE_STIPPLE,
glPushMatrix,
glPopMatrix,
glTranslatef,
glRotatef,
GL_BLEND,
glBlendFunc,
GL_SRC_ALPHA,
GL_ONE_MINUS_SRC_ALPHA,
)
logger = logging.getLogger(__name__)
# =============================================================================
# Image Styling
# =============================================================================
class ImageStyle:
"""
Styling properties for images and placeholders.
This class encapsulates all visual styling that can be applied to images:
- Rounded corners
- Borders (width, color)
- Drop shadows
- Decorative frames
Styles are attached to both ImageData and PlaceholderData. When an image
is dropped onto a placeholder, it inherits the placeholder's style.
"""
def __init__(
self,
corner_radius: float = 0.0,
border_width: float = 0.0,
border_color: Tuple[int, int, int] = (0, 0, 0),
shadow_enabled: bool = False,
shadow_offset: Tuple[float, float] = (2.0, 2.0),
shadow_blur: float = 3.0,
shadow_color: Tuple[int, int, int, int] = (0, 0, 0, 128),
frame_style: Optional[str] = None,
frame_color: Tuple[int, int, int] = (0, 0, 0),
frame_corners: Optional[Tuple[bool, bool, bool, bool]] = None,
):
"""
Initialize image style.
Args:
corner_radius: Corner radius as percentage of shorter side (0-50)
border_width: Border width in mm (0 = no border)
border_color: Border color as RGB tuple (0-255)
shadow_enabled: Whether drop shadow is enabled
shadow_offset: Shadow offset in mm (x, y)
shadow_blur: Shadow blur radius in mm
shadow_color: Shadow color as RGBA tuple (0-255)
frame_style: Name of decorative frame style (None = no frame)
frame_color: Frame tint color as RGB tuple (0-255)
frame_corners: Which corners get frame decoration (TL, TR, BR, BL).
None means all corners, (True, True, True, True) means all,
(True, False, False, True) means only left corners, etc.
"""
self.corner_radius = corner_radius
self.border_width = border_width
self.border_color = tuple(border_color)
self.shadow_enabled = shadow_enabled
self.shadow_offset = tuple(shadow_offset)
self.shadow_blur = shadow_blur
self.shadow_color = tuple(shadow_color)
self.frame_style = frame_style
self.frame_color = tuple(frame_color)
# frame_corners: (top_left, top_right, bottom_right, bottom_left)
self.frame_corners = tuple(frame_corners) if frame_corners else (True, True, True, True)
def copy(self) -> "ImageStyle":
"""Create a copy of this style."""
return ImageStyle(
corner_radius=self.corner_radius,
border_width=self.border_width,
border_color=self.border_color,
shadow_enabled=self.shadow_enabled,
shadow_offset=self.shadow_offset,
shadow_blur=self.shadow_blur,
shadow_color=self.shadow_color,
frame_style=self.frame_style,
frame_color=self.frame_color,
frame_corners=self.frame_corners,
)
def has_styling(self) -> bool:
"""Check if any styling is applied (non-default values)."""
return (
self.corner_radius > 0
or self.border_width > 0
or self.shadow_enabled
or self.frame_style is not None
)
def serialize(self) -> Dict[str, Any]:
"""Serialize style to dictionary."""
return {
"corner_radius": self.corner_radius,
"border_width": self.border_width,
"border_color": list(self.border_color),
"shadow_enabled": self.shadow_enabled,
"shadow_offset": list(self.shadow_offset),
"shadow_blur": self.shadow_blur,
"shadow_color": list(self.shadow_color),
"frame_style": self.frame_style,
"frame_color": list(self.frame_color),
"frame_corners": list(self.frame_corners),
}
@classmethod
def deserialize(cls, data: Dict[str, Any]) -> "ImageStyle":
"""Deserialize style from dictionary."""
if data is None:
return cls()
frame_corners_data = data.get("frame_corners")
frame_corners = tuple(frame_corners_data) if frame_corners_data else None
return cls(
corner_radius=data.get("corner_radius", 0.0),
border_width=data.get("border_width", 0.0),
border_color=tuple(data.get("border_color", (0, 0, 0))),
shadow_enabled=data.get("shadow_enabled", False),
shadow_offset=tuple(data.get("shadow_offset", (2.0, 2.0))),
shadow_blur=data.get("shadow_blur", 3.0),
shadow_color=tuple(data.get("shadow_color", (0, 0, 0, 128))),
frame_style=data.get("frame_style"),
frame_color=tuple(data.get("frame_color", (0, 0, 0))),
frame_corners=frame_corners,
)
def __eq__(self, other):
if not isinstance(other, ImageStyle):
return False
return (
self.corner_radius == other.corner_radius
and self.border_width == other.border_width
and self.border_color == other.border_color
and self.shadow_enabled == other.shadow_enabled
and self.shadow_offset == other.shadow_offset
and self.shadow_blur == other.shadow_blur
and self.shadow_color == other.shadow_color
and self.frame_style == other.frame_style
and self.frame_color == other.frame_color
and self.frame_corners == other.frame_corners
)
def __repr__(self):
if not self.has_styling():
return "ImageStyle()"
parts = []
if self.corner_radius > 0:
parts.append(f"corner_radius={self.corner_radius}")
if self.border_width > 0:
parts.append(f"border_width={self.border_width}")
if self.shadow_enabled:
parts.append("shadow_enabled=True")
if self.frame_style:
parts.append(f"frame_style='{self.frame_style}'")
return f"ImageStyle({', '.join(parts)})"
# Global configuration for asset path resolution
_asset_search_paths: List[str] = []
_primary_project_folder: Optional[str] = None
def set_asset_resolution_context(project_folder: str, additional_search_paths: Optional[List[str]] = None):
"""
Set the context for resolving asset paths.
Args:
project_folder: Primary project folder path
additional_search_paths: Optional list of additional paths to search for assets
"""
global _primary_project_folder, _asset_search_paths
_primary_project_folder = project_folder
_asset_search_paths = additional_search_paths or []
print(f"Asset resolution context set: project={project_folder}, search_paths={_asset_search_paths}")
def get_asset_search_paths() -> Tuple[Optional[str], List[str]]:
"""Get the current asset resolution context."""
return _primary_project_folder, _asset_search_paths
class BaseLayoutElement(ABC):
"""Abstract base class for all layout elements"""
def __init__(
self, x: float = 0, y: float = 0, width: float = 100, height: float = 100, rotation: float = 0, z_index: int = 0
):
self.position = (x, y)
self.size = (width, height)
self.rotation = rotation
self.z_index = z_index
# UUID for merge conflict resolution (v3.0+)
self.uuid = str(uuid.uuid4())
# Timestamps for merge conflict resolution (v3.0+)
now = datetime.now(timezone.utc).isoformat()
self.created = now
self.last_modified = now
# Deletion tracking for merge (v3.0+)
self.deleted = False
self.deleted_at: Optional[str] = None
def mark_modified(self):
"""Update the last_modified timestamp to now."""
self.last_modified = datetime.now(timezone.utc).isoformat()
def mark_deleted(self):
"""Mark this element as deleted."""
self.deleted = True
self.deleted_at = datetime.now(timezone.utc).isoformat()
self.mark_modified()
def _serialize_base_fields(self) -> Dict[str, Any]:
"""Serialize base fields common to all elements (v3.0+)."""
return {
"uuid": self.uuid,
"created": self.created,
"last_modified": self.last_modified,
"deleted": self.deleted,
"deleted_at": self.deleted_at,
}
def _deserialize_base_fields(self, data: Dict[str, Any]):
"""Deserialize base fields common to all elements (v3.0+)."""
# UUID (required in v3.0+, generate if missing for backwards compatibility)
self.uuid = data.get("uuid", str(uuid.uuid4()))
# Timestamps (required in v3.0+, use current time if missing)
now = datetime.now(timezone.utc).isoformat()
self.created = data.get("created", now)
self.last_modified = data.get("last_modified", now)
# Deletion tracking (default to not deleted)
self.deleted = data.get("deleted", False)
self.deleted_at = data.get("deleted_at", None)
@abstractmethod
def render(self):
"""Render the element using OpenGL"""
pass
@abstractmethod
def serialize(self) -> Dict[str, Any]:
"""Serialize the element to a dictionary"""
pass
@abstractmethod
def deserialize(self, data: Dict[str, Any]):
"""Deserialize from a dictionary"""
pass
class ImageData(BaseLayoutElement):
"""Class to store image data and properties"""
def __init__(
self,
image_path: str = "",
crop_info: Optional[Tuple] = None,
image_dimensions: Optional[Tuple[int, int]] = None,
style: Optional["ImageStyle"] = None,
**kwargs,
):
super().__init__(**kwargs)
self.image_path = image_path
self.crop_info = crop_info or (0, 0, 1, 1) # Default: no crop
# Metadata: Store image dimensions for aspect ratio calculations before full load
# This allows correct rendering even while async loading is in progress
self.image_dimensions = image_dimensions # (width, height) or None
# PIL-level rotation: number of 90° rotations to apply to the loaded image
# This is separate from the visual rotation field (which should stay at 0)
self.pil_rotation_90 = 0 # 0, 1, 2, or 3 (for 0°, 90°, 180°, 270°)
# Styling properties (rounded corners, borders, shadows, frames)
self.style = style if style is not None else ImageStyle()
# If dimensions not provided and we have a path, try to extract them quickly
if not self.image_dimensions and self.image_path:
self._extract_dimensions_metadata()
# Async loading state
self._async_loading = False
self._async_load_requested = False
def resolve_image_path(self) -> Optional[str]:
"""
Resolve the image path to an absolute path.
Returns the absolute path if the image exists, None otherwise.
"""
if not self.image_path:
return None
# Already absolute
if os.path.isabs(self.image_path):
if os.path.exists(self.image_path):
return self.image_path
return None
# Relative path - look in project folder
project_folder, _ = get_asset_search_paths()
if project_folder:
full_path = os.path.join(project_folder, self.image_path)
if os.path.exists(full_path):
return full_path
return None
def _extract_dimensions_metadata(self):
"""
Extract image dimensions without loading the full image.
Uses the centralized get_image_dimensions() utility.
"""
from pyPhotoAlbum.async_backend import get_image_dimensions
image_path = self.resolve_image_path()
if image_path:
# Use centralized utility (max 2048px for texture loading)
self.image_dimensions = get_image_dimensions(image_path, max_size=2048)
if self.image_dimensions:
print(f"ImageData: Extracted dimensions {self.image_dimensions} for {self.image_path}")
def render(self):
"""Render the image using OpenGL"""
x, y = self.position
w, h = self.size
texture_id = None
# Create texture from pending image if one exists (deferred from async load)
# Texture creation must happen during render when GL context is active
if hasattr(self, "_pending_pil_image") and self._pending_pil_image is not None:
self._create_texture_from_pending_image()
# Check if style changed and texture needs regeneration
if hasattr(self, "_texture_id") and self._texture_id:
current_hash = self._get_style_hash()
cached_hash = getattr(self, "_texture_style_hash", None)
if cached_hash is None:
# First time check - assume texture was loaded without styling
# Set hash to 0 (no corner radius) to match legacy behavior
self._texture_style_hash = hash((0.0,))
cached_hash = self._texture_style_hash
if cached_hash != current_hash:
# Style changed - mark for reload
self._async_load_requested = False
glDeleteTextures([self._texture_id])
delattr(self, "_texture_id") # Remove attribute so async loader will re-trigger
# Draw drop shadow first (behind everything)
if self.style.shadow_enabled:
self._render_shadow(x, y, w, h)
# Use cached texture if available
if hasattr(self, "_texture_id") and self._texture_id:
texture_id = self._texture_id
# Check if texture was pre-cropped (for styled images with rounded corners)
if getattr(self, "_texture_precropped", False):
# Texture is already cropped to visible region - use full texture
tx_min, ty_min, tx_max, ty_max = 0.0, 0.0, 1.0, 1.0
else:
# Get image dimensions (from loaded texture or metadata)
if hasattr(self, "_img_width") and hasattr(self, "_img_height"):
img_width, img_height = self._img_width, self._img_height
elif self.image_dimensions:
img_width, img_height = self.image_dimensions
else:
# No dimensions available, render without aspect ratio correction
img_width, img_height = int(w), int(h)
# Calculate texture coordinates for center crop with element's crop_info
tx_min, ty_min, tx_max, ty_max = calculate_center_crop_coords(img_width, img_height, w, h, self.crop_info)
# Enable blending for transparency (rounded corners)
glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
# Enable texturing and draw with crop
glEnable(GL_TEXTURE_2D)
glBindTexture(GL_TEXTURE_2D, texture_id)
glColor4f(1.0, 1.0, 1.0, 1.0) # White color to show texture as-is
glBegin(GL_QUADS)
glTexCoord2f(tx_min, ty_min)
glVertex2f(x, y)
glTexCoord2f(tx_max, ty_min)
glVertex2f(x + w, y)
glTexCoord2f(tx_max, ty_max)
glVertex2f(x + w, y + h)
glTexCoord2f(tx_min, ty_max)
glVertex2f(x, y + h)
glEnd()
glDisable(GL_TEXTURE_2D)
glDisable(GL_BLEND)
# If no image or loading failed, draw placeholder
if not texture_id:
glColor3f(0.7, 0.85, 1.0) # Light blue
glBegin(GL_QUADS)
glVertex2f(x, y)
glVertex2f(x + w, y)
glVertex2f(x + w, y + h)
glVertex2f(x, y + h)
glEnd()
# Draw styled border if specified, otherwise default thin black border
if self.style.border_width > 0:
self._render_border(x, y, w, h)
else:
# Default thin border for visibility
glColor3f(0.0, 0.0, 0.0) # Black border
glBegin(GL_LINE_LOOP)
glVertex2f(x, y)
glVertex2f(x + w, y)
glVertex2f(x + w, y + h)
glVertex2f(x, y + h)
glEnd()
# Draw decorative frame if specified
if self.style.frame_style:
self._render_frame(x, y, w, h)
def _render_shadow(self, x: float, y: float, w: float, h: float):
"""Render drop shadow behind the image."""
# Convert shadow offset from mm to pixels (approximate, assuming 96 DPI for screen)
dpi = 96.0
mm_to_px = dpi / 25.4
offset_x = self.style.shadow_offset[0] * mm_to_px
offset_y = self.style.shadow_offset[1] * mm_to_px
# Shadow color with alpha
r, g, b, a = self.style.shadow_color
glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
glColor4f(r / 255.0, g / 255.0, b / 255.0, a / 255.0)
# Draw shadow quad (slightly offset)
shadow_x = x + offset_x
shadow_y = y + offset_y
glBegin(GL_QUADS)
glVertex2f(shadow_x, shadow_y)
glVertex2f(shadow_x + w, shadow_y)
glVertex2f(shadow_x + w, shadow_y + h)
glVertex2f(shadow_x, shadow_y + h)
glEnd()
glDisable(GL_BLEND)
def _render_border(self, x: float, y: float, w: float, h: float):
"""Render styled border around the image."""
# Convert border width from mm to pixels
dpi = 96.0
mm_to_px = dpi / 25.4
border_px = self.style.border_width * mm_to_px
# Border color
r, g, b = self.style.border_color
glColor3f(r / 255.0, g / 255.0, b / 255.0)
# Draw border as thick line (OpenGL line width)
from OpenGL.GL import glLineWidth
glLineWidth(max(1.0, border_px))
glBegin(GL_LINE_LOOP)
glVertex2f(x, y)
glVertex2f(x + w, y)
glVertex2f(x + w, y + h)
glVertex2f(x, y + h)
glEnd()
glLineWidth(1.0) # Reset to default
def _render_frame(self, x: float, y: float, w: float, h: float):
"""Render decorative frame around the image."""
from pyPhotoAlbum.frame_manager import get_frame_manager
frame_manager = get_frame_manager()
frame_manager.render_frame_opengl(
frame_name=self.style.frame_style,
x=x,
y=y,
width=w,
height=h,
color=self.style.frame_color,
corners=self.style.frame_corners,
)
def serialize(self) -> Dict[str, Any]:
"""Serialize image data to dictionary"""
data = {
"type": "image",
"position": self.position,
"size": self.size,
"rotation": self.rotation,
"z_index": self.z_index,
"image_path": self.image_path,
"crop_info": self.crop_info,
"pil_rotation_90": getattr(self, "pil_rotation_90", 0),
}
# Include image dimensions metadata if available
if self.image_dimensions:
data["image_dimensions"] = self.image_dimensions
# Include style if non-default (v3.1+)
if self.style.has_styling():
data["style"] = self.style.serialize()
# Add base fields (v3.0+)
data.update(self._serialize_base_fields())
return data
def deserialize(self, data: Dict[str, Any]):
"""Deserialize from dictionary"""
# Deserialize base fields first (v3.0+)
self._deserialize_base_fields(data)
self.position = tuple(data.get("position", (0, 0)))
self.size = tuple(data.get("size", (100, 100)))
self.rotation = data.get("rotation", 0)
self.z_index = data.get("z_index", 0)
self.image_path = data.get("image_path", "")
self.crop_info = tuple(data.get("crop_info", (0, 0, 1, 1)))
self.pil_rotation_90 = data.get("pil_rotation_90", 0)
# Backwards compatibility: convert old visual rotation to PIL rotation
if self.pil_rotation_90 == 0 and self.rotation != 0:
# Old project with visual rotation - convert to PIL rotation
# Round to nearest 90 degrees
normalized_rotation = round(self.rotation / 90) * 90
if normalized_rotation == 90:
self.pil_rotation_90 = 1
elif normalized_rotation == 180:
self.pil_rotation_90 = 2
elif normalized_rotation == 270:
self.pil_rotation_90 = 3
# Reset visual rotation
self.rotation = 0
print(f"ImageData: Converted old visual rotation to pil_rotation_90={self.pil_rotation_90}")
# Load image dimensions metadata if available
self.image_dimensions = data.get("image_dimensions", None)
if self.image_dimensions:
self.image_dimensions = tuple(self.image_dimensions)
# Load style (v3.1+, backwards compatible - defaults to no styling)
self.style = ImageStyle.deserialize(data.get("style"))
def _on_async_image_loaded(self, pil_image):
"""
Callback when async image loading completes.
NOTE: This is called from a signal, potentially before GL context is ready.
We store the image and create the texture during the next render() call
when the GL context is guaranteed to be active.
Args:
pil_image: Loaded PIL Image (already RGBA, already resized)
"""
try:
logger.debug(f"ImageData: Async load completed for {self.image_path}, size: {pil_image.size}")
# Apply PIL-level rotation if needed
if hasattr(self, "pil_rotation_90") and self.pil_rotation_90 > 0:
pil_image = apply_pil_rotation(pil_image, self.pil_rotation_90)
logger.debug(f"ImageData: Applied PIL rotation {self.pil_rotation_90 * 90}° to {self.image_path}")
# For rounded corners, we need to pre-crop the image to the visible region
# so that the corners are applied to what will actually be displayed.
# Calculate the crop region based on element aspect ratio and crop_info.
if self.style.corner_radius > 0:
from pyPhotoAlbum.image_utils import apply_rounded_corners, crop_image_to_coords
# Get element dimensions for aspect ratio calculation
element_width, element_height = self.size
# Calculate crop coordinates (same logic as render-time)
crop_coords = calculate_center_crop_coords(
pil_image.width, pil_image.height, element_width, element_height, self.crop_info
)
# Pre-crop the image to the visible region
pil_image = crop_image_to_coords(pil_image, crop_coords)
logger.debug(f"ImageData: Pre-cropped to {pil_image.size} for styling")
# Now apply rounded corners to the cropped image
pil_image = apply_rounded_corners(pil_image, self.style.corner_radius)
logger.debug(f"ImageData: Applied {self.style.corner_radius}% corner radius to {self.image_path}")
# Mark that texture is pre-cropped (no further crop needed at render time)
self._texture_precropped = True
else:
self._texture_precropped = False
# Store the image for texture creation during next render()
# This avoids GL context issues when callback runs on wrong thread/timing
self._pending_pil_image = pil_image
self._img_width = pil_image.width
self._img_height = pil_image.height
self._async_loading = False
# Track which style was applied to this texture (for cache invalidation)
self._texture_style_hash = self._get_style_hash()
# Update metadata for future renders - always update to reflect dimensions
self.image_dimensions = (pil_image.width, pil_image.height)
logger.debug(f"ImageData: Queued for texture creation: {self.image_path}")
except Exception as e:
logger.error(f"ImageData: Error processing async loaded image {self.image_path}: {e}")
self._pending_pil_image = None
self._async_loading = False
def _get_style_hash(self) -> int:
"""Get a hash of the current style settings that affect texture rendering."""
# Corner radius affects the texture, and when styled, crop_info and size also matter
# because we pre-crop the image before applying rounded corners
if self.style.corner_radius > 0:
return hash((self.style.corner_radius, self.crop_info, self.size))
return hash((self.style.corner_radius,))
def _create_texture_from_pending_image(self):
"""
Create OpenGL texture from pending PIL image.
Called during render() when GL context is active.
"""
if not hasattr(self, "_pending_pil_image") or self._pending_pil_image is None:
return False
try:
# Verify GL context is actually current before creating textures
# glGetString returns None if no context is active
gl_version = glGetString(GL_VERSION)
if gl_version is None:
# No GL context - keep pending image and try again next render
logger.debug(f"ImageData: No GL context for texture creation, deferring: {self.image_path}")
return False
logger.debug(f"ImageData: Creating texture for {self.image_path} (GL version: {gl_version})")
pil_image = self._pending_pil_image
# Ensure RGBA format for GL_RGBA texture (defensive check)
if pil_image.mode != "RGBA":
pil_image = pil_image.convert("RGBA")
# Delete old texture if it exists
if hasattr(self, "_texture_id") and self._texture_id:
glDeleteTextures([self._texture_id])
# Create GPU texture from pre-processed PIL image
img_data = pil_image.tobytes()
texture_id = glGenTextures(1)
glBindTexture(GL_TEXTURE_2D, texture_id)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
glTexImage2D(
GL_TEXTURE_2D, 0, GL_RGBA, pil_image.width, pil_image.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, img_data
)
# Cache texture
self._texture_id = texture_id
self._texture_path = self.image_path
# Clear pending image to free memory
self._pending_pil_image = None
# Clear the warning flag if we successfully created the texture
if hasattr(self, "_gl_context_warned"):
delattr(self, "_gl_context_warned")
logger.info(f"ImageData: Successfully created texture for {self.image_path}")
return True
except Exception as e:
error_str = str(e)
# Check if this is a GL context error (err 1282 = GL_INVALID_OPERATION)
# These are typically caused by no GL context being current
if "GLError" in error_str and "1282" in error_str:
# GL context not ready - keep pending image and try again next render
# Don't spam the console with repeated messages
if not hasattr(self, "_gl_context_warned"):
logger.warning(
f"ImageData: GL context error (1282) for {self.image_path}, will retry on next render"
)
self._gl_context_warned = True
return False
else:
# Other error - give up on this image
logger.error(f"ImageData: Error creating texture for {self.image_path}: {e}")
self._texture_id = None
self._pending_pil_image = None
return False
def _on_async_image_load_failed(self, error_msg: str):
"""
Callback when async image loading fails.
Args:
error_msg: Error message
"""
logger.error(f"ImageData: Async load failed for {self.image_path}: {error_msg}")
self._async_loading = False
self._async_load_requested = False
class PlaceholderData(BaseLayoutElement):
"""Class to store placeholder data"""
def __init__(
self,
placeholder_type: str = "image",
default_content: str = "",
style: Optional["ImageStyle"] = None,
**kwargs,
):
super().__init__(**kwargs)
self.placeholder_type = placeholder_type
self.default_content = default_content
# Style to apply when an image is dropped onto this placeholder
self.style = style if style is not None else ImageStyle()
def render(self):
"""Render the placeholder using OpenGL"""
x, y = self.position
w, h = self.size
# Apply rotation if needed
if self.rotation != 0:
glPushMatrix()
# Translate to center of element
center_x = x + w / 2
center_y = y + h / 2
glTranslatef(center_x, center_y, 0)
glRotatef(self.rotation, 0, 0, 1)
glTranslatef(-w / 2, -h / 2, 0)
# Now render at origin (rotation pivot is at element center)
x, y = 0, 0
# Draw a light gray rectangle as placeholder background
glColor3f(0.9, 0.9, 0.9) # Light gray
glBegin(GL_QUADS)
glVertex2f(x, y)
glVertex2f(x + w, y)
glVertex2f(x + w, y + h)
glVertex2f(x, y + h)
glEnd()
# Draw dashed border for placeholder
glEnable(GL_LINE_STIPPLE)
glLineStipple(1, 0x00FF) # Dashed pattern
glColor3f(0.5, 0.5, 0.5) # Gray border
glBegin(GL_LINE_LOOP)
glVertex2f(x, y)
glVertex2f(x + w, y)
glVertex2f(x + w, y + h)
glVertex2f(x, y + h)
glEnd()
glDisable(GL_LINE_STIPPLE)
# Pop matrix if we pushed for rotation
if self.rotation != 0:
glPopMatrix()
def serialize(self) -> Dict[str, Any]:
"""Serialize placeholder data to dictionary"""
data = {
"type": "placeholder",
"position": self.position,
"size": self.size,
"rotation": self.rotation,
"z_index": self.z_index,
"placeholder_type": self.placeholder_type,
"default_content": self.default_content,
}
# Include style if non-default (v3.1+) - for templatable styling
if self.style.has_styling():
data["style"] = self.style.serialize()
# Add base fields (v3.0+)
data.update(self._serialize_base_fields())
return data
def deserialize(self, data: Dict[str, Any]):
"""Deserialize from dictionary"""
# Deserialize base fields first (v3.0+)
self._deserialize_base_fields(data)
self.position = tuple(data.get("position", (0, 0)))
self.size = tuple(data.get("size", (100, 100)))
self.rotation = data.get("rotation", 0)
self.z_index = data.get("z_index", 0)
self.placeholder_type = data.get("placeholder_type", "image")
self.default_content = data.get("default_content", "")
# Load style (v3.1+, backwards compatible)
self.style = ImageStyle.deserialize(data.get("style"))
class TextBoxData(BaseLayoutElement):
"""Class to store text box data"""
def __init__(self, text_content: str = "", font_settings: Optional[Dict] = None, alignment: str = "left", **kwargs):
super().__init__(**kwargs)
self.text_content = text_content
self.font_settings = font_settings or {"family": "Arial", "size": 12, "color": (0, 0, 0)}
self.alignment = alignment
def render(self):
"""Render the text box using OpenGL"""
x, y = self.position
w, h = self.size
# Apply rotation if needed
if self.rotation != 0:
glPushMatrix()
# Translate to center of element
center_x = x + w / 2
center_y = y + h / 2
glTranslatef(center_x, center_y, 0)
glRotatef(self.rotation, 0, 0, 1)
glTranslatef(-w / 2, -h / 2, 0)
# Now render at origin (rotation pivot is at element center)
x, y = 0, 0
# No background fill - text boxes are transparent in final output
# Just draw a light dashed border for editing visibility
glEnable(GL_LINE_STIPPLE)
glLineStipple(2, 0xAAAA) # Dashed line pattern
glColor3f(0.7, 0.7, 0.7) # Light gray border
glBegin(GL_LINE_LOOP)
glVertex2f(x, y)
glVertex2f(x + w, y)
glVertex2f(x + w, y + h)
glVertex2f(x, y + h)
glEnd()
glDisable(GL_LINE_STIPPLE)
# Pop matrix if we pushed for rotation
if self.rotation != 0:
glPopMatrix()
# Note: Text content is rendered using QPainter overlay in GLWidget.paintGL()
def serialize(self) -> Dict[str, Any]:
"""Serialize text box data to dictionary"""
data = {
"type": "textbox",
"position": self.position,
"size": self.size,
"rotation": self.rotation,
"z_index": self.z_index,
"text_content": self.text_content,
"font_settings": self.font_settings,
"alignment": self.alignment,
}
# Add base fields (v3.0+)
data.update(self._serialize_base_fields())
return data
def deserialize(self, data: Dict[str, Any]):
"""Deserialize from dictionary"""
# Deserialize base fields first (v3.0+)
self._deserialize_base_fields(data)
self.position = tuple(data.get("position", (0, 0)))
self.size = tuple(data.get("size", (100, 100)))
self.rotation = data.get("rotation", 0)
self.z_index = data.get("z_index", 0)
self.text_content = data.get("text_content", "")
self.font_settings = data.get("font_settings", {"family": "Arial", "size": 12, "color": (0, 0, 0)})
self.alignment = data.get("alignment", "left")
class GhostPageData(BaseLayoutElement):
"""Class to represent a ghost page placeholder for alignment in double-page spreads"""
def __init__(self, page_size: Tuple[float, float] = (210, 297), **kwargs):
super().__init__(**kwargs)
self.page_size = page_size # Size in mm
self.is_ghost = True
def render(self):
"""Render the ghost page with 'Add Page' button in page-local coordinates"""
# Render at page origin (0,0) in page-local coordinates
# PageRenderer will handle transformation to screen coordinates
x, y = 0, 0
# Calculate dimensions from page_size (in mm) - assume 300 DPI for now
# This will be overridden by proper size calculation in PageRenderer
dpi = 300 # Default DPI for rendering
w = self.page_size[0] * dpi / 25.4
h = self.page_size[1] * dpi / 25.4
# Enable alpha blending for transparency
glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
# Draw a light grey semi-transparent rectangle as ghost page background
glColor4f(0.8, 0.8, 0.8, 0.5) # Light grey with 50% opacity
glBegin(GL_QUADS)
glVertex2f(x, y)
glVertex2f(x + w, y)
glVertex2f(x + w, y + h)
glVertex2f(x, y + h)
glEnd()
glDisable(GL_BLEND)
# Draw dashed border
glEnable(GL_LINE_STIPPLE)
glLineStipple(2, 0x0F0F) # Dashed pattern
glColor3f(0.5, 0.5, 0.5) # Grey border
glBegin(GL_LINE_LOOP)
glVertex2f(x, y)
glVertex2f(x + w, y)
glVertex2f(x + w, y + h)
glVertex2f(x, y + h)
glEnd()
glDisable(GL_LINE_STIPPLE)
# Note: "Click to Add Page" text is rendered using QPainter overlay in GLWidget
# The entire page is clickable
def get_page_rect(self) -> Tuple[float, float, float, float]:
"""Get the bounding box of the entire ghost page in page-local coordinates (x, y, width, height)"""
# Return in page-local coordinates (matching render method)
x, y = 0, 0
dpi = 300 # Default DPI
w = self.page_size[0] * dpi / 25.4
h = self.page_size[1] * dpi / 25.4
return (x, y, w, h)
def serialize(self) -> Dict[str, Any]:
"""Serialize ghost page data to dictionary"""
data = {"type": "ghostpage", "position": self.position, "size": self.size, "page_size": self.page_size}
# Add base fields (v3.0+)
data.update(self._serialize_base_fields())
return data
def deserialize(self, data: Dict[str, Any]):
"""Deserialize from dictionary"""
# Deserialize base fields first (v3.0+)
self._deserialize_base_fields(data)
self.position = tuple(data.get("position", (0, 0)))
self.size = tuple(data.get("size", (100, 100)))
self.page_size = tuple(data.get("page_size", (210, 297)))