Coverage for pyPhotoAlbum/mixins/rendering.py: 81%

241 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-20 12:55 +0000

1""" 

2Rendering mixin for GLWidget - handles OpenGL rendering 

3""" 

4 

5import math 

6 

7from pyPhotoAlbum.gl_imports import * 

8from PyQt6.QtGui import QPainter, QFont, QColor, QPen 

9from PyQt6.QtCore import Qt, QRectF 

10from pyPhotoAlbum.models import TextBoxData 

11 

12 

13class RenderingMixin: 

14 """ 

15 Mixin providing OpenGL rendering functionality. 

16 

17 This mixin handles rendering pages, elements, selection handles, 

18 and text overlays. 

19 """ 

20 

21 def paintGL(self): 

22 """Main rendering function - renders all pages vertically""" 

23 from pyPhotoAlbum.page_renderer import PageRenderer 

24 

25 glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) 

26 glLoadIdentity() 

27 

28 # Use stored reference to main window 

29 main_window = getattr(self, "_main_window", None) 

30 if main_window is None: 

31 # Fallback to window() if _main_window not set 

32 main_window = self.window() 

33 

34 if main_window is None: 

35 return 

36 

37 try: 

38 project = main_window.project 

39 if not project: 

40 return 

41 if not project.pages: 

42 return 

43 except AttributeError: 

44 # Project not yet initialized 

45 return 

46 

47 # Set initial zoom and center the page if not done yet 

48 if not self.initial_zoom_set: 

49 self.zoom_level = self._calculate_fit_to_screen_zoom() 

50 self.pan_offset = self._calculate_center_pan_offset(self.zoom_level) 

51 self.initial_zoom_set = True 

52 

53 # Update scrollbars now that we have content bounds 

54 if hasattr(self, "_main_window") and hasattr(self._main_window, "update_scrollbars"): 

55 self._main_window.update_scrollbars() 

56 

57 dpi = project.working_dpi 

58 

59 # Calculate page positions with ghosts 

60 page_positions = self._get_page_positions() 

61 

62 # Store page renderers for later use 

63 self._page_renderers = [] 

64 

65 # Left margin for page rendering 

66 PAGE_MARGIN = 50 

67 

68 # Render all pages 

69 pages_rendered = 0 

70 for page_info in page_positions: 

71 page_type, page_or_ghost, y_offset = page_info 

72 

73 if page_type == "page": 

74 page = page_or_ghost 

75 page_width_mm, page_height_mm = page.layout.size 

76 

77 screen_x = PAGE_MARGIN + self.pan_offset[0] 

78 screen_y = (y_offset * self.zoom_level) + self.pan_offset[1] 

79 

80 renderer = PageRenderer( 

81 page_width_mm=page_width_mm, 

82 page_height_mm=page_height_mm, 

83 screen_x=screen_x, 

84 screen_y=screen_y, 

85 dpi=dpi, 

86 zoom=self.zoom_level, 

87 ) 

88 

89 self._page_renderers.append((renderer, page)) 

90 

91 renderer.begin_render() 

92 # Pass widget reference for async loading 

93 page.layout._parent_widget = self 

94 page.layout.render(dpi=dpi, project=project) 

95 renderer.end_render() 

96 

97 # Draw bleed/cut/safe-area guides for this page 

98 self._draw_page_print_guides(renderer, project) 

99 

100 pages_rendered += 1 

101 

102 elif page_type == "ghost": 

103 ghost = page_or_ghost 

104 ghost_width_mm, ghost_height_mm = ghost.page_size 

105 

106 screen_x = PAGE_MARGIN + self.pan_offset[0] 

107 screen_y = (y_offset * self.zoom_level) + self.pan_offset[1] 

108 

109 renderer = PageRenderer( 

110 page_width_mm=ghost_width_mm, 

111 page_height_mm=ghost_height_mm, 

112 screen_x=screen_x, 

113 screen_y=screen_y, 

114 dpi=dpi, 

115 zoom=self.zoom_level, 

116 ) 

117 

118 self._render_ghost_page(ghost, renderer) 

119 

120 # Update PageRenderer references for selected elements 

121 for element in self.selected_elements: 

122 if hasattr(element, "_parent_page"): 

123 for renderer, page in self._page_renderers: 

124 if page is element._parent_page: 

125 element._page_renderer = renderer 

126 break 

127 

128 # Draw selection handles for all selected elements 

129 for element in self.selected_elements: 

130 self._draw_selection_handles(element) 

131 

132 # Render text overlays using QPainter 

133 # Qt will handle OpenGL/QPainter coordination automatically 

134 self._render_text_overlays() 

135 

136 def _draw_selection_handles(self, element): 

137 """Draw selection handles around the given element""" 

138 if not element: 

139 return 

140 

141 main_window = self.window() 

142 if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages: 

143 return 

144 

145 if not hasattr(element, "_page_renderer"): 

146 return 

147 

148 renderer = element._page_renderer 

149 

150 elem_x, elem_y = element.position 

151 elem_w, elem_h = element.size 

152 handle_size = 8 

153 

154 x, y = renderer.page_to_screen(elem_x, elem_y) 

155 w = elem_w * renderer.zoom 

156 h = elem_h * renderer.zoom 

157 

158 center_x = x + w / 2 

159 center_y = y + h / 2 

160 

161 # No rotation transformation needed - images are already rotated at PIL level 

162 

163 if self.rotation_mode: 

164 glColor3f(1.0, 0.5, 0.0) 

165 else: 

166 glColor3f(0.0, 0.5, 1.0) 

167 

168 glLineWidth(2.0) 

169 glBegin(GL_LINE_LOOP) 

170 glVertex2f(x, y) 

171 glVertex2f(x + w, y) 

172 glVertex2f(x + w, y + h) 

173 glVertex2f(x, y + h) 

174 glEnd() 

175 glLineWidth(1.0) 

176 

177 if self.rotation_mode: 

178 handle_radius = 6 

179 handles = [(x, y), (x + w, y), (x, y + h), (x + w, y + h)] 

180 

181 glColor3f(1.0, 0.5, 0.0) 

182 glBegin(GL_TRIANGLE_FAN) 

183 glVertex2f(center_x, center_y) 

184 for angle in range(0, 361, 10): 

185 rad = math.radians(angle) 

186 hx = center_x + 3 * math.cos(rad) 

187 hy = center_y + 3 * math.sin(rad) 

188 glVertex2f(hx, hy) 

189 glEnd() 

190 

191 for hx, hy in handles: 

192 glColor3f(1.0, 1.0, 1.0) 

193 glBegin(GL_TRIANGLE_FAN) 

194 glVertex2f(hx, hy) 

195 for angle in range(0, 361, 30): 

196 rad = math.radians(angle) 

197 px = hx + handle_radius * math.cos(rad) 

198 py = hy + handle_radius * math.sin(rad) 

199 glVertex2f(px, py) 

200 glEnd() 

201 

202 glColor3f(1.0, 0.5, 0.0) 

203 glBegin(GL_LINE_LOOP) 

204 for angle in range(0, 361, 30): 

205 rad = math.radians(angle) 

206 px = hx + handle_radius * math.cos(rad) 

207 py = hy + handle_radius * math.sin(rad) 

208 glVertex2f(px, py) 

209 glEnd() 

210 else: 

211 handles = [ 

212 (x - handle_size / 2, y - handle_size / 2), 

213 (x + w - handle_size / 2, y - handle_size / 2), 

214 (x - handle_size / 2, y + h - handle_size / 2), 

215 (x + w - handle_size / 2, y + h - handle_size / 2), 

216 ] 

217 

218 glColor3f(1.0, 1.0, 1.0) 

219 for hx, hy in handles: 

220 glBegin(GL_QUADS) 

221 glVertex2f(hx, hy) 

222 glVertex2f(hx + handle_size, hy) 

223 glVertex2f(hx + handle_size, hy + handle_size) 

224 glVertex2f(hx, hy + handle_size) 

225 glEnd() 

226 

227 glColor3f(0.0, 0.5, 1.0) 

228 for hx, hy in handles: 

229 glBegin(GL_LINE_LOOP) 

230 glVertex2f(hx, hy) 

231 glVertex2f(hx + handle_size, hy) 

232 glVertex2f(hx + handle_size, hy + handle_size) 

233 glVertex2f(hx, hy + handle_size) 

234 glEnd() 

235 

236 def _render_text_overlays(self): 

237 """Render text content for TextBoxData elements using QPainter overlay""" 

238 if not hasattr(self, "_page_renderers") or not self._page_renderers: 

239 return 

240 

241 painter = QPainter(self) 

242 painter.setRenderHint(QPainter.RenderHint.Antialiasing) 

243 painter.setRenderHint(QPainter.RenderHint.TextAntialiasing) 

244 

245 try: 

246 for renderer, page in self._page_renderers: 

247 text_elements = [elem for elem in page.layout.elements if isinstance(elem, TextBoxData)] 

248 

249 for element in text_elements: 

250 if not element.text_content: 

251 continue 

252 

253 x, y = element.position 

254 w, h = element.size 

255 

256 screen_x, screen_y = renderer.page_to_screen(x, y) 

257 screen_w = w * renderer.zoom 

258 screen_h = h * renderer.zoom 

259 

260 font_family = element.font_settings.get("family", "Arial") 

261 # Use base font size without zoom - zoom is applied via painter transform 

262 font_size = int(element.font_settings.get("size", 12)) 

263 font = QFont(font_family, font_size) 

264 painter.setFont(font) 

265 

266 font_color = element.font_settings.get("color", (0, 0, 0)) 

267 if all(isinstance(c, int) and c > 1 for c in font_color): 

268 color = QColor(*font_color) 

269 else: 

270 color = QColor(int(font_color[0] * 255), int(font_color[1] * 255), int(font_color[2] * 255)) 

271 painter.setPen(QPen(color)) 

272 

273 # Apply zoom via painter transform so font scales consistently with page 

274 painter.save() 

275 painter.translate(screen_x, screen_y) 

276 painter.scale(renderer.zoom, renderer.zoom) 

277 

278 if element.rotation != 0: 

279 painter.translate(w / 2, h / 2) 

280 painter.rotate(element.rotation) 

281 painter.translate(-w / 2, -h / 2) 

282 

283 rect = QRectF(0, 0, w, h) 

284 

285 alignment = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop 

286 if element.alignment == "center": 

287 alignment = Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop 

288 elif element.alignment == "right": 

289 alignment = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop 

290 elif element.alignment == "justify": 

291 alignment = Qt.AlignmentFlag.AlignJustify | Qt.AlignmentFlag.AlignTop 

292 

293 text_flags = Qt.TextFlag.TextWordWrap 

294 

295 painter.drawText(rect, int(alignment | text_flags), element.text_content) 

296 

297 painter.restore() 

298 

299 finally: 

300 painter.end() 

301 

302 def _render_ghost_page(self, ghost_data, renderer): 

303 """Render a ghost page using PageRenderer""" 

304 renderer.begin_render() 

305 ghost_data.render() 

306 renderer.end_render() 

307 

308 painter = QPainter(self) 

309 painter.setRenderHint(QPainter.RenderHint.Antialiasing) 

310 painter.setRenderHint(QPainter.RenderHint.TextAntialiasing) 

311 

312 try: 

313 px, py, pw, ph = ghost_data.get_page_rect() 

314 

315 screen_x, screen_y = renderer.page_to_screen(px, py) 

316 

317 # Use base font size without zoom - zoom is applied via painter transform 

318 font = QFont("Arial", 16, QFont.Weight.Bold) 

319 painter.setFont(font) 

320 painter.setPen(QColor(120, 120, 120)) 

321 

322 painter.save() 

323 painter.translate(screen_x, screen_y) 

324 painter.scale(renderer.zoom, renderer.zoom) 

325 

326 rect = QRectF(0, 0, pw, ph) 

327 painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "Click to Add Page") 

328 

329 painter.restore() 

330 

331 finally: 

332 painter.end() 

333 

334 def _draw_page_print_guides(self, renderer, project): 

335 """ 

336 Draw bleed/cut/safe-area guide lines around a page using OpenGL. 

337 

338 - Green dashed rectangle: bleed boundary (extend backgrounds to here) 

339 - Magenta rectangle: cut/trim line (finished page edge, only shown when bleed > 0) 

340 - Red rectangle: safe area (keep text and logos inside) 

341 

342 Lines are only drawn when the corresponding project settings are non-zero. 

343 """ 

344 if not getattr(project, "show_print_guides", False): 

345 return 

346 

347 bleed_mm = getattr(project, "page_bleed_mm", 0.0) 

348 safe_mm = getattr(project, "page_safe_area_mm", 0.0) 

349 

350 if bleed_mm <= 0.0 and safe_mm <= 0.0: 

351 return 

352 

353 dpi = project.working_dpi 

354 zoom = renderer.zoom 

355 sx = renderer.screen_x 

356 sy = renderer.screen_y 

357 sw = renderer.screen_width 

358 sh = renderer.screen_height 

359 

360 # Convert mm to screen pixels 

361 bleed_screen = bleed_mm * dpi / 25.4 * zoom 

362 safe_screen = safe_mm * dpi / 25.4 * zoom 

363 

364 glLineWidth(1.0) 

365 

366 def _draw_rect_outline(x, y, w, h, r, g, b, dashed=False): 

367 glColor3f(r, g, b) 

368 if dashed: 

369 glEnable(GL_LINE_STIPPLE) 

370 glLineStipple(1, 0x00FF) 

371 else: 

372 glDisable(GL_LINE_STIPPLE) 

373 glBegin(GL_LINE_LOOP) 

374 glVertex2f(x, y) 

375 glVertex2f(x + w, y) 

376 glVertex2f(x + w, y + h) 

377 glVertex2f(x, y + h) 

378 glEnd() 

379 glDisable(GL_LINE_STIPPLE) 

380 

381 # Bleed boundary – green dashed rectangle outside the page 

382 if bleed_screen > 0: 

383 _draw_rect_outline( 

384 sx - bleed_screen, 

385 sy - bleed_screen, 

386 sw + 2 * bleed_screen, 

387 sh + 2 * bleed_screen, 

388 0.0, 

389 0.67, 

390 0.0, 

391 dashed=True, 

392 ) 

393 

394 # Cut/trim line – magenta rectangle at the page edge (only meaningful when bleed > 0) 

395 if bleed_screen > 0: 

396 _draw_rect_outline(sx, sy, sw, sh, 0.8, 0.0, 0.8) 

397 

398 # Safe area – red rectangle inside the page 

399 if safe_screen > 0: 

400 _draw_rect_outline( 

401 sx + safe_screen, 

402 sy + safe_screen, 

403 sw - 2 * safe_screen, 

404 sh - 2 * safe_screen, 

405 0.8, 

406 0.0, 

407 0.0, 

408 ) 

409 

410 glColor3f(1.0, 1.0, 1.0) # Reset colour