Coverage for pyPhotoAlbum/frame_manager.py: 23%
357 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"""
2Frame manager for pyPhotoAlbum
4Manages decorative frames that can be applied to images:
5- Loading frame assets (SVG/PNG)
6- Rendering frames in OpenGL and PDF
7- Frame categories (modern, vintage)
8- Color override for SVG frames
9"""
11import io
12import os
13import re
14from pathlib import Path
15from typing import Dict, List, Optional, Tuple
16from dataclasses import dataclass, field
17from enum import Enum
19from PIL import Image
22class FrameCategory(Enum):
23 """Categories for organizing frames"""
25 MODERN = "modern"
26 VINTAGE = "vintage"
27 GEOMETRIC = "geometric"
28 CUSTOM = "custom"
31class FrameType(Enum):
32 """How the frame is structured"""
34 CORNERS = "corners" # 4 corner pieces, rotated/mirrored
35 FULL = "full" # Complete frame as single image
36 EDGES = "edges" # Tileable edge pieces
39@dataclass
40class FrameDefinition:
41 """Definition of a decorative frame"""
43 name: str
44 display_name: str
45 category: FrameCategory
46 frame_type: FrameType
47 description: str = ""
49 # Asset path (relative to frames/corners directory for CORNERS type)
50 # For CORNERS type: single SVG that gets rotated for each corner
51 asset_path: Optional[str] = None
53 # Which corner the SVG asset is designed for: "tl", "tr", "br", "bl"
54 # This determines how to flip for other corners
55 asset_corner: str = "tl"
57 # Whether the frame can be tinted with a custom color
58 colorizable: bool = True
60 # Default thickness as percentage of shorter image side
61 default_thickness: float = 5.0
63 # Cached textures for OpenGL rendering: key = (color, size) tuple
64 _texture_cache: Dict[tuple, int] = field(default_factory=dict, repr=False)
65 _image_cache: Dict[tuple, Image.Image] = field(default_factory=dict, repr=False)
68class FrameManager:
69 """
70 Manages loading and rendering of decorative frames.
72 Frames are stored in the frames/ directory with the following structure:
73 frames/
74 corners/
75 floral_corner.svg
76 ornate_corner.svg
77 CREDITS.txt
78 """
80 def __init__(self):
81 self.frames: Dict[str, FrameDefinition] = {}
82 self._frames_dir = self._get_frames_directory()
83 self._load_bundled_frames()
85 def _get_frames_directory(self) -> Path:
86 """Get the frames directory path"""
87 app_dir = Path(__file__).parent
88 return app_dir / "frames"
90 def _load_bundled_frames(self):
91 """Load bundled frame definitions"""
92 # Modern frames (programmatic - no SVG assets)
93 self._register_frame(
94 FrameDefinition(
95 name="simple_line",
96 display_name="Simple Line",
97 category=FrameCategory.MODERN,
98 frame_type=FrameType.FULL,
99 description="Clean single-line border",
100 colorizable=True,
101 default_thickness=2.0,
102 )
103 )
105 self._register_frame(
106 FrameDefinition(
107 name="double_line",
108 display_name="Double Line",
109 category=FrameCategory.MODERN,
110 frame_type=FrameType.FULL,
111 description="Double parallel lines",
112 colorizable=True,
113 default_thickness=4.0,
114 )
115 )
117 # Geometric frames (programmatic)
118 self._register_frame(
119 FrameDefinition(
120 name="geometric_corners",
121 display_name="Geometric Corners",
122 category=FrameCategory.GEOMETRIC,
123 frame_type=FrameType.CORNERS,
124 description="Angular geometric corner decorations",
125 colorizable=True,
126 default_thickness=8.0,
127 )
128 )
130 # SVG-based vintage frames
131 # Each SVG is designed for a specific corner position:
132 # corner_decoration.svg -> top left (tl)
133 # corner_ornament.svg -> bottom left (bl)
134 # floral_corner.svg -> bottom left (bl)
135 # floral_flourish.svg -> bottom right (br)
136 # ornate_corner.svg -> top left (tl)
137 # simple_corner.svg -> top left (tl)
138 corners_dir = self._frames_dir / "corners"
140 # Floral Corner (designed for bottom-left)
141 if (corners_dir / "floral_corner.svg").exists():
142 self._register_frame(
143 FrameDefinition(
144 name="floral_corner",
145 display_name="Floral Corner",
146 category=FrameCategory.VINTAGE,
147 frame_type=FrameType.CORNERS,
148 description="Decorative floral corner ornament",
149 asset_path="corners/floral_corner.svg",
150 asset_corner="bl",
151 colorizable=True,
152 default_thickness=12.0,
153 )
154 )
156 # Floral Flourish (designed for bottom-right)
157 if (corners_dir / "floral_flourish.svg").exists():
158 self._register_frame(
159 FrameDefinition(
160 name="floral_flourish",
161 display_name="Floral Flourish",
162 category=FrameCategory.VINTAGE,
163 frame_type=FrameType.CORNERS,
164 description="Elegant floral flourish design",
165 asset_path="corners/floral_flourish.svg",
166 asset_corner="br",
167 colorizable=True,
168 default_thickness=10.0,
169 )
170 )
172 # Ornate Corner (designed for top-left)
173 if (corners_dir / "ornate_corner.svg").exists():
174 self._register_frame(
175 FrameDefinition(
176 name="ornate_corner",
177 display_name="Ornate Corner",
178 category=FrameCategory.VINTAGE,
179 frame_type=FrameType.CORNERS,
180 description="Classic ornate line art corner",
181 asset_path="corners/ornate_corner.svg",
182 asset_corner="tl",
183 colorizable=True,
184 default_thickness=10.0,
185 )
186 )
188 # Simple Corner (designed for top-left)
189 if (corners_dir / "simple_corner.svg").exists():
190 self._register_frame(
191 FrameDefinition(
192 name="simple_corner",
193 display_name="Simple Corner",
194 category=FrameCategory.VINTAGE,
195 frame_type=FrameType.CORNERS,
196 description="Simple decorative corner ornament",
197 asset_path="corners/simple_corner.svg",
198 asset_corner="tl",
199 colorizable=True,
200 default_thickness=8.0,
201 )
202 )
204 # Corner Decoration (designed for top-left)
205 if (corners_dir / "corner_decoration.svg").exists():
206 self._register_frame(
207 FrameDefinition(
208 name="corner_decoration",
209 display_name="Corner Decoration",
210 category=FrameCategory.VINTAGE,
211 frame_type=FrameType.CORNERS,
212 description="Decorative corner piece",
213 asset_path="corners/corner_decoration.svg",
214 asset_corner="tl",
215 colorizable=True,
216 default_thickness=10.0,
217 )
218 )
220 # Corner Ornament (designed for bottom-left)
221 if (corners_dir / "corner_ornament.svg").exists():
222 self._register_frame(
223 FrameDefinition(
224 name="corner_ornament",
225 display_name="Corner Ornament",
226 category=FrameCategory.VINTAGE,
227 frame_type=FrameType.CORNERS,
228 description="Vintage corner ornament design",
229 asset_path="corners/corner_ornament.svg",
230 asset_corner="bl",
231 colorizable=True,
232 default_thickness=10.0,
233 )
234 )
236 def _register_frame(self, frame: FrameDefinition):
237 """Register a frame definition"""
238 self.frames[frame.name] = frame
240 def get_frame(self, name: str) -> Optional[FrameDefinition]:
241 """Get a frame by name"""
242 return self.frames.get(name)
244 def get_frames_by_category(self, category: FrameCategory) -> List[FrameDefinition]:
245 """Get all frames in a category"""
246 return [f for f in self.frames.values() if f.category == category]
248 def get_all_frames(self) -> List[FrameDefinition]:
249 """Get all available frames"""
250 return list(self.frames.values())
252 def get_frame_names(self) -> List[str]:
253 """Get list of all frame names"""
254 return list(self.frames.keys())
256 def _load_svg_as_image(
257 self,
258 svg_path: Path,
259 target_size: int,
260 color: Optional[Tuple[int, int, int]] = None,
261 ) -> Optional[Image.Image]:
262 """
263 Load an SVG file and render it to a PIL Image.
265 Args:
266 svg_path: Path to the SVG file
267 target_size: Target size in pixels for the corner
268 color: Optional color override as RGB tuple (0-255)
270 Returns:
271 PIL Image with alpha channel, or None if loading fails
272 """
273 try:
274 import cairosvg
275 except ImportError:
276 print("Warning: cairosvg not installed, SVG frames will use fallback rendering")
277 return None
279 # Validate svg_path type
280 if not isinstance(svg_path, (str, Path)):
281 print(f"Warning: Invalid svg_path type: {type(svg_path)}, expected Path or str")
282 return None
284 # Ensure svg_path is a Path object
285 if isinstance(svg_path, str):
286 svg_path = Path(svg_path)
288 if not svg_path.exists():
289 return None
291 try:
292 # Read SVG content
293 svg_content = svg_path.read_text()
295 # Apply color override if specified
296 if color is not None:
297 svg_content = self._recolor_svg(svg_content, color)
299 # Render SVG to PNG bytes
300 png_data = cairosvg.svg2png(
301 bytestring=svg_content.encode("utf-8"),
302 output_width=target_size,
303 output_height=target_size,
304 )
306 # Validate png_data type
307 if not isinstance(png_data, bytes):
308 print(f"Warning: cairosvg returned {type(png_data)} instead of bytes")
309 return None
311 # Load as PIL Image from bytes buffer
312 buffer = io.BytesIO(png_data)
313 img: Image.Image = Image.open(buffer)
314 if img.mode != "RGBA":
315 img = img.convert("RGBA")
317 # Force load the image data to avoid issues with BytesIO going out of scope
318 img.load()
320 return img
322 except Exception as e:
323 import traceback
325 print(f"Error loading SVG {svg_path}: {e}")
326 traceback.print_exc()
327 return None
329 def _recolor_svg(self, svg_content: str, color: Tuple[int, int, int]) -> str:
330 """
331 Recolor an SVG by replacing fill and stroke colors.
333 Args:
334 svg_content: SVG file content as string
335 color: New color as RGB tuple (0-255)
337 Returns:
338 Modified SVG content with new colors
339 """
340 r, g, b = color
341 hex_color = f"#{r:02x}{g:02x}{b:02x}"
342 rgb_color = f"rgb({r},{g},{b})"
344 # Replace common color patterns
345 # Replace fill colors (hex, rgb, named colors)
346 svg_content = re.sub(
347 r'fill\s*[:=]\s*["\']?(?:#[0-9a-fA-F]{3,6}|rgb\([^)]+\)|black|white|none)["\']?',
348 f'fill="{hex_color}"',
349 svg_content,
350 flags=re.IGNORECASE,
351 )
353 # Replace stroke colors
354 svg_content = re.sub(
355 r'stroke\s*[:=]\s*["\']?(?:#[0-9a-fA-F]{3,6}|rgb\([^)]+\)|black|white)["\']?',
356 f'stroke="{hex_color}"',
357 svg_content,
358 flags=re.IGNORECASE,
359 )
361 # Replace style-based fill/stroke
362 svg_content = re.sub(
363 r"(fill\s*:\s*)(?:#[0-9a-fA-F]{3,6}|rgb\([^)]+\)|black|white)",
364 f"\\1{hex_color}",
365 svg_content,
366 flags=re.IGNORECASE,
367 )
368 svg_content = re.sub(
369 r"(stroke\s*:\s*)(?:#[0-9a-fA-F]{3,6}|rgb\([^)]+\)|black|white)",
370 f"\\1{hex_color}",
371 svg_content,
372 flags=re.IGNORECASE,
373 )
375 return svg_content
377 def _get_corner_image(
378 self,
379 frame: FrameDefinition,
380 corner_size: int,
381 color: Tuple[int, int, int],
382 ) -> Optional[Image.Image]:
383 """
384 Get a corner image, using cache if available.
386 Args:
387 frame: Frame definition
388 corner_size: Size in pixels
389 color: Color as RGB tuple
391 Returns:
392 PIL Image or None
393 """
394 cache_key = (color, corner_size)
396 if cache_key in frame._image_cache:
397 return frame._image_cache[cache_key]
399 if frame.asset_path:
400 try:
401 svg_path = self._frames_dir / frame.asset_path
402 img = self._load_svg_as_image(svg_path, corner_size, color)
403 if img:
404 frame._image_cache[cache_key] = img
405 return img
406 except Exception as e:
407 import traceback
409 print(f"Error getting corner image for {frame.name}: {e}")
410 traceback.print_exc()
411 return None
413 return None
415 def render_frame_opengl(
416 self,
417 frame_name: str,
418 x: float,
419 y: float,
420 width: float,
421 height: float,
422 color: Tuple[int, int, int] = (0, 0, 0),
423 thickness: Optional[float] = None,
424 corners: Optional[Tuple[bool, bool, bool, bool]] = None,
425 ):
426 """
427 Render a decorative frame using OpenGL.
429 Args:
430 frame_name: Name of the frame to render
431 x, y: Position of the image
432 width, height: Size of the image
433 color: Frame color as RGB (0-255)
434 thickness: Frame thickness (None = use default)
435 corners: Which corners to render (TL, TR, BR, BL). None = all corners
436 """
437 frame = self.get_frame(frame_name)
438 if not frame:
439 return
441 # Default to all corners if not specified
442 if corners is None:
443 corners = (True, True, True, True)
445 from pyPhotoAlbum.gl_imports import (
446 glColor3f,
447 glColor4f,
448 glBegin,
449 glEnd,
450 glVertex2f,
451 GL_LINE_LOOP,
452 glLineWidth,
453 glEnable,
454 glDisable,
455 GL_BLEND,
456 glBlendFunc,
457 GL_SRC_ALPHA,
458 GL_ONE_MINUS_SRC_ALPHA,
459 GL_TEXTURE_2D,
460 glBindTexture,
461 glTexCoord2f,
462 GL_QUADS,
463 )
465 # Calculate thickness
466 shorter_side = min(width, height)
467 frame_thickness = thickness if thickness else (shorter_side * frame.default_thickness / 100)
469 glEnable(GL_BLEND)
470 glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
472 # Try to render with SVG asset if available
473 if frame.asset_path and frame.frame_type == FrameType.CORNERS:
474 corner_size = int(frame_thickness * 2)
475 if self._render_svg_corners_gl(frame, x, y, width, height, corner_size, color, corners):
476 glDisable(GL_BLEND)
477 return
479 # Fall back to programmatic rendering
480 r, g, b = color[0] / 255.0, color[1] / 255.0, color[2] / 255.0
481 glColor3f(r, g, b)
483 if frame.frame_type == FrameType.CORNERS:
484 self._render_corner_frame_gl(x, y, width, height, frame_thickness, frame_name, corners)
485 elif frame.frame_type == FrameType.FULL:
486 self._render_full_frame_gl(x, y, width, height, frame_thickness)
488 glDisable(GL_BLEND)
490 def _render_svg_corners_gl(
491 self,
492 frame: FrameDefinition,
493 x: float,
494 y: float,
495 w: float,
496 h: float,
497 corner_size: int,
498 color: Tuple[int, int, int],
499 corners: Tuple[bool, bool, bool, bool],
500 ) -> bool:
501 """
502 Render SVG-based corners using OpenGL textures.
504 Returns True if rendering was successful, False to fall back to programmatic.
505 """
506 from pyPhotoAlbum.gl_imports import (
507 glEnable,
508 glDisable,
509 glBindTexture,
510 glTexCoord2f,
511 glVertex2f,
512 glBegin,
513 glEnd,
514 glColor4f,
515 GL_TEXTURE_2D,
516 GL_QUADS,
517 glGenTextures,
518 glTexParameteri,
519 glTexImage2D,
520 GL_TEXTURE_MIN_FILTER,
521 GL_TEXTURE_MAG_FILTER,
522 GL_LINEAR,
523 GL_RGBA,
524 GL_UNSIGNED_BYTE,
525 )
527 # Get or create corner image
528 corner_img = self._get_corner_image(frame, corner_size, color)
529 if corner_img is None:
530 return False
532 # Create texture if not cached
533 cache_key = (color, corner_size, "texture")
534 if cache_key not in frame._texture_cache:
535 img_data = corner_img.tobytes()
536 texture_id = glGenTextures(1)
537 glBindTexture(GL_TEXTURE_2D, texture_id)
538 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
539 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
540 glTexImage2D(
541 GL_TEXTURE_2D,
542 0,
543 GL_RGBA,
544 corner_img.width,
545 corner_img.height,
546 0,
547 GL_RGBA,
548 GL_UNSIGNED_BYTE,
549 img_data,
550 )
551 frame._texture_cache[cache_key] = texture_id
553 texture_id = frame._texture_cache[cache_key]
555 # Render corners
556 glEnable(GL_TEXTURE_2D)
557 glBindTexture(GL_TEXTURE_2D, texture_id)
558 glColor4f(1.0, 1.0, 1.0, 1.0) # White to show texture colors
560 tl, tr, br, bl = corners
561 cs = float(corner_size)
563 # Helper to draw a textured quad with optional flipping
564 # flip_h: flip horizontally, flip_v: flip vertically
565 def draw_corner_quad(cx, cy, flip_h=False, flip_v=False):
566 # Calculate texture coordinates based on flipping
567 u0, u1 = (1, 0) if flip_h else (0, 1)
568 v0, v1 = (1, 0) if flip_v else (0, 1)
570 glBegin(GL_QUADS)
571 glTexCoord2f(u0, v0)
572 glVertex2f(cx, cy)
573 glTexCoord2f(u1, v0)
574 glVertex2f(cx + cs, cy)
575 glTexCoord2f(u1, v1)
576 glVertex2f(cx + cs, cy + cs)
577 glTexCoord2f(u0, v1)
578 glVertex2f(cx, cy + cs)
579 glEnd()
581 # Calculate flips based on the asset's designed corner vs target corner
582 # Each SVG is designed for a specific corner (asset_corner field)
583 # To render it at a different corner, we flip horizontally and/or vertically
584 #
585 # Corner positions:
586 # tl (top-left) tr (top-right)
587 # bl (bottom-left) br (bottom-right)
588 #
589 # To go from asset corner to target corner:
590 # - flip_h if horizontal position differs (l->r or r->l)
591 # - flip_v if vertical position differs (t->b or b->t)
593 asset_corner = frame.asset_corner # e.g., "tl", "bl", "br", "tr"
594 asset_h = asset_corner[1] # 'l' or 'r'
595 asset_v = asset_corner[0] # 't' or 'b'
597 def get_flips(target_corner: str) -> Tuple[bool, bool]:
598 """Calculate flip_h, flip_v to transform from asset_corner to target_corner"""
599 target_h = target_corner[1] # 'l' or 'r'
600 target_v = target_corner[0] # 't' or 'b'
601 flip_h = asset_h != target_h
602 flip_v = asset_v != target_v
603 return flip_h, flip_v
605 # Top-left corner
606 if tl:
607 flip_h, flip_v = get_flips("tl")
608 draw_corner_quad(x, y, flip_h=flip_h, flip_v=flip_v)
610 # Top-right corner
611 if tr:
612 flip_h, flip_v = get_flips("tr")
613 draw_corner_quad(x + w - cs, y, flip_h=flip_h, flip_v=flip_v)
615 # Bottom-right corner
616 if br:
617 flip_h, flip_v = get_flips("br")
618 draw_corner_quad(x + w - cs, y + h - cs, flip_h=flip_h, flip_v=flip_v)
620 # Bottom-left corner
621 if bl:
622 flip_h, flip_v = get_flips("bl")
623 draw_corner_quad(x, y + h - cs, flip_h=flip_h, flip_v=flip_v)
625 glDisable(GL_TEXTURE_2D)
626 return True
628 def _render_corner_frame_gl(
629 self,
630 x: float,
631 y: float,
632 w: float,
633 h: float,
634 thickness: float,
635 frame_name: str,
636 corners: Tuple[bool, bool, bool, bool] = (True, True, True, True),
637 ):
638 """Render corner-style frame decorations (programmatic fallback)."""
639 from pyPhotoAlbum.gl_imports import glBegin, glEnd, glVertex2f, glLineWidth, GL_LINE_STRIP
641 corner_size = thickness * 2
643 glLineWidth(2.0)
645 tl, tr, br, bl = corners
647 # Top-left corner
648 if tl:
649 glBegin(GL_LINE_STRIP)
650 glVertex2f(x, y + corner_size)
651 glVertex2f(x, y)
652 glVertex2f(x + corner_size, y)
653 glEnd()
655 # Top-right corner
656 if tr:
657 glBegin(GL_LINE_STRIP)
658 glVertex2f(x + w - corner_size, y)
659 glVertex2f(x + w, y)
660 glVertex2f(x + w, y + corner_size)
661 glEnd()
663 # Bottom-right corner
664 if br:
665 glBegin(GL_LINE_STRIP)
666 glVertex2f(x + w, y + h - corner_size)
667 glVertex2f(x + w, y + h)
668 glVertex2f(x + w - corner_size, y + h)
669 glEnd()
671 # Bottom-left corner
672 if bl:
673 glBegin(GL_LINE_STRIP)
674 glVertex2f(x + corner_size, y + h)
675 glVertex2f(x, y + h)
676 glVertex2f(x, y + h - corner_size)
677 glEnd()
679 # Add decorative swirls for vintage frames
680 if "leafy" in frame_name or "ornate" in frame_name or "flourish" in frame_name:
681 self._render_decorative_swirls_gl(x, y, w, h, corner_size, corners)
683 glLineWidth(1.0)
685 def _render_decorative_swirls_gl(
686 self,
687 x: float,
688 y: float,
689 w: float,
690 h: float,
691 size: float,
692 corners: Tuple[bool, bool, bool, bool] = (True, True, True, True),
693 ):
694 """Render decorative swirl elements at corners (programmatic fallback)."""
695 from pyPhotoAlbum.gl_imports import glBegin, glEnd, glVertex2f, GL_LINE_STRIP
696 import math
698 steps = 8
699 radius = size * 0.4
701 tl, tr, br, bl = corners
703 corner_data = [
704 (tl, x + size * 0.5, y + size * 0.5, math.pi),
705 (tr, x + w - size * 0.5, y + size * 0.5, math.pi * 1.5),
706 (br, x + w - size * 0.5, y + h - size * 0.5, 0),
707 (bl, x + size * 0.5, y + h - size * 0.5, math.pi * 0.5),
708 ]
710 for enabled, cx, cy, start_angle in corner_data:
711 if not enabled:
712 continue
713 glBegin(GL_LINE_STRIP)
714 for i in range(steps + 1):
715 angle = start_angle + (math.pi * 0.5 * i / steps)
716 px = cx + radius * math.cos(angle)
717 py = cy + radius * math.sin(angle)
718 glVertex2f(px, py)
719 glEnd()
721 def _render_full_frame_gl(self, x: float, y: float, w: float, h: float, thickness: float):
722 """Render full-border frame (programmatic)"""
723 from pyPhotoAlbum.gl_imports import glBegin, glEnd, glVertex2f, GL_LINE_LOOP, glLineWidth
725 glLineWidth(max(1.0, thickness * 0.5))
726 glBegin(GL_LINE_LOOP)
727 glVertex2f(x - thickness * 0.5, y - thickness * 0.5)
728 glVertex2f(x + w + thickness * 0.5, y - thickness * 0.5)
729 glVertex2f(x + w + thickness * 0.5, y + h + thickness * 0.5)
730 glVertex2f(x - thickness * 0.5, y + h + thickness * 0.5)
731 glEnd()
733 glBegin(GL_LINE_LOOP)
734 glVertex2f(x + thickness * 0.3, y + thickness * 0.3)
735 glVertex2f(x + w - thickness * 0.3, y + thickness * 0.3)
736 glVertex2f(x + w - thickness * 0.3, y + h - thickness * 0.3)
737 glVertex2f(x + thickness * 0.3, y + h - thickness * 0.3)
738 glEnd()
740 glLineWidth(1.0)
742 def render_frame_pdf(
743 self,
744 canvas,
745 frame_name: str,
746 x_pt: float,
747 y_pt: float,
748 width_pt: float,
749 height_pt: float,
750 color: Tuple[int, int, int] = (0, 0, 0),
751 thickness_pt: Optional[float] = None,
752 corners: Optional[Tuple[bool, bool, bool, bool]] = None,
753 ):
754 """
755 Render a decorative frame on a PDF canvas.
757 Args:
758 canvas: ReportLab canvas
759 frame_name: Name of the frame to render
760 x_pt, y_pt: Position in points
761 width_pt, height_pt: Size in points
762 color: Frame color as RGB (0-255)
763 thickness_pt: Frame thickness in points (None = use default)
764 corners: Which corners to render (TL, TR, BR, BL). None = all corners
765 """
766 frame = self.get_frame(frame_name)
767 if not frame:
768 return
770 if corners is None:
771 corners = (True, True, True, True)
773 shorter_side = min(width_pt, height_pt)
774 frame_thickness = thickness_pt if thickness_pt else (shorter_side * frame.default_thickness / 100)
776 r, g, b = color[0] / 255.0, color[1] / 255.0, color[2] / 255.0
778 canvas.saveState()
779 canvas.setStrokeColorRGB(r, g, b)
780 canvas.setLineWidth(max(0.5, frame_thickness * 0.3))
782 # Try SVG rendering for PDF
783 if frame.asset_path and frame.frame_type == FrameType.CORNERS:
784 corner_size_pt = frame_thickness * 2
785 if self._render_svg_corners_pdf(
786 canvas, frame, x_pt, y_pt, width_pt, height_pt, corner_size_pt, color, corners
787 ):
788 canvas.restoreState()
789 return
791 # Fall back to programmatic
792 if frame.frame_type == FrameType.CORNERS:
793 self._render_corner_frame_pdf(canvas, x_pt, y_pt, width_pt, height_pt, frame_thickness, frame_name, corners)
794 elif frame.frame_type == FrameType.FULL:
795 self._render_full_frame_pdf(canvas, x_pt, y_pt, width_pt, height_pt, frame_thickness)
797 canvas.restoreState()
799 def _render_svg_corners_pdf(
800 self,
801 canvas,
802 frame: FrameDefinition,
803 x: float,
804 y: float,
805 w: float,
806 h: float,
807 corner_size_pt: float,
808 color: Tuple[int, int, int],
809 corners: Tuple[bool, bool, bool, bool],
810 ) -> bool:
811 """Render SVG corners on PDF canvas. Returns True if successful."""
812 from reportlab.lib.utils import ImageReader
814 # Get corner image at high resolution for PDF
815 corner_size_px = int(corner_size_pt * 4) # 4x for PDF quality
816 if corner_size_px < 1:
817 corner_size_px = 1
818 corner_img = self._get_corner_image(frame, corner_size_px, color)
819 if corner_img is None:
820 return False
822 tl, tr, br, bl = corners
823 cs = corner_size_pt
825 # For PDF, we use PIL to flip the image rather than canvas transformations
826 # This is more reliable across different PDF renderers
827 def get_flipped_image(target_corner: str) -> Image.Image:
828 """Get image flipped appropriately for the target corner"""
829 asset_corner = frame.asset_corner
830 asset_h = asset_corner[1] # 'l' or 'r'
831 asset_v = asset_corner[0] # 't' or 'b'
832 target_h = target_corner[1]
833 target_v = target_corner[0]
835 img = corner_img.copy()
837 # Flip horizontally if h position differs
838 if asset_h != target_h:
839 img = img.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
841 # Flip vertically if v position differs
842 if asset_v != target_v:
843 img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
845 return img
847 # Note: PDF Y-axis is bottom-up, so corners are positioned differently
848 # Top-left in screen coordinates = high Y in PDF
849 if tl:
850 img = get_flipped_image("tl")
851 img_reader = ImageReader(img)
852 canvas.drawImage(img_reader, x, y + h - cs, cs, cs, mask="auto")
854 # Top-right
855 if tr:
856 img = get_flipped_image("tr")
857 img_reader = ImageReader(img)
858 canvas.drawImage(img_reader, x + w - cs, y + h - cs, cs, cs, mask="auto")
860 # Bottom-right
861 if br:
862 img = get_flipped_image("br")
863 img_reader = ImageReader(img)
864 canvas.drawImage(img_reader, x + w - cs, y, cs, cs, mask="auto")
866 # Bottom-left
867 if bl:
868 img = get_flipped_image("bl")
869 img_reader = ImageReader(img)
870 canvas.drawImage(img_reader, x, y, cs, cs, mask="auto")
872 return True
874 def _render_corner_frame_pdf(
875 self,
876 canvas,
877 x: float,
878 y: float,
879 w: float,
880 h: float,
881 thickness: float,
882 frame_name: str,
883 corners: Tuple[bool, bool, bool, bool] = (True, True, True, True),
884 ):
885 """Render corner-style frame on PDF (programmatic fallback)."""
886 corner_size = thickness * 2
887 tl, tr, br, bl = corners
889 path = canvas.beginPath()
891 if tl:
892 path.moveTo(x, y + h - corner_size)
893 path.lineTo(x, y + h)
894 path.lineTo(x + corner_size, y + h)
896 if tr:
897 path.moveTo(x + w - corner_size, y + h)
898 path.lineTo(x + w, y + h)
899 path.lineTo(x + w, y + h - corner_size)
901 if br:
902 path.moveTo(x + w, y + corner_size)
903 path.lineTo(x + w, y)
904 path.lineTo(x + w - corner_size, y)
906 if bl:
907 path.moveTo(x + corner_size, y)
908 path.lineTo(x, y)
909 path.lineTo(x, y + corner_size)
911 canvas.drawPath(path, stroke=1, fill=0)
913 def _render_full_frame_pdf(self, canvas, x: float, y: float, w: float, h: float, thickness: float):
914 """Render full-border frame on PDF"""
915 canvas.rect(
916 x - thickness * 0.5,
917 y - thickness * 0.5,
918 w + thickness,
919 h + thickness,
920 stroke=1,
921 fill=0,
922 )
924 canvas.rect(
925 x + thickness * 0.3,
926 y + thickness * 0.3,
927 w - thickness * 0.6,
928 h - thickness * 0.6,
929 stroke=1,
930 fill=0,
931 )
934# Global frame manager instance
935_frame_manager: Optional[FrameManager] = None
938def get_frame_manager() -> FrameManager:
939 """Get the global frame manager instance"""
940 global _frame_manager
941 if _frame_manager is None:
942 _frame_manager = FrameManager()
943 return _frame_manager