1 """

2 Rendering mixin for GLWidget - handles OpenGL rendering

3 """

4

5 import math

6

7 from pyPhotoAlbum.gl_imports import *

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

9 from PyQt6.QtCore import Qt, QRectF

10 from pyPhotoAlbum.models import TextBoxData

11

12

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