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

1""" 

2Frame manager for pyPhotoAlbum 

3 

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""" 

10 

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 

18 

19from PIL import Image 

20 

21 

22class FrameCategory(Enum): 

23 """Categories for organizing frames""" 

24 

25 MODERN = "modern" 

26 VINTAGE = "vintage" 

27 GEOMETRIC = "geometric" 

28 CUSTOM = "custom" 

29 

30 

31class FrameType(Enum): 

32 """How the frame is structured""" 

33 

34 CORNERS = "corners" # 4 corner pieces, rotated/mirrored 

35 FULL = "full" # Complete frame as single image 

36 EDGES = "edges" # Tileable edge pieces 

37 

38 

39@dataclass 

40class FrameDefinition: 

41 """Definition of a decorative frame""" 

42 

43 name: str 

44 display_name: str 

45 category: FrameCategory 

46 frame_type: FrameType 

47 description: str = "" 

48 

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 

52 

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" 

56 

57 # Whether the frame can be tinted with a custom color 

58 colorizable: bool = True 

59 

60 # Default thickness as percentage of shorter image side 

61 default_thickness: float = 5.0 

62 

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) 

66 

67 

68class FrameManager: 

69 """ 

70 Manages loading and rendering of decorative frames. 

71 

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 """ 

79 

80 def __init__(self): 

81 self.frames: Dict[str, FrameDefinition] = {} 

82 self._frames_dir = self._get_frames_directory() 

83 self._load_bundled_frames() 

84 

85 def _get_frames_directory(self) -> Path: 

86 """Get the frames directory path""" 

87 app_dir = Path(__file__).parent 

88 return app_dir / "frames" 

89 

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 ) 

104 

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 ) 

116 

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 ) 

129 

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" 

139 

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 ) 

155 

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 ) 

171 

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 ) 

187 

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 ) 

203 

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 ) 

219 

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 ) 

235 

236 def _register_frame(self, frame: FrameDefinition): 

237 """Register a frame definition""" 

238 self.frames[frame.name] = frame 

239 

240 def get_frame(self, name: str) -> Optional[FrameDefinition]: 

241 """Get a frame by name""" 

242 return self.frames.get(name) 

243 

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] 

247 

248 def get_all_frames(self) -> List[FrameDefinition]: 

249 """Get all available frames""" 

250 return list(self.frames.values()) 

251 

252 def get_frame_names(self) -> List[str]: 

253 """Get list of all frame names""" 

254 return list(self.frames.keys()) 

255 

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. 

264 

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) 

269 

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 

278 

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 

283 

284 # Ensure svg_path is a Path object 

285 if isinstance(svg_path, str): 

286 svg_path = Path(svg_path) 

287 

288 if not svg_path.exists(): 

289 return None 

290 

291 try: 

292 # Read SVG content 

293 svg_content = svg_path.read_text() 

294 

295 # Apply color override if specified 

296 if color is not None: 

297 svg_content = self._recolor_svg(svg_content, color) 

298 

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 ) 

305 

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 

310 

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") 

316 

317 # Force load the image data to avoid issues with BytesIO going out of scope 

318 img.load() 

319 

320 return img 

321 

322 except Exception as e: 

323 import traceback 

324 

325 print(f"Error loading SVG {svg_path}: {e}") 

326 traceback.print_exc() 

327 return None 

328 

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. 

332 

333 Args: 

334 svg_content: SVG file content as string 

335 color: New color as RGB tuple (0-255) 

336 

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})" 

343 

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 ) 

352 

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 ) 

360 

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 ) 

374 

375 return svg_content 

376 

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. 

385 

386 Args: 

387 frame: Frame definition 

388 corner_size: Size in pixels 

389 color: Color as RGB tuple 

390 

391 Returns: 

392 PIL Image or None 

393 """ 

394 cache_key = (color, corner_size) 

395 

396 if cache_key in frame._image_cache: 

397 return frame._image_cache[cache_key] 

398 

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 

408 

409 print(f"Error getting corner image for {frame.name}: {e}") 

410 traceback.print_exc() 

411 return None 

412 

413 return None 

414 

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. 

428 

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 

440 

441 # Default to all corners if not specified 

442 if corners is None: 

443 corners = (True, True, True, True) 

444 

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 ) 

464 

465 # Calculate thickness 

466 shorter_side = min(width, height) 

467 frame_thickness = thickness if thickness else (shorter_side * frame.default_thickness / 100) 

468 

469 glEnable(GL_BLEND) 

470 glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) 

471 

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 

478 

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) 

482 

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) 

487 

488 glDisable(GL_BLEND) 

489 

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. 

503 

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 ) 

526 

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 

531 

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 

552 

553 texture_id = frame._texture_cache[cache_key] 

554 

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 

559 

560 tl, tr, br, bl = corners 

561 cs = float(corner_size) 

562 

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) 

569 

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() 

580 

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) 

592 

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' 

596 

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 

604 

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) 

609 

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) 

614 

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) 

619 

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) 

624 

625 glDisable(GL_TEXTURE_2D) 

626 return True 

627 

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 

640 

641 corner_size = thickness * 2 

642 

643 glLineWidth(2.0) 

644 

645 tl, tr, br, bl = corners 

646 

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() 

654 

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() 

662 

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() 

670 

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() 

678 

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) 

682 

683 glLineWidth(1.0) 

684 

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 

697 

698 steps = 8 

699 radius = size * 0.4 

700 

701 tl, tr, br, bl = corners 

702 

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 ] 

709 

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() 

720 

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 

724 

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() 

732 

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() 

739 

740 glLineWidth(1.0) 

741 

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. 

756 

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 

769 

770 if corners is None: 

771 corners = (True, True, True, True) 

772 

773 shorter_side = min(width_pt, height_pt) 

774 frame_thickness = thickness_pt if thickness_pt else (shorter_side * frame.default_thickness / 100) 

775 

776 r, g, b = color[0] / 255.0, color[1] / 255.0, color[2] / 255.0 

777 

778 canvas.saveState() 

779 canvas.setStrokeColorRGB(r, g, b) 

780 canvas.setLineWidth(max(0.5, frame_thickness * 0.3)) 

781 

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 

790 

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) 

796 

797 canvas.restoreState() 

798 

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 

813 

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 

821 

822 tl, tr, br, bl = corners 

823 cs = corner_size_pt 

824 

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] 

834 

835 img = corner_img.copy() 

836 

837 # Flip horizontally if h position differs 

838 if asset_h != target_h: 

839 img = img.transpose(Image.Transpose.FLIP_LEFT_RIGHT) 

840 

841 # Flip vertically if v position differs 

842 if asset_v != target_v: 

843 img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM) 

844 

845 return img 

846 

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") 

853 

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") 

859 

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") 

865 

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") 

871 

872 return True 

873 

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 

888 

889 path = canvas.beginPath() 

890 

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) 

895 

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) 

900 

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) 

905 

906 if bl: 

907 path.moveTo(x + corner_size, y) 

908 path.lineTo(x, y) 

909 path.lineTo(x, y + corner_size) 

910 

911 canvas.drawPath(path, stroke=1, fill=0) 

912 

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 ) 

923 

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 ) 

932 

933 

934# Global frame manager instance 

935_frame_manager: Optional[FrameManager] = None 

936 

937 

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