599 lines
24 KiB
Python
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)))
|