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
Improved pdf generation speed
1003 lines
38 KiB
Python
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)))
|