416 lines
16 KiB
Python
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)))
|