1 """

2 Frame manager for pyPhotoAlbum

3

4 Manages 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

11 import io

12 import os

13 import re

14 from pathlib import Path

15 from typing import Dict, List, Optional, Tuple

16 from dataclasses import dataclass, field

17 from enum import Enum

18

19 from PIL import Image

20

21

22 class FrameCategory(Enum):

23 """Categories for organizing frames"""

24

25 MODERN = "modern"

26 VINTAGE = "vintage"

27 GEOMETRIC = "geometric"

28 CUSTOM = "custom"

29

30

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

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

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

938 def 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