Coverage for pyPhotoAlbum/page_layout.py: 99%
185 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
1"""
2Page layout and template system for pyPhotoAlbum
3"""
5from typing import List, Dict, Any, Optional, Tuple, TYPE_CHECKING
7if TYPE_CHECKING:
8 from PyQt6.QtWidgets import QWidget
10from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData
11from pyPhotoAlbum.snapping import SnappingSystem
12from pyPhotoAlbum.gl_imports import (
13 glBegin,
14 glEnd,
15 glVertex2f,
16 glColor3f,
17 glColor4f,
18 GL_QUADS,
19 GL_LINE_LOOP,
20 GL_LINES,
21 glLineWidth,
22 glEnable,
23 glDisable,
24 GL_DEPTH_TEST,
25 GL_BLEND,
26 glBlendFunc,
27 GL_SRC_ALPHA,
28 GL_ONE_MINUS_SRC_ALPHA,
29)
32class PageLayout:
33 """Class to manage page layout and templates"""
35 def __init__(self, width: float = 210, height: float = 297, is_facing_page: bool = False):
36 """
37 Initialize page layout.
39 Args:
40 width: Width in mm (doubled automatically if is_facing_page=True)
41 height: Height in mm
42 is_facing_page: If True, width is doubled for facing page spread
43 """
44 self.base_width = width # Store the base single-page width
45 self.is_facing_page = is_facing_page
46 self.size = (width * 2 if is_facing_page else width, height)
47 self.elements: List[BaseLayoutElement] = []
48 self.grid_layout: Optional[GridLayout] = None
49 self.background_color = (1.0, 1.0, 1.0) # White background
50 self.snapping_system = SnappingSystem()
51 self.show_snap_lines = True # Show snap lines while dragging
52 self._parent_widget: Optional["QWidget"] = None # Set by renderer
54 def add_element(self, element: BaseLayoutElement):
55 """Add a layout element to the page"""
56 if element not in self.elements:
57 self.elements.append(element)
59 def remove_element(self, element: BaseLayoutElement):
60 """Remove a layout element from the page"""
61 self.elements.remove(element)
63 def set_grid_layout(self, grid: "GridLayout"):
64 """Set a grid layout for the page"""
65 self.grid_layout = grid
67 def render(self, dpi: int = 300, project=None):
68 """
69 Render all elements on the page in page-local coordinates.
71 Note: This method assumes OpenGL transformations have already been set up
72 by PageRenderer.begin_render(). All coordinates here are in page-local space.
74 Args:
75 dpi: Working DPI for converting mm to pixels
76 project: Optional project instance for global snapping settings
77 """
78 # Disable depth testing for 2D rendering
79 glDisable(GL_DEPTH_TEST)
81 # Convert size from mm to pixels based on DPI
82 width_px = self.size[0] * dpi / 25.4
83 height_px = self.size[1] * dpi / 25.4
85 # All rendering is at page origin (0, 0) in page-local coordinates
86 page_x = 0
87 page_y = 0
89 # Draw drop shadow FIRST (behind everything)
90 shadow_offset = 5
91 glColor3f(0.5, 0.5, 0.5)
92 glBegin(GL_QUADS)
93 glVertex2f(page_x + shadow_offset, page_y + height_px)
94 glVertex2f(page_x + width_px + shadow_offset, page_y + height_px)
95 glVertex2f(page_x + width_px + shadow_offset, page_y + height_px + shadow_offset)
96 glVertex2f(page_x + shadow_offset, page_y + height_px + shadow_offset)
97 glEnd()
99 glBegin(GL_QUADS)
100 glVertex2f(page_x + width_px, page_y + shadow_offset)
101 glVertex2f(page_x + width_px + shadow_offset, page_y + shadow_offset)
102 glVertex2f(page_x + width_px + shadow_offset, page_y + height_px)
103 glVertex2f(page_x + width_px, page_y + height_px)
104 glEnd()
106 # Draw page background (slightly off-white to distinguish from canvas)
107 glColor3f(0.98, 0.98, 0.98)
108 glBegin(GL_QUADS)
109 glVertex2f(page_x, page_y)
110 glVertex2f(page_x + width_px, page_y)
111 glVertex2f(page_x + width_px, page_y + height_px)
112 glVertex2f(page_x, page_y + height_px)
113 glEnd()
115 # Render elements in list order (list position = z-order)
116 # For ImageData elements, request async loading if available
117 for element in self.elements:
118 # Check if this is an ImageData element that needs async loading
119 if isinstance(element, ImageData) and not hasattr(element, "_texture_id"):
120 # Try to get async loader from a parent widget
121 if hasattr(self, "_async_loader"):
122 loader = self._async_loader
123 elif hasattr(self, "_parent_widget") and hasattr(self._parent_widget, "async_image_loader"):
124 loader = self._parent_widget.async_image_loader # type: ignore[union-attr]
125 else:
126 loader = None
128 # Request async load if loader is available and not already requested
129 if loader and not element._async_load_requested:
130 from pyPhotoAlbum.async_backend import LoadPriority
132 # Determine priority based on visibility (HIGH for now, can be refined)
133 if hasattr(self._parent_widget, "request_image_load"):
134 self._parent_widget.request_image_load(element, priority=LoadPriority.HIGH) # type: ignore[union-attr]
135 element._async_load_requested = True
136 element._async_loading = True
138 element.render()
140 # Draw page border LAST (on top of everything)
141 glColor3f(0.7, 0.7, 0.7)
142 glLineWidth(2.0)
143 glBegin(GL_LINE_LOOP)
144 glVertex2f(page_x, page_y)
145 glVertex2f(page_x + width_px, page_y)
146 glVertex2f(page_x + width_px, page_y + height_px)
147 glVertex2f(page_x, page_y + height_px)
148 glEnd()
149 glLineWidth(1.0)
151 # Draw center line for facing pages
152 if self.is_facing_page:
153 center_x = page_x + (width_px / 2)
154 glColor3f(0.5, 0.5, 0.5) # Gray line
155 glLineWidth(1.5)
156 glBegin(GL_LINES)
157 glVertex2f(center_x, page_y)
158 glVertex2f(center_x, page_y + height_px)
159 glEnd()
160 glLineWidth(1.0)
162 # Always render snap lines (grid shows when show_grid is on, guides show when show_snap_lines is on)
163 self._render_snap_lines(dpi, page_x, page_y, project)
165 # Re-enable depth testing
166 glEnable(GL_DEPTH_TEST)
168 def _render_snap_lines(self, dpi: int, page_x: float, page_y: float, project=None):
169 """Render snap lines (grid, edges, guides)"""
170 # Use project settings if available, otherwise fall back to local snapping_system
171 if project:
172 # Use project-level global settings
173 snap_to_grid = project.snap_to_grid
174 snap_to_edges = project.snap_to_edges
175 snap_to_guides = project.snap_to_guides
176 grid_size_mm = project.grid_size_mm
177 snap_threshold_mm = project.snap_threshold_mm
178 show_grid = project.show_grid
179 show_snap_lines = project.show_snap_lines
180 else:
181 # Fall back to per-page settings (backward compatibility)
182 snap_to_grid = self.snapping_system.snap_to_grid
183 snap_to_edges = self.snapping_system.snap_to_edges
184 snap_to_guides = self.snapping_system.snap_to_guides
185 grid_size_mm = self.snapping_system.grid_size_mm
186 snap_threshold_mm = self.snapping_system.snap_threshold_mm
187 show_grid = snap_to_grid # Old behavior: grid only shows when snapping
188 show_snap_lines = self.show_snap_lines
190 # Create a temporary snapping system with project settings to get snap lines
191 from pyPhotoAlbum.snapping import SnappingSystem
193 temp_snap_sys = SnappingSystem(snap_threshold_mm=snap_threshold_mm)
194 temp_snap_sys.grid_size_mm = grid_size_mm
195 temp_snap_sys.snap_to_grid = snap_to_grid
196 temp_snap_sys.snap_to_edges = snap_to_edges
197 temp_snap_sys.snap_to_guides = snap_to_guides
198 temp_snap_sys.guides = self.snapping_system.guides # Use page-specific guides
200 snap_lines = temp_snap_sys.get_snap_lines(self.size, dpi)
202 # Draw grid lines (light gray, fully opaque) - visible when show_grid is enabled
203 if show_grid and snap_lines["grid"]:
204 glColor3f(0.8, 0.8, 0.8) # Light gray, fully opaque
205 glLineWidth(1.0)
206 for orientation, position in snap_lines["grid"]:
207 glBegin(GL_LINES)
208 if orientation == "vertical":
209 glVertex2f(page_x + position, page_y)
210 glVertex2f(page_x + position, page_y + self.size[1] * dpi / 25.4)
211 else: # horizontal
212 glVertex2f(page_x, page_y + position)
213 glVertex2f(page_x + self.size[0] * dpi / 25.4, page_y + position)
214 glEnd()
216 # Draw guides (cyan, fully opaque) - only show when show_snap_lines is on
217 if show_snap_lines and snap_lines["guides"]:
218 glColor3f(0.0, 0.7, 0.9) # Cyan, fully opaque
219 glLineWidth(1.5)
220 for orientation, position in snap_lines["guides"]:
221 glBegin(GL_LINES)
222 if orientation == "vertical":
223 glVertex2f(page_x + position, page_y)
224 glVertex2f(page_x + position, page_y + self.size[1] * dpi / 25.4)
225 else: # horizontal
226 glVertex2f(page_x, page_y + position)
227 glVertex2f(page_x + self.size[0] * dpi / 25.4, page_y + position)
228 glEnd()
230 glLineWidth(1.0)
232 def serialize(self) -> Dict[str, Any]:
233 """Serialize page layout to dictionary"""
234 return {
235 "size": self.size,
236 "base_width": self.base_width,
237 "is_facing_page": self.is_facing_page,
238 "background_color": self.background_color,
239 "elements": [elem.serialize() for elem in self.elements],
240 "grid_layout": self.grid_layout.serialize() if self.grid_layout else None,
241 "snapping_system": self.snapping_system.serialize(),
242 "show_snap_lines": self.show_snap_lines,
243 }
245 def deserialize(self, data: Dict[str, Any]):
246 """Deserialize from dictionary"""
247 self.size = tuple(data.get("size", (210, 297)))
248 self.base_width = data.get("base_width", self.size[0])
249 self.is_facing_page = data.get("is_facing_page", False)
250 self.background_color = tuple(data.get("background_color", (1.0, 1.0, 1.0)))
251 self.elements = []
253 # Deserialize elements and sort by z_index to establish list order
254 # This ensures backward compatibility with projects that used z_index
255 elem_list: List[BaseLayoutElement] = []
256 for elem_data in data.get("elements", []):
257 elem_type = elem_data.get("type")
258 elem: BaseLayoutElement
259 if elem_type == "image":
260 elem = ImageData()
261 elif elem_type == "placeholder":
262 elem = PlaceholderData()
263 elif elem_type == "textbox":
264 elem = TextBoxData()
265 else:
266 continue
268 elem.deserialize(elem_data)
269 elem_list.append(elem)
271 # Sort by z_index to establish proper list order (lower z_index = earlier in list = behind)
272 elem_list.sort(key=lambda e: e.z_index)
273 self.elements = elem_list
275 # Deserialize grid layout
276 grid_data = data.get("grid_layout")
277 if grid_data:
278 self.grid_layout = GridLayout()
279 self.grid_layout.deserialize(grid_data)
281 # Deserialize snapping system
282 snap_data = data.get("snapping_system")
283 if snap_data:
284 self.snapping_system.deserialize(snap_data)
286 self.show_snap_lines = data.get("show_snap_lines", True)
289class GridLayout:
290 """Class to manage grid layouts"""
292 def __init__(self, rows: int = 1, columns: int = 1, spacing: float = 10.0):
293 self.rows = rows
294 self.columns = columns
295 self.spacing = spacing
296 self.merged_cells: List[Tuple[int, int]] = [] # List of (row, col) for merged cells
298 def merge_cells(self, row: int, col: int):
299 """Merge cells in the grid"""
300 self.merged_cells.append((row, col))
302 def get_cell_position(
303 self, row: int, col: int, page_width: float = 800, page_height: float = 600
304 ) -> Tuple[float, float]:
305 """Get the position of a grid cell"""
306 cell_width = (page_width - (self.spacing * (self.columns + 1))) / self.columns
307 cell_height = (page_height - (self.spacing * (self.rows + 1))) / self.rows
309 x = self.spacing + (col * (cell_width + self.spacing))
310 y = self.spacing + (row * (cell_height + self.spacing))
312 return (x, y)
314 def get_cell_size(self, page_width: float = 800, page_height: float = 600) -> Tuple[float, float]:
315 """Get the size of a grid cell"""
316 cell_width = (page_width - (self.spacing * (self.columns + 1))) / self.columns
317 cell_height = (page_height - (self.spacing * (self.rows + 1))) / self.rows
319 return (cell_width, cell_height)
321 def serialize(self) -> Dict[str, Any]:
322 """Serialize grid layout to dictionary"""
323 return {"rows": self.rows, "columns": self.columns, "spacing": self.spacing, "merged_cells": self.merged_cells}
325 def deserialize(self, data: Dict[str, Any]):
326 """Deserialize from dictionary"""
327 self.rows = data.get("rows", 1)
328 self.columns = data.get("columns", 1)
329 self.spacing = data.get("spacing", 10.0)
330 self.merged_cells = data.get("merged_cells", [])