pyPhotoAlbum/pyPhotoAlbum/frame_manager.py
Duncan Tourolle 54cc78783a
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
Added styling
Improved pdf generation speed
2026-01-01 13:37:14 +01:00

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