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
« 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"""
5import math
7from pyPhotoAlbum.gl_imports import *
8from PyQt6.QtGui import QPainter, QFont, QColor, QPen
9from PyQt6.QtCore import Qt, QRectF
10from pyPhotoAlbum.models import TextBoxData
13class RenderingMixin:
14 """
15 Mixin providing OpenGL rendering functionality.
17 This mixin handles rendering pages, elements, selection handles,
18 and text overlays.
19 """
21 def paintGL(self):
22 """Main rendering function - renders all pages vertically"""
23 from pyPhotoAlbum.page_renderer import PageRenderer
25 glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
26 glLoadIdentity()
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()
34 if main_window is None:
35 return
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
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
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()
57 dpi = project.working_dpi
59 # Calculate page positions with ghosts
60 page_positions = self._get_page_positions()
62 # Store page renderers for later use
63 self._page_renderers = []
65 # Left margin for page rendering
66 PAGE_MARGIN = 50
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
73 if page_type == "page":
74 page = page_or_ghost
75 page_width_mm, page_height_mm = page.layout.size
77 screen_x = PAGE_MARGIN + self.pan_offset[0]
78 screen_y = (y_offset * self.zoom_level) + self.pan_offset[1]
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 )
89 self._page_renderers.append((renderer, page))
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()
97 # Draw bleed/cut/safe-area guides for this page
98 self._draw_page_print_guides(renderer, project)
100 pages_rendered += 1
102 elif page_type == "ghost":
103 ghost = page_or_ghost
104 ghost_width_mm, ghost_height_mm = ghost.page_size
106 screen_x = PAGE_MARGIN + self.pan_offset[0]
107 screen_y = (y_offset * self.zoom_level) + self.pan_offset[1]
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 )
118 self._render_ghost_page(ghost, renderer)
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
128 # Draw selection handles for all selected elements
129 for element in self.selected_elements:
130 self._draw_selection_handles(element)
132 # Render text overlays using QPainter
133 # Qt will handle OpenGL/QPainter coordination automatically
134 self._render_text_overlays()
136 def _draw_selection_handles(self, element):
137 """Draw selection handles around the given element"""
138 if not element:
139 return
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
145 if not hasattr(element, "_page_renderer"):
146 return
148 renderer = element._page_renderer
150 elem_x, elem_y = element.position
151 elem_w, elem_h = element.size
152 handle_size = 8
154 x, y = renderer.page_to_screen(elem_x, elem_y)
155 w = elem_w * renderer.zoom
156 h = elem_h * renderer.zoom
158 center_x = x + w / 2
159 center_y = y + h / 2
161 # No rotation transformation needed - images are already rotated at PIL level
163 if self.rotation_mode:
164 glColor3f(1.0, 0.5, 0.0)
165 else:
166 glColor3f(0.0, 0.5, 1.0)
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)
177 if self.rotation_mode:
178 handle_radius = 6
179 handles = [(x, y), (x + w, y), (x, y + h), (x + w, y + h)]
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()
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()
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 ]
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()
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()
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
241 painter = QPainter(self)
242 painter.setRenderHint(QPainter.RenderHint.Antialiasing)
243 painter.setRenderHint(QPainter.RenderHint.TextAntialiasing)
245 try:
246 for renderer, page in self._page_renderers:
247 text_elements = [elem for elem in page.layout.elements if isinstance(elem, TextBoxData)]
249 for element in text_elements:
250 if not element.text_content:
251 continue
253 x, y = element.position
254 w, h = element.size
256 screen_x, screen_y = renderer.page_to_screen(x, y)
257 screen_w = w * renderer.zoom
258 screen_h = h * renderer.zoom
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)
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))
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)
278 if element.rotation != 0:
279 painter.translate(w / 2, h / 2)
280 painter.rotate(element.rotation)
281 painter.translate(-w / 2, -h / 2)
283 rect = QRectF(0, 0, w, h)
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
293 text_flags = Qt.TextFlag.TextWordWrap
295 painter.drawText(rect, int(alignment | text_flags), element.text_content)
297 painter.restore()
299 finally:
300 painter.end()
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()
308 painter = QPainter(self)
309 painter.setRenderHint(QPainter.RenderHint.Antialiasing)
310 painter.setRenderHint(QPainter.RenderHint.TextAntialiasing)
312 try:
313 px, py, pw, ph = ghost_data.get_page_rect()
315 screen_x, screen_y = renderer.page_to_screen(px, py)
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))
322 painter.save()
323 painter.translate(screen_x, screen_y)
324 painter.scale(renderer.zoom, renderer.zoom)
326 rect = QRectF(0, 0, pw, ph)
327 painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "Click to Add Page")
329 painter.restore()
331 finally:
332 painter.end()
334 def _draw_page_print_guides(self, renderer, project):
335 """
336 Draw bleed/cut/safe-area guide lines around a page using OpenGL.
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)
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
347 bleed_mm = getattr(project, "page_bleed_mm", 0.0)
348 safe_mm = getattr(project, "page_safe_area_mm", 0.0)
350 if bleed_mm <= 0.0 and safe_mm <= 0.0:
351 return
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
360 # Convert mm to screen pixels
361 bleed_screen = bleed_mm * dpi / 25.4 * zoom
362 safe_screen = safe_mm * dpi / 25.4 * zoom
364 glLineWidth(1.0)
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)
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 )
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)
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 )
410 glColor3f(1.0, 1.0, 1.0) # Reset colour