Duncan Tourolle e972fb864e
All checks were successful
Python CI / test (push) Successful in 1m22s
Lint / lint (push) Successful in 1m20s
Tests / test (3.10) (push) Successful in 1m4s
Tests / test (3.11) (push) Successful in 1m5s
Tests / test (3.9) (push) Successful in 58s
More bug fixes and usability changes
2025-11-21 23:06:06 +01:00

599 lines
24 KiB
Python

"""
Data model classes for pyPhotoAlbum
"""
from abc import ABC, abstractmethod
from typing import Tuple, Optional, Dict, Any, List
import json
import os
from PIL import Image
# 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
@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, **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°)
# 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 _extract_dimensions_metadata(self):
"""
Extract image dimensions without loading the full image.
Uses PIL's lazy loading to just read the header.
"""
try:
# Resolve path
image_path = self.image_path
if not os.path.isabs(self.image_path):
project_folder, search_paths = get_asset_search_paths()
if project_folder and os.path.exists(os.path.join(project_folder, self.image_path)):
image_path = os.path.join(project_folder, self.image_path)
if os.path.exists(image_path):
# Use PIL to just read dimensions (fast, doesn't load pixel data)
with Image.open(image_path) as img:
width, height = img.width, img.height
# Apply same downsampling logic as the old sync code (max 2048px)
max_size = 2048
if width > max_size or height > max_size:
scale = min(max_size / width, max_size / height)
width = int(width * scale)
height = int(height * scale)
self.image_dimensions = (width, height)
print(f"ImageData: Extracted dimensions {self.image_dimensions} for {self.image_path}")
except Exception as e:
print(f"ImageData: Could not extract dimensions for {self.image_path}: {e}")
self.image_dimensions = None
def render(self):
"""Render the image using OpenGL"""
from OpenGL.GL import (glBegin, glEnd, glVertex2f, glColor3f, glColor4f, GL_QUADS, GL_LINE_LOOP,
glEnable, glDisable, GL_TEXTURE_2D, glBindTexture, glTexCoord2f,
glGenTextures, glTexImage2D, GL_RGBA, GL_UNSIGNED_BYTE,
glTexParameteri, GL_TEXTURE_MIN_FILTER, GL_TEXTURE_MAG_FILTER, GL_LINEAR,
glDeleteTextures)
from PIL import Image
import os
x, y = self.position
w, h = self.size
# Note: Rotation is now handled at the PIL image level, not visually
# The image data itself is rotated, so we render it without transformation
# Try to load and render the actual image
texture_id = None
# Handle both absolute and relative paths
image_full_path = self.image_path
if self.image_path and not os.path.isabs(self.image_path):
# Relative path - use resolution context
project_folder, search_paths = get_asset_search_paths()
possible_paths = []
# Try project folder first if available
if project_folder:
possible_paths.append(os.path.join(project_folder, self.image_path))
# Try additional search paths
for search_path in search_paths:
possible_paths.append(os.path.join(search_path, self.image_path))
# Fallback to old behavior for compatibility
possible_paths.extend([
self.image_path, # Try as-is
os.path.join(os.getcwd(), self.image_path), # Relative to CWD
os.path.join(os.path.dirname(os.getcwd()), self.image_path), # Parent of CWD
])
for path in possible_paths:
if os.path.exists(path):
image_full_path = path
print(f"ImageData: Resolved {self.image_path}{path}")
break
else:
print(f"ImageData: Could not resolve path: {self.image_path}")
print(f" Tried paths: {possible_paths[:3]}") # Print first 3 to avoid clutter
# NOTE: Async loading is now handled by page_layout.py calling request_image_load()
# This sync path should only be reached if async loading is not available
# The actual image will be loaded in the background and the texture created
# via _on_async_image_loaded() callback when ready
# Use cached texture if available
if hasattr(self, '_texture_id') and self._texture_id:
texture_id = self._texture_id
# 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)
# Get crop info
crop_x_min, crop_y_min, crop_x_max, crop_y_max = self.crop_info
# Calculate aspect ratios for center crop
img_aspect = img_width / img_height
target_aspect = w / h
# Calculate texture coordinates for center crop
if img_aspect > target_aspect:
# Image is wider - crop horizontally
scale = target_aspect / img_aspect
tx_offset = (1.0 - scale) / 2.0
tx_min_base = tx_offset
tx_max_base = 1.0 - tx_offset
ty_min_base = 0.0
ty_max_base = 1.0
else:
# Image is taller - crop vertically
scale = img_aspect / target_aspect
ty_offset = (1.0 - scale) / 2.0
tx_min_base = 0.0
tx_max_base = 1.0
ty_min_base = ty_offset
ty_max_base = 1.0 - ty_offset
# Apply additional crop from crop_info (for spanning elements)
tx_range = tx_max_base - tx_min_base
ty_range = ty_max_base - ty_min_base
tx_min = tx_min_base + crop_x_min * tx_range
tx_max = tx_min_base + crop_x_max * tx_range
ty_min = ty_min_base + crop_y_min * ty_range
ty_max = ty_min_base + crop_y_max * ty_range
# 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)
# 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 border
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()
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
return data
def deserialize(self, data: Dict[str, Any]):
"""Deserialize from dictionary"""
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)
def _on_async_image_loaded(self, pil_image):
"""
Callback when async image loading completes.
Args:
pil_image: Loaded PIL Image (already RGBA, already resized)
"""
from OpenGL.GL import (glGenTextures, glBindTexture, glTexImage2D, GL_TEXTURE_2D,
glTexParameteri, GL_TEXTURE_MIN_FILTER, GL_TEXTURE_MAG_FILTER,
GL_LINEAR, GL_RGBA, GL_UNSIGNED_BYTE, glDeleteTextures)
from PIL import Image
try:
# Apply PIL-level rotation if needed
if hasattr(self, 'pil_rotation_90') and self.pil_rotation_90 > 0:
# Rotate counter-clockwise by 90° * pil_rotation_90
# PIL.Image.ROTATE_90 rotates counter-clockwise
angle = self.pil_rotation_90 * 90
if angle == 90:
pil_image = pil_image.transpose(Image.ROTATE_270) # CCW 90 = rotate right
elif angle == 180:
pil_image = pil_image.transpose(Image.ROTATE_180)
elif angle == 270:
pil_image = pil_image.transpose(Image.ROTATE_90) # CCW 270 = rotate left
print(f"ImageData: Applied PIL rotation {angle}° to {self.image_path}")
# 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
self._img_width = pil_image.width
self._img_height = pil_image.height
self._async_loading = False
# Update metadata for future renders - always update to reflect rotated dimensions
self.image_dimensions = (pil_image.width, pil_image.height)
print(f"ImageData: Async loaded texture for {self.image_path}")
except Exception as e:
print(f"ImageData: Error creating texture from async loaded image: {e}")
self._texture_id = None
self._async_loading = False
def _on_async_image_load_failed(self, error_msg: str):
"""
Callback when async image loading fails.
Args:
error_msg: Error message
"""
print(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 = "", **kwargs):
super().__init__(**kwargs)
self.placeholder_type = placeholder_type
self.default_content = default_content
def render(self):
"""Render the placeholder using OpenGL"""
from OpenGL.GL import (glBegin, glEnd, glVertex2f, glColor3f, GL_QUADS, GL_LINE_LOOP, glLineStipple,
glEnable, glDisable, GL_LINE_STIPPLE, glPushMatrix, glPopMatrix, glTranslatef, glRotatef)
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"""
return {
"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
}
def deserialize(self, data: Dict[str, Any]):
"""Deserialize from dictionary"""
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", "")
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"""
from OpenGL.GL import (glBegin, glEnd, glVertex2f, glColor3f, glColor4f, GL_QUADS, GL_LINE_LOOP,
glEnable, glDisable, GL_BLEND, glBlendFunc, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA,
glPushMatrix, glPopMatrix, glTranslatef, glRotatef)
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
# Enable alpha blending for transparency
glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
# Draw a semi-transparent yellow rectangle as text box background
glColor4f(1.0, 1.0, 0.7, 0.3) # Light yellow with 30% 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 border
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()
# 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"""
return {
"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
}
def deserialize(self, data: Dict[str, Any]):
"""Deserialize from dictionary"""
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"""
from OpenGL.GL import (glBegin, glEnd, glVertex2f, glColor3f, glColor4f, GL_QUADS, GL_LINE_LOOP,
glEnable, glDisable, GL_BLEND, glBlendFunc, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA,
glLineStipple, GL_LINE_STIPPLE)
# 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"""
return {
"type": "ghostpage",
"position": self.position,
"size": self.size,
"page_size": self.page_size
}
def deserialize(self, data: Dict[str, Any]):
"""Deserialize from dictionary"""
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)))