Duncan Tourolle 46585228fd
Some checks failed
Lint / lint (push) Failing after 2m46s
Tests / test (3.11) (push) Has been cancelled
Tests / test (3.9) (push) Has been cancelled
Tests / test (3.10) (push) Has been cancelled
first commit
2025-10-21 22:02:49 +02:00

416 lines
16 KiB
Python

"""
Data model classes for pyPhotoAlbum
"""
from abc import ABC, abstractmethod
from typing import Tuple, Optional, Dict, Any
import json
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, **kwargs):
super().__init__(**kwargs)
self.image_path = image_path
self.crop_info = crop_info or (0, 0, 1, 1) # Default: no crop
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
# 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 - need to resolve relative to project folder
# Try common locations
possible_paths = [
self.image_path, # Try as-is first
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
]
# Also try to find projects folder
cwd_parts = os.getcwd().split(os.sep)
for i in range(len(cwd_parts), 0, -1):
base = os.sep.join(cwd_parts[:i])
test_path = os.path.join(base, self.image_path)
possible_paths.append(test_path)
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
if image_full_path and os.path.exists(image_full_path):
# Check if we already have a cached texture
if not hasattr(self, '_texture_id') or not hasattr(self, '_texture_path') or self._texture_path != image_full_path:
try:
# Load image
img = Image.open(image_full_path)
img = img.convert('RGBA')
# Scale down large images for performance (max 2048px on longest side)
max_size = 2048
if img.width > max_size or img.height > max_size:
scale = min(max_size / img.width, max_size / img.height)
new_width = int(img.width * scale)
new_height = int(img.height * scale)
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
img_data = img.tobytes()
# Delete old texture if it exists
if hasattr(self, '_texture_id') and self._texture_id:
glDeleteTextures([self._texture_id])
# Create texture
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, img.width, img.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, img_data)
# Cache texture ID and path
self._texture_id = texture_id
self._texture_path = image_full_path
self._img_width = img.width
self._img_height = img.height
except Exception as e:
print(f"Error loading image {image_full_path}: {e}")
print(f" Original path: {self.image_path}")
self._texture_id = None
# Use cached texture
if hasattr(self, '_texture_id') and self._texture_id:
texture_id = self._texture_id
# 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 = self._img_width / self._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"""
return {
"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
}
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)))
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
x, y = self.position
w, h = self.size
# 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)
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)
x, y = self.position
w, h = self.size
# 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()
# 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)))