1 """

2 Page layout and template system for pyPhotoAlbum

3 """

4

5 from typing import List, Dict, Any, Optional, Tuple, TYPE_CHECKING

6

7 if TYPE_CHECKING:

8 from PyQt6.QtWidgets import QWidget

9

10 from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData

11 from pyPhotoAlbum.snapping import SnappingSystem

12 from 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 )

30

31

32 class PageLayout:

33 """Class to manage page layout and templates"""

34

35 def __init__(self, width: float = 210, height: float = 297, is_facing_page: bool = False):

36 """

37 Initialize page layout.

38

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

53

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)

58

59 def remove_element(self, element: BaseLayoutElement):

60 """Remove a layout element from the page"""

61 self.elements.remove(element)

62

63 def set_grid_layout(self, grid: "GridLayout"):

64 """Set a grid layout for the page"""

65 self.grid_layout = grid

66

67 def render(self, dpi: int = 300, project=None):

68 """

69 Render all elements on the page in page-local coordinates.

70

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.

73

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)

80

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

84

85 # All rendering is at page origin (0, 0) in page-local coordinates

86 page_x = 0

87 page_y = 0

88

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()

98

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()

105

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()

114

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

127

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

131

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

137

138 element.render()

139

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)

150

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)

161

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)

164

165 # Re-enable depth testing

166 glEnable(GL_DEPTH_TEST)

167

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

189

190 # Create a temporary snapping system with project settings to get snap lines

191 from pyPhotoAlbum.snapping import SnappingSystem

192

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

199

200 snap_lines = temp_snap_sys.get_snap_lines(self.size, dpi)

201

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()

215

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()

229

230 glLineWidth(1.0)

231

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 }

244

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 = []

252

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

267

268 elem.deserialize(elem_data)

269 elem_list.append(elem)

270

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

274

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)

280

281 # Deserialize snapping system

282 snap_data = data.get("snapping_system")

283 if snap_data:

284 self.snapping_system.deserialize(snap_data)

285

286 self.show_snap_lines = data.get("show_snap_lines", True)

287

288

289 class GridLayout:

290 """Class to manage grid layouts"""

291

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

297

298 def merge_cells(self, row: int, col: int):

299 """Merge cells in the grid"""

300 self.merged_cells.append((row, col))

301

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

308

309 x = self.spacing + (col * (cell_width + self.spacing))

310 y = self.spacing + (row * (cell_height + self.spacing))

311

312 return (x, y)

313

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

318

319 return (cell_width, cell_height)

320

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}

324

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