pyPhotoAlbum/pyPhotoAlbum/page_layout.py
Duncan Tourolle 5257c6654a
All checks were successful
Python CI / test (push) Successful in 55s
Lint / lint (push) Successful in 1m4s
Tests / test (3.10) (push) Successful in 42s
Tests / test (3.11) (push) Successful in 45s
Tests / test (3.9) (push) Successful in 41s
Improved undo-redo system
2025-10-28 21:03:30 +01:00

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", [])