All checks were successful
Python CI / test (push) Successful in 1m44s
Lint / lint (push) Successful in 1m29s
Tests / test (3.11) (push) Successful in 1m49s
Tests / test (3.12) (push) Successful in 1m52s
Tests / test (3.13) (push) Successful in 1m45s
Tests / test (3.14) (push) Successful in 1m28s
Improved pdf generation speed
926 lines
30 KiB
Python
926 lines
30 KiB
Python
"""
|
|
Frame manager for pyPhotoAlbum
|
|
|
|
Manages decorative frames that can be applied to images:
|
|
- Loading frame assets (SVG/PNG)
|
|
- Rendering frames in OpenGL and PDF
|
|
- Frame categories (modern, vintage)
|
|
- Color override for SVG frames
|
|
"""
|
|
|
|
import io
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional, Tuple
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
|
|
from PIL import Image
|
|
|
|
|
|
class FrameCategory(Enum):
|
|
"""Categories for organizing frames"""
|
|
|
|
MODERN = "modern"
|
|
VINTAGE = "vintage"
|
|
GEOMETRIC = "geometric"
|
|
CUSTOM = "custom"
|
|
|
|
|
|
class FrameType(Enum):
|
|
"""How the frame is structured"""
|
|
|
|
CORNERS = "corners" # 4 corner pieces, rotated/mirrored
|
|
FULL = "full" # Complete frame as single image
|
|
EDGES = "edges" # Tileable edge pieces
|
|
|
|
|
|
@dataclass
|
|
class FrameDefinition:
|
|
"""Definition of a decorative frame"""
|
|
|
|
name: str
|
|
display_name: str
|
|
category: FrameCategory
|
|
frame_type: FrameType
|
|
description: str = ""
|
|
|
|
# Asset path (relative to frames/corners directory for CORNERS type)
|
|
# For CORNERS type: single SVG that gets rotated for each corner
|
|
asset_path: Optional[str] = None
|
|
|
|
# Which corner the SVG asset is designed for: "tl", "tr", "br", "bl"
|
|
# This determines how to flip for other corners
|
|
asset_corner: str = "tl"
|
|
|
|
# Whether the frame can be tinted with a custom color
|
|
colorizable: bool = True
|
|
|
|
# Default thickness as percentage of shorter image side
|
|
default_thickness: float = 5.0
|
|
|
|
# Cached textures for OpenGL rendering: key = (color, size) tuple
|
|
_texture_cache: Dict[tuple, int] = field(default_factory=dict, repr=False)
|
|
_image_cache: Dict[tuple, Image.Image] = field(default_factory=dict, repr=False)
|
|
|
|
|
|
class FrameManager:
|
|
"""
|
|
Manages loading and rendering of decorative frames.
|
|
|
|
Frames are stored in the frames/ directory with the following structure:
|
|
frames/
|
|
corners/
|
|
floral_corner.svg
|
|
ornate_corner.svg
|
|
CREDITS.txt
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.frames: Dict[str, FrameDefinition] = {}
|
|
self._frames_dir = self._get_frames_directory()
|
|
self._load_bundled_frames()
|
|
|
|
def _get_frames_directory(self) -> Path:
|
|
"""Get the frames directory path"""
|
|
app_dir = Path(__file__).parent
|
|
return app_dir / "frames"
|
|
|
|
def _load_bundled_frames(self):
|
|
"""Load bundled frame definitions"""
|
|
# Modern frames (programmatic - no SVG assets)
|
|
self._register_frame(
|
|
FrameDefinition(
|
|
name="simple_line",
|
|
display_name="Simple Line",
|
|
category=FrameCategory.MODERN,
|
|
frame_type=FrameType.FULL,
|
|
description="Clean single-line border",
|
|
colorizable=True,
|
|
default_thickness=2.0,
|
|
)
|
|
)
|
|
|
|
self._register_frame(
|
|
FrameDefinition(
|
|
name="double_line",
|
|
display_name="Double Line",
|
|
category=FrameCategory.MODERN,
|
|
frame_type=FrameType.FULL,
|
|
description="Double parallel lines",
|
|
colorizable=True,
|
|
default_thickness=4.0,
|
|
)
|
|
)
|
|
|
|
# Geometric frames (programmatic)
|
|
self._register_frame(
|
|
FrameDefinition(
|
|
name="geometric_corners",
|
|
display_name="Geometric Corners",
|
|
category=FrameCategory.GEOMETRIC,
|
|
frame_type=FrameType.CORNERS,
|
|
description="Angular geometric corner decorations",
|
|
colorizable=True,
|
|
default_thickness=8.0,
|
|
)
|
|
)
|
|
|
|
# SVG-based vintage frames
|
|
# Each SVG is designed for a specific corner position:
|
|
# corner_decoration.svg -> top left (tl)
|
|
# corner_ornament.svg -> bottom left (bl)
|
|
# floral_corner.svg -> bottom left (bl)
|
|
# floral_flourish.svg -> bottom right (br)
|
|
# ornate_corner.svg -> top left (tl)
|
|
# simple_corner.svg -> top left (tl)
|
|
corners_dir = self._frames_dir / "corners"
|
|
|
|
# Floral Corner (designed for bottom-left)
|
|
if (corners_dir / "floral_corner.svg").exists():
|
|
self._register_frame(
|
|
FrameDefinition(
|
|
name="floral_corner",
|
|
display_name="Floral Corner",
|
|
category=FrameCategory.VINTAGE,
|
|
frame_type=FrameType.CORNERS,
|
|
description="Decorative floral corner ornament",
|
|
asset_path="corners/floral_corner.svg",
|
|
asset_corner="bl",
|
|
colorizable=True,
|
|
default_thickness=12.0,
|
|
)
|
|
)
|
|
|
|
# Floral Flourish (designed for bottom-right)
|
|
if (corners_dir / "floral_flourish.svg").exists():
|
|
self._register_frame(
|
|
FrameDefinition(
|
|
name="floral_flourish",
|
|
display_name="Floral Flourish",
|
|
category=FrameCategory.VINTAGE,
|
|
frame_type=FrameType.CORNERS,
|
|
description="Elegant floral flourish design",
|
|
asset_path="corners/floral_flourish.svg",
|
|
asset_corner="br",
|
|
colorizable=True,
|
|
default_thickness=10.0,
|
|
)
|
|
)
|
|
|
|
# Ornate Corner (designed for top-left)
|
|
if (corners_dir / "ornate_corner.svg").exists():
|
|
self._register_frame(
|
|
FrameDefinition(
|
|
name="ornate_corner",
|
|
display_name="Ornate Corner",
|
|
category=FrameCategory.VINTAGE,
|
|
frame_type=FrameType.CORNERS,
|
|
description="Classic ornate line art corner",
|
|
asset_path="corners/ornate_corner.svg",
|
|
asset_corner="tl",
|
|
colorizable=True,
|
|
default_thickness=10.0,
|
|
)
|
|
)
|
|
|
|
# Simple Corner (designed for top-left)
|
|
if (corners_dir / "simple_corner.svg").exists():
|
|
self._register_frame(
|
|
FrameDefinition(
|
|
name="simple_corner",
|
|
display_name="Simple Corner",
|
|
category=FrameCategory.VINTAGE,
|
|
frame_type=FrameType.CORNERS,
|
|
description="Simple decorative corner ornament",
|
|
asset_path="corners/simple_corner.svg",
|
|
asset_corner="tl",
|
|
colorizable=True,
|
|
default_thickness=8.0,
|
|
)
|
|
)
|
|
|
|
# Corner Decoration (designed for top-left)
|
|
if (corners_dir / "corner_decoration.svg").exists():
|
|
self._register_frame(
|
|
FrameDefinition(
|
|
name="corner_decoration",
|
|
display_name="Corner Decoration",
|
|
category=FrameCategory.VINTAGE,
|
|
frame_type=FrameType.CORNERS,
|
|
description="Decorative corner piece",
|
|
asset_path="corners/corner_decoration.svg",
|
|
asset_corner="tl",
|
|
colorizable=True,
|
|
default_thickness=10.0,
|
|
)
|
|
)
|
|
|
|
# Corner Ornament (designed for bottom-left)
|
|
if (corners_dir / "corner_ornament.svg").exists():
|
|
self._register_frame(
|
|
FrameDefinition(
|
|
name="corner_ornament",
|
|
display_name="Corner Ornament",
|
|
category=FrameCategory.VINTAGE,
|
|
frame_type=FrameType.CORNERS,
|
|
description="Vintage corner ornament design",
|
|
asset_path="corners/corner_ornament.svg",
|
|
asset_corner="bl",
|
|
colorizable=True,
|
|
default_thickness=10.0,
|
|
)
|
|
)
|
|
|
|
def _register_frame(self, frame: FrameDefinition):
|
|
"""Register a frame definition"""
|
|
self.frames[frame.name] = frame
|
|
|
|
def get_frame(self, name: str) -> Optional[FrameDefinition]:
|
|
"""Get a frame by name"""
|
|
return self.frames.get(name)
|
|
|
|
def get_frames_by_category(self, category: FrameCategory) -> List[FrameDefinition]:
|
|
"""Get all frames in a category"""
|
|
return [f for f in self.frames.values() if f.category == category]
|
|
|
|
def get_all_frames(self) -> List[FrameDefinition]:
|
|
"""Get all available frames"""
|
|
return list(self.frames.values())
|
|
|
|
def get_frame_names(self) -> List[str]:
|
|
"""Get list of all frame names"""
|
|
return list(self.frames.keys())
|
|
|
|
def _load_svg_as_image(
|
|
self,
|
|
svg_path: Path,
|
|
target_size: int,
|
|
color: Optional[Tuple[int, int, int]] = None,
|
|
) -> Optional[Image.Image]:
|
|
"""
|
|
Load an SVG file and render it to a PIL Image.
|
|
|
|
Args:
|
|
svg_path: Path to the SVG file
|
|
target_size: Target size in pixels for the corner
|
|
color: Optional color override as RGB tuple (0-255)
|
|
|
|
Returns:
|
|
PIL Image with alpha channel, or None if loading fails
|
|
"""
|
|
try:
|
|
import cairosvg
|
|
except ImportError:
|
|
print("Warning: cairosvg not installed, SVG frames will use fallback rendering")
|
|
return None
|
|
|
|
# Validate svg_path type
|
|
if not isinstance(svg_path, (str, Path)):
|
|
print(f"Warning: Invalid svg_path type: {type(svg_path)}, expected Path or str")
|
|
return None
|
|
|
|
# Ensure svg_path is a Path object
|
|
if isinstance(svg_path, str):
|
|
svg_path = Path(svg_path)
|
|
|
|
if not svg_path.exists():
|
|
return None
|
|
|
|
try:
|
|
# Read SVG content
|
|
svg_content = svg_path.read_text()
|
|
|
|
# Apply color override if specified
|
|
if color is not None:
|
|
svg_content = self._recolor_svg(svg_content, color)
|
|
|
|
# Render SVG to PNG bytes
|
|
png_data = cairosvg.svg2png(
|
|
bytestring=svg_content.encode("utf-8"),
|
|
output_width=target_size,
|
|
output_height=target_size,
|
|
)
|
|
|
|
# Load as PIL Image
|
|
img = Image.open(io.BytesIO(png_data))
|
|
if img.mode != "RGBA":
|
|
img = img.convert("RGBA")
|
|
|
|
# Force load the image data to avoid issues with BytesIO going out of scope
|
|
img.load()
|
|
|
|
return img
|
|
|
|
except Exception as e:
|
|
print(f"Error loading SVG {svg_path}: {e}")
|
|
return None
|
|
|
|
def _recolor_svg(self, svg_content: str, color: Tuple[int, int, int]) -> str:
|
|
"""
|
|
Recolor an SVG by replacing fill and stroke colors.
|
|
|
|
Args:
|
|
svg_content: SVG file content as string
|
|
color: New color as RGB tuple (0-255)
|
|
|
|
Returns:
|
|
Modified SVG content with new colors
|
|
"""
|
|
r, g, b = color
|
|
hex_color = f"#{r:02x}{g:02x}{b:02x}"
|
|
rgb_color = f"rgb({r},{g},{b})"
|
|
|
|
# Replace common color patterns
|
|
# Replace fill colors (hex, rgb, named colors)
|
|
svg_content = re.sub(
|
|
r'fill\s*[:=]\s*["\']?(?:#[0-9a-fA-F]{3,6}|rgb\([^)]+\)|black|white|none)["\']?',
|
|
f'fill="{hex_color}"',
|
|
svg_content,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
|
|
# Replace stroke colors
|
|
svg_content = re.sub(
|
|
r'stroke\s*[:=]\s*["\']?(?:#[0-9a-fA-F]{3,6}|rgb\([^)]+\)|black|white)["\']?',
|
|
f'stroke="{hex_color}"',
|
|
svg_content,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
|
|
# Replace style-based fill/stroke
|
|
svg_content = re.sub(
|
|
r"(fill\s*:\s*)(?:#[0-9a-fA-F]{3,6}|rgb\([^)]+\)|black|white)",
|
|
f"\\1{hex_color}",
|
|
svg_content,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
svg_content = re.sub(
|
|
r"(stroke\s*:\s*)(?:#[0-9a-fA-F]{3,6}|rgb\([^)]+\)|black|white)",
|
|
f"\\1{hex_color}",
|
|
svg_content,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
|
|
return svg_content
|
|
|
|
def _get_corner_image(
|
|
self,
|
|
frame: FrameDefinition,
|
|
corner_size: int,
|
|
color: Tuple[int, int, int],
|
|
) -> Optional[Image.Image]:
|
|
"""
|
|
Get a corner image, using cache if available.
|
|
|
|
Args:
|
|
frame: Frame definition
|
|
corner_size: Size in pixels
|
|
color: Color as RGB tuple
|
|
|
|
Returns:
|
|
PIL Image or None
|
|
"""
|
|
cache_key = (color, corner_size)
|
|
|
|
if cache_key in frame._image_cache:
|
|
return frame._image_cache[cache_key]
|
|
|
|
if frame.asset_path:
|
|
svg_path = self._frames_dir / frame.asset_path
|
|
img = self._load_svg_as_image(svg_path, corner_size, color)
|
|
if img:
|
|
frame._image_cache[cache_key] = img
|
|
return img
|
|
|
|
return None
|
|
|
|
def render_frame_opengl(
|
|
self,
|
|
frame_name: str,
|
|
x: float,
|
|
y: float,
|
|
width: float,
|
|
height: float,
|
|
color: Tuple[int, int, int] = (0, 0, 0),
|
|
thickness: Optional[float] = None,
|
|
corners: Optional[Tuple[bool, bool, bool, bool]] = None,
|
|
):
|
|
"""
|
|
Render a decorative frame using OpenGL.
|
|
|
|
Args:
|
|
frame_name: Name of the frame to render
|
|
x, y: Position of the image
|
|
width, height: Size of the image
|
|
color: Frame color as RGB (0-255)
|
|
thickness: Frame thickness (None = use default)
|
|
corners: Which corners to render (TL, TR, BR, BL). None = all corners
|
|
"""
|
|
frame = self.get_frame(frame_name)
|
|
if not frame:
|
|
return
|
|
|
|
# Default to all corners if not specified
|
|
if corners is None:
|
|
corners = (True, True, True, True)
|
|
|
|
from pyPhotoAlbum.gl_imports import (
|
|
glColor3f,
|
|
glColor4f,
|
|
glBegin,
|
|
glEnd,
|
|
glVertex2f,
|
|
GL_LINE_LOOP,
|
|
glLineWidth,
|
|
glEnable,
|
|
glDisable,
|
|
GL_BLEND,
|
|
glBlendFunc,
|
|
GL_SRC_ALPHA,
|
|
GL_ONE_MINUS_SRC_ALPHA,
|
|
GL_TEXTURE_2D,
|
|
glBindTexture,
|
|
glTexCoord2f,
|
|
GL_QUADS,
|
|
)
|
|
|
|
# Calculate thickness
|
|
shorter_side = min(width, height)
|
|
frame_thickness = thickness if thickness else (shorter_side * frame.default_thickness / 100)
|
|
|
|
glEnable(GL_BLEND)
|
|
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
|
|
|
|
# Try to render with SVG asset if available
|
|
if frame.asset_path and frame.frame_type == FrameType.CORNERS:
|
|
corner_size = int(frame_thickness * 2)
|
|
if self._render_svg_corners_gl(frame, x, y, width, height, corner_size, color, corners):
|
|
glDisable(GL_BLEND)
|
|
return
|
|
|
|
# Fall back to programmatic rendering
|
|
r, g, b = color[0] / 255.0, color[1] / 255.0, color[2] / 255.0
|
|
glColor3f(r, g, b)
|
|
|
|
if frame.frame_type == FrameType.CORNERS:
|
|
self._render_corner_frame_gl(x, y, width, height, frame_thickness, frame_name, corners)
|
|
elif frame.frame_type == FrameType.FULL:
|
|
self._render_full_frame_gl(x, y, width, height, frame_thickness)
|
|
|
|
glDisable(GL_BLEND)
|
|
|
|
def _render_svg_corners_gl(
|
|
self,
|
|
frame: FrameDefinition,
|
|
x: float,
|
|
y: float,
|
|
w: float,
|
|
h: float,
|
|
corner_size: int,
|
|
color: Tuple[int, int, int],
|
|
corners: Tuple[bool, bool, bool, bool],
|
|
) -> bool:
|
|
"""
|
|
Render SVG-based corners using OpenGL textures.
|
|
|
|
Returns True if rendering was successful, False to fall back to programmatic.
|
|
"""
|
|
from pyPhotoAlbum.gl_imports import (
|
|
glEnable,
|
|
glDisable,
|
|
glBindTexture,
|
|
glTexCoord2f,
|
|
glVertex2f,
|
|
glBegin,
|
|
glEnd,
|
|
glColor4f,
|
|
GL_TEXTURE_2D,
|
|
GL_QUADS,
|
|
glGenTextures,
|
|
glTexParameteri,
|
|
glTexImage2D,
|
|
GL_TEXTURE_MIN_FILTER,
|
|
GL_TEXTURE_MAG_FILTER,
|
|
GL_LINEAR,
|
|
GL_RGBA,
|
|
GL_UNSIGNED_BYTE,
|
|
)
|
|
|
|
# Get or create corner image
|
|
corner_img = self._get_corner_image(frame, corner_size, color)
|
|
if corner_img is None:
|
|
return False
|
|
|
|
# Create texture if not cached
|
|
cache_key = (color, corner_size, "texture")
|
|
if cache_key not in frame._texture_cache:
|
|
img_data = corner_img.tobytes()
|
|
texture_id = glGenTextures(1)
|
|
glBindTexture(GL_TEXTURE_2D, texture_id)
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
|
|
glTexImage2D(
|
|
GL_TEXTURE_2D,
|
|
0,
|
|
GL_RGBA,
|
|
corner_img.width,
|
|
corner_img.height,
|
|
0,
|
|
GL_RGBA,
|
|
GL_UNSIGNED_BYTE,
|
|
img_data,
|
|
)
|
|
frame._texture_cache[cache_key] = texture_id
|
|
|
|
texture_id = frame._texture_cache[cache_key]
|
|
|
|
# Render corners
|
|
glEnable(GL_TEXTURE_2D)
|
|
glBindTexture(GL_TEXTURE_2D, texture_id)
|
|
glColor4f(1.0, 1.0, 1.0, 1.0) # White to show texture colors
|
|
|
|
tl, tr, br, bl = corners
|
|
cs = float(corner_size)
|
|
|
|
# Helper to draw a textured quad with optional flipping
|
|
# flip_h: flip horizontally, flip_v: flip vertically
|
|
def draw_corner_quad(cx, cy, flip_h=False, flip_v=False):
|
|
# Calculate texture coordinates based on flipping
|
|
u0, u1 = (1, 0) if flip_h else (0, 1)
|
|
v0, v1 = (1, 0) if flip_v else (0, 1)
|
|
|
|
glBegin(GL_QUADS)
|
|
glTexCoord2f(u0, v0)
|
|
glVertex2f(cx, cy)
|
|
glTexCoord2f(u1, v0)
|
|
glVertex2f(cx + cs, cy)
|
|
glTexCoord2f(u1, v1)
|
|
glVertex2f(cx + cs, cy + cs)
|
|
glTexCoord2f(u0, v1)
|
|
glVertex2f(cx, cy + cs)
|
|
glEnd()
|
|
|
|
# Calculate flips based on the asset's designed corner vs target corner
|
|
# Each SVG is designed for a specific corner (asset_corner field)
|
|
# To render it at a different corner, we flip horizontally and/or vertically
|
|
#
|
|
# Corner positions:
|
|
# tl (top-left) tr (top-right)
|
|
# bl (bottom-left) br (bottom-right)
|
|
#
|
|
# To go from asset corner to target corner:
|
|
# - flip_h if horizontal position differs (l->r or r->l)
|
|
# - flip_v if vertical position differs (t->b or b->t)
|
|
|
|
asset_corner = frame.asset_corner # e.g., "tl", "bl", "br", "tr"
|
|
asset_h = asset_corner[1] # 'l' or 'r'
|
|
asset_v = asset_corner[0] # 't' or 'b'
|
|
|
|
def get_flips(target_corner: str) -> Tuple[bool, bool]:
|
|
"""Calculate flip_h, flip_v to transform from asset_corner to target_corner"""
|
|
target_h = target_corner[1] # 'l' or 'r'
|
|
target_v = target_corner[0] # 't' or 'b'
|
|
flip_h = asset_h != target_h
|
|
flip_v = asset_v != target_v
|
|
return flip_h, flip_v
|
|
|
|
# Top-left corner
|
|
if tl:
|
|
flip_h, flip_v = get_flips("tl")
|
|
draw_corner_quad(x, y, flip_h=flip_h, flip_v=flip_v)
|
|
|
|
# Top-right corner
|
|
if tr:
|
|
flip_h, flip_v = get_flips("tr")
|
|
draw_corner_quad(x + w - cs, y, flip_h=flip_h, flip_v=flip_v)
|
|
|
|
# Bottom-right corner
|
|
if br:
|
|
flip_h, flip_v = get_flips("br")
|
|
draw_corner_quad(x + w - cs, y + h - cs, flip_h=flip_h, flip_v=flip_v)
|
|
|
|
# Bottom-left corner
|
|
if bl:
|
|
flip_h, flip_v = get_flips("bl")
|
|
draw_corner_quad(x, y + h - cs, flip_h=flip_h, flip_v=flip_v)
|
|
|
|
glDisable(GL_TEXTURE_2D)
|
|
return True
|
|
|
|
def _render_corner_frame_gl(
|
|
self,
|
|
x: float,
|
|
y: float,
|
|
w: float,
|
|
h: float,
|
|
thickness: float,
|
|
frame_name: str,
|
|
corners: Tuple[bool, bool, bool, bool] = (True, True, True, True),
|
|
):
|
|
"""Render corner-style frame decorations (programmatic fallback)."""
|
|
from pyPhotoAlbum.gl_imports import glBegin, glEnd, glVertex2f, glLineWidth, GL_LINE_STRIP
|
|
|
|
corner_size = thickness * 2
|
|
|
|
glLineWidth(2.0)
|
|
|
|
tl, tr, br, bl = corners
|
|
|
|
# Top-left corner
|
|
if tl:
|
|
glBegin(GL_LINE_STRIP)
|
|
glVertex2f(x, y + corner_size)
|
|
glVertex2f(x, y)
|
|
glVertex2f(x + corner_size, y)
|
|
glEnd()
|
|
|
|
# Top-right corner
|
|
if tr:
|
|
glBegin(GL_LINE_STRIP)
|
|
glVertex2f(x + w - corner_size, y)
|
|
glVertex2f(x + w, y)
|
|
glVertex2f(x + w, y + corner_size)
|
|
glEnd()
|
|
|
|
# Bottom-right corner
|
|
if br:
|
|
glBegin(GL_LINE_STRIP)
|
|
glVertex2f(x + w, y + h - corner_size)
|
|
glVertex2f(x + w, y + h)
|
|
glVertex2f(x + w - corner_size, y + h)
|
|
glEnd()
|
|
|
|
# Bottom-left corner
|
|
if bl:
|
|
glBegin(GL_LINE_STRIP)
|
|
glVertex2f(x + corner_size, y + h)
|
|
glVertex2f(x, y + h)
|
|
glVertex2f(x, y + h - corner_size)
|
|
glEnd()
|
|
|
|
# Add decorative swirls for vintage frames
|
|
if "leafy" in frame_name or "ornate" in frame_name or "flourish" in frame_name:
|
|
self._render_decorative_swirls_gl(x, y, w, h, corner_size, corners)
|
|
|
|
glLineWidth(1.0)
|
|
|
|
def _render_decorative_swirls_gl(
|
|
self,
|
|
x: float,
|
|
y: float,
|
|
w: float,
|
|
h: float,
|
|
size: float,
|
|
corners: Tuple[bool, bool, bool, bool] = (True, True, True, True),
|
|
):
|
|
"""Render decorative swirl elements at corners (programmatic fallback)."""
|
|
from pyPhotoAlbum.gl_imports import glBegin, glEnd, glVertex2f, GL_LINE_STRIP
|
|
import math
|
|
|
|
steps = 8
|
|
radius = size * 0.4
|
|
|
|
tl, tr, br, bl = corners
|
|
|
|
corner_data = [
|
|
(tl, x + size * 0.5, y + size * 0.5, math.pi),
|
|
(tr, x + w - size * 0.5, y + size * 0.5, math.pi * 1.5),
|
|
(br, x + w - size * 0.5, y + h - size * 0.5, 0),
|
|
(bl, x + size * 0.5, y + h - size * 0.5, math.pi * 0.5),
|
|
]
|
|
|
|
for enabled, cx, cy, start_angle in corner_data:
|
|
if not enabled:
|
|
continue
|
|
glBegin(GL_LINE_STRIP)
|
|
for i in range(steps + 1):
|
|
angle = start_angle + (math.pi * 0.5 * i / steps)
|
|
px = cx + radius * math.cos(angle)
|
|
py = cy + radius * math.sin(angle)
|
|
glVertex2f(px, py)
|
|
glEnd()
|
|
|
|
def _render_full_frame_gl(self, x: float, y: float, w: float, h: float, thickness: float):
|
|
"""Render full-border frame (programmatic)"""
|
|
from pyPhotoAlbum.gl_imports import glBegin, glEnd, glVertex2f, GL_LINE_LOOP, glLineWidth
|
|
|
|
glLineWidth(max(1.0, thickness * 0.5))
|
|
glBegin(GL_LINE_LOOP)
|
|
glVertex2f(x - thickness * 0.5, y - thickness * 0.5)
|
|
glVertex2f(x + w + thickness * 0.5, y - thickness * 0.5)
|
|
glVertex2f(x + w + thickness * 0.5, y + h + thickness * 0.5)
|
|
glVertex2f(x - thickness * 0.5, y + h + thickness * 0.5)
|
|
glEnd()
|
|
|
|
glBegin(GL_LINE_LOOP)
|
|
glVertex2f(x + thickness * 0.3, y + thickness * 0.3)
|
|
glVertex2f(x + w - thickness * 0.3, y + thickness * 0.3)
|
|
glVertex2f(x + w - thickness * 0.3, y + h - thickness * 0.3)
|
|
glVertex2f(x + thickness * 0.3, y + h - thickness * 0.3)
|
|
glEnd()
|
|
|
|
glLineWidth(1.0)
|
|
|
|
def render_frame_pdf(
|
|
self,
|
|
canvas,
|
|
frame_name: str,
|
|
x_pt: float,
|
|
y_pt: float,
|
|
width_pt: float,
|
|
height_pt: float,
|
|
color: Tuple[int, int, int] = (0, 0, 0),
|
|
thickness_pt: Optional[float] = None,
|
|
corners: Optional[Tuple[bool, bool, bool, bool]] = None,
|
|
):
|
|
"""
|
|
Render a decorative frame on a PDF canvas.
|
|
|
|
Args:
|
|
canvas: ReportLab canvas
|
|
frame_name: Name of the frame to render
|
|
x_pt, y_pt: Position in points
|
|
width_pt, height_pt: Size in points
|
|
color: Frame color as RGB (0-255)
|
|
thickness_pt: Frame thickness in points (None = use default)
|
|
corners: Which corners to render (TL, TR, BR, BL). None = all corners
|
|
"""
|
|
frame = self.get_frame(frame_name)
|
|
if not frame:
|
|
return
|
|
|
|
if corners is None:
|
|
corners = (True, True, True, True)
|
|
|
|
shorter_side = min(width_pt, height_pt)
|
|
frame_thickness = thickness_pt if thickness_pt else (shorter_side * frame.default_thickness / 100)
|
|
|
|
r, g, b = color[0] / 255.0, color[1] / 255.0, color[2] / 255.0
|
|
|
|
canvas.saveState()
|
|
canvas.setStrokeColorRGB(r, g, b)
|
|
canvas.setLineWidth(max(0.5, frame_thickness * 0.3))
|
|
|
|
# Try SVG rendering for PDF
|
|
if frame.asset_path and frame.frame_type == FrameType.CORNERS:
|
|
corner_size_pt = frame_thickness * 2
|
|
if self._render_svg_corners_pdf(canvas, frame, x_pt, y_pt, width_pt, height_pt, corner_size_pt, color, corners):
|
|
canvas.restoreState()
|
|
return
|
|
|
|
# Fall back to programmatic
|
|
if frame.frame_type == FrameType.CORNERS:
|
|
self._render_corner_frame_pdf(canvas, x_pt, y_pt, width_pt, height_pt, frame_thickness, frame_name, corners)
|
|
elif frame.frame_type == FrameType.FULL:
|
|
self._render_full_frame_pdf(canvas, x_pt, y_pt, width_pt, height_pt, frame_thickness)
|
|
|
|
canvas.restoreState()
|
|
|
|
def _render_svg_corners_pdf(
|
|
self,
|
|
canvas,
|
|
frame: FrameDefinition,
|
|
x: float,
|
|
y: float,
|
|
w: float,
|
|
h: float,
|
|
corner_size_pt: float,
|
|
color: Tuple[int, int, int],
|
|
corners: Tuple[bool, bool, bool, bool],
|
|
) -> bool:
|
|
"""Render SVG corners on PDF canvas. Returns True if successful."""
|
|
from reportlab.lib.utils import ImageReader
|
|
|
|
# Get corner image at high resolution for PDF
|
|
corner_size_px = int(corner_size_pt * 4) # 4x for PDF quality
|
|
if corner_size_px < 1:
|
|
corner_size_px = 1
|
|
corner_img = self._get_corner_image(frame, corner_size_px, color)
|
|
if corner_img is None:
|
|
return False
|
|
|
|
tl, tr, br, bl = corners
|
|
cs = corner_size_pt
|
|
|
|
# For PDF, we use PIL to flip the image rather than canvas transformations
|
|
# This is more reliable across different PDF renderers
|
|
def get_flipped_image(target_corner: str) -> Image.Image:
|
|
"""Get image flipped appropriately for the target corner"""
|
|
asset_corner = frame.asset_corner
|
|
asset_h = asset_corner[1] # 'l' or 'r'
|
|
asset_v = asset_corner[0] # 't' or 'b'
|
|
target_h = target_corner[1]
|
|
target_v = target_corner[0]
|
|
|
|
img = corner_img.copy()
|
|
|
|
# Flip horizontally if h position differs
|
|
if asset_h != target_h:
|
|
img = img.transpose(Image.FLIP_LEFT_RIGHT)
|
|
|
|
# Flip vertically if v position differs
|
|
if asset_v != target_v:
|
|
img = img.transpose(Image.FLIP_TOP_BOTTOM)
|
|
|
|
return img
|
|
|
|
# Note: PDF Y-axis is bottom-up, so corners are positioned differently
|
|
# Top-left in screen coordinates = high Y in PDF
|
|
if tl:
|
|
img = get_flipped_image("tl")
|
|
img_reader = ImageReader(img)
|
|
canvas.drawImage(img_reader, x, y + h - cs, cs, cs, mask="auto")
|
|
|
|
# Top-right
|
|
if tr:
|
|
img = get_flipped_image("tr")
|
|
img_reader = ImageReader(img)
|
|
canvas.drawImage(img_reader, x + w - cs, y + h - cs, cs, cs, mask="auto")
|
|
|
|
# Bottom-right
|
|
if br:
|
|
img = get_flipped_image("br")
|
|
img_reader = ImageReader(img)
|
|
canvas.drawImage(img_reader, x + w - cs, y, cs, cs, mask="auto")
|
|
|
|
# Bottom-left
|
|
if bl:
|
|
img = get_flipped_image("bl")
|
|
img_reader = ImageReader(img)
|
|
canvas.drawImage(img_reader, x, y, cs, cs, mask="auto")
|
|
|
|
return True
|
|
|
|
def _render_corner_frame_pdf(
|
|
self,
|
|
canvas,
|
|
x: float,
|
|
y: float,
|
|
w: float,
|
|
h: float,
|
|
thickness: float,
|
|
frame_name: str,
|
|
corners: Tuple[bool, bool, bool, bool] = (True, True, True, True),
|
|
):
|
|
"""Render corner-style frame on PDF (programmatic fallback)."""
|
|
corner_size = thickness * 2
|
|
tl, tr, br, bl = corners
|
|
|
|
path = canvas.beginPath()
|
|
|
|
if tl:
|
|
path.moveTo(x, y + h - corner_size)
|
|
path.lineTo(x, y + h)
|
|
path.lineTo(x + corner_size, y + h)
|
|
|
|
if tr:
|
|
path.moveTo(x + w - corner_size, y + h)
|
|
path.lineTo(x + w, y + h)
|
|
path.lineTo(x + w, y + h - corner_size)
|
|
|
|
if br:
|
|
path.moveTo(x + w, y + corner_size)
|
|
path.lineTo(x + w, y)
|
|
path.lineTo(x + w - corner_size, y)
|
|
|
|
if bl:
|
|
path.moveTo(x + corner_size, y)
|
|
path.lineTo(x, y)
|
|
path.lineTo(x, y + corner_size)
|
|
|
|
canvas.drawPath(path, stroke=1, fill=0)
|
|
|
|
def _render_full_frame_pdf(self, canvas, x: float, y: float, w: float, h: float, thickness: float):
|
|
"""Render full-border frame on PDF"""
|
|
canvas.rect(
|
|
x - thickness * 0.5,
|
|
y - thickness * 0.5,
|
|
w + thickness,
|
|
h + thickness,
|
|
stroke=1,
|
|
fill=0,
|
|
)
|
|
|
|
canvas.rect(
|
|
x + thickness * 0.3,
|
|
y + thickness * 0.3,
|
|
w - thickness * 0.6,
|
|
h - thickness * 0.6,
|
|
stroke=1,
|
|
fill=0,
|
|
)
|
|
|
|
|
|
# Global frame manager instance
|
|
_frame_manager: Optional[FrameManager] = None
|
|
|
|
|
|
def get_frame_manager() -> FrameManager:
|
|
"""Get the global frame manager instance"""
|
|
global _frame_manager
|
|
if _frame_manager is None:
|
|
_frame_manager = FrameManager()
|
|
return _frame_manager
|