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

1""" 

2Page layout and template system for pyPhotoAlbum 

3""" 

4 

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

6 

7if TYPE_CHECKING: 

8 from PyQt6.QtWidgets import QWidget 

9 

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) 

30 

31 

32class 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 

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