266 lines
11 KiB
Python
266 lines
11 KiB
Python
"""
|
|
Page layout and template system for pyPhotoAlbum
|
|
"""
|
|
|
|
from typing import List, Dict, Any, Optional, Tuple
|
|
from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData
|
|
from pyPhotoAlbum.snapping import SnappingSystem
|
|
|
|
class PageLayout:
|
|
"""Class to manage page layout and templates"""
|
|
|
|
def __init__(self, width: float = 210, height: float = 297, is_facing_page: bool = False):
|
|
"""
|
|
Initialize page layout.
|
|
|
|
Args:
|
|
width: Width in mm (doubled automatically if is_facing_page=True)
|
|
height: Height in mm
|
|
is_facing_page: If True, width is doubled for facing page spread
|
|
"""
|
|
self.base_width = width # Store the base single-page width
|
|
self.is_facing_page = is_facing_page
|
|
self.size = (width * 2 if is_facing_page else width, height)
|
|
self.elements: List[BaseLayoutElement] = []
|
|
self.grid_layout: Optional[GridLayout] = None
|
|
self.background_color = (1.0, 1.0, 1.0) # White background
|
|
self.snapping_system = SnappingSystem()
|
|
self.show_snap_lines = True # Show snap lines while dragging
|
|
|
|
def add_element(self, element: BaseLayoutElement):
|
|
"""Add a layout element to the page"""
|
|
self.elements.append(element)
|
|
|
|
def remove_element(self, element: BaseLayoutElement):
|
|
"""Remove a layout element from the page"""
|
|
self.elements.remove(element)
|
|
|
|
def set_grid_layout(self, grid: 'GridLayout'):
|
|
"""Set a grid layout for the page"""
|
|
self.grid_layout = grid
|
|
|
|
def render(self, dpi: int = 300):
|
|
"""
|
|
Render all elements on the page in page-local coordinates.
|
|
|
|
Note: This method assumes OpenGL transformations have already been set up
|
|
by PageRenderer.begin_render(). All coordinates here are in page-local space.
|
|
|
|
Args:
|
|
dpi: Working DPI for converting mm to pixels
|
|
"""
|
|
from OpenGL.GL import glBegin, glEnd, glVertex2f, glColor3f, GL_QUADS, GL_LINE_LOOP, GL_LINES, glLineWidth, glDisable, glEnable, GL_DEPTH_TEST
|
|
|
|
# Disable depth testing for 2D rendering
|
|
glDisable(GL_DEPTH_TEST)
|
|
|
|
# Convert size from mm to pixels based on DPI
|
|
width_px = self.size[0] * dpi / 25.4
|
|
height_px = self.size[1] * dpi / 25.4
|
|
|
|
# All rendering is at page origin (0, 0) in page-local coordinates
|
|
page_x = 0
|
|
page_y = 0
|
|
|
|
# Draw drop shadow FIRST (behind everything)
|
|
shadow_offset = 5
|
|
glColor3f(0.5, 0.5, 0.5)
|
|
glBegin(GL_QUADS)
|
|
glVertex2f(page_x + shadow_offset, page_y + height_px)
|
|
glVertex2f(page_x + width_px + shadow_offset, page_y + height_px)
|
|
glVertex2f(page_x + width_px + shadow_offset, page_y + height_px + shadow_offset)
|
|
glVertex2f(page_x + shadow_offset, page_y + height_px + shadow_offset)
|
|
glEnd()
|
|
|
|
glBegin(GL_QUADS)
|
|
glVertex2f(page_x + width_px, page_y + shadow_offset)
|
|
glVertex2f(page_x + width_px + shadow_offset, page_y + shadow_offset)
|
|
glVertex2f(page_x + width_px + shadow_offset, page_y + height_px)
|
|
glVertex2f(page_x + width_px, page_y + height_px)
|
|
glEnd()
|
|
|
|
# Draw page background (slightly off-white to distinguish from canvas)
|
|
glColor3f(0.98, 0.98, 0.98)
|
|
glBegin(GL_QUADS)
|
|
glVertex2f(page_x, page_y)
|
|
glVertex2f(page_x + width_px, page_y)
|
|
glVertex2f(page_x + width_px, page_y + height_px)
|
|
glVertex2f(page_x, page_y + height_px)
|
|
glEnd()
|
|
|
|
# Render elements in list order (list position = z-order)
|
|
for element in self.elements:
|
|
element.render()
|
|
|
|
# Draw page border LAST (on top of everything)
|
|
glColor3f(0.7, 0.7, 0.7)
|
|
glLineWidth(2.0)
|
|
glBegin(GL_LINE_LOOP)
|
|
glVertex2f(page_x, page_y)
|
|
glVertex2f(page_x + width_px, page_y)
|
|
glVertex2f(page_x + width_px, page_y + height_px)
|
|
glVertex2f(page_x, page_y + height_px)
|
|
glEnd()
|
|
glLineWidth(1.0)
|
|
|
|
# Draw center line for facing pages
|
|
if self.is_facing_page:
|
|
center_x = page_x + (width_px / 2)
|
|
glColor3f(0.5, 0.5, 0.5) # Gray line
|
|
glLineWidth(1.5)
|
|
glBegin(GL_LINES)
|
|
glVertex2f(center_x, page_y)
|
|
glVertex2f(center_x, page_y + height_px)
|
|
glEnd()
|
|
glLineWidth(1.0)
|
|
|
|
# Always render snap lines (grid shows when snap_to_grid is on, guides show when show_snap_lines is on)
|
|
self._render_snap_lines(dpi, page_x, page_y)
|
|
|
|
# Re-enable depth testing
|
|
glEnable(GL_DEPTH_TEST)
|
|
|
|
def _render_snap_lines(self, dpi: int, page_x: float, page_y: float):
|
|
"""Render snap lines (grid, edges, guides)"""
|
|
from OpenGL.GL import (glColor3f, glColor4f, glLineWidth, glBegin, glEnd,
|
|
glVertex2f, GL_LINES, glEnable, glDisable, GL_BLEND,
|
|
glBlendFunc, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
|
|
|
|
snap_lines = self.snapping_system.get_snap_lines(self.size, dpi)
|
|
|
|
# Enable alpha blending for transparency
|
|
glEnable(GL_BLEND)
|
|
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
|
|
|
|
# Draw grid lines (darker gray with transparency) - always visible when snap_to_grid is enabled
|
|
if self.snapping_system.snap_to_grid and snap_lines['grid']:
|
|
glColor4f(0.6, 0.6, 0.6, 0.4) # Gray with 40% opacity
|
|
glLineWidth(1.0)
|
|
for orientation, position in snap_lines['grid']:
|
|
glBegin(GL_LINES)
|
|
if orientation == 'vertical':
|
|
glVertex2f(page_x + position, page_y)
|
|
glVertex2f(page_x + position, page_y + self.size[1] * dpi / 25.4)
|
|
else: # horizontal
|
|
glVertex2f(page_x, page_y + position)
|
|
glVertex2f(page_x + self.size[0] * dpi / 25.4, page_y + position)
|
|
glEnd()
|
|
|
|
# Draw guides (cyan, more visible with transparency) - only show when show_snap_lines is on
|
|
if self.show_snap_lines and snap_lines['guides']:
|
|
glColor4f(0.0, 0.7, 0.9, 0.8) # Cyan with 80% opacity
|
|
glLineWidth(1.5)
|
|
for orientation, position in snap_lines['guides']:
|
|
glBegin(GL_LINES)
|
|
if orientation == 'vertical':
|
|
glVertex2f(page_x + position, page_y)
|
|
glVertex2f(page_x + position, page_y + self.size[1] * dpi / 25.4)
|
|
else: # horizontal
|
|
glVertex2f(page_x, page_y + position)
|
|
glVertex2f(page_x + self.size[0] * dpi / 25.4, page_y + position)
|
|
glEnd()
|
|
|
|
glLineWidth(1.0)
|
|
glDisable(GL_BLEND)
|
|
|
|
def serialize(self) -> Dict[str, Any]:
|
|
"""Serialize page layout to dictionary"""
|
|
return {
|
|
"size": self.size,
|
|
"base_width": self.base_width,
|
|
"is_facing_page": self.is_facing_page,
|
|
"background_color": self.background_color,
|
|
"elements": [elem.serialize() for elem in self.elements],
|
|
"grid_layout": self.grid_layout.serialize() if self.grid_layout else None,
|
|
"snapping_system": self.snapping_system.serialize(),
|
|
"show_snap_lines": self.show_snap_lines
|
|
}
|
|
|
|
def deserialize(self, data: Dict[str, Any]):
|
|
"""Deserialize from dictionary"""
|
|
self.size = tuple(data.get("size", (210, 297)))
|
|
self.base_width = data.get("base_width", self.size[0])
|
|
self.is_facing_page = data.get("is_facing_page", False)
|
|
self.background_color = tuple(data.get("background_color", (1.0, 1.0, 1.0)))
|
|
self.elements = []
|
|
|
|
# Deserialize elements and sort by z_index to establish list order
|
|
# This ensures backward compatibility with projects that used z_index
|
|
elem_list = []
|
|
for elem_data in data.get("elements", []):
|
|
elem_type = elem_data.get("type")
|
|
if elem_type == "image":
|
|
elem = ImageData()
|
|
elif elem_type == "placeholder":
|
|
elem = PlaceholderData()
|
|
elif elem_type == "textbox":
|
|
elem = TextBoxData()
|
|
else:
|
|
continue
|
|
|
|
elem.deserialize(elem_data)
|
|
elem_list.append(elem)
|
|
|
|
# Sort by z_index to establish proper list order (lower z_index = earlier in list = behind)
|
|
elem_list.sort(key=lambda e: e.z_index)
|
|
self.elements = elem_list
|
|
|
|
# Deserialize grid layout
|
|
grid_data = data.get("grid_layout")
|
|
if grid_data:
|
|
self.grid_layout = GridLayout()
|
|
self.grid_layout.deserialize(grid_data)
|
|
|
|
# Deserialize snapping system
|
|
snap_data = data.get("snapping_system")
|
|
if snap_data:
|
|
self.snapping_system.deserialize(snap_data)
|
|
|
|
self.show_snap_lines = data.get("show_snap_lines", True)
|
|
|
|
class GridLayout:
|
|
"""Class to manage grid layouts"""
|
|
|
|
def __init__(self, rows: int = 1, columns: int = 1, spacing: float = 10.0):
|
|
self.rows = rows
|
|
self.columns = columns
|
|
self.spacing = spacing
|
|
self.merged_cells: List[Tuple[int, int]] = [] # List of (row, col) for merged cells
|
|
|
|
def merge_cells(self, row: int, col: int):
|
|
"""Merge cells in the grid"""
|
|
self.merged_cells.append((row, col))
|
|
|
|
def get_cell_position(self, row: int, col: int, page_width: float = 800, page_height: float = 600) -> Tuple[float, float]:
|
|
"""Get the position of a grid cell"""
|
|
cell_width = (page_width - (self.spacing * (self.columns + 1))) / self.columns
|
|
cell_height = (page_height - (self.spacing * (self.rows + 1))) / self.rows
|
|
|
|
x = self.spacing + (col * (cell_width + self.spacing))
|
|
y = self.spacing + (row * (cell_height + self.spacing))
|
|
|
|
return (x, y)
|
|
|
|
def get_cell_size(self, page_width: float = 800, page_height: float = 600) -> Tuple[float, float]:
|
|
"""Get the size of a grid cell"""
|
|
cell_width = (page_width - (self.spacing * (self.columns + 1))) / self.columns
|
|
cell_height = (page_height - (self.spacing * (self.rows + 1))) / self.rows
|
|
|
|
return (cell_width, cell_height)
|
|
|
|
def serialize(self) -> Dict[str, Any]:
|
|
"""Serialize grid layout to dictionary"""
|
|
return {
|
|
"rows": self.rows,
|
|
"columns": self.columns,
|
|
"spacing": self.spacing,
|
|
"merged_cells": self.merged_cells
|
|
}
|
|
|
|
def deserialize(self, data: Dict[str, Any]):
|
|
"""Deserialize from dictionary"""
|
|
self.rows = data.get("rows", 1)
|
|
self.columns = data.get("columns", 1)
|
|
self.spacing = data.get("spacing", 10.0)
|
|
self.merged_cells = data.get("merged_cells", [])
|