Added styling
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
This commit is contained in:
Duncan Tourolle 2026-01-01 13:37:14 +01:00
parent cf27d9ebee
commit 54cc78783a
27 changed files with 7984 additions and 98 deletions

View File

@ -84,19 +84,19 @@ install_package() {
case "$install_mode" in
system)
print_info "Installing pyPhotoAlbum system-wide..."
sudo pip install .
sudo pip install --upgrade .
;;
venv)
print_info "Installing pyPhotoAlbum in virtual environment..."
pip install .
pip install --upgrade .
;;
user-force)
print_info "Installing pyPhotoAlbum for current user (forcing --user)..."
pip install --user .
pip install --user --upgrade .
;;
*)
print_info "Installing pyPhotoAlbum for current user..."
pip install --user .
pip install --user --upgrade .
;;
esac
}

View File

@ -786,6 +786,8 @@ class CommandHistory:
for cmd_data in data.get("undo_stack", []):
cmd = self._deserialize_command(cmd_data, project)
if cmd:
# Fix up page_layout references for commands that need them
self._fixup_page_layout(cmd, project)
self.undo_stack.append(cmd)
# Deserialize redo stack
@ -793,8 +795,34 @@ class CommandHistory:
for cmd_data in data.get("redo_stack", []):
cmd = self._deserialize_command(cmd_data, project)
if cmd:
# Fix up page_layout references for commands that need them
self._fixup_page_layout(cmd, project)
self.redo_stack.append(cmd)
def _fixup_page_layout(self, cmd: Command, project):
"""
Fix up page_layout references after deserialization.
Commands like AddElementCommand store page_layout as None during
deserialization because the page_layout object doesn't exist yet.
This method finds the correct page_layout based on the element.
"""
# Check if command has a page_layout attribute that's None
if not hasattr(cmd, "page_layout") or cmd.page_layout is not None:
return
# Try to find the page containing this element
if hasattr(cmd, "element") and cmd.element:
element = cmd.element
for page in project.pages:
if element in page.layout.elements:
cmd.page_layout = page.layout
return
# Element not found in any page - use first page as fallback
# This can happen for newly added elements not yet in a page
if project.pages:
cmd.page_layout = project.pages[0].layout
# Command type registry for deserialization
_COMMAND_DESERIALIZERS = {
"add_element": AddElementCommand.deserialize,

View File

@ -0,0 +1,352 @@
"""
Frame picker dialog for pyPhotoAlbum
Dialog for selecting decorative frames to apply to images.
"""
from typing import Optional, Tuple
from PyQt6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QLabel,
QPushButton,
QTabWidget,
QWidget,
QGridLayout,
QScrollArea,
QFrame,
QGroupBox,
QCheckBox,
)
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QPainter, QColor, QPen
from pyPhotoAlbum.frame_manager import get_frame_manager, FrameCategory, FrameDefinition, FrameType
class FramePreviewWidget(QFrame):
"""Widget that shows a preview of a frame"""
clicked = pyqtSignal(str) # Emits frame name when clicked
def __init__(self, frame: FrameDefinition, parent=None):
super().__init__(parent)
self.frame = frame
self.selected = False
self.setFixedSize(100, 100)
self.setFrameStyle(QFrame.Shape.Box)
self.setCursor(Qt.CursorShape.PointingHandCursor)
def paintEvent(self, event):
super().paintEvent(event)
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# Background
if self.selected:
painter.fillRect(self.rect(), QColor(200, 220, 255))
else:
painter.fillRect(self.rect(), QColor(245, 245, 245))
# Draw a simple preview of the frame style
margin = 15
rect = self.rect().adjusted(margin, margin, -margin, -margin)
# Draw "photo" placeholder
painter.fillRect(rect, QColor(180, 200, 220))
# Draw frame preview based on type
pen = QPen(QColor(80, 80, 80))
pen.setWidth(2)
painter.setPen(pen)
if self.frame.frame_type.value == "corners":
# Draw corner decorations
corner_size = 12
x, y, w, h = rect.x(), rect.y(), rect.width(), rect.height()
# Top-left
painter.drawLine(x, y + corner_size, x, y)
painter.drawLine(x, y, x + corner_size, y)
# Top-right
painter.drawLine(x + w - corner_size, y, x + w, y)
painter.drawLine(x + w, y, x + w, y + corner_size)
# Bottom-right
painter.drawLine(x + w, y + h - corner_size, x + w, y + h)
painter.drawLine(x + w, y + h, x + w - corner_size, y + h)
# Bottom-left
painter.drawLine(x + corner_size, y + h, x, y + h)
painter.drawLine(x, y + h, x, y + h - corner_size)
else:
# Draw full border
painter.drawRect(rect.adjusted(-3, -3, 3, 3))
painter.drawRect(rect)
# Draw frame name
painter.setPen(QColor(0, 0, 0))
text_rect = self.rect().adjusted(0, 0, 0, 0)
text_rect.setTop(self.rect().bottom() - 20)
painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, self.frame.display_name)
def mousePressEvent(self, event):
self.clicked.emit(self.frame.name)
def set_selected(self, selected: bool):
self.selected = selected
self.update()
class FramePickerDialog(QDialog):
"""Dialog for selecting a decorative frame"""
def __init__(
self,
parent,
current_frame: Optional[str] = None,
current_color: Tuple[int, int, int] = (0, 0, 0),
current_corners: Tuple[bool, bool, bool, bool] = (True, True, True, True),
):
super().__init__(parent)
self.setWindowTitle("Select Frame")
self.setMinimumSize(500, 500)
self.selected_frame: Optional[str] = current_frame
self.frame_color = current_color
self.frame_corners = current_corners # (TL, TR, BR, BL)
self.frame_widgets: dict[str, FramePreviewWidget] = {}
self._setup_ui()
def _setup_ui(self):
layout = QVBoxLayout(self)
# Tab widget for categories
self.tab_widget = QTabWidget()
# All frames tab
all_tab = self._create_category_tab(None)
self.tab_widget.addTab(all_tab, "All")
# Category tabs
for category in FrameCategory:
tab = self._create_category_tab(category)
self.tab_widget.addTab(tab, category.value.title())
layout.addWidget(self.tab_widget)
# Selected frame info
info_group = QGroupBox("Selected Frame")
info_layout = QVBoxLayout(info_group)
# Frame name and color row
name_color_layout = QHBoxLayout()
self.selected_label = QLabel("None")
name_color_layout.addWidget(self.selected_label)
# Color button
from pyPhotoAlbum.dialogs.style_dialogs import ColorButton
name_color_layout.addWidget(QLabel("Color:"))
self.color_btn = ColorButton(self.frame_color)
name_color_layout.addWidget(self.color_btn)
name_color_layout.addStretch()
info_layout.addLayout(name_color_layout)
# Corner selection (for corner-type frames)
self.corners_group = QGroupBox("Corner Decorations")
corners_layout = QGridLayout(self.corners_group)
# Create a visual grid for corner checkboxes
self.corner_tl = QCheckBox("Top-Left")
self.corner_tl.setChecked(self.frame_corners[0])
self.corner_tl.stateChanged.connect(self._update_corners)
self.corner_tr = QCheckBox("Top-Right")
self.corner_tr.setChecked(self.frame_corners[1])
self.corner_tr.stateChanged.connect(self._update_corners)
self.corner_br = QCheckBox("Bottom-Right")
self.corner_br.setChecked(self.frame_corners[2])
self.corner_br.stateChanged.connect(self._update_corners)
self.corner_bl = QCheckBox("Bottom-Left")
self.corner_bl.setChecked(self.frame_corners[3])
self.corner_bl.stateChanged.connect(self._update_corners)
corners_layout.addWidget(self.corner_tl, 0, 0)
corners_layout.addWidget(self.corner_tr, 0, 1)
corners_layout.addWidget(self.corner_bl, 1, 0)
corners_layout.addWidget(self.corner_br, 1, 1)
# Quick selection buttons
quick_btns_layout = QHBoxLayout()
all_btn = QPushButton("All")
all_btn.clicked.connect(self._select_all_corners)
none_btn = QPushButton("None")
none_btn.clicked.connect(self._select_no_corners)
diag_btn = QPushButton("Diagonal")
diag_btn.clicked.connect(self._select_diagonal_corners)
quick_btns_layout.addWidget(all_btn)
quick_btns_layout.addWidget(none_btn)
quick_btns_layout.addWidget(diag_btn)
quick_btns_layout.addStretch()
corners_layout.addLayout(quick_btns_layout, 2, 0, 1, 2)
info_layout.addWidget(self.corners_group)
layout.addWidget(info_group)
# Update corners group visibility based on frame type
self._update_corners_visibility()
# Buttons
button_layout = QHBoxLayout()
clear_btn = QPushButton("No Frame")
clear_btn.clicked.connect(self._clear_selection)
button_layout.addWidget(clear_btn)
button_layout.addStretch()
ok_btn = QPushButton("OK")
ok_btn.clicked.connect(self.accept)
button_layout.addWidget(ok_btn)
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.reject)
button_layout.addWidget(cancel_btn)
layout.addLayout(button_layout)
# Update selection display
self._update_selection_display()
def _create_category_tab(self, category: Optional[FrameCategory]) -> QWidget:
"""Create a tab for a frame category"""
widget = QWidget()
layout = QVBoxLayout(widget)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
content = QWidget()
grid = QGridLayout(content)
grid.setSpacing(10)
frame_manager = get_frame_manager()
if category:
frames = frame_manager.get_frames_by_category(category)
else:
frames = frame_manager.get_all_frames()
row, col = 0, 0
max_cols = 4
for frame in frames:
preview = FramePreviewWidget(frame)
preview.clicked.connect(self._on_frame_clicked)
if frame.name == self.selected_frame:
preview.set_selected(True)
grid.addWidget(preview, row, col)
self.frame_widgets[frame.name] = preview
col += 1
if col >= max_cols:
col = 0
row += 1
# Add stretch at the bottom
grid.setRowStretch(row + 1, 1)
scroll.setWidget(content)
layout.addWidget(scroll)
return widget
def _on_frame_clicked(self, frame_name: str):
"""Handle frame selection"""
# Deselect previous
if self.selected_frame and self.selected_frame in self.frame_widgets:
self.frame_widgets[self.selected_frame].set_selected(False)
# Select new
self.selected_frame = frame_name
if frame_name in self.frame_widgets:
self.frame_widgets[frame_name].set_selected(True)
self._update_selection_display()
self._update_corners_visibility()
def _clear_selection(self):
"""Clear frame selection"""
if self.selected_frame and self.selected_frame in self.frame_widgets:
self.frame_widgets[self.selected_frame].set_selected(False)
self.selected_frame = None
self._update_selection_display()
self._update_corners_visibility()
def _update_selection_display(self):
"""Update the selected frame label"""
if self.selected_frame:
frame = get_frame_manager().get_frame(self.selected_frame)
if frame:
self.selected_label.setText(f"{frame.display_name} - {frame.description}")
else:
self.selected_label.setText(self.selected_frame)
else:
self.selected_label.setText("None")
def _update_corners(self):
"""Update corner selection from checkboxes"""
self.frame_corners = (
self.corner_tl.isChecked(),
self.corner_tr.isChecked(),
self.corner_br.isChecked(),
self.corner_bl.isChecked(),
)
def _update_corners_visibility(self):
"""Show/hide corners group based on selected frame type"""
if self.selected_frame:
frame = get_frame_manager().get_frame(self.selected_frame)
if frame and frame.frame_type == FrameType.CORNERS:
self.corners_group.setVisible(True)
return
self.corners_group.setVisible(False)
def _select_all_corners(self):
"""Select all corners"""
self.corner_tl.setChecked(True)
self.corner_tr.setChecked(True)
self.corner_br.setChecked(True)
self.corner_bl.setChecked(True)
self._update_corners()
def _select_no_corners(self):
"""Deselect all corners"""
self.corner_tl.setChecked(False)
self.corner_tr.setChecked(False)
self.corner_br.setChecked(False)
self.corner_bl.setChecked(False)
self._update_corners()
def _select_diagonal_corners(self):
"""Select diagonal corners (TL and BR)"""
self.corner_tl.setChecked(True)
self.corner_tr.setChecked(False)
self.corner_br.setChecked(True)
self.corner_bl.setChecked(False)
self._update_corners()
def get_values(self) -> Tuple[Optional[str], Tuple[int, int, int], Tuple[bool, bool, bool, bool]]:
"""Get selected frame name, color, and corner configuration"""
return self.selected_frame, self.color_btn.get_color(), self.frame_corners

View File

@ -0,0 +1,297 @@
"""
Style dialogs for pyPhotoAlbum
Dialogs for configuring image styling options:
- Corner radius
- Border (width and color)
- Drop shadow
"""
from typing import Tuple
from PyQt6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QLabel,
QSlider,
QSpinBox,
QDoubleSpinBox,
QPushButton,
QCheckBox,
QColorDialog,
QGroupBox,
QFormLayout,
QWidget,
)
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QColor
class CornerRadiusDialog(QDialog):
"""Dialog for setting corner radius"""
def __init__(self, parent, current_radius: float = 0.0):
super().__init__(parent)
self.setWindowTitle("Corner Radius")
self.setMinimumWidth(300)
layout = QVBoxLayout(self)
# Slider with label
slider_layout = QHBoxLayout()
slider_layout.addWidget(QLabel("Radius:"))
self.slider = QSlider(Qt.Orientation.Horizontal)
self.slider.setMinimum(0)
self.slider.setMaximum(50)
self.slider.setValue(int(current_radius))
self.slider.valueChanged.connect(self._on_slider_changed)
slider_layout.addWidget(self.slider)
self.value_label = QLabel(f"{int(current_radius)}%")
self.value_label.setMinimumWidth(40)
slider_layout.addWidget(self.value_label)
layout.addLayout(slider_layout)
# Preset buttons
preset_layout = QHBoxLayout()
for value, label in [(0, "None"), (5, "Slight"), (15, "Medium"), (25, "Large"), (50, "Circle")]:
btn = QPushButton(label)
btn.clicked.connect(lambda checked, v=value: self.slider.setValue(v))
preset_layout.addWidget(btn)
layout.addLayout(preset_layout)
# OK/Cancel buttons
button_layout = QHBoxLayout()
ok_btn = QPushButton("OK")
ok_btn.clicked.connect(self.accept)
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.reject)
button_layout.addStretch()
button_layout.addWidget(ok_btn)
button_layout.addWidget(cancel_btn)
layout.addLayout(button_layout)
def _on_slider_changed(self, value):
self.value_label.setText(f"{value}%")
def get_value(self) -> float:
return float(self.slider.value())
class ColorButton(QPushButton):
"""Button that shows a color and opens color picker on click"""
def __init__(self, color: Tuple[int, int, int], parent=None):
super().__init__(parent)
self.setFixedSize(40, 25)
self._color = color
self._update_style()
self.clicked.connect(self._pick_color)
def _update_style(self):
r, g, b = self._color
self.setStyleSheet(f"background-color: rgb({r}, {g}, {b}); border: 1px solid #666;")
def _pick_color(self):
r, g, b = self._color
initial = QColor(r, g, b)
color = QColorDialog.getColor(initial, self, "Select Color")
if color.isValid():
self._color = (color.red(), color.green(), color.blue())
self._update_style()
def get_color(self) -> Tuple[int, int, int]:
return self._color
class BorderDialog(QDialog):
"""Dialog for configuring border"""
def __init__(
self,
parent,
current_width: float = 0.0,
current_color: Tuple[int, int, int] = (0, 0, 0),
):
super().__init__(parent)
self.setWindowTitle("Border Settings")
self.setMinimumWidth(300)
layout = QVBoxLayout(self)
# Border width
width_layout = QHBoxLayout()
width_layout.addWidget(QLabel("Width (mm):"))
self.width_spin = QDoubleSpinBox()
self.width_spin.setRange(0, 20)
self.width_spin.setSingleStep(0.5)
self.width_spin.setValue(current_width)
self.width_spin.setDecimals(1)
width_layout.addWidget(self.width_spin)
layout.addLayout(width_layout)
# Border color
color_layout = QHBoxLayout()
color_layout.addWidget(QLabel("Color:"))
self.color_btn = ColorButton(current_color)
color_layout.addWidget(self.color_btn)
color_layout.addStretch()
layout.addLayout(color_layout)
# Preset buttons
preset_layout = QHBoxLayout()
presets = [
("None", 0, (0, 0, 0)),
("Thin Black", 0.5, (0, 0, 0)),
("White", 2, (255, 255, 255)),
("Gold", 1.5, (212, 175, 55)),
]
for label, width, color in presets:
btn = QPushButton(label)
btn.clicked.connect(lambda checked, w=width, c=color: self._apply_preset(w, c))
preset_layout.addWidget(btn)
layout.addLayout(preset_layout)
# OK/Cancel buttons
button_layout = QHBoxLayout()
ok_btn = QPushButton("OK")
ok_btn.clicked.connect(self.accept)
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.reject)
button_layout.addStretch()
button_layout.addWidget(ok_btn)
button_layout.addWidget(cancel_btn)
layout.addLayout(button_layout)
def _apply_preset(self, width, color):
self.width_spin.setValue(width)
self.color_btn._color = color
self.color_btn._update_style()
def get_values(self) -> Tuple[float, Tuple[int, int, int]]:
return self.width_spin.value(), self.color_btn.get_color()
class ShadowDialog(QDialog):
"""Dialog for configuring drop shadow"""
def __init__(
self,
parent,
enabled: bool = False,
offset: Tuple[float, float] = (2.0, 2.0),
blur: float = 3.0,
color: Tuple[int, int, int, int] = (0, 0, 0, 128),
):
super().__init__(parent)
self.setWindowTitle("Shadow Settings")
self.setMinimumWidth(350)
layout = QVBoxLayout(self)
# Enable checkbox
self.enabled_check = QCheckBox("Enable Drop Shadow")
self.enabled_check.setChecked(enabled)
self.enabled_check.stateChanged.connect(self._update_controls)
layout.addWidget(self.enabled_check)
# Settings group
self.settings_group = QGroupBox("Shadow Settings")
form = QFormLayout(self.settings_group)
# Offset X
self.offset_x = QDoubleSpinBox()
self.offset_x.setRange(-20, 20)
self.offset_x.setSingleStep(0.5)
self.offset_x.setValue(offset[0])
self.offset_x.setDecimals(1)
form.addRow("Offset X (mm):", self.offset_x)
# Offset Y
self.offset_y = QDoubleSpinBox()
self.offset_y.setRange(-20, 20)
self.offset_y.setSingleStep(0.5)
self.offset_y.setValue(offset[1])
self.offset_y.setDecimals(1)
form.addRow("Offset Y (mm):", self.offset_y)
# Blur
self.blur_spin = QDoubleSpinBox()
self.blur_spin.setRange(0, 20)
self.blur_spin.setSingleStep(0.5)
self.blur_spin.setValue(blur)
self.blur_spin.setDecimals(1)
form.addRow("Blur (mm):", self.blur_spin)
# Color
color_widget = QWidget()
color_layout = QHBoxLayout(color_widget)
color_layout.setContentsMargins(0, 0, 0, 0)
self.color_btn = ColorButton(color[:3])
color_layout.addWidget(self.color_btn)
color_layout.addStretch()
form.addRow("Color:", color_widget)
# Opacity
self.opacity_slider = QSlider(Qt.Orientation.Horizontal)
self.opacity_slider.setRange(0, 255)
self.opacity_slider.setValue(color[3] if len(color) > 3 else 128)
opacity_layout = QHBoxLayout()
opacity_layout.addWidget(self.opacity_slider)
self.opacity_label = QLabel(f"{self.opacity_slider.value()}")
self.opacity_label.setMinimumWidth(30)
opacity_layout.addWidget(self.opacity_label)
self.opacity_slider.valueChanged.connect(lambda v: self.opacity_label.setText(str(v)))
form.addRow("Opacity:", opacity_layout)
layout.addWidget(self.settings_group)
# Preset buttons
preset_layout = QHBoxLayout()
presets = [
("Subtle", True, (1.0, 1.0), 2.0, (0, 0, 0, 60)),
("Normal", True, (2.0, 2.0), 3.0, (0, 0, 0, 100)),
("Strong", True, (3.0, 3.0), 5.0, (0, 0, 0, 150)),
]
for label, en, off, bl, col in presets:
btn = QPushButton(label)
btn.clicked.connect(lambda checked, e=en, o=off, b=bl, c=col: self._apply_preset(e, o, b, c))
preset_layout.addWidget(btn)
layout.addLayout(preset_layout)
# OK/Cancel buttons
button_layout = QHBoxLayout()
ok_btn = QPushButton("OK")
ok_btn.clicked.connect(self.accept)
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.reject)
button_layout.addStretch()
button_layout.addWidget(ok_btn)
button_layout.addWidget(cancel_btn)
layout.addLayout(button_layout)
self._update_controls()
def _update_controls(self):
self.settings_group.setEnabled(self.enabled_check.isChecked())
def _apply_preset(self, enabled, offset, blur, color):
self.enabled_check.setChecked(enabled)
self.offset_x.setValue(offset[0])
self.offset_y.setValue(offset[1])
self.blur_spin.setValue(blur)
self.color_btn._color = color[:3]
self.color_btn._update_style()
self.opacity_slider.setValue(color[3] if len(color) > 3 else 128)
def get_values(self) -> Tuple[bool, Tuple[float, float], float, Tuple[int, int, int, int]]:
color_rgb = self.color_btn.get_color()
color_rgba = color_rgb + (self.opacity_slider.value(),)
return (
self.enabled_check.isChecked(),
(self.offset_x.value(), self.offset_y.value()),
self.blur_spin.value(),
color_rgba,
)

View File

@ -0,0 +1,925 @@
"""
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

View File

@ -0,0 +1,23 @@
Decorative Frame Assets - Credits and Licenses
===============================================
All decorative corner SVG assets in this directory are sourced from FreeSVG.org
and are released under the Creative Commons Zero (CC0) Public Domain license.
This means you can copy, modify, distribute, and use them for commercial purposes,
all without asking permission or providing attribution.
However, we gratefully acknowledge the following sources:
Corner Decorations
------------------
- corner_decoration.svg - FreeSVG.org (OpenClipart)
- corner_ornament.svg - FreeSVG.org (RebeccaRead/OpenClipart)
- floral_corner.svg - FreeSVG.org (OpenClipart)
- floral_flourish.svg - FreeSVG.org (OpenClipart)
- ornate_corner.svg - FreeSVG.org (OpenClipart)
- simple_corner.svg - FreeSVG.org (OpenClipart)
Source: https://freesvg.org
License: CC0 1.0 Universal (Public Domain)
License URL: https://creativecommons.org/publicdomain/zero/1.0/

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 1841.389 1732.463" enable-background="new 0 0 1841.389 1732.463" xml:space="preserve">
<path d="M184.653,96.295c1.15-14.484,13.733-31.753,38.55-29.904c29.204,4.076,42.908,45.411,8.953,64.39
c0.023,60.887,0.07,552.723-0.005,570.255c38.034,27.832,49.897,75.373,48.848,122.498
c-16.504,188.412-190.187,324.389-180.679,564.55c1.477,12.405-1.462,120.691,59.234,189.029
c41.404,45.424,106.154,65.484,144.66,61.721c0.01-0.217,0.025-0.653,0.035-0.87c-101.171-51.399-226.038-227.923-76.517-543.644
c63.962-135.204,126.934-191.678,97.933-292.904c25.013,1.679,55.741,13.99,68.385,38.53
c37.641,72.201-74.811,159.089-91.294,182.904c-0.638,1.483,1.241,1.122,1.804,0.386c66.486-54.828,80.583-14.788,224.163-55.398
c-2.803,29.004-17.49,60.919-43.306,81.97c-40.102,32.437-92.657,27.109-152.846,63.669c-24.711,15.401-40.752,35.538-47.473,52.427
c4.069-1.374,22.147-11.821,53.51-20.842c109.773-32.011,219.625,2.926,259.841,99.243
c73.343,179.044-170.407,316.569-276.348,182.592c-34.819-44.759-25.714-103.207,4.652-123.823c1.622-1.177,3.614-1.933,4.761-3.653
c-30.783-3.947-65.948,51.188-47.226,114.716c38.729,133.524,279.285,176.476,398.262,57.781
c38.612-37.569,68.479-108.457,44.547-155.743c-18.193-37.729-57.937-36.345-62.804-82.464
c-2.762-50.859,60.605-60.299,84.303-15.711c0.771,1.285,1.29,2.966,2.857,3.51c-7.765-45.051-47.815-113.135-83.839-140.67
c-0.01-0.227-0.025-0.682-0.035-0.91c30.333-7.572,51.561-4.551,59.704-4.4c-37.721-112.279,18.498-176.688,80.517-183.161
c27.057-4.285,78.192,10.172,77.007,48.813c0.526,20.185-15.404,39.847-20.195,22.592c-1.56-4.961,0.958-21.982-13.669-33.003
c-15.829-12.263-42.279-8.734-55.245,11.192c-55.269,81.238,181.193,219.377,102.995,317.394
c33.196-1.605,52.222,21.494,57.9,45.521c-18.135-0.985-21.631-11.204-71.475,71.109c-25.625,41.334-60.584,78.848-95.881,105.694
c-1.518,1.216-3.505,2.121-4.158,4.118c11.689-2.368,46.189-28.835,57.296-37.957c94.629-77.732,128.727-135.385,239.424-110.534
c21.531,5.01,30.999,9.577,34.833,10.718c-8.894,26.039-24.603,36.121-44.893,42.545c-0.114,0.267-0.341,0.801-0.455,1.068
c28.557,2.119,53.529,23.403,59.704,50.736c192.237,0.044,384.469,0.025,576.706,0.01c15.283-26.042,52.749-21.042,61.592,5.947
c13.052,39.741-43.46,63.559-63.071,24.535c-291.078,0.076-576.278-0.056-578.026,0.084c-1.33,1.127-1.953,2.828-2.951,4.232
c-22.205,31.744-58.788,21.901-64.816,18.573c2.645-0.292,5.314,0.049,7.974-0.143c42.13-2.471,40.518-54.133,11.672-72.681
c-10.145-7.151-30.452-11.674-43.336-12.779c-136.137-4.945-250.616,166.126-515.979,168.048
c1.288,50.475-52.655,93.797-141.526,83.018c-34.4-4.311-23.027-7.397-34.64-3.915c-73.552,24.828-155.421-4.746-198.095-56.308
c-55.492-62.957-83.424-182.369-66.126-297.437c3.442-23.872,15.723-70.315,5.596-122.873
c-4.835-25.755-15.503-52.649-15.518-78.341c-1.172-50.249,19.305-90.939,15.933-118.681c-0.198-2.333-0.539-4.657-0.593-6.99
c14.591,7.231,41.682,29.066,50.919,62.982c5.006,18.17,2.906,32.105,3.179,35.03c1.463-1.582,2.155-3.658,3.03-5.581
c34.931-81.401-63.977-103.566-14.129-222.571c50.281,12.177,83.149,48.884,78.129,111.483c-0.45,4.805-1.364,9.551-1.963,14.341
c4.373-3.68,46.006-80.086,40.829-149.831c-2.328-35.437-11.496-82.418-47.004-80.808c-15.512,2.457-19.603,12.066-29.662,15.36
c-26.231,8.804-40.365-43.123,11.029-60.757c6.946-2.229,14.084-4.331,21.455-4.192c0.01-31.06,0.073-537.774-0.04-562.953
C190.727,123.194,184.282,112.876,184.653,96.295 M396.031,1642.443c66.063,10.096,95.962-36.85,72.859-69.235
c-1.117-1.913-3.658-1.577-5.517-2.096c-170.088-34.001-211.965-148.234-205.194-199.84c1.73-71.28,57.756-112.691,104.834-103.786
c60.029,9.739,75.038,74.317,29.731,83.428c-6.238,1.475-20.58,2.308-28.099-4.123c-7.742-7.076-7.962-21.503-6.946-31.901
c-37.145,11.637-45.122,83.882,9.803,110.628c113.072,56.562,191.342-87.317,141.392-172.651
c-23.058-40.393-81.69-75.012-149.945-74.075C108.948,1186.177,135.34,1608.76,396.031,1642.443z"/>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 32 KiB

View File

@ -0,0 +1,522 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="120.2629 30.938 896.7742 728.0339" enable-background="new 120.2629 30.938 896.7742 728.0339" xml:space="preserve">
<g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="-1080.8132" y1="8.0304" x2="-1080.8132" y2="776.3375" gradientTransform="matrix(-1 0 0 -1 -212.8987 792)">
<stop offset="0" style="stop-color:#6E3600"/>
<stop offset="0.0338" style="stop-color:#7A4005"/>
<stop offset="0.0952" style="stop-color:#9A5A11"/>
<stop offset="0.1765" style="stop-color:#CE8424"/>
<stop offset="0.2" style="stop-color:#DE912A"/>
<stop offset="0.4299" style="stop-color:#834B00"/>
<stop offset="0.4638" style="stop-color:#915A0D"/>
<stop offset="0.5293" style="stop-color:#B78230"/>
<stop offset="0.6175" style="stop-color:#F2C167"/>
<stop offset="0.7387" style="stop-color:#D48C2E"/>
<stop offset="0.9045" style="stop-color:#825121"/>
</linearGradient>
<path fill="url(#SVGID_1_)" d="M887.6567,523.3495c-16.137-11.164-32.957-20.432-47.223-26.9
c-7.1289-15.531-16.168-41.627-11.465-72.766c6.3125-41.775,24.479-67.732,57.168-81.688
c24.383-10.408,52.9821-8.3306,78.465,5.7022c10.8361,5.9668,19.912,14.484,26.936,24.746
c-4.6406,5.5117-10.207,10.029-16.2791,13.287c-17.0291,9.1406-46.418,8.1289-57.965,6.6602
c-0.2773-0.043-1.3047-0.2051-2.8066-0.4258c-0.2695-0.0508-0.5176-0.0996-0.7344-0.1484l-0.0098,0.041
c-3.7168-0.5332-9.623-1.3164-14.525-1.6465c-1.0234-0.0703-2.0586-0.123-3.0762-0.1621
c-10.799-0.3965-18.965,1.0449-23.828,2.2871c5.1758-3.1816,15.377-7.5859,31.406-6.7969c8.4746,0.4141,17.084,2.7754,17.17,2.7988
l0.7441,0.207l0.3184-0.707c0.1758-0.3848,4.2246-9.5527-1.7324-15.789c-3.1777-3.3247-8.127-5.2017-14.312-5.4292
c-12.57-0.4639-27.473,5.9546-37.082,15.972c-7.873,8.207-18.135,22.557-18.236,22.701l-1.5586,2.1836l2.5586-0.791
c0.1387-0.043,14.328-4.3965,22.896-3.791c9.7988,0.6973,17.693,4.4238,23.457,7.1465c2.9004,1.3691,5.1914,2.4512,6.9023,2.7305
c1.248,0.2031,2.4668,0.3281,3.6191,0.3691c4.8418,0.1797,8.7012-1.0391,11.158-3.5254c1.8262-1.8496,2.7246-4.2695,2.6035-7
c-0.1738-3.8262-1.5469-6.5098-2.7227-8.1348c2.582,0.2695,5.6953,0.498,9.1641,0.625c14.15,0.5215,34.152-0.6426,47.4431-7.7773
c6.3184-3.3906,11.8979-7.8945,16.52-13.246c14.283,21.924,19.432,51.359,12.631,80.906
c-8.6328,37.512-44.062,64.533-84.244,64.252c-36.223-0.2656-67.562-16.895-72.91-38.713
c-3.7637-15.354,2.3691-24.92,7.5488-29.959l0.0254,0.0117l0.0645-0.1035c0.5957-0.5742,1.1777-1.0859,1.7285-1.5449
c2.8086-1.8633,9.2441-5.5059,15.807-6.8203c3.9023-0.7813,7.0644-1.2832,10.41-1.8144c4.3496-0.6895,8.8496-1.4004,15.631-2.8164
c5.6602-1.1836,10.158-2.918,13.488-4.5469c-3.1035,2.7109-6.9863,5.5938-10.473,6.6992l-1.6777,0.5352
c-5.2676,1.6836-8.748,2.7949-17.201,4.209c-11.451,1.9121-19.67,6.2305-22.549,11.844c-1.2578,2.4551-1.3672,5.0059-0.3047,7.1816
c2.043,4.1836,7.7832,8.8184,15.555,9.1035c4.0254,0.1484,8.0352-0.8809,11.92-3.0625c36.307-20.379,43.799-52.762,43.869-53.086
l0.8613-3.9121l-2.4551,3.1602c-0.0508,0.0664-5.2461,6.6504-13.275,7.8477c-2.4941,0.3711-5.7891,0.4746-10.076,0.3164
c-5.9394-0.2168-12.641-0.9023-18.551-1.5059c-2.0371-0.209-3.9707-0.4063-5.7344-0.5703
c-13.723-1.2734-23.355,1.1894-28.506,7.2832c-3.9004,4.6172-4.4121,10.596-3.5879,14.652c0.6895,3.3887,2.4492,5.2168,3.502,6.041
c-5.4219,5.5-11.617,15.609-7.7734,31.289c5.3574,21.865,35.566,38.709,71.049,40.016c1.1953,0.0449,2.3984,0.0703,3.6035,0.0781
c41.0179,0.2871,77.191-27.328,86.016-65.658c5.2832-22.953,3.7148-45.812-4.5371-66.105
c-2.4004-5.9062-5.332-11.459-8.7188-16.578c4.0332-4.9663,7.2734-10.602,9.5352-16.73c5.5-14.891,4.834-31.301-1.9297-47.455
c-7.7324-18.471-23.158-27.004-34.736-30.91c-13.209-4.456-28.936-5.2168-41.045-1.9829
c-13.154,3.5132-26.213,11.133-35.379,17.344c8.5-7.2988,16.848-13.414,24.262-18.844c0.5332-0.3906,1.0606-0.7759,1.5879-1.1626
c6.459-4.0015,27.248-16.797,42.1541-25.012c11.25-6.2007,25.412-16.56,29-30.694c0.8828-3.4795,3.2715-15.614-3.0625-25.656
c-2.6211-4.1582-6.3281-7.2202-11.051-9.1616c1.957-2.3613,3.8008-4.686,5.5293-6.9717c14.191-18.764,18.873-37.575,20.082-51.382
c1.2148,0.7315,3.1914,1.6401,5.6563,1.7305c2.8086,0.104,5.5313-0.876,8.0898-2.9106c4.8496-3.8599,6.0352-12.814,2.6445-19.961
c-5.9023-12.432-15.283-21.835-22.137-28.7c-2.1426-2.1484-3.9922-4.0029-5.4609-5.6636l-3.0273-3.4228l1.498,4.3223
c0.0352,0.1016,3.4941,10.234,1.5137,17.759c-1.5293,5.8066-2.584,7.8555-4.502,11.574c-0.7031,1.3599-1.498,2.9014-2.4766,4.9248
c-2.8906,5.977-3.5,11.614-1.6699,15.467c1.2168,2.5644,3.4746,4.2998,6.5254,5.0176c0.5664,0.1333,1.1973,0.2129,1.875,0.2383
c2.9551,0.1089,5.9922-0.8457,6.1211-0.8862l0.4746-0.1509l0.125-0.4824c0.5234-2.0376,1.2617-6.0317,1.2168-14.041
c-0.0098-1.9228,0.3887-14.694-2.5098-27.918c4.6328,10.495,4.375,43.917,4.457,39.991c-0.7637,13.745-4.9551,33.593-19.934,53.4
c-1.832,2.4209-3.7949,4.8862-5.8848,7.394c-1.2734-0.4258-2.6133-0.7773-4.0176-1.0537c-0.3613-0.0708-0.7285-0.1299-1.0977-0.186
c0.0215-4.8535,0.0039-10.68-0.0156-17.41c-0.0176-6.0903-0.0371-12.909-0.0313-20.393c0.0098-16.619,0.4004-29.825,2.5117-55.891
c2.373-29.333,11.838-51.479,11.934-51.699l1.8203-4.2051l-3.2793,3.1948c-0.9746,0.9526-23.986,23.555-30.176,44.466
c-5.4961,18.577-3.8594,39.297-3.4141,43.785c-2.6699,0.4761-10.564,2.5776-16.4301,11.286
c-6.0606,8.999-1.293,23.991-1.0898,24.625l0.9765,2.9976l0.7598-3.0586c0.0215-0.0791,2.0391-7.9541,7.8887-11.056
c3.0078-1.5952,6.5527-1.6572,10.537-0.1812c3.5098,1.2988,13.688,7.3154,15.561,33.27c-13.799-0.9946-46.008,6.3477-70.326,36.879
c3.9102-4.9927,7.3789-14.938,27.74-30.707c8.4316-6.5317,18.264-7.8892,18.264-7.8892c1.1367-3.2876-6.3691-17.875-21.438-12.273
c-12.324,4.582-16.855,12.851-21.545,24.757c-3.2168,8.1719-5.4668,15.952-9.1914,28.84c-1.3516,4.6768-2.8828,9.978-4.7441,16.287
l-1.4258,4.8286l3.0137-4.0273c0.1133-0.1514,5.252-7.3745,19.725-15.907c4.1856-2.4683,17.297-8.1352,22.029-9.2612
c9.1719-2.1831,16.174-2.5225,19.846-5.5674c2.8105-2.3306,4.4844-5.5024,4.709-8.9316c0.2031-3.1079-0.7891-6.0884-2.7227-8.1782
c-2.082-2.2471-4.293-3.3369-7.1113-3.5244c6.4824-1.9678,15.619-4.1001,23.295-3.4971c0.1387,2.4917,0.2012,5.1562,0.1777,8.0083
c-13.08,14.355-30.537,30.826-50.672,48.077c-12.467,10.68-48.738,44.198-72.203,96.934
c-11.709,26.317-15.332,39.254-19.951,57.201c-1.459,5.6641-2.8398,13.082-3.4727,20.01c-0.207,2.2832-1.8496,16.404-0.002,34.145
c1.8652,17.945,6.5996,31.643,14.631,46.486c-21.939-9.2871-36.643-9.3301-48.248-10.055
c-32.658-2.0391-52.726,8.5039-61.72,29.123c-5.4033,12.389-4.5,24.01,1.0869,35.9c3.3643,7.1602,10.064,11.18,17.334,13.498
c8.7446,2.793,16.02-0.4844,19.534-2.6445c0.8672,3.2246,3.9492,10.986,13.627,11.344c0.3984,0.0156,0.8105,0.0156,1.2324,0.0059
c13.764-0.377,17.842-10.854,21.119-19.273c0.8262-2.1211,1.6055-4.127,2.4824-5.8398l0.1152-0.2285
c6.7012-13.107,9.7598-19.092,21.262-22.375c6.0449-1.7285,13.666-2.1699,13.74-2.1758l3.4609-0.1914l-3.1133-1.5273
c-0.5137-0.25-12.666-6.1719-21.795-7.7891c-6.4883-1.1504-12.277-1.8066-17.695-2.0059
c-5.0879-0.1895-9.9414,0.0371-14.836,0.6855c-15.584,2.0664-23.102,7.7285-28.398,12.994
c-4.875,4.8457-8.6128,13.119-4.4844,21.801c2.4648,5.1856,9.5,9.4707,9.7988,9.6504l0.3145,0.1895l0.3594-0.084
c0.3828-0.0918,3.916-1.1582,9.8047-10.352c2.416-3.7754,8.3125-10.816,13.096-14.217c4.8555-3.4512,10.521-5.4082,15.316-6.5215
c-0.3496,0.1523-0.7051,0.3125-1.0664,0.4805c-0.4297,0.2012-0.8535,0.3926-1.2793,0.582
c-5.1328,2.2988-10.441,4.6777-23.02,22.174c-4.4883,6.2402-9.9375,10.402-9.9922,10.443l-0.2754,0.2051
c-2.8672,1.8887-10.127,5.6992-18.759,2.9453c-6.7812-2.166-12.225-7.0547-15.327-13.771
c-4.1641-9.0117-5.8398-22.045-0.2314-33.775c8.2344-17.229,23.646-26.377,56.033-26.543
c10.865-0.0547,30.229,1.8496,52.756,11.723c1.3711,2.3906,3.7227,6.9023,5.2539,9.2012c1.0742,1.6152,69.076,108.13,82.693,148.95
c16.91,50.688,24.33,92.812,5.0742,103.44l1.8145-0.123c21.67-4.6777,19.443-52.178,1.2344-103.01
c-24.691-68.932-68.994-120.9-83.703-146.78c-1.8887-2.9297-2.3828-3.9492-4.377-7.7285c13.576,6.3418,29.008,13.719,43.758,24.844
c32.684,24.654,46.0861,42.58,59.084,75.105c13.699,34.275,14.963,51.723,14.066,86.943
c-0.6035,23.672-11.3199,69.805-28.109,70.697l2.7832,0.1016c20.881-4.6816,32.105-39.494,33.541-70.293
c1.4961-32.117-4.2012-64.631-18.553-95.975c-14.8-32.2-31.7-48.67-61.61-69.39L887.6567,523.3495z M982.6667,192.5796
c4.7246,7.4936,4.5625,17.436,2.8359,24.241c-3.4258,13.504-17.182,23.524-28.1169,29.553
c-8.4844,4.6753-18.865,10.828-27.438,16.007c1.1211-0.9297,2.2109-1.855,3.2383-2.7759c13.639-12.22,28.078-28.297,31.746-54.893
c0.4004-2.9141,0.6738-5.9526,0.8086-9.0322c0.1523-1.1787,0.2617-2.8867,0.3379-5.0913c2.043-2.2583,3.9902-4.4854,5.8418-6.6807
c4.7,1.79,8.4,4.69,10.9,8.67L982.6667,192.5796z M966.1667,182.3696c0.2539,0.042,0.5098,0.0786,0.7598,0.1279
c1.0762,0.2119,2.1094,0.4761,3.1016,0.7803c-1.2637,1.4912-2.5762,2.998-3.9297,4.5176v-5.43L966.1667,182.3696z
M847.6767,331.0195c25.152-51.59,53.029-77.839,65.832-89.074c19.3051-16.936,37.5291-35.109,50.479-49.119
c-0.0215,0.895-0.0527,1.8018-0.0918,2.7319c-0.1328,3.0371-0.4004,6.0322-0.7949,8.9033c-3.5879,26.014-17.766,41.789-31.16,53.79
c-4.7578,4.2617-10.631,8.626-16.889,13.212c-1.207,0.7476-1.9004,1.1807-1.9434,1.2065l0.0938,0.1494
c-14.523,10.634-32.506,22.65-48.373,42.287c-12.816,15.861-21.521,26.165-33.588,57.312c6.1-19.26,8.76-25.57,16.47-41.4
L847.6767,331.0195z M822.6867,412.3695c0.8906-5.25,1.6719-10.775,3.209-16.295l0.0078,0.002
c0.0039-0.0137,0.0234-0.0938,0.0547-0.2207c7.5781-27.081,22.033-56.765,40.232-78.393c14.195-16.869,36.232-33.77,59.859-40.081
c21.879-5.8442,60.418,0.2354,73.652,31.843c10.146,24.24,5.0332,46.57-7.0508,61.845c-7.3145-10.55-16.6021-19.137-27.184-24.963
c-25.973-14.302-55.145-16.409-80.039-5.7812c-16.062,6.8564-28.406,16.28-37.74,28.811
c-10.389,13.943-18.254,30.246-21.666,52.822c-4.1445,27.439-0.875,41.41,4.1465,57.277
c-5.5644-16.051-6.6074-20.389-7.7832-34.848c-1.49-18.4,0.06-30.6,0.3-32.03L822.6867,412.3695z"/>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="-1083.3602" y1="8.0304" x2="-1083.3602" y2="776.3354" gradientTransform="matrix(-1 0 0 -1 -212.8987 792)">
<stop offset="0" style="stop-color:#6E3600"/>
<stop offset="0.0338" style="stop-color:#7A4005"/>
<stop offset="0.0952" style="stop-color:#9A5A11"/>
<stop offset="0.1765" style="stop-color:#CE8424"/>
<stop offset="0.2" style="stop-color:#DE912A"/>
<stop offset="0.4299" style="stop-color:#834B00"/>
<stop offset="0.4638" style="stop-color:#915A0D"/>
<stop offset="0.5293" style="stop-color:#B78230"/>
<stop offset="0.6175" style="stop-color:#F2C167"/>
<stop offset="0.7387" style="stop-color:#D48C2E"/>
<stop offset="0.9045" style="stop-color:#825121"/>
</linearGradient>
<path fill="url(#SVGID_2_)" d="M895.0468,653.3495c-7.2559-5.9922-17.59-13.248-27.584-20.268
c-10.689-7.5078-20.867-14.656-26.676-19.793c-2.6934-3.1387-5.6543-7.3594-8.4727-12.965
c-2.0606-5.1543-3.8691-11.217-5.3359-16.834c3.8242,9.2109,9.1211,19.686,15.174,25.275
c3.5508,3.2754,7.8555,5.0977,12.451,5.2695c7.8945,0.291,15.178-4.4805,16.945-11.096c2.7969-10.477-2.2344-18.893-16.312-27.285
c-1.1523-0.6855-2.2676-1.3457-3.3477-1.9863c-10.969-6.4883-18.215-10.777-23.34-19.176
c-4.2168-6.9102-7.0391-23.127-7.0684-23.291l-0.5938-3.4492l-1.1484,3.3066c-0.0723,0.2051-7.1348,20.604-10.883,37.168
c-1.2383,5.4707-3.373,18.004-3.5293,30.279c-0.1523,11.867,2.2109,22.33,6.4844,28.703c1.5644,2.3359,7.4453,9.9844,17.381,10.35
c2.1348,0.0801,4.3125-0.2012,6.4785-0.834c11.539-3.3691,14.426-10.637,15.109-13.295c4.7344,3.4727,10.123,7.2578,15.646,11.137
c9.9668,7,20.271,14.236,27.473,20.182c17.137,14.145,26.83,28.451,34.516,57.582c11.121,42.156-2.5918,46.002-2.5859,46.26
l1.0137,0.1133c11.9771-7.2266,9.6074-25.158,5.0254-46.818c-6.2-29.98-19.3-44.12-36.72-58.52L895.0468,653.3495z"/>
<linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="-1128.5793" y1="8.0304" x2="-1128.5793" y2="776.3384" gradientTransform="matrix(-1 0 0 -1 -212.8987 792)">
<stop offset="0" style="stop-color:#6E3600"/>
<stop offset="0.0338" style="stop-color:#7A4005"/>
<stop offset="0.0952" style="stop-color:#9A5A11"/>
<stop offset="0.1765" style="stop-color:#CE8424"/>
<stop offset="0.2" style="stop-color:#DE912A"/>
<stop offset="0.4299" style="stop-color:#834B00"/>
<stop offset="0.4638" style="stop-color:#915A0D"/>
<stop offset="0.5293" style="stop-color:#B78230"/>
<stop offset="0.6175" style="stop-color:#F2C167"/>
<stop offset="0.7387" style="stop-color:#D48C2E"/>
<stop offset="0.9045" style="stop-color:#825121"/>
</linearGradient>
<path fill="url(#SVGID_3_)" d="M948.0667,731.4296c0.0313-0.1367,2.7715-4.4941,5.7246-24.129
c1.2852-8.5527,2.0664-20.654,1.9512-25.713c-0.6738-29.553-3.9863-45.898-18.449-77.033
c-22.656-48.785-61.686-68.453-61.686-68.453s22.504,14.578,33.693,37.957c4.4102,9.2207,7.2617,21.076,8.7988,29.814
c0.7578,4.2969,0.8125,9.9219,3.377,16.66c4.7031,12.359,16.303,32.861,20.084,48.09c7.1,29.08,6.4,62.8,6.4,62.8
L948.0667,731.4296z"/>
<linearGradient id="SVGID_4_" gradientUnits="userSpaceOnUse" x1="-1200.0969" y1="-67.3895" x2="-1200.0969" y2="907.8705" gradientTransform="matrix(-1 0 0 -1 -212.8987 792)">
<stop offset="0" style="stop-color:#6E3600"/>
<stop offset="0.0338" style="stop-color:#7A4005"/>
<stop offset="0.0952" style="stop-color:#9A5A11"/>
<stop offset="0.1765" style="stop-color:#CE8424"/>
<stop offset="0.2" style="stop-color:#DE912A"/>
<stop offset="0.4299" style="stop-color:#834B00"/>
<stop offset="0.4638" style="stop-color:#915A0D"/>
<stop offset="0.5293" style="stop-color:#B78230"/>
<stop offset="0.6175" style="stop-color:#F2C167"/>
<stop offset="0.7387" style="stop-color:#D48C2E"/>
<stop offset="0.9045" style="stop-color:#825121"/>
</linearGradient>
<path fill="url(#SVGID_4_)" d="M1008.8668,508.6495l3.0723-4.6523l-4.3809,3.4375c-0.1953,0.1523-19.572,15.455-32.545,34.041
c-6.9043,9.8926-15.6071,26.627-11.496,57.619c1.1426,8.6191,2.2285,14.58,3.5156,19.766
c12.426,50.041,6.4258,70.041,6.4258,70.041s7.1123-38.802,6.666-58.995c-0.2539-11.492-1.6992-19.513-1.7891-29.157
c-0.0605-6.627-0.125-13.482,0.6934-20.758c1.6602-14.709,5.1738-26.893,7.9473-34.775c-1.8203,9.0527-4.1602,22.227-4.584,31.951
c-0.3496,8.0215,0.0898,14.385,0.5117,20.537c0.5547,8.0938,2.1602,19.493,2.0547,30.369
c-0.334,34.495-10.667,70.333-10.667,70.333s15.152-24.767,22.5-49.5c4.5674-15.373,7.2471-38.265,3.8057-58.909
c-2.7578-16.529-4.9355-36.346-0.1895-57.979c3.4-15.63,8.4-23.3,8.5-23.37L1008.8668,508.6495z"/>
<linearGradient id="SVGID_5_" gradientUnits="userSpaceOnUse" x1="-998.1218" y1="8.0204" x2="-998.1218" y2="776.3414" gradientTransform="matrix(-1 0 0 -1 -212.8987 792)">
<stop offset="0" style="stop-color:#6E3600"/>
<stop offset="0.0338" style="stop-color:#7A4005"/>
<stop offset="0.0952" style="stop-color:#9A5A11"/>
<stop offset="0.1765" style="stop-color:#CE8424"/>
<stop offset="0.2" style="stop-color:#DE912A"/>
<stop offset="0.4299" style="stop-color:#834B00"/>
<stop offset="0.4638" style="stop-color:#915A0D"/>
<stop offset="0.5293" style="stop-color:#B78230"/>
<stop offset="0.6175" style="stop-color:#F2C167"/>
<stop offset="0.7387" style="stop-color:#D48C2E"/>
<stop offset="0.9045" style="stop-color:#825121"/>
</linearGradient>
<path fill="url(#SVGID_5_)" d="M751.7667,448.5796c3.0098-1.9727,7.8574-3.0352,12.969-2.8477
c4.377,0.1602,8.5898,1.207,12.182,3.0254c6.3438,3.209,11.037,8.002,16.006,13.076c2.457,2.5078,4.9961,5.1016,7.793,7.5449
c10.066,8.7949,18.037,13.512,18.117,13.561l2.2598,1.3281l-0.9648-2.4453c-0.0371-0.0957-3.8144-9.7441-6.0547-22.213
c-5.0664-28.221-4.6875-40.334-1.0781-60.305c1.6309-9.0254,5.6289-20.043,9.8633-31.709
c3.9707-10.948,8.0781-22.269,10.584-32.915c6.7949-28.878-9.3496-54.282-9.5117-54.534l-2.3535-3.6377l0.7031,4.2788
c0.0254,0.1479,2.4199,14.868,1.2109,24.768c-1.6758,13.729-3.0859,19.245-8.8672,34.718
c-1.7871,4.7832-4.2383,9.2188-6.8301,13.915c-5.6894,10.301-12.137,21.978-14.945,42.494
c-2.6504,19.359,1.4922,35.984,4.541,44.898c-4.8652-6.2344-13.207-15.242-21.916-17.031
c-2.2695-0.4648-4.4844-0.7422-6.582-0.8203c-10.648-0.3906-19.08,4.2168-25.772,14.09c-6.0478,8.9238-7.3975,24.15-7.4512,24.795
l-0.335,3.9629l2.0117-3.4277c0.05-0.09,5.01-8.41,14.42-14.57L751.7667,448.5796z"/>
<linearGradient id="SVGID_6_" gradientUnits="userSpaceOnUse" x1="-1059.9657" y1="8.0304" x2="-1059.9657" y2="776.3395" gradientTransform="matrix(-1 0 0 -1 -212.8987 792)">
<stop offset="0" style="stop-color:#6E3600"/>
<stop offset="0.0338" style="stop-color:#7A4005"/>
<stop offset="0.0952" style="stop-color:#9A5A11"/>
<stop offset="0.1765" style="stop-color:#CE8424"/>
<stop offset="0.2" style="stop-color:#DE912A"/>
<stop offset="0.4299" style="stop-color:#834B00"/>
<stop offset="0.4638" style="stop-color:#915A0D"/>
<stop offset="0.5293" style="stop-color:#B78230"/>
<stop offset="0.6175" style="stop-color:#F2C167"/>
<stop offset="0.7387" style="stop-color:#D48C2E"/>
<stop offset="0.9045" style="stop-color:#825121"/>
</linearGradient>
<path fill="url(#SVGID_6_)" d="M856.2968,289.8695c8.7832-21.179,10.922-45.943,12.801-76.116
c2.041-32.708,21.543-49.919,21.74-50.089l3.7051-3.2017l-4.6016,1.6626c-0.2988,0.1084-30.117,11.15-40.463,37.731
c-2.9648,7.6221-2.0273,18.878-1.5039,23.263c-2.916-1.5288-9.4668-4.4551-17.334-4.7451
c-1.4824-0.0547-2.9609-0.0107-4.3906,0.1309c-20.393,2.0176-26.082,20.516-26.137,20.703l-0.5215,1.7671l1.7129-0.6655
c0.0684-0.0259,6.7637-2.585,14.959-2.2827c8.4883,0.3125,19.975,3.8267,26.486,18.792"/>
<linearGradient id="SVGID_7_" gradientUnits="userSpaceOnUse" x1="-1181.3455" y1="8.0304" x2="-1181.3455" y2="776.3375" gradientTransform="matrix(-1 0 0 -1 -212.8987 792)">
<stop offset="0" style="stop-color:#6E3600"/>
<stop offset="0.0338" style="stop-color:#7A4005"/>
<stop offset="0.0952" style="stop-color:#9A5A11"/>
<stop offset="0.1765" style="stop-color:#CE8424"/>
<stop offset="0.2" style="stop-color:#DE912A"/>
<stop offset="0.4299" style="stop-color:#834B00"/>
<stop offset="0.4638" style="stop-color:#915A0D"/>
<stop offset="0.5293" style="stop-color:#B78230"/>
<stop offset="0.6175" style="stop-color:#F2C167"/>
<stop offset="0.7387" style="stop-color:#D48C2E"/>
<stop offset="0.9045" style="stop-color:#825121"/>
</linearGradient>
<path fill="url(#SVGID_7_)" d="M996.2668,217.6096c0,0,1.1719,19.598-12.859,32.867c-9.6035,9.0815-53.432,19.486-53.432,19.486
s18.441-2.8774,30.1331-3.0195c12.355-0.1484,28.031,2.2422,39.104,15.141c4.8594,5.6631,7.6641,9.9522,7.6641,9.9522
s0.5781-9.5391-2.6113-16.313c-5.3086-11.276-14.7321-14.344-14.7321-14.344s3.6563-1.1738,8.2012-10.072
c6.1-12.12-1.5-33.71-1.5-33.71L996.2668,217.6096z"/>
<linearGradient id="SVGID_8_" gradientUnits="userSpaceOnUse" x1="-1130.5813" y1="8.0304" x2="-1130.5813" y2="776.3375" gradientTransform="matrix(-1 0 0 -1 -212.8987 792)">
<stop offset="0" style="stop-color:#6E3600"/>
<stop offset="0.0338" style="stop-color:#7A4005"/>
<stop offset="0.0952" style="stop-color:#9A5A11"/>
<stop offset="0.1765" style="stop-color:#CE8424"/>
<stop offset="0.2" style="stop-color:#DE912A"/>
<stop offset="0.4299" style="stop-color:#834B00"/>
<stop offset="0.4638" style="stop-color:#915A0D"/>
<stop offset="0.5293" style="stop-color:#B78230"/>
<stop offset="0.6175" style="stop-color:#F2C167"/>
<stop offset="0.7387" style="stop-color:#D48C2E"/>
<stop offset="0.9045" style="stop-color:#825121"/>
</linearGradient>
<path fill="url(#SVGID_8_)" d="M856.1967,355.4395c0,0,22.207-22.299,51.486-23.94c22.107-1.2383,32.951,0.9126,46.9059,6.4131
c8.5195,3.3589,10.699,4.9731,10.699,4.9731s8.2129-3.4805,11.359-10.959c7.7578-18.45-4.8594-31.607-4.8594-31.607
s-9.998,19.097-30.4919,18.216c-24.66-1.0596-33.445,1.1504-44.785,5.4507c-22.13,8.4-40.29,31.46-40.29,31.46L856.1967,355.4395z"
/>
<linearGradient id="SVGID_9_" gradientUnits="userSpaceOnUse" x1="-1174.0514" y1="8.0304" x2="-1174.0514" y2="776.3375" gradientTransform="matrix(-1 0 0 -1 -212.8987 792)">
<stop offset="0" style="stop-color:#6E3600"/>
<stop offset="0.0338" style="stop-color:#7A4005"/>
<stop offset="0.0952" style="stop-color:#9A5A11"/>
<stop offset="0.1765" style="stop-color:#CE8424"/>
<stop offset="0.2" style="stop-color:#DE912A"/>
<stop offset="0.4299" style="stop-color:#834B00"/>
<stop offset="0.4638" style="stop-color:#915A0D"/>
<stop offset="0.5293" style="stop-color:#B78230"/>
<stop offset="0.6175" style="stop-color:#F2C167"/>
<stop offset="0.7387" style="stop-color:#D48C2E"/>
<stop offset="0.9045" style="stop-color:#825121"/>
</linearGradient>
<path fill="url(#SVGID_9_)" d="M1004.6667,420.8896c0,0,6.4766,32.723-16.9139,62.533c-18.324,23.355-38.486,29.982-49.66,31.562
c-6.3594,0.8984-8.0059,0.6113-8.0059,0.6113c-2.9863-1.6172-7.2305-4.0449-11.564-13.117
c-4.4219-9.2559,1.1445-21.414,1.1445-21.414s14.932,10.25,29.324,7.9102c14.395-2.3418,28.068-5.2812,39.965-21.826
c13.4-18.53,15.8-46.25,15.8-46.25L1004.6667,420.8896z"/>
<path fill="#1B2851" d="M766.9367,413.7395c0,0-0.3809,0.3477-0.9785,0.9746c0.0449-0.043,0.0977-0.0918,0.1406-0.1328
c0.36-0.34,0.76-0.73,0.84-0.84L766.9367,413.7395z"/>
<path fill="#1B2851" d="M766.0167,426.3695c0,0-0.3809,0.3477-0.9785,0.9746c0.0449-0.043,0.0977-0.0918,0.1387-0.1328
c0.35-0.34,0.76-0.72,0.84-0.84L766.0167,426.3695z"/>
<linearGradient id="SVGID_10_" gradientUnits="userSpaceOnUse" x1="-876.9413" y1="1101.4695" x2="-876.9413" y2="1869.7794" gradientTransform="matrix(0.022 -0.9998 0.9998 0.022 -985.1675 -309.9955)">
<stop offset="0" style="stop-color:#6E3600"/>
<stop offset="0.0338" style="stop-color:#7A4005"/>
<stop offset="0.0952" style="stop-color:#9A5A11"/>
<stop offset="0.1765" style="stop-color:#CE8424"/>
<stop offset="0.2" style="stop-color:#DE912A"/>
<stop offset="0.4299" style="stop-color:#834B00"/>
<stop offset="0.4638" style="stop-color:#915A0D"/>
<stop offset="0.5293" style="stop-color:#B78230"/>
<stop offset="0.6175" style="stop-color:#F2C167"/>
<stop offset="0.7387" style="stop-color:#D48C2E"/>
<stop offset="0.9045" style="stop-color:#825121"/>
</linearGradient>
<path fill="url(#SVGID_10_)" d="M356.8567,616.4695c11.516-15.887,21.152-32.499,27.933-46.619
c15.685-6.7852,41.974-15.247,73.001-9.8594c41.626,7.2305,67.177,25.963,80.409,58.953c9.8691,24.605,7.1621,53.152-7.4277,78.32
c-6.2041,10.701-14.92,19.588-25.333,26.383c-5.4082-4.7598-9.8018-10.424-12.926-16.566
c-8.7637-17.228-7.1045-46.586-5.3818-58.099c0.0488-0.2764,0.2334-1.2998,0.4873-2.7959
c0.0566-0.2686,0.1104-0.5156,0.1641-0.7314l-0.041-0.0107c0.6152-3.7041,1.5283-9.5918,1.9668-14.485
c0.0928-1.0215,0.168-2.0557,0.2295-3.0713c0.6338-10.788-0.627-18.984-1.7617-23.873c3.0664,5.2441,7.2451,15.54,6.1035,31.548
c-0.6006,8.4639-3.1504,17.019-3.1758,17.104l-0.2236,0.7393l0.6992,0.334c0.3809,0.1846,9.458,4.4336,15.824-1.3838
c3.3936-3.1045,5.3789-8.0117,5.7433-14.19c0.7402-12.558-5.3486-27.597-15.152-37.425c-8.0312-8.0518-22.151-18.627-22.294-18.731
l-2.1494-1.6064l0.7354,2.5762c0.04,0.1387,4.0801,14.421,3.2861,22.975c-0.9131,9.7803-4.8125,17.591-7.6621,23.293
c-1.4326,2.8701-2.5644,5.1367-2.8809,6.8408c-0.2314,1.2432-0.3828,2.459-0.4492,3.6104
c-0.2861,4.8359,0.8477,8.7217,3.2783,11.232c1.8096,1.8672,4.209,2.8184,6.9414,2.7578c3.8291-0.0898,6.543-1.4033,8.1934-2.543
c-0.3271,2.5752-0.624,5.6826-0.8271,9.1484c-0.833,14.135-0.1094,34.158,6.7305,47.603c3.251,6.3916,7.6309,12.069,12.88,16.808
c-22.233,13.797-51.775,18.295-81.165,10.846c-37.312-9.457-63.547-45.473-62.382-85.639
c1.0635-36.208,18.379-67.174,40.309-72.039c15.434-3.4258,24.862,2.917,29.786,8.206l-0.0117,0.0254l0.1016,0.0664
c0.5605,0.6084,1.0596,1.2012,1.5068,1.7627c1.8008,2.8486,5.3008,9.3623,6.4697,15.952c0.6953,3.919,1.1279,7.0908,1.5859,10.448
c0.5928,4.3633,1.2041,8.8779,2.4707,15.688c1.0596,5.6846,2.6943,10.22,4.249,13.585c-2.6426-3.1621-5.4385-7.1074-6.4668-10.617
l-0.499-1.6894c-1.5664-5.3027-2.6006-8.8066-3.8281-17.289c-1.6602-11.49-5.7959-19.803-11.345-22.805
c-2.4268-1.3115-4.9746-1.4766-7.1738-0.4629c-4.2275,1.9512-8.9873,7.5879-9.4434,15.352
c-0.2363,4.0205,0.7041,8.0527,2.7998,11.984c19.574,36.746,51.784,44.949,52.107,45.027l3.8916,0.9473l-3.1055-2.5244
c-0.0654-0.0518-6.5332-5.3916-7.5537-13.445c-0.3164-2.501-0.3467-5.7978-0.0947-10.081c0.3477-5.9336,1.1807-12.617,1.915-18.513
c0.2529-2.0322,0.4932-3.96,0.6953-5.7197c1.5762-13.692-0.6738-23.377-6.6533-28.66c-4.5303-4.001-10.496-4.6445-14.569-3.9102
c-3.4033,0.6152-5.2695,2.334-6.1172,3.3691c-5.3789-5.542-15.35-11.959-31.11-8.4609c-21.978,4.875-39.482,34.705-41.57,70.15
c-0.0713,1.1943-0.123,2.3965-0.1572,3.6016c-1.1904,41.001,25.622,77.773,63.748,87.439c22.832,5.7871,45.72,4.7227,66.189-3.0801
c5.958-2.2695,11.573-5.0781,16.767-8.3516c4.876,4.1416,10.4391,7.5059,16.516,9.9014c14.766,5.8272,31.188,5.5215,47.486-0.8838
c18.636-7.3242,27.508-22.559,31.667-34.048c4.7461-13.108,5.8525-28.813,2.8862-40.991
c-3.2227-13.229-10.553-26.452-16.562-35.752c7.1104,8.6582,13.04,17.139,18.305,24.67c0.3789,0.541,0.7524,1.0781,1.127,1.6133
c3.8584,6.5459,16.194,27.611,24.078,42.695c5.9512,11.383,15.996,25.77,30.048,29.668c3.459,0.959,15.538,3.6152,25.717-2.4961
c4.2148-2.5293,7.3574-6.168,9.4023-10.847c2.3184,2.0088,4.6016,3.9033,6.8486,5.6807c18.446,14.602,37.149,19.697,50.928,21.209
c-0.7578,1.1982-1.7109,3.1543-1.8555,5.6172c-0.166,2.8057,0.7539,5.5488,2.7324,8.1514c3.752,4.9336,12.678,6.3154,19.898,3.084
c12.559-5.6279,22.166-14.8,29.18-21.499c2.1953-2.0957,4.0898-3.9043,5.7832-5.3359l3.4883-2.9512l-4.3535,1.4023
c-0.1035,0.0332-10.309,3.2686-17.789,1.123c-5.7715-1.6572-7.7969-2.7568-11.473-4.7559
c-1.3438-0.7334-2.8672-1.5625-4.8691-2.584c-5.9121-3.0225-11.533-3.7559-15.426-2.0107
c-2.5898,1.1602-4.375,3.3789-5.1602,6.4131c-0.1445,0.5635-0.2383,1.1924-0.2793,1.8691
c-0.1738,2.9522,0.7129,6.0098,0.752,6.1396l0.1387,0.4775l0.4805,0.1357c2.0254,0.5684,6.002,1.3945,14.012,1.5264
c1.9219,0.0322,14.682,0.7109,27.967-1.8945c-10.596,4.4004-44.004,3.4062-40.08,3.5752
c-13.724-1.0664-33.475-5.6943-52.949-21.104c-2.3789-1.8848-4.8008-3.9023-7.2617-6.0469
c0.4531-1.2637,0.834-2.5957,1.1416-3.9922c0.0791-0.3604,0.1455-0.7266,0.21-1.0938c4.8516,0.1279,10.678,0.2383,17.406,0.3672
c6.0889,0.1172,12.906,0.2471,20.389,0.418c16.615,0.375,29.808,1.0566,55.822,3.7422c29.273,3.0176,51.205,12.968,51.424,13.068
l4.1641,1.9121l-3.123-3.3477c-0.9297-0.9961-23.02-24.5-43.791-31.148c-18.451-5.9043-39.203-4.7227-43.697-4.377
c-0.418-2.6797-2.3457-10.619-10.921-16.675c-8.8633-6.2568-23.957-1.8203-24.595-1.6318l-3.0186,0.9111l3.041,0.8271
c0.0781,0.0225,7.9082,2.2129,10.88,8.1289c1.5283,3.043,1.5127,6.5879-0.0508,10.539c-1.376,3.4805-7.6143,13.523-33.604,14.824
c1.2988-13.773-5.332-46.137-35.321-71.121c4.9053,4.0195,14.771,7.707,30.089,28.41c6.3438,8.5732,7.4854,18.433,7.4854,18.433
c3.2617,1.209,18.011-5.9736,12.741-21.162c-4.3086-12.422-12.476-17.134-24.275-22.085
c-8.0996-3.3965-15.828-5.8164-28.631-9.8242c-4.6465-1.4531-9.9121-3.1016-16.179-5.1016l-4.7959-1.5312l3.96,3.1016
c0.1484,0.1172,7.2568,5.4121,15.468,20.07c2.3755,4.2383,7.7529,17.471,8.7739,22.228c1.981,9.2178,2.166,16.226,5.1289,19.964
c2.2686,2.8613,5.4033,4.6035,8.8262,4.9043c3.1025,0.2715,6.1045-0.6553,8.2363-2.543c2.292-2.0312,3.4307-4.2178,3.6797-7.0312
c1.8252,6.5244,3.7549,15.705,2.9834,23.366c-2.4941,0.084-5.1592,0.0869-8.0098,0.001c-14.064-13.393-30.146-31.208-46.95-51.718
c-10.403-12.699-43.115-49.7-95.321-74.319c-26.053-12.286-38.907-16.193-56.748-21.206c-5.6309-1.583-13.017-3.1279-19.929-3.9121
c-2.2773-0.2568-16.359-2.21-34.136-0.7539c-17.982,1.4697-31.78,5.9014-46.797,13.604c9.7676-21.73,10.134-36.429,11.114-48.016
c2.7578-32.605-7.3408-52.9-27.758-62.347c-12.267-5.6748-23.904-5.0283-35.915,0.2959c-7.2324,3.206-11.398,9.8164-13.877,17.033
c-2.9844,8.6816,0.1318,16.026,2.2148,19.587c-3.2441,0.7959-11.071,3.706-11.643,13.374
c-0.0244,0.3984-0.0332,0.8105-0.0322,1.2324c0.0732,13.768,10.458,18.076,18.805,21.537c2.1016,0.873,4.0898,1.6973,5.7832,2.6113
l0.2256,0.1201c12.957,6.9883,18.872,10.178,21.902,21.749c1.5938,6.082,1.8672,13.711,1.8721,13.785l0.1152,3.4639l1.5957-3.0791
c0.2617-0.5078,6.4492-12.526,8.2666-21.618c1.293-6.4609,2.0762-12.234,2.3945-17.646c0.3018-5.083,0.1816-9.9404-0.3584-14.847
c-1.7227-15.627-7.2178-23.267-12.365-28.678c-4.7373-4.9805-12.927-8.9004-21.697-4.9639
c-5.2383,2.3506-9.6777,9.2891-9.8643,9.584l-0.1963,0.3096l0.0762,0.3613c0.083,0.3848,1.0723,3.9414,10.134,10.031
c3.7217,2.498,10.631,8.5488,13.925,13.404c3.3438,4.9316,5.1758,10.639,6.1826,15.457c-0.1445-0.3535-0.2969-0.7119-0.457-1.0772
c-0.1914-0.4346-0.373-0.8613-0.5537-1.292c-2.1846-5.1816-4.4463-10.541-21.661-23.502c-6.1406-4.624-10.182-10.164-10.221-10.22
l-0.1992-0.2803c-1.8252-2.9072-5.4746-10.25-2.5312-18.818c2.3144-6.7324,7.3213-12.066,14.104-15.021
c9.1016-3.9639,22.169-5.3525,33.772,0.5127c17.044,8.6123,25.851,24.222,25.304,56.604c-0.1846,10.863-2.5146,30.18-12.882,52.484
c-2.4199,1.3184-6.9824,3.5703-9.3144,5.0508c-1.6387,1.0371-109.62,66.678-150.74,79.393
c-51.048,15.791-93.326,22.281-103.52,2.7959l0.083,1.8174c4.1992,21.768,51.736,20.587,102.96,3.502
c69.458-23.168,122.39-66.316,148.58-80.451c2.9717-1.8242,4.001-2.2959,7.8232-4.207c-6.6387,13.434-14.354,28.699-25.801,43.201
c-25.368,32.133-43.585,45.137-76.389,57.416c-34.568,12.941-52.039,13.82-87.231,12.148
c-23.653-1.124-69.539-12.854-70.062-29.659l-0.1631,2.7803c4.2207,20.979,38.778,32.968,69.538,35.08
c32.076,2.2041,64.707-2.7764,96.359-16.434c32.44-14.01,49.28-30.48,70.65-59.95L356.8567,616.4695z M685.4567,718.6995
c-7.5967,4.5586-17.532,4.1777-24.298,2.3018c-13.426-3.7227-23.141-17.696-28.927-28.761
c-4.4873-8.5859-10.409-19.1-15.399-27.783c0.9053,1.1406,1.8057,2.25,2.7041,3.2978c11.916,13.905,27.672,28.694,54.181,32.947
c2.9038,0.4648,5.936,0.8047,9.0112,1.0068c1.1758,0.1787,2.8809,0.3262,5.084,0.4502c2.2119,2.0928,4.3955,4.0879,6.5488,5.9873
c-1.87,4.58-4.86,8.11-8.89,10.54L685.4567,718.6995z M696.0267,702.4796c-0.0469,0.2539-0.0889,0.5078-0.1436,0.7578
c-0.2354,1.0703-0.5225,2.0977-0.8486,3.082c-1.4629-1.2949-2.9414-2.6406-4.4297-4.0273c1.61,0.07,3.42,0.13,5.42,0.19
L696.0267,702.4796z M550.0167,580.7296c51.025,26.282,76.654,54.73,87.603,67.777c16.507,19.674,34.274,38.293,47.997,51.548
c-0.8945-0.041-1.8008-0.0928-2.7305-0.1514c-3.0332-0.2002-6.0215-0.5332-8.8833-0.9912
c-25.929-4.1602-41.388-18.682-53.09-32.337c-4.1558-4.8506-8.3896-10.818-12.836-17.176
c-0.7212-1.2227-1.1392-1.9258-1.1641-1.9688l-0.1514,0.0898c-10.311-14.754-21.929-32.996-41.211-49.293
c-15.575-13.162-25.685-22.092-56.558-34.841c19.12,6.49,25.37,9.28,41.0201,17.34L550.0167,580.7296z M469.2367,553.9595
c5.2285,1.0059,10.735,1.9082,16.22,3.5674l-0.002,0.0078c0.0137,0.0049,0.0938,0.0254,0.2197,0.0596
c26.907,8.1719,56.266,23.277,77.488,41.949c16.552,14.562,32.964,36.967,38.753,60.727c5.3608,22.002-1.5654,60.398-33.458,72.934
c-24.458,9.6094-46.669,4.0059-61.675-8.4102c10.709-7.081,19.4991-16.177,25.557-26.629c14.87-25.65,17.618-54.77,7.542-79.892
c-6.502-16.21-15.651-28.758-27.974-38.365c-13.711-10.693-29.837-18.915-52.332-22.823c-27.342-4.748-41.381-1.7871-57.354,2.8838
c16.169-5.21,20.529-6.1572,35.011-7.0146c18.41-1.11,30.57,0.72,31.99,0.99L469.2367,553.9595z"/>
<linearGradient id="SVGID_11_" gradientUnits="userSpaceOnUse" x1="-879.4864" y1="1101.4695" x2="-879.4864" y2="1869.7815" gradientTransform="matrix(0.022 -0.9998 0.9998 0.022 -985.1675 -309.9955)">
<stop offset="0" style="stop-color:#6E3600"/>
<stop offset="0.0338" style="stop-color:#7A4005"/>
<stop offset="0.0952" style="stop-color:#9A5A11"/>
<stop offset="0.1765" style="stop-color:#CE8424"/>
<stop offset="0.2" style="stop-color:#DE912A"/>
<stop offset="0.4299" style="stop-color:#834B00"/>
<stop offset="0.4638" style="stop-color:#915A0D"/>
<stop offset="0.5293" style="stop-color:#B78230"/>
<stop offset="0.6175" style="stop-color:#F2C167"/>
<stop offset="0.7387" style="stop-color:#D48C2E"/>
<stop offset="0.9045" style="stop-color:#825121"/>
</linearGradient>
<path fill="url(#SVGID_11_)" d="M226.7267,620.9896c6.1514-7.1221,13.633-17.294,20.87-27.131
c7.7412-10.521,15.111-20.54,20.375-26.233c3.1973-2.623,7.4824-5.4902,13.148-8.1856c5.1992-1.9453,11.3-3.6211,16.948-4.9639
c-9.294,3.6201-19.882,8.6865-25.604,14.614c-3.3535,3.4775-5.2705,7.7412-5.543,12.332
c-0.4648,7.8867,4.1455,15.272,10.721,17.186c10.412,3.0273,18.937-1.8184,27.638-15.708
c0.7109-1.1367,1.3955-2.2373,2.0586-3.3037c6.7285-10.822,11.177-17.973,19.686-22.912c7.001-4.0625,23.276-6.5273,23.441-6.5527
l3.4609-0.5176l-3.2803-1.2207c-0.2031-0.0781-20.441-7.5879-36.92-11.699c-5.4424-1.3584-17.926-3.7686-30.193-4.1953
c-11.862-0.4141-22.374,1.7188-28.839,5.8516c-2.3711,1.5117-10.146,7.2227-10.73,17.148
c-0.127,2.1328,0.1064,4.3164,0.6914,6.4951c3.1143,11.61,10.315,14.657,12.959,15.398c-3.5762,4.6572-7.4785,9.96-11.479,15.397
c-7.2168,9.8106-14.679,19.953-20.781,27.021c-14.518,16.821-29.034,26.197-58.327,33.24
c-42.392,10.189-45.935-3.6045-46.192-3.6045l-0.1357,1.0107c6.9609,12.133,24.941,10.159,46.697,6.0547
c30.12-5.69,44.54-18.4,59.32-35.53L226.7267,620.9896z"/>
<linearGradient id="SVGID_12_" gradientUnits="userSpaceOnUse" x1="-924.6873" y1="1101.4695" x2="-924.6873" y2="1869.7784" gradientTransform="matrix(0.022 -0.9998 0.9998 0.022 -985.1675 -309.9955)">
<stop offset="0" style="stop-color:#6E3600"/>
<stop offset="0.0338" style="stop-color:#7A4005"/>
<stop offset="0.0952" style="stop-color:#9A5A11"/>
<stop offset="0.1765" style="stop-color:#CE8424"/>
<stop offset="0.2" style="stop-color:#DE912A"/>
<stop offset="0.4299" style="stop-color:#834B00"/>
<stop offset="0.4638" style="stop-color:#915A0D"/>
<stop offset="0.5293" style="stop-color:#B78230"/>
<stop offset="0.6175" style="stop-color:#F2C167"/>
<stop offset="0.7387" style="stop-color:#D48C2E"/>
<stop offset="0.9045" style="stop-color:#825121"/>
</linearGradient>
<path fill="url(#SVGID_12_)" d="M147.4967,672.2595c0.1348,0.0342,4.4316,2.8691,23.997,6.2539
c8.5215,1.4736,20.603,2.5215,25.663,2.5176c29.561-0.0234,45.976-2.9746,77.421-16.749c49.272-21.577,69.795-60.163,69.795-60.163
s-15.07,22.178-38.69,32.85c-9.3144,4.2051-21.23,6.7949-30,8.1396c-4.3125,0.6631-9.9385,0.5947-16.731,3.0088
c-12.46,4.4307-33.212,15.576-48.52,19.021c-29.23,6.58-62.93,5.12-62.93,5.12L147.4967,672.2595z"/>
<linearGradient id="SVGID_13_" gradientUnits="userSpaceOnUse" x1="-996.1837" y1="1026.0594" x2="-996.1837" y2="2001.3094" gradientTransform="matrix(0.022 -0.9998 0.9998 0.022 -985.1675 -309.9955)">
<stop offset="0" style="stop-color:#6E3600"/>
<stop offset="0.0338" style="stop-color:#7A4005"/>
<stop offset="0.0952" style="stop-color:#9A5A11"/>
<stop offset="0.1765" style="stop-color:#CE8424"/>
<stop offset="0.2" style="stop-color:#DE912A"/>
<stop offset="0.4299" style="stop-color:#834B00"/>
<stop offset="0.4638" style="stop-color:#915A0D"/>
<stop offset="0.5293" style="stop-color:#B78230"/>
<stop offset="0.6175" style="stop-color:#F2C167"/>
<stop offset="0.7387" style="stop-color:#D48C2E"/>
<stop offset="0.9045" style="stop-color:#825121"/>
</linearGradient>
<path fill="url(#SVGID_13_)" d="M368.8867,737.9296l4.583,3.1738l-3.3398-4.4551c-0.1475-0.1992-15.021-19.908-33.316-33.287
c-9.7373-7.1211-26.276-16.19-57.352-12.762c-8.6426,0.9521-14.626,1.9062-19.838,3.0801
c-50.303,11.32-70.166,4.8809-70.166,4.8809s38.636,7.9648,58.834,7.9639c11.494-0.001,19.545-1.2695,29.189-1.1475
c6.626,0.0859,13.482,0.1719,20.737,1.1504c14.669,1.9844,26.772,5.7656,34.592,8.7109c-9.0098-2.0186-22.129-4.6484-31.842-5.2852
c-8.0127-0.5273-14.383-0.2275-20.544,0.0586c-8.1035,0.377-19.536,1.7305-30.406,1.3857
c-34.48-1.0928-70.082-12.213-70.082-12.213s24.428,15.694,48.993,23.585c15.269,4.9043,38.097,8.0869,58.811,5.1016
c16.586-2.3936,36.445-4.1348,57.969,1.0869c15.57,3.78,23.12,8.93,23.19,8.98L368.8867,737.9296z"/>
<linearGradient id="SVGID_14_" gradientUnits="userSpaceOnUse" x1="-794.2529" y1="1101.4695" x2="-794.2529" y2="1869.7834" gradientTransform="matrix(0.022 -0.9998 0.9998 0.022 -985.1675 -309.9955)">
<stop offset="0" style="stop-color:#6E3600"/>
<stop offset="0.0338" style="stop-color:#7A4005"/>
<stop offset="0.0952" style="stop-color:#9A5A11"/>
<stop offset="0.1765" style="stop-color:#CE8424"/>
<stop offset="0.2" style="stop-color:#DE912A"/>
<stop offset="0.4299" style="stop-color:#834B00"/>
<stop offset="0.4638" style="stop-color:#915A0D"/>
<stop offset="0.5293" style="stop-color:#B78230"/>
<stop offset="0.6175" style="stop-color:#F2C167"/>
<stop offset="0.7387" style="stop-color:#D48C2E"/>
<stop offset="0.9045" style="stop-color:#825121"/>
</linearGradient>
<path fill="url(#SVGID_14_)" d="M434.6067,482.2596c1.9053,3.0527,2.8613,7.9219,2.5606,13.027
c-0.2559,4.373-1.3955,8.5625-3.292,12.113c-3.3486,6.2715-8.2432,10.857-13.426,15.713c-2.5615,2.4023-5.21,4.8828-7.7148,7.625
c-9.0146,9.8711-13.905,17.736-13.956,17.814l-1.3779,2.2305l2.4668-0.9102c0.0957-0.0352,9.8252-3.5996,22.341-5.5644
c28.325-4.4443,40.427-3.7988,60.313,0.25c8.9873,1.8281,19.914,6.0684,31.484,10.559c10.858,4.2109,22.085,8.5664,32.674,11.307
c28.722,7.4287,54.475-8.1523,54.731-8.3086l3.6885-2.2734l-4.2935,0.6094c-0.1484,0.0215-14.917,2.0918-24.789,0.6641
c-13.688-1.9766-19.172-3.5088-34.514-9.6289c-4.7432-1.8926-9.123-4.4404-13.761-7.1348
c-10.174-5.9141-21.706-12.617-42.154-15.877c-19.297-3.0762-36.009,0.6992-44.988,3.5508
c6.3398-4.7266,15.529-12.868,17.51-21.535c0.5146-2.2588,0.8408-4.4668,0.9648-6.5625c0.625-10.638-3.7959-19.169-13.519-26.076
c-8.7881-6.2422-23.981-7.9277-24.625-7.9951l-3.9541-0.4229l3.3818,2.0879c0.08,0.04,8.3,5.18,14.25,14.73L434.6067,482.2596z"/>
<linearGradient id="SVGID_15_" gradientUnits="userSpaceOnUse" x1="-856.0887" y1="1101.4695" x2="-856.0887" y2="1869.7834" gradientTransform="matrix(0.022 -0.9998 0.9998 0.022 -985.1675 -309.9955)">
<stop offset="0" style="stop-color:#6E3600"/>
<stop offset="0.0338" style="stop-color:#7A4005"/>
<stop offset="0.0952" style="stop-color:#9A5A11"/>
<stop offset="0.1765" style="stop-color:#CE8424"/>
<stop offset="0.2" style="stop-color:#DE912A"/>
<stop offset="0.4299" style="stop-color:#834B00"/>
<stop offset="0.4638" style="stop-color:#915A0D"/>
<stop offset="0.5293" style="stop-color:#B78230"/>
<stop offset="0.6175" style="stop-color:#F2C167"/>
<stop offset="0.7387" style="stop-color:#D48C2E"/>
<stop offset="0.9045" style="stop-color:#825121"/>
</linearGradient>
<path fill="url(#SVGID_15_)" d="M590.9667,590.2496c20.98,9.2471,45.691,11.931,75.816,14.473
c32.655,2.7617,49.433,22.638,49.599,22.838l3.1191,3.7754l-1.5615-4.6367c-0.1016-0.3018-10.484-30.355-36.831-41.284
c-7.5547-3.1318-18.829-2.4424-23.224-2.0156c1.5923-2.8818,4.6621-9.3662,5.125-17.226c0.0874-1.4805,0.0762-2.96-0.0342-4.3926
c-1.5679-20.432-19.936-26.527-20.122-26.586l-1.7554-0.5605l0.6274,1.7275c0.0244,0.0693,2.4356,6.8193,1.9531,15.006
c-0.4995,8.4795-4.2656,19.886-19.371,26.065"/>
<linearGradient id="SVGID_16_" gradientUnits="userSpaceOnUse" x1="-977.4203" y1="1101.4695" x2="-977.4203" y2="1869.7804" gradientTransform="matrix(0.022 -0.9998 0.9998 0.022 -985.1675 -309.9955)">
<stop offset="0" style="stop-color:#6E3600"/>
<stop offset="0.0338" style="stop-color:#7A4005"/>
<stop offset="0.0952" style="stop-color:#9A5A11"/>
<stop offset="0.1765" style="stop-color:#CE8424"/>
<stop offset="0.2" style="stop-color:#DE912A"/>
<stop offset="0.4299" style="stop-color:#834B00"/>
<stop offset="0.4638" style="stop-color:#915A0D"/>
<stop offset="0.5293" style="stop-color:#B78230"/>
<stop offset="0.6175" style="stop-color:#F2C167"/>
<stop offset="0.7387" style="stop-color:#D48C2E"/>
<stop offset="0.9045" style="stop-color:#825121"/>
</linearGradient>
<path fill="url(#SVGID_16_)" d="M660.1367,731.7296c0,0-19.619,0.7402-32.576-13.58c-8.8677-9.8008-18.305-53.848-18.305-53.848
s2.4712,18.501,2.355,30.192c-0.1235,12.356-2.8584,27.976-15.998,38.761c-5.7686,4.7334-10.118,7.4434-10.118,7.4434
s9.5234,0.7881,16.367-2.252c11.391-5.0586,14.665-14.412,14.665-14.412s1.0928,3.6807,9.8892,8.4199
c11.96,6.46,33.72-0.72,33.72-0.72L660.1367,731.7296z"/>
<linearGradient id="SVGID_17_" gradientUnits="userSpaceOnUse" x1="-926.7069" y1="1101.4695" x2="-926.7069" y2="1869.7804" gradientTransform="matrix(0.022 -0.9998 0.9998 0.022 -985.1675 -309.9955)">
<stop offset="0" style="stop-color:#6E3600"/>
<stop offset="0.0338" style="stop-color:#7A4005"/>
<stop offset="0.0952" style="stop-color:#9A5A11"/>
<stop offset="0.1765" style="stop-color:#CE8424"/>
<stop offset="0.2" style="stop-color:#DE912A"/>
<stop offset="0.4299" style="stop-color:#834B00"/>
<stop offset="0.4638" style="stop-color:#915A0D"/>
<stop offset="0.5293" style="stop-color:#B78230"/>
<stop offset="0.6175" style="stop-color:#F2C167"/>
<stop offset="0.7387" style="stop-color:#D48C2E"/>
<stop offset="0.9045" style="stop-color:#825121"/>
</linearGradient>
<path fill="url(#SVGID_17_)" d="M525.4268,588.7095c0,0,21.806,22.692,22.801,52c0.752,22.13-1.6377,32.924-7.4434,46.754
c-3.5459,8.4443-5.208,10.588-5.208,10.588s3.2988,8.2871,10.707,11.598c18.275,8.1621,31.706-4.1621,31.706-4.1621
s-18.873-10.416-17.541-30.887c1.6025-24.63-0.4136-33.462-4.4634-44.894c-7.93-22.33-30.57-41-30.57-41L525.4268,588.7095z"/>
<linearGradient id="SVGID_18_" gradientUnits="userSpaceOnUse" x1="-970.2051" y1="1101.4695" x2="-970.2051" y2="1869.7834" gradientTransform="matrix(0.022 -0.9998 0.9998 0.022 -985.1675 -309.9955)">
<stop offset="0" style="stop-color:#6E3600"/>
<stop offset="0.0338" style="stop-color:#7A4005"/>
<stop offset="0.0952" style="stop-color:#9A5A11"/>
<stop offset="0.1765" style="stop-color:#CE8424"/>
<stop offset="0.2" style="stop-color:#DE912A"/>
<stop offset="0.4299" style="stop-color:#834B00"/>
<stop offset="0.4638" style="stop-color:#915A0D"/>
<stop offset="0.5293" style="stop-color:#B78230"/>
<stop offset="0.6175" style="stop-color:#F2C167"/>
<stop offset="0.7387" style="stop-color:#D48C2E"/>
<stop offset="0.9045" style="stop-color:#825121"/>
</linearGradient>
<path fill="url(#SVGID_18_)" d="M456.7167,735.7296c0,0-32.857,5.7549-62.146-18.286c-22.946-18.835-29.128-39.138-30.461-50.344
c-0.7588-6.377-0.4355-8.0176-0.4355-8.0176c1.6836-2.9492,4.2031-7.1387,13.369-11.272c9.3506-4.2168,21.384,1.6162,21.384,1.6162
s-10.576,14.702-8.5537,29.143c2.0244,14.442,4.6611,28.178,20.94,40.436c18.25,13.73,45.91,16.72,45.91,16.72L456.7167,735.7296z"
/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 43 KiB

View File

@ -0,0 +1,167 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 1770.836 1767.559" enable-background="new 0 0 1770.836 1767.559" xml:space="preserve">
<g>
<path d="M581.339,577.203c44.942-59.167,42.215-56.099,46.471-56.441c123.901-9.935,230.483-81.742,241.81-94.921
c-3.087,1.369-5.2,2.187-7.207,3.213c-129.752,66.308-299.555,62.547-361.954-78.95c-4.085-9.263-3.783-11.716-7.578-10.81
c-19.053,4.548-20.912-4.169-26.85,2.614c-40.082,45.784-126.308,24.947-137.509-44.772
c-12.43-77.368,53.514-141.431,162.087-109.046c4.33,1.292,4.391,1.328,6.326-2.864c39.507-85.613,139.834-123.431,223.317-86.321
c37.392,16.622,37.781,31.949,46.748,28.7c45.624-16.532,96.74-1.747,113.556,50.571c6.239,19.411,1.795,23.955,6.997,23.547
c0.119-0.009,114.077-0.006,114.196-0.006c4.172,0,5.993,0.783,7.211-3.192c17.641-57.562-11.78-112.342-12.135-114.747
c3.443,1.772,21.983,39.836,23.666,90.662c0.908,27.445-4.043,27.066,2.533,27.266c0.367,0.011,639.538,0.017,639.906,0.017
c3.814,0,7.271,0.237,10.243-3.505c2.777-3.496,8.56-2.555,11.588,0.906c7.609,8.697-6.286,19.623-12.229,11.579
c-3.485-4.717,39.974-3.161-647.676-3.161c-6.368,0-6.188-0.251-6.876,4.487c-3.417,23.52-11.87,58.076-35.57,107.404
c-88.591,184.391-331.918,285.362-343.41,280.728c-17.691-7.134-35.45-14.1-53.185-21.126
C584.581,578.548,583.357,578.031,581.339,577.203z M631.913,190.091c-23.601,14.765-39.329,32.907-41.861,64.971
c-0.976,12.354,0.277,24.409,4.452,36.111c1.506,4.22,1.551,4.239,5.584,3.022c21.898-6.608,45.765,1.158,54.818,22.119
c9.277,21.479-3.17,28.458,6.11,29.896c26.184,4.059,56.961-2.668,82.131-22.12c5.477-4.232,0.383-1.697-12.615-18.92
c-36.537-48.415-35.512-119.543,4.404-158.47c2.614-2.549,3.421-2.466-5.359-8.527c-62.56-43.189-159.113-24.698-204.584,60.753
c-1.978,3.716-1.906,3.777,1.74,6.212c23.045,15.395,40.17,35.768,51.452,61.076c0.584,1.311,0.679,3.006,2.747,3.767
C582.835,233.585,596.349,204.616,631.913,190.091z M938.08,320.493c-32.176,18.305-65.482,34.755-114.22,31.341
c-20.006-1.401-38.943-6.418-56.621-15.917c-2.406-1.293-3.978-1.373-6.214,0.465c-24.683,20.291-56.966,29.39-84.444,27.819
c-24.309-1.39-25.306-6.399-28.922-1.288c-0.911,1.288-2.061,2.726-4.675,3.412c5.391-7.012,5.669-6.672,0.711-8.386
c-22.368-7.731-39.034-22.304-50.754-43.385c-0.684-1.23-0.939-3.444-2.806-3.036c-3.402,0.744-0.314,11.258-3.993,19.797
c0.197-4.902,1.109-9.785,0.375-14.712c-1.923,0.134-2.321,1.347-2.925,2.288c-22.272,34.733,4.918,110.538,121.601,116.928
c98.146,5.375,177.658-42.337,183.697-49.801c-2.304,0.649-4.461,1.503-6.524,2.543c-84.976,42.834-171.348,51.75-196.296,22.111
c-7.609-9.039-12.348-29.646,14.1-39.017c50.03-17.728,125.734,15.205,213.639-33.718C922.593,333.049,931,327.599,938.08,320.493z
M843.413,312.188c-0.829-4.699,11.709-31.161,41.137-44.212c20.746-9.2,45.623-5.403,75.558-17.329
c33.867-13.493,45.367-40.564,44.348-42.727c-1.189-0.597-118.66-0.359-117.658-0.353c-2.911-0.018-1.692,1.69-4.162,12.415
c-7.855,34.096-41.484,53.235-74.653,42.439c-2.345-0.763-3.172-0.524-3.709,2.04c-5.444,25.949-10.818,25.159-7.115,30.5
c29.812,43.004,124.738,35.865,178.051-23.44c2.118-2.356,4.103-4.854,5.31-7.94C942.399,301.4,895.43,313.668,843.413,312.188z
M393.944,312.826c-10.028-4.157-21.494-20.052-18.889-42.201c0.24-2.039,0.362-3.629-1.724-5.173
c-35.155-26.018,11.766-100.299,115.319-69.334c1.196,0.358,2.874,1.399,3.406-0.327c0.886-2.876-46.599-16.258-86.888-6.001
c-50.612,12.885-74.173,57.368-63.702,104.618c12.035,54.311,76.906,73.782,111.753,41.086c5.781-5.424-6.045-3.312-13.391-22.677
c-8.253-21.758-6.256-42.356,7.666-61.388c2.429-3.32,2.56-3.373-1.128-5.355c-11.179-6.009-23.048-7.782-35.499-5.36
c-4.094,0.796-4.069,0.924-3.496,5.173c2.665,19.751-15.011,25.646-24.396,23.628C376.271,268.071,374.657,295.402,393.944,312.826
z M943.8,351.357c-0.18-0.172-0.359-0.344-0.539-0.516c-9.226,8.306-3.896,4.775-23.744,21.217
c-56.717,46.985-129.558,73.428-197.025,76.511c-136.459,6.235-182.276-91.208-145.958-138.041
c4.951-6.384,8.846-7.158,7.68-10.957c-13.144-42.838-52.027-25.525-72.581-36.339c-0.909-0.478-1.872-1.605-3.197-0.543
c-4.475,18.01,11.5,11.088,13.647,34.539c1.92,20.97-8.606,22.575-5.799,31.782C574.368,519.53,846.717,470.437,943.8,351.357z
M735.745,234.632c4.688,2.176,13.478,12.658,38.321,17.02c8.608,1.511,7.767,1.063,7.922-6.562
c0.628-30.928-10.299-60.484-27.067-82.425c-1.315-1.721-2.504-2.029-4.069-0.43c-37.536,38.343-32.088,107.977,5.764,143.531
c2.196,2.063,2.181,3.696,6.898-3.415c1.012-1.525-0.38-2.443-1.254-3.397C741.505,276.295,735.713,244.397,735.745,234.632z
M868.717,200.862c-7.499-40.566-38.855-67.805-87.516-54.803c-2.524,0.674-2.444,1.746-1.26,3.515
c9.021,13.472,15.775,28.015,20.395,43.543c1.444,4.855,2.821,0.433,11.082-0.924c7.768-1.277,14.833,0.837,17.424,7.481
c1.042,2.671-0.679,2.091,36.21,2.031C866.041,201.703,867.073,201.862,868.717,200.862z M808.571,255.428
c32.022,15.303,59.976-8.376,61.023-43.346c0.181-6.04,2.764-4.469-37.165-4.545c-1.6-0.003-2.64,0.414-3.637,1.941
c-5.715,8.762-13.263,8.731-18.545-0.067c-1.199-1.997-2.379-1.858-4.125-1.149c-2.254,0.916-1.616,2.624-1.299,4.229
C810.464,241.085,802.771,252.656,808.571,255.428z M376.153,261.612c3.73-1.317,7.729-28.02,45.006-30.11
c12.21-0.684,23.585,2.207,34.047,8.51c1.859,1.12,3.222,1.11,4.988-0.126c15.336-10.728,25.645-6.796,23.856-14.969
c-0.91-4.159-16.66,0.867-45.536,3.319c-34.704,2.946-36.347-34.704,24.813-30.092c19.779,1.491,26,5.922,26.682,3.366
c2.11-7.91-115.752-21.011-121.913,39.944C367.28,249.535,369.708,256.513,376.153,261.612z M604.583,302.371
c-4.24,1.334-4.448,1.876-2.211,5.495c11.015,17.82,26.419,29.974,46.622,35.855c4.138,1.205,4.724,0.863,5.006-3.353
C655.955,311.067,631.296,293.968,604.583,302.371z M449.987,289.848c-0.321-17.755,5.917-26.476,8.809-31.387
c1.248-2.119-1.083-5.304-3.46-2.575c-14.702,16.873-14.464,50.417,3.781,66.368c2.752,2.406,3.18,2.366,4.829-0.667
c6.331-11.637,8.988-24.067,7.593-37.266c-0.575-5.444-0.95-5.49-6.292-3.755C459.77,282.345,455.11,285.483,449.987,289.848z
M927.206,314.612c-1.535-0.335-3.851,0.561-5.358,1.134c-33.207,12.632-70.952,20.019-105.685,12.826
c-30.95-6.409-33.554-18.306-37.898-9.004c-2.93,6.273,66.388,35.459,143.154-1.168
C923.494,317.409,925.617,316.484,927.206,314.612z M381.827,264.443c22.916,8.787,28.944-24.669,20.513-21.077
C392.667,247.488,386.072,254.542,381.827,264.443z M483.512,300.816c-3.197,25.179-12.333,28.842-5.452,29.266
c20.232,1.246,13.272,1.189,7.303-27.197C485.202,302.12,485.183,301.174,483.512,300.816z M558.424,258.015
c-6.271-7.025-20.366-15.085-41.937-16.866c-3.646-0.301-6.115-0.348-6.198,2.895c-0.1,3.89,1.721,0.561,20.065,3.695
C540.336,249.444,549.472,253.455,558.424,258.015z M512.208,307.608c4.142-9.577,2.272-21.835-3.877-25.76
C509.282,290.673,509.663,299.268,512.208,307.608z M469.559,247.877c8.952-5.253,11.338-6.099,11.339-6.099
c1.386-0.782,0.932-3.32-1.235-2.558C460.161,246.08,465.178,250.448,469.559,247.877z M479.564,268.224
c0.343-0.034,0.685-0.067,1.028-0.101c-0.193-4.81,0.229-9.639-0.315-14.973C473.967,259.056,474.954,258.264,479.564,268.224z
M513.944,227.064c1.969,4.04-1.752,0.476,19.047,7.103C527.316,230.808,521.272,228.571,513.944,227.064z M512.941,236.148
c-0.076,0.468-0.151,0.936-0.227,1.404c5.039,0.723,10.078,1.446,15.117,2.169C523.072,237.27,518.048,236.449,512.941,236.148z
M521.469,209.177c1.742,1.073,3.038,3.051,6.412,3.154C525.306,210.54,523.773,209.102,521.469,209.177z"/>
<path d="M83.935,992.123c35.367,16.879,74.429,24.872,113.07,15.002c5.713-1.46,8.102-1.525,8.087-6.051
c-0.454-131.154,1.858-118.95-4.03-119.625c-61.312-7.033-90.004-59.237-70.523-116.641c1.166-3.434,0.88-5.655-1.698-8.323
C53.24,678.233,87.274,536.832,189.97,493.101c6.469-2.755-2.979-6.53-6.243-45.074c-4.867-57.468,17.059-106.087,72.758-120.623
c96.57-25.202,147.083,82.706,87.924,135.999c-1.699,1.531-2.26,2.938-1.782,5.23c4.073,19.531-4.213,22.657,2.625,25.289
c117.932,45.39,150.278,166.653,114.115,295.087c-13.066,46.402-25.929,66.824-28.941,74.296c-0.172,0.428-0.197,0.915-0.424,2.043
c14.431-10.928,87.326-128.7,94.13-241.25c0.246-4.061-2.834-0.63,52.991-43.564c0.908-0.699,1.944-1.231,2.818-1.778
c1.912,1.006-0.084-1.138,23.906,58.108c3.172,7.833-109.576,303.365-352.996,370.348c-41.352,11.38-40.036,4.473-40.171,12.642
c-0.002,0.106-0.008,639.828-0.008,639.933c0,3.811-0.259,7.233,3.605,10.174c3.726,2.836,2.93,8.355-0.561,11.55
c-8.815,8.069-19.545-6.199-11.829-12.004c4.64-3.491,3.112,40.21,3.111-646.768c0-11.9-1.347-2.125-40.614-6.099
c-18.185-1.841-36.078-5.319-53.355-11.524C101.559,1001.714,92.35,997.781,83.935,992.123z M334.846,582.521l-0.097-0.071
c-0.711,2.357-11.494,2.516-15.316,2.663c-1.785,0.069-4.226-0.449-4.688,1.816c-0.414,2.032,2.098,2.246,3.435,2.99
c20.643,11.475,35.122,28.167,43.024,50.492c0.434,1.227,0.508,2.674,1.813,3.624c2.376-0.118,3.587-2.938,6.682-3.285
c-12.229,14.205,0.178,8.533-2.267,44.639c-1.819,26.869-10.738,51.003-27.535,72.1c-1.848,2.321-2.147,3.973-0.728,6.642
c26.97,50.732,19.015,112.934-10.608,162.181c-1.755,2.918-3.35,5.932-5.407,9.591c2.014-1.166,1.143-0.32,3.207-2.911
c72.019-90.41,25.351-200.07,52.731-244.961c16.482-27.023,71.917-9.784,45.328,102.787
c-12.275,51.969-32.994,87.239-35.848,96.223c15.471-17.316,65.77-113.3,46.604-216.522
c-16.403-88.339-80.118-111.105-113.613-89.775c-0.903,0.575-2.262,0.989-1.486,3.079
C324.917,583.396,329.881,582.959,334.846,582.521z M273.304,577.737c-0.732-1.981-2.083-1.976-3.095-2.424
c-16.424-7.266-31.122-17.041-43.835-29.784c-19.828-19.873-17.82-27.752-23.565-24.087c-1.893,1.208-27.029,12.235-48.556,38.407
c-47.594,57.864-43.084,128.274-9.667,170.799c5.941,7.56,2.563-2.715,28.308-16.308c48.522-25.62,113.377-13.628,149.551,26.005
c2.186,2.395,2.658,2.389,4.645-0.103c17.15-21.508,26.846-51.935,22.353-83.031c-0.421-2.911-1.705-3.396-3.965-2.839
c-31.059,7.654-58.459-22.336-48.218-57.139c1.305-4.433,1.315-4.495-2.857-5.946c-34.806-12.104-77.622-1.778-97.727,32.526
c-1.109,1.893-1.202,2.616-2.714,1.776C209.974,592.323,238.036,579.366,273.304,577.737z M266.718,977.59
c55.713-34.538,82.912-147.65,31.578-183.571c-4.146-2.901-0.93-0.969-33.154,7.94c0,4.103,6.879,11.9,1.681,34.035
c-6.329,26.952-25.362,41.52-52.593,45.125c-4.69,0.621-3.55,1.934-3.55,19.229c-0.001,112.084-2.282,103.548,5.715,99.372
c73.272-38.261,18.805-117.312,90.1-156.519c2.609-1.435,5.266-2.842,8.845-3.489C316.932,892.245,304.659,939.333,266.718,977.59z
M316.673,391.814c-11.13-12.102-23.661-18.903-41.394-15.465c-4.474,0.867-1.732,2.59-2.406,9.197
c-1.211,11.868-9.238,20.353-23.567,18.571c-4.524-0.563-4.661-0.566-5.476,3.938c-2.258,12.481-0.398,24.346,5.734,35.479
c1.794,3.256,1.925,3.207,5.167,0.73c26.63-20.344,63.823-14.189,79.641,6.01c1.421,1.815,2.63,1.732,4.024,0.102
c13.011-15.212,18.942-31.594,15.43-52.591c-13.618-81.418-137.389-85.06-160.393,2.813c-10.137,38.721,1.422,82.83,4.211,87.667
c0.948,1.644,2.655,0.645,2.362-0.962c-0.409-2.237-9.803-28.568-6.858-61.003c2.894-31.876,17.957-63.773,50.066-66.946
c22.222-2.196,22.124,13.434,31.254,12.434C297.567,369.26,312.985,381.829,316.673,391.814z M265.805,505.309
c-0.436,1.16-0.003,1.889,0.393,2.626c3.484,6.498,5.015,13.434,4.777,20.796c-0.817,25.174,6.484,44.131,30.393,51.736
c2.819,0.897,4.582,0.507,6.593-1.669c30.144-32.627,98.698-20.692,128.916,48.679c35.566,81.649,6.742,210.443-66.865,295.174
c-5.453,6.277-11.092,12.382-16.59,18.642c62.834-43.994,110.917-165.368,105.993-255.314
c-8.012-146.375-128.436-177.388-134.545-172.247c-5.405,4.549-33.32,11.841-46.376-7.055c-0.72-1.042-1.611-1.677-2.907-1.644
C272.283,505.117,268.952,504.667,265.805,505.309z M238,732.49c11.127,0.254,41.214,6.196,63.139,25.657
c3.538,3.141,3.548,3.13,7.415,0.16c2.88-2.212,2.923-2.252,0.464-4.888c-35.98-38.588-108.57-42.523-143.566-5.704
c-2.268,2.386-0.741,3.191,2.929,5.834c26.073,18.783,57.429,26.683,82.982,25.177c4.261-0.251,4.695-0.7,4.076-4.887
c-1.961-13.275-6.296-25.649-14.352-36.551C240.122,735.982,238.692,734.985,238,732.49z M202.113,865.075
c2.663,0.567,2.856,0.359,2.871-2.936c0.163-36.712,0.749-35.138-2.261-36.553c-8.42-3.958-8.25-12.387-6.326-21.018
c0.497-2.231,3.228-4.289,1.837-6.397c-1.204-1.826-4.206-1.663-6.4-2.397c-39.67-13.27-40.181-25.487-42.7-17.025
C137.115,819.124,157.209,855.508,202.113,865.075z M210.68,846.021c0.003,0,0.006,0,0.009,0c0,18.528-1.005,20.492,3.211,20.523
c7.654,0.057,19.859-2.456,30.94-8.964c27.275-16.019,19.247-54.385,9.271-54.377c-1.845,0.001-22.425,1.707-38.964-1.73
c-1.756-0.365-3.147-0.108-3.899,2.042c-0.93,2.66,0.853,3.073,2.493,3.967c9.048,4.931,5.005,15.159-0.256,17.788
C209.816,827.102,210.68,827.946,210.68,846.021z M226.913,480.611c11.973-0.3,3.487-5.221,15.362-22.245
c1.845-2.646,2.112-4.581,0.383-7.596c-15.767-27.493-8.012-64.386,19.904-75.927c2.723-1.126,2.837-1.843,0.754-3.787
c-26.312-24.56-83.943,20.82-60.559,113.411c0.289,1.143,0.827,2.702,1.975,2.457c1.02-0.218,1.098-0.803-0.012-5.995
c-17.205-80.508,30.929-86.069,26.642-43.584c-0.89,8.82-2.189,17.587-3.428,26.366
C227.153,469.261,225.923,474.91,226.913,480.611z M347.399,649.722c0.313-1.686-4.551-31.477-37.55-51.239
c-2.235-1.339-2.856-0.988-3.864,1.943C294.441,633.992,324.807,657.569,347.399,649.722z M292.847,446.917
c-3.777,4.954-6.918,7.958-9.615,16.781c-0.984,3.221-0.689,3.589,2.741,4.327c6.046,1.301,22.847,1.488,38.778-7.277
c3.073-1.691,3.109-2.077,0.772-4.823c-23.936-28.121-71.012-6.827-67.26-1.358c2.058,2.999,3.635,0.627,7.236-1.354
C273.715,448.692,282.473,446.484,292.847,446.917z M316.29,926.535c7.921-7.576,39.474-78.366,13.564-141.221
c-4.632-11.238-5.494-10.824-6.595-10.512c-3.084,0.872-4.625,4.119-3.086,6.831C346.326,827.686,332.537,880.506,316.29,926.535z
M264.942,379.829c-8.185,4.102-14.339,10.219-18.06,18.65c-1.205,2.73-0.722,3.526,2.258,3.902
C270.563,405.083,273.107,375.737,264.942,379.829z M303.632,480.505c2.654,2.831,5.121,0.996,26.344,7.89
c3.036,0.986,4.143-1.268,3.251-14.051C332.828,468.622,329.88,476.593,303.632,480.505z M260.395,553.949
c-2.842-7.221-11.251-21.515-10.9-43.934c0.021-1.31,0.253-2.879-1.753-2.955c-1.863-0.07-3.346,0.473-3.34,2.743
C244.438,525.314,250.137,544.629,260.395,553.949z M311.606,508.749c-8.753-2.134-17.597-3.51-27.254-3.605
C290.037,511.947,303.005,513.61,311.606,508.749z M252.256,464.191c-0.661-2.02-2.291-2.52-3.412-0.827
c-1.807,2.725-8.634,14.224-5.432,14.898c2.85,0.6,1.732-3.671,8.136-12.494C251.918,465.257,252.08,464.595,252.256,464.191z
M272.541,477.34c0.073-0.199,0.146-0.399,0.219-0.599c-9.27-4.581-11.783-7.01-16.404,0.599
C261.89,477.34,267.215,477.34,272.541,477.34z M236.903,528.473c-4.691-18.019-4.092-17.397-5.296-17.194
C228.472,511.807,235.169,525.827,236.903,528.473z M240.888,509.475c-0.488,0.078-0.976,0.156-1.465,0.234
c0.232,4.678,0.867,9.285,3.301,13.586C242.112,518.688,241.5,514.081,240.888,509.475z M217.399,527.494
c-2.853-7.291-2.931-7.399-6.104-9.129C213.554,521.744,215.476,524.619,217.399,527.494z"/>
<path d="M645.459,486.168c-0.211,28.555-43.356,27.735-42.94-0.111C602.958,456.661,645.664,458.351,645.459,486.168z"/>
<path d="M489.465,599.331c28.961,0.387,27.377,43.85-0.745,42.89C460.296,641.251,461.365,598.956,489.465,599.331z"/>
<path d="M685.994,490.81c0.105,23.478-35.311,23.196-35.225,0.044C650.857,467.215,685.892,467.82,685.994,490.81z"/>
<path d="M476.517,665.094c0.432-23.729,35.149-23.029,35.081,0.216C511.528,688.69,476.096,688.166,476.517,665.094z"/>
<path d="M723.616,487.827c0.016,19.216-28.856,19.832-29.392,0.177C693.708,469.119,723.6,468.064,723.616,487.827z"/>
<path d="M476.542,705.965c0.045-19.954,29.043-19.695,29.176-0.345C505.852,725.154,476.499,725.371,476.542,705.965z"/>
<path d="M743.963,489.152c-13.001,0.245-13.61-19.558-0.405-19.707C756.829,469.296,756.701,488.913,743.963,489.152z"/>
<path d="M459.953,580.311c-0.195-12.765,19.554-13.366,19.691-0.231C479.781,593.223,460.147,592.983,459.953,580.311z"/>
<path d="M492.26,740.837c-0.297,12.813-19.848,12.908-19.435-0.727C473.217,727.139,492.559,727.923,492.26,740.837z"/>
<path d="M583.48,476.4c-12.928,0.128-13.033-19.149-0.442-19.617C595.879,456.306,596.721,476.269,583.48,476.4z"/>
<path d="M454.597,566.748c-10.2-0.288-10.001-15.633,0.198-15.682C465.208,551.016,465.382,567.052,454.597,566.748z"/>
<path d="M476.218,775.724c-9.794,0.142-11.066-15.52-0.491-15.828C486.939,759.568,486.185,775.579,476.218,775.724z"/>
<path d="M763.071,472.894c-0.021-10.682,15.303-10.306,15.82-0.295C779.403,482.528,763.092,484.011,763.071,472.894z"/>
<path d="M569.917,451.795c-0.243,10.364-15.475,9.987-15.603-0.12C554.182,441.23,570.166,441.181,569.917,451.795z"/>
<path d="M432.333,541.463c0.035-7.331,11.043-5.962,10.455,0.371C442.206,548.117,432.301,548.364,432.333,541.463z"/>
<path d="M544.65,439.569c-6.622-0.13-6.782-10.909,0.418-10.503C551.906,429.452,550.792,439.69,544.65,439.569z"/>
<path d="M788.051,463.284c0.103-7.053,10.519-6.542,10.1,0.379C797.774,469.886,787.949,470.269,788.051,463.284z"/>
<path d="M471.667,789.755c0.144,7.093-10.453,6.779-10.251-0.022C461.602,783.487,471.528,782.949,471.667,789.755z"/>
<path d="M424.25,525.992c3.566,0.215,3.8,5.345,0.125,5.978C420.431,532.65,419.618,525.713,424.25,525.992z"/>
<path d="M532.613,423.831c-3.656,0.543-4.905-5.085-0.81-5.953C535.224,417.154,536.78,423.213,532.613,423.831z"/>
<path d="M462.717,808.436c-0.363,3.943-6.073,3.617-5.823-0.328C457.165,803.842,463.05,804.818,462.717,808.436z"/>
<path d="M811.432,459.43c-3.84,0.046-4.153-5.743-0.042-5.754C814.736,453.667,815.983,459.375,811.432,459.43z"/>
<path d="M830.087,448.685c-0.315,3.024-4.487,2.669-4.407,0.032C825.745,446.567,829.374,445.987,830.087,448.685z"/>
<path d="M454.057,824.646c-0.678,3.451-4.188,2.629-3.997-0.172C450.221,822.126,453.485,821.431,454.057,824.646z"/>
<path d="M407.777,518.303c-0.099-2.854,3.921-3.198,4.314-0.719C412.448,519.829,408.865,520.751,407.777,518.303z"/>
<path d="M523.013,406.786c-0.515,3.097-3.925,2.523-3.757-0.121C519.427,403.976,522.706,403.9,523.013,406.786z"/>
<path d="M334.846,582.521c1.135-0.135,2.27-0.27,3.406-0.406c-1.123,0.615-2.279,0.817-3.487,0.348
C334.749,582.451,334.846,582.521,334.846,582.521z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 138 KiB

View File

@ -22,6 +22,7 @@ try:
glVertex2f,
GL_QUADS,
GL_LINE_LOOP,
GL_LINE_STRIP,
GL_LINES,
GL_TRIANGLE_FAN,
# Colors
@ -99,7 +100,7 @@ except ImportError:
glGetString = _gl_stub
# Constants
GL_QUADS = GL_LINE_LOOP = GL_LINES = GL_TRIANGLE_FAN = 0
GL_QUADS = GL_LINE_LOOP = GL_LINE_STRIP = GL_LINES = GL_TRIANGLE_FAN = 0
GL_LINE_STIPPLE = GL_DEPTH_TEST = GL_BLEND = 0
GL_SRC_ALPHA = GL_ONE_MINUS_SRC_ALPHA = 0
GL_TEXTURE_2D = GL_RGBA = GL_UNSIGNED_BYTE = 0

View File

@ -158,3 +158,278 @@ def resize_to_fit(
new_height = int(image.height * scale)
return image.resize((new_width, new_height), resample)
# =============================================================================
# Image Styling Utilities
# =============================================================================
def apply_rounded_corners(
image: Image.Image,
radius_percent: float,
antialias: bool = True,
) -> Image.Image:
"""
Apply rounded corners to an image.
Args:
image: PIL Image (should be RGBA)
radius_percent: Corner radius as percentage of shorter side (0-50)
antialias: If True, use supersampling for smooth antialiased edges
Returns:
PIL Image with rounded corners (transparent outside corners)
"""
from PIL import ImageDraw
if radius_percent <= 0:
return image
# Ensure RGBA mode for transparency
if image.mode != "RGBA":
image = image.convert("RGBA")
width, height = image.size
shorter_side = min(width, height)
# Clamp radius to 0-50%
radius_percent = max(0, min(50, radius_percent))
radius = int(shorter_side * radius_percent / 100)
if radius <= 0:
return image
# Use supersampling for antialiasing
if antialias:
# Create mask at higher resolution (4x), then downscale for smooth edges
supersample_factor = 4
ss_width = width * supersample_factor
ss_height = height * supersample_factor
ss_radius = radius * supersample_factor
mask_large = Image.new("L", (ss_width, ss_height), 0)
draw = ImageDraw.Draw(mask_large)
draw.rounded_rectangle(
[0, 0, ss_width - 1, ss_height - 1], radius=ss_radius, fill=255
)
# Downscale with LANCZOS for smooth antialiased edges
mask = mask_large.resize((width, height), Image.Resampling.LANCZOS)
else:
# Original non-antialiased path
mask = Image.new("L", (width, height), 0)
draw = ImageDraw.Draw(mask)
draw.rounded_rectangle([0, 0, width - 1, height - 1], radius=radius, fill=255)
# Apply mask to alpha channel
result = image.copy()
if result.mode == "RGBA":
# Composite with existing alpha
r, g, b, a = result.split()
# Combine existing alpha with our mask
from PIL import ImageChops
new_alpha = ImageChops.multiply(a, mask)
result = Image.merge("RGBA", (r, g, b, new_alpha))
else:
result.putalpha(mask)
return result
def apply_drop_shadow(
image: Image.Image,
offset: Tuple[float, float] = (2.0, 2.0),
blur_radius: float = 3.0,
shadow_color: Tuple[int, int, int, int] = (0, 0, 0, 128),
expand: bool = True,
) -> Image.Image:
"""
Apply a drop shadow effect to an image.
Args:
image: PIL Image (should be RGBA with transparency for best results)
offset: Shadow offset in pixels (x, y)
blur_radius: Shadow blur radius in pixels
shadow_color: Shadow color as RGBA tuple (0-255)
expand: If True, expand canvas to fit shadow; if False, shadow may be clipped
Returns:
PIL Image with drop shadow
"""
from PIL import ImageFilter
# Ensure RGBA
if image.mode != "RGBA":
image = image.convert("RGBA")
offset_x, offset_y = int(offset[0]), int(offset[1])
blur_radius = max(0, int(blur_radius))
# Calculate canvas expansion needed
if expand:
# Account for blur spread and offset
padding = blur_radius * 2 + max(abs(offset_x), abs(offset_y))
new_width = image.width + padding * 2
new_height = image.height + padding * 2
img_x = padding
img_y = padding
else:
new_width = image.width
new_height = image.height
padding = 0
img_x = 0
img_y = 0
# Create shadow layer from alpha channel
_, _, _, alpha = image.split()
# Create shadow image (same shape as alpha, filled with shadow color)
shadow = Image.new("RGBA", (image.width, image.height), shadow_color[:3] + (0,))
shadow.putalpha(alpha)
# Apply blur to shadow
if blur_radius > 0:
shadow = shadow.filter(ImageFilter.GaussianBlur(blur_radius))
# Adjust shadow alpha based on shadow_color alpha
if shadow_color[3] < 255:
r, g, b, a = shadow.split()
# Scale alpha by shadow_color alpha
a = a.point(lambda x: int(x * shadow_color[3] / 255))
shadow = Image.merge("RGBA", (r, g, b, a))
# Create result canvas
result = Image.new("RGBA", (new_width, new_height), (0, 0, 0, 0))
# Paste shadow (offset from image position)
shadow_x = img_x + offset_x
shadow_y = img_y + offset_y
result.paste(shadow, (shadow_x, shadow_y), shadow)
# Paste original image on top
result.paste(image, (img_x, img_y), image)
return result
def create_border_image(
width: int,
height: int,
border_width: int,
border_color: Tuple[int, int, int] = (0, 0, 0),
corner_radius: int = 0,
) -> Image.Image:
"""
Create an image with just a border (transparent center).
Args:
width: Image width in pixels
height: Image height in pixels
border_width: Border width in pixels
border_color: Border color as RGB tuple (0-255)
corner_radius: Corner radius in pixels (0 for square corners)
Returns:
PIL Image with border only (RGBA with transparent center)
"""
from PIL import ImageDraw
if border_width <= 0:
return Image.new("RGBA", (width, height), (0, 0, 0, 0))
result = Image.new("RGBA", (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(result)
# Draw outer rounded rectangle
outer_color = border_color + (255,) # Add full alpha
if corner_radius > 0:
draw.rounded_rectangle(
[0, 0, width - 1, height - 1],
radius=corner_radius,
fill=outer_color,
)
# Draw inner transparent area
inner_radius = max(0, corner_radius - border_width)
draw.rounded_rectangle(
[border_width, border_width, width - 1 - border_width, height - 1 - border_width],
radius=inner_radius,
fill=(0, 0, 0, 0),
)
else:
draw.rectangle([0, 0, width - 1, height - 1], fill=outer_color)
draw.rectangle(
[border_width, border_width, width - 1 - border_width, height - 1 - border_width],
fill=(0, 0, 0, 0),
)
return result
def apply_style_to_image(
image: Image.Image,
corner_radius: float = 0.0,
border_width: float = 0.0,
border_color: Tuple[int, int, int] = (0, 0, 0),
shadow_enabled: bool = False,
shadow_offset: Tuple[float, float] = (2.0, 2.0),
shadow_blur: float = 3.0,
shadow_color: Tuple[int, int, int, int] = (0, 0, 0, 128),
dpi: float = 96.0,
) -> Image.Image:
"""
Apply all styling effects to an image in the correct order.
Args:
image: Source PIL Image
corner_radius: Corner radius as percentage (0-50)
border_width: Border width in mm
border_color: Border color as RGB (0-255)
shadow_enabled: Whether to apply drop shadow
shadow_offset: Shadow offset in mm (x, y)
shadow_blur: Shadow blur in mm
shadow_color: Shadow color as RGBA (0-255)
dpi: DPI for converting mm to pixels
Returns:
Styled PIL Image
"""
# Ensure RGBA
result = convert_to_rgba(image)
# Convert mm to pixels
mm_to_px = dpi / 25.4
border_width_px = int(border_width * mm_to_px)
shadow_offset_px = (shadow_offset[0] * mm_to_px, shadow_offset[1] * mm_to_px)
shadow_blur_px = shadow_blur * mm_to_px
# 1. Apply rounded corners first
if corner_radius > 0:
result = apply_rounded_corners(result, corner_radius)
# 2. Apply border (composite border image on top)
if border_width_px > 0:
shorter_side = min(result.width, result.height)
corner_radius_px = int(shorter_side * min(50, corner_radius) / 100) if corner_radius > 0 else 0
border_img = create_border_image(
result.width,
result.height,
border_width_px,
border_color,
corner_radius_px,
)
result = Image.alpha_composite(result, border_img)
# 3. Apply shadow last (expands canvas)
if shadow_enabled:
result = apply_drop_shadow(
result,
offset=shadow_offset_px,
blur_radius=shadow_blur_px,
shadow_color=shadow_color,
expand=True,
)
return result

View File

@ -44,6 +44,7 @@ from pyPhotoAlbum.mixins.operations import (
SizeOperationsMixin,
ZOrderOperationsMixin,
MergeOperationsMixin,
StyleOperationsMixin,
)
@ -62,6 +63,7 @@ class MainWindow(
SizeOperationsMixin,
ZOrderOperationsMixin,
MergeOperationsMixin,
StyleOperationsMixin,
):
"""
Main application window using mixin architecture.

View File

@ -90,6 +90,8 @@ class AssetDropMixin:
width=placeholder.size[0],
height=placeholder.size[1],
z_index=placeholder.z_index,
# Inherit styling from placeholder (for templatable styles)
style=placeholder.style.copy(),
)
if not main_window.project.pages:

View File

@ -116,9 +116,11 @@ class AsyncLoadingMixin:
logger.debug(f"PDF progress: {current}/{total} - {message}")
# Update progress dialog if it exists
if hasattr(self, "_pdf_progress_dialog") and self._pdf_progress_dialog:
self._pdf_progress_dialog.setValue(current)
self._pdf_progress_dialog.setLabelText(message)
# Use local reference to avoid race condition
dialog = getattr(self, "_pdf_progress_dialog", None)
if dialog is not None:
dialog.setValue(current)
dialog.setLabelText(message)
def _on_pdf_complete(self, success: bool, warnings: list):
"""

View File

@ -13,6 +13,7 @@ from pyPhotoAlbum.mixins.operations.distribution_ops import DistributionOperatio
from pyPhotoAlbum.mixins.operations.size_ops import SizeOperationsMixin
from pyPhotoAlbum.mixins.operations.zorder_ops import ZOrderOperationsMixin
from pyPhotoAlbum.mixins.operations.merge_ops import MergeOperationsMixin
from pyPhotoAlbum.mixins.operations.style_ops import StyleOperationsMixin
__all__ = [
"FileOperationsMixin",
@ -26,4 +27,5 @@ __all__ = [
"SizeOperationsMixin",
"ZOrderOperationsMixin",
"MergeOperationsMixin",
"StyleOperationsMixin",
]

View File

@ -0,0 +1,372 @@
"""
Style operations mixin for pyPhotoAlbum
Provides ribbon actions for applying visual styles to images:
- Rounded corners
- Borders
- Drop shadows
- (Future) Decorative frames
"""
from pyPhotoAlbum.decorators import ribbon_action
from pyPhotoAlbum.models import ImageData, ImageStyle
class StyleOperationsMixin:
"""Mixin providing element styling operations"""
def _get_selected_images(self):
"""Get list of selected ImageData elements"""
if not self.gl_widget.selected_elements:
return []
return [e for e in self.gl_widget.selected_elements if isinstance(e, ImageData)]
def _apply_style_change(self, style_updater, description: str):
"""
Apply a style change to selected images with undo support.
Args:
style_updater: Function that takes an ImageStyle and modifies it
description: Description for undo history
"""
images = self._get_selected_images()
if not images:
self.show_status("No images selected", 2000)
return
# Store old styles for undo
old_styles = [(img, img.style.copy()) for img in images]
# Create undo command
from pyPhotoAlbum.commands import Command
class StyleChangeCommand(Command):
def __init__(self, old_styles, new_style_updater, desc):
self.old_styles = old_styles
self.new_style_updater = new_style_updater
self.description = desc
def _invalidate_texture(self, img):
"""Invalidate the image texture so it will be regenerated."""
# Clear the style hash to force regeneration check
if hasattr(img, "_texture_style_hash"):
delattr(img, "_texture_style_hash")
# Clear async load state so it will reload
img._async_load_requested = False
# Delete texture if it exists (will be recreated on next render)
if hasattr(img, "_texture_id") and img._texture_id:
from pyPhotoAlbum.gl_imports import glDeleteTextures
try:
glDeleteTextures([img._texture_id])
except Exception:
pass # GL context might not be available
delattr(img, "_texture_id")
def execute(self):
for img, _ in self.old_styles:
self.new_style_updater(img.style)
self._invalidate_texture(img)
def undo(self):
for img, old_style in self.old_styles:
img.style = old_style.copy()
self._invalidate_texture(img)
def redo(self):
self.execute()
def serialize(self):
# Style changes are not serialized (session-only undo)
return {"type": "style_change", "description": self.description}
@staticmethod
def deserialize(data, project):
# Style changes cannot be deserialized (session-only)
return None
cmd = StyleChangeCommand(old_styles, style_updater, description)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"{description} applied to {len(images)} image(s)", 2000)
# =========================================================================
# Corner Radius
# =========================================================================
@ribbon_action(
label="Round Corners",
tooltip="Set corner radius for selected images",
tab="Style",
group="Corners",
requires_selection=True,
)
def show_corner_radius_dialog(self):
"""Show dialog to set corner radius"""
images = self._get_selected_images()
if not images:
self.show_status("No images selected", 2000)
return
from pyPhotoAlbum.dialogs.style_dialogs import CornerRadiusDialog
# Get current radius from first selected image
current_radius = images[0].style.corner_radius
dialog = CornerRadiusDialog(self, current_radius)
if dialog.exec():
new_radius = dialog.get_value()
self._apply_style_change(
lambda style: setattr(style, "corner_radius", new_radius),
f"Set corner radius to {new_radius}%",
)
@ribbon_action(
label="No Corners",
tooltip="Remove rounded corners from selected images",
tab="Style",
group="Corners",
requires_selection=True,
)
def remove_corner_radius(self):
"""Remove corner radius (set to 0)"""
self._apply_style_change(
lambda style: setattr(style, "corner_radius", 0.0),
"Remove corner radius",
)
# =========================================================================
# Borders
# =========================================================================
@ribbon_action(
label="Border...",
tooltip="Set border for selected images",
tab="Style",
group="Border",
requires_selection=True,
)
def show_border_dialog(self):
"""Show dialog to configure border"""
images = self._get_selected_images()
if not images:
self.show_status("No images selected", 2000)
return
from pyPhotoAlbum.dialogs.style_dialogs import BorderDialog
# Get current border from first selected image
current_style = images[0].style
dialog = BorderDialog(self, current_style.border_width, current_style.border_color)
if dialog.exec():
width, color = dialog.get_values()
def update_border(style):
style.border_width = width
style.border_color = color
self._apply_style_change(update_border, f"Set border ({width}mm)")
@ribbon_action(
label="No Border",
tooltip="Remove border from selected images",
tab="Style",
group="Border",
requires_selection=True,
)
def remove_border(self):
"""Remove border (set width to 0)"""
self._apply_style_change(
lambda style: setattr(style, "border_width", 0.0),
"Remove border",
)
# =========================================================================
# Shadows
# =========================================================================
@ribbon_action(
label="Shadow...",
tooltip="Configure drop shadow for selected images",
tab="Style",
group="Effects",
requires_selection=True,
)
def show_shadow_dialog(self):
"""Show dialog to configure drop shadow"""
images = self._get_selected_images()
if not images:
self.show_status("No images selected", 2000)
return
from pyPhotoAlbum.dialogs.style_dialogs import ShadowDialog
# Get current shadow settings from first selected image
current_style = images[0].style
dialog = ShadowDialog(
self,
current_style.shadow_enabled,
current_style.shadow_offset,
current_style.shadow_blur,
current_style.shadow_color,
)
if dialog.exec():
enabled, offset, blur, color = dialog.get_values()
def update_shadow(style):
style.shadow_enabled = enabled
style.shadow_offset = offset
style.shadow_blur = blur
style.shadow_color = color
self._apply_style_change(update_shadow, "Configure shadow")
@ribbon_action(
label="Toggle Shadow",
tooltip="Toggle drop shadow on/off for selected images",
tab="Style",
group="Effects",
requires_selection=True,
)
def toggle_shadow(self):
"""Toggle shadow enabled/disabled"""
images = self._get_selected_images()
if not images:
self.show_status("No images selected", 2000)
return
# Toggle based on first selected image
new_state = not images[0].style.shadow_enabled
self._apply_style_change(
lambda style: setattr(style, "shadow_enabled", new_state),
"Enable shadow" if new_state else "Disable shadow",
)
# =========================================================================
# Style Presets
# =========================================================================
@ribbon_action(
label="Polaroid",
tooltip="Apply Polaroid-style frame (white border, shadow)",
tab="Style",
group="Presets",
requires_selection=True,
)
def apply_polaroid_style(self):
"""Apply Polaroid-style preset"""
def apply_preset(style):
style.corner_radius = 0.0
style.border_width = 3.0 # 3mm white border
style.border_color = (255, 255, 255)
style.shadow_enabled = True
style.shadow_offset = (2.0, 2.0)
style.shadow_blur = 4.0
style.shadow_color = (0, 0, 0, 100)
self._apply_style_change(apply_preset, "Apply Polaroid style")
@ribbon_action(
label="Rounded",
tooltip="Apply rounded photo style",
tab="Style",
group="Presets",
requires_selection=True,
)
def apply_rounded_style(self):
"""Apply rounded corners preset"""
def apply_preset(style):
style.corner_radius = 10.0 # 10% rounded
style.border_width = 0.0
style.shadow_enabled = True
style.shadow_offset = (1.5, 1.5)
style.shadow_blur = 3.0
style.shadow_color = (0, 0, 0, 80)
self._apply_style_change(apply_preset, "Apply rounded style")
@ribbon_action(
label="Clear Style",
tooltip="Remove all styling from selected images",
tab="Style",
group="Presets",
requires_selection=True,
)
def clear_style(self):
"""Remove all styling (reset to defaults)"""
def clear_all(style):
style.corner_radius = 0.0
style.border_width = 0.0
style.border_color = (0, 0, 0)
style.shadow_enabled = False
style.shadow_offset = (2.0, 2.0)
style.shadow_blur = 3.0
style.shadow_color = (0, 0, 0, 128)
style.frame_style = None
style.frame_color = (0, 0, 0)
style.frame_corners = (True, True, True, True)
self._apply_style_change(clear_all, "Clear style")
# =========================================================================
# Decorative Frames
# =========================================================================
@ribbon_action(
label="Frame...",
tooltip="Add decorative frame to selected images",
tab="Style",
group="Frame",
requires_selection=True,
)
def show_frame_picker(self):
"""Show dialog to select decorative frame"""
images = self._get_selected_images()
if not images:
self.show_status("No images selected", 2000)
return
from pyPhotoAlbum.dialogs.frame_picker_dialog import FramePickerDialog
# Get current frame settings from first selected image
current_style = images[0].style
dialog = FramePickerDialog(
self,
current_frame=current_style.frame_style,
current_color=current_style.frame_color,
current_corners=current_style.frame_corners,
)
if dialog.exec():
frame_name, color, corners = dialog.get_values()
def update_frame(style):
style.frame_style = frame_name
style.frame_color = color
style.frame_corners = corners
desc = f"Apply frame '{frame_name}'" if frame_name else "Remove frame"
self._apply_style_change(update_frame, desc)
@ribbon_action(
label="Remove Frame",
tooltip="Remove decorative frame from selected images",
tab="Style",
group="Frame",
requires_selection=True,
)
def remove_frame(self):
"""Remove decorative frame"""
def clear_frame(style):
style.frame_style = None
style.frame_color = (0, 0, 0)
style.frame_corners = (True, True, True, True)
self._apply_style_change(clear_frame, "Remove frame")

View File

@ -51,6 +51,158 @@ from pyPhotoAlbum.gl_imports import (
logger = logging.getLogger(__name__)
# =============================================================================
# Image Styling
# =============================================================================
class ImageStyle:
"""
Styling properties for images and placeholders.
This class encapsulates all visual styling that can be applied to images:
- Rounded corners
- Borders (width, color)
- Drop shadows
- Decorative frames
Styles are attached to both ImageData and PlaceholderData. When an image
is dropped onto a placeholder, it inherits the placeholder's style.
"""
def __init__(
self,
corner_radius: float = 0.0,
border_width: float = 0.0,
border_color: Tuple[int, int, int] = (0, 0, 0),
shadow_enabled: bool = False,
shadow_offset: Tuple[float, float] = (2.0, 2.0),
shadow_blur: float = 3.0,
shadow_color: Tuple[int, int, int, int] = (0, 0, 0, 128),
frame_style: Optional[str] = None,
frame_color: Tuple[int, int, int] = (0, 0, 0),
frame_corners: Optional[Tuple[bool, bool, bool, bool]] = None,
):
"""
Initialize image style.
Args:
corner_radius: Corner radius as percentage of shorter side (0-50)
border_width: Border width in mm (0 = no border)
border_color: Border color as RGB tuple (0-255)
shadow_enabled: Whether drop shadow is enabled
shadow_offset: Shadow offset in mm (x, y)
shadow_blur: Shadow blur radius in mm
shadow_color: Shadow color as RGBA tuple (0-255)
frame_style: Name of decorative frame style (None = no frame)
frame_color: Frame tint color as RGB tuple (0-255)
frame_corners: Which corners get frame decoration (TL, TR, BR, BL).
None means all corners, (True, True, True, True) means all,
(True, False, False, True) means only left corners, etc.
"""
self.corner_radius = corner_radius
self.border_width = border_width
self.border_color = tuple(border_color)
self.shadow_enabled = shadow_enabled
self.shadow_offset = tuple(shadow_offset)
self.shadow_blur = shadow_blur
self.shadow_color = tuple(shadow_color)
self.frame_style = frame_style
self.frame_color = tuple(frame_color)
# frame_corners: (top_left, top_right, bottom_right, bottom_left)
self.frame_corners = tuple(frame_corners) if frame_corners else (True, True, True, True)
def copy(self) -> "ImageStyle":
"""Create a copy of this style."""
return ImageStyle(
corner_radius=self.corner_radius,
border_width=self.border_width,
border_color=self.border_color,
shadow_enabled=self.shadow_enabled,
shadow_offset=self.shadow_offset,
shadow_blur=self.shadow_blur,
shadow_color=self.shadow_color,
frame_style=self.frame_style,
frame_color=self.frame_color,
frame_corners=self.frame_corners,
)
def has_styling(self) -> bool:
"""Check if any styling is applied (non-default values)."""
return (
self.corner_radius > 0
or self.border_width > 0
or self.shadow_enabled
or self.frame_style is not None
)
def serialize(self) -> Dict[str, Any]:
"""Serialize style to dictionary."""
return {
"corner_radius": self.corner_radius,
"border_width": self.border_width,
"border_color": list(self.border_color),
"shadow_enabled": self.shadow_enabled,
"shadow_offset": list(self.shadow_offset),
"shadow_blur": self.shadow_blur,
"shadow_color": list(self.shadow_color),
"frame_style": self.frame_style,
"frame_color": list(self.frame_color),
"frame_corners": list(self.frame_corners),
}
@classmethod
def deserialize(cls, data: Dict[str, Any]) -> "ImageStyle":
"""Deserialize style from dictionary."""
if data is None:
return cls()
frame_corners_data = data.get("frame_corners")
frame_corners = tuple(frame_corners_data) if frame_corners_data else None
return cls(
corner_radius=data.get("corner_radius", 0.0),
border_width=data.get("border_width", 0.0),
border_color=tuple(data.get("border_color", (0, 0, 0))),
shadow_enabled=data.get("shadow_enabled", False),
shadow_offset=tuple(data.get("shadow_offset", (2.0, 2.0))),
shadow_blur=data.get("shadow_blur", 3.0),
shadow_color=tuple(data.get("shadow_color", (0, 0, 0, 128))),
frame_style=data.get("frame_style"),
frame_color=tuple(data.get("frame_color", (0, 0, 0))),
frame_corners=frame_corners,
)
def __eq__(self, other):
if not isinstance(other, ImageStyle):
return False
return (
self.corner_radius == other.corner_radius
and self.border_width == other.border_width
and self.border_color == other.border_color
and self.shadow_enabled == other.shadow_enabled
and self.shadow_offset == other.shadow_offset
and self.shadow_blur == other.shadow_blur
and self.shadow_color == other.shadow_color
and self.frame_style == other.frame_style
and self.frame_color == other.frame_color
and self.frame_corners == other.frame_corners
)
def __repr__(self):
if not self.has_styling():
return "ImageStyle()"
parts = []
if self.corner_radius > 0:
parts.append(f"corner_radius={self.corner_radius}")
if self.border_width > 0:
parts.append(f"border_width={self.border_width}")
if self.shadow_enabled:
parts.append("shadow_enabled=True")
if self.frame_style:
parts.append(f"frame_style='{self.frame_style}'")
return f"ImageStyle({', '.join(parts)})"
# Global configuration for asset path resolution
_asset_search_paths: List[str] = []
_primary_project_folder: Optional[str] = None
@ -156,6 +308,7 @@ class ImageData(BaseLayoutElement):
image_path: str = "",
crop_info: Optional[Tuple] = None,
image_dimensions: Optional[Tuple[int, int]] = None,
style: Optional["ImageStyle"] = None,
**kwargs,
):
super().__init__(**kwargs)
@ -170,6 +323,9 @@ class ImageData(BaseLayoutElement):
# This is separate from the visual rotation field (which should stay at 0)
self.pil_rotation_90 = 0 # 0, 1, 2, or 3 (for 0°, 90°, 180°, 270°)
# Styling properties (rounded corners, borders, shadows, frames)
self.style = style if style is not None else ImageStyle()
# If dimensions not provided and we have a path, try to extract them quickly
if not self.image_dimensions and self.image_path:
self._extract_dimensions_metadata()
@ -228,21 +384,49 @@ class ImageData(BaseLayoutElement):
if hasattr(self, "_pending_pil_image") and self._pending_pil_image is not None:
self._create_texture_from_pending_image()
# Check if style changed and texture needs regeneration
if hasattr(self, "_texture_id") and self._texture_id:
current_hash = self._get_style_hash()
cached_hash = getattr(self, "_texture_style_hash", None)
if cached_hash is None:
# First time check - assume texture was loaded without styling
# Set hash to 0 (no corner radius) to match legacy behavior
self._texture_style_hash = hash((0.0,))
cached_hash = self._texture_style_hash
if cached_hash != current_hash:
# Style changed - mark for reload
self._async_load_requested = False
glDeleteTextures([self._texture_id])
delattr(self, "_texture_id") # Remove attribute so async loader will re-trigger
# Draw drop shadow first (behind everything)
if self.style.shadow_enabled:
self._render_shadow(x, y, w, h)
# Use cached texture if available
if hasattr(self, "_texture_id") and self._texture_id:
texture_id = self._texture_id
# Get image dimensions (from loaded texture or metadata)
if hasattr(self, "_img_width") and hasattr(self, "_img_height"):
img_width, img_height = self._img_width, self._img_height
elif self.image_dimensions:
img_width, img_height = self.image_dimensions
# Check if texture was pre-cropped (for styled images with rounded corners)
if getattr(self, "_texture_precropped", False):
# Texture is already cropped to visible region - use full texture
tx_min, ty_min, tx_max, ty_max = 0.0, 0.0, 1.0, 1.0
else:
# No dimensions available, render without aspect ratio correction
img_width, img_height = int(w), int(h)
# Get image dimensions (from loaded texture or metadata)
if hasattr(self, "_img_width") and hasattr(self, "_img_height"):
img_width, img_height = self._img_width, self._img_height
elif self.image_dimensions:
img_width, img_height = self.image_dimensions
else:
# No dimensions available, render without aspect ratio correction
img_width, img_height = int(w), int(h)
# Calculate texture coordinates for center crop with element's crop_info
tx_min, ty_min, tx_max, ty_max = calculate_center_crop_coords(img_width, img_height, w, h, self.crop_info)
# Calculate texture coordinates for center crop with element's crop_info
tx_min, ty_min, tx_max, ty_max = calculate_center_crop_coords(img_width, img_height, w, h, self.crop_info)
# Enable blending for transparency (rounded corners)
glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
# Enable texturing and draw with crop
glEnable(GL_TEXTURE_2D)
@ -261,6 +445,7 @@ class ImageData(BaseLayoutElement):
glEnd()
glDisable(GL_TEXTURE_2D)
glDisable(GL_BLEND)
# If no image or loading failed, draw placeholder
if not texture_id:
@ -272,14 +457,86 @@ class ImageData(BaseLayoutElement):
glVertex2f(x, y + h)
glEnd()
# Draw border
glColor3f(0.0, 0.0, 0.0) # Black border
# Draw styled border if specified, otherwise default thin black border
if self.style.border_width > 0:
self._render_border(x, y, w, h)
else:
# Default thin border for visibility
glColor3f(0.0, 0.0, 0.0) # Black border
glBegin(GL_LINE_LOOP)
glVertex2f(x, y)
glVertex2f(x + w, y)
glVertex2f(x + w, y + h)
glVertex2f(x, y + h)
glEnd()
# Draw decorative frame if specified
if self.style.frame_style:
self._render_frame(x, y, w, h)
def _render_shadow(self, x: float, y: float, w: float, h: float):
"""Render drop shadow behind the image."""
# Convert shadow offset from mm to pixels (approximate, assuming 96 DPI for screen)
dpi = 96.0
mm_to_px = dpi / 25.4
offset_x = self.style.shadow_offset[0] * mm_to_px
offset_y = self.style.shadow_offset[1] * mm_to_px
# Shadow color with alpha
r, g, b, a = self.style.shadow_color
glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
glColor4f(r / 255.0, g / 255.0, b / 255.0, a / 255.0)
# Draw shadow quad (slightly offset)
shadow_x = x + offset_x
shadow_y = y + offset_y
glBegin(GL_QUADS)
glVertex2f(shadow_x, shadow_y)
glVertex2f(shadow_x + w, shadow_y)
glVertex2f(shadow_x + w, shadow_y + h)
glVertex2f(shadow_x, shadow_y + h)
glEnd()
glDisable(GL_BLEND)
def _render_border(self, x: float, y: float, w: float, h: float):
"""Render styled border around the image."""
# Convert border width from mm to pixels
dpi = 96.0
mm_to_px = dpi / 25.4
border_px = self.style.border_width * mm_to_px
# Border color
r, g, b = self.style.border_color
glColor3f(r / 255.0, g / 255.0, b / 255.0)
# Draw border as thick line (OpenGL line width)
from OpenGL.GL import glLineWidth
glLineWidth(max(1.0, border_px))
glBegin(GL_LINE_LOOP)
glVertex2f(x, y)
glVertex2f(x + w, y)
glVertex2f(x + w, y + h)
glVertex2f(x, y + h)
glEnd()
glLineWidth(1.0) # Reset to default
def _render_frame(self, x: float, y: float, w: float, h: float):
"""Render decorative frame around the image."""
from pyPhotoAlbum.frame_manager import get_frame_manager
frame_manager = get_frame_manager()
frame_manager.render_frame_opengl(
frame_name=self.style.frame_style,
x=x,
y=y,
width=w,
height=h,
color=self.style.frame_color,
corners=self.style.frame_corners,
)
def serialize(self) -> Dict[str, Any]:
"""Serialize image data to dictionary"""
@ -297,6 +554,10 @@ class ImageData(BaseLayoutElement):
if self.image_dimensions:
data["image_dimensions"] = self.image_dimensions
# Include style if non-default (v3.1+)
if self.style.has_styling():
data["style"] = self.style.serialize()
# Add base fields (v3.0+)
data.update(self._serialize_base_fields())
@ -335,6 +596,9 @@ class ImageData(BaseLayoutElement):
if self.image_dimensions:
self.image_dimensions = tuple(self.image_dimensions)
# Load style (v3.1+, backwards compatible - defaults to no styling)
self.style = ImageStyle.deserialize(data.get("style"))
def _on_async_image_loaded(self, pil_image):
"""
Callback when async image loading completes.
@ -354,6 +618,33 @@ class ImageData(BaseLayoutElement):
pil_image = apply_pil_rotation(pil_image, self.pil_rotation_90)
logger.debug(f"ImageData: Applied PIL rotation {self.pil_rotation_90 * 90}° to {self.image_path}")
# For rounded corners, we need to pre-crop the image to the visible region
# so that the corners are applied to what will actually be displayed.
# Calculate the crop region based on element aspect ratio and crop_info.
if self.style.corner_radius > 0:
from pyPhotoAlbum.image_utils import apply_rounded_corners, crop_image_to_coords
# Get element dimensions for aspect ratio calculation
element_width, element_height = self.size
# Calculate crop coordinates (same logic as render-time)
crop_coords = calculate_center_crop_coords(
pil_image.width, pil_image.height, element_width, element_height, self.crop_info
)
# Pre-crop the image to the visible region
pil_image = crop_image_to_coords(pil_image, crop_coords)
logger.debug(f"ImageData: Pre-cropped to {pil_image.size} for styling")
# Now apply rounded corners to the cropped image
pil_image = apply_rounded_corners(pil_image, self.style.corner_radius)
logger.debug(f"ImageData: Applied {self.style.corner_radius}% corner radius to {self.image_path}")
# Mark that texture is pre-cropped (no further crop needed at render time)
self._texture_precropped = True
else:
self._texture_precropped = False
# Store the image for texture creation during next render()
# This avoids GL context issues when callback runs on wrong thread/timing
self._pending_pil_image = pil_image
@ -361,7 +652,10 @@ class ImageData(BaseLayoutElement):
self._img_height = pil_image.height
self._async_loading = False
# Update metadata for future renders - always update to reflect rotated dimensions
# Track which style was applied to this texture (for cache invalidation)
self._texture_style_hash = self._get_style_hash()
# Update metadata for future renders - always update to reflect dimensions
self.image_dimensions = (pil_image.width, pil_image.height)
logger.debug(f"ImageData: Queued for texture creation: {self.image_path}")
@ -371,6 +665,14 @@ class ImageData(BaseLayoutElement):
self._pending_pil_image = None
self._async_loading = False
def _get_style_hash(self) -> int:
"""Get a hash of the current style settings that affect texture rendering."""
# Corner radius affects the texture, and when styled, crop_info and size also matter
# because we pre-crop the image before applying rounded corners
if self.style.corner_radius > 0:
return hash((self.style.corner_radius, self.crop_info, self.size))
return hash((self.style.corner_radius,))
def _create_texture_from_pending_image(self):
"""
Create OpenGL texture from pending PIL image.
@ -459,10 +761,18 @@ class ImageData(BaseLayoutElement):
class PlaceholderData(BaseLayoutElement):
"""Class to store placeholder data"""
def __init__(self, placeholder_type: str = "image", default_content: str = "", **kwargs):
def __init__(
self,
placeholder_type: str = "image",
default_content: str = "",
style: Optional["ImageStyle"] = None,
**kwargs,
):
super().__init__(**kwargs)
self.placeholder_type = placeholder_type
self.default_content = default_content
# Style to apply when an image is dropped onto this placeholder
self.style = style if style is not None else ImageStyle()
def render(self):
"""Render the placeholder using OpenGL"""
@ -518,6 +828,9 @@ class PlaceholderData(BaseLayoutElement):
"placeholder_type": self.placeholder_type,
"default_content": self.default_content,
}
# Include style if non-default (v3.1+) - for templatable styling
if self.style.has_styling():
data["style"] = self.style.serialize()
# Add base fields (v3.0+)
data.update(self._serialize_base_fields())
return data
@ -533,6 +846,8 @@ class PlaceholderData(BaseLayoutElement):
self.z_index = data.get("z_index", 0)
self.placeholder_type = data.get("placeholder_type", "image")
self.default_content = data.get("default_content", "")
# Load style (v3.1+, backwards compatible)
self.style = ImageStyle.deserialize(data.get("style"))
class TextBoxData(BaseLayoutElement):

View File

@ -53,11 +53,13 @@ class PageLayout:
def add_element(self, element: BaseLayoutElement):
"""Add a layout element to the page"""
self.elements.append(element)
if element not in self.elements:
self.elements.append(element)
def remove_element(self, element: BaseLayoutElement):
"""Remove a layout element from the page"""
self.elements.remove(element)
if element in self.elements:
self.elements.remove(element)
def set_grid_layout(self, grid: "GridLayout"):
"""Set a grid layout for the page"""

View File

@ -1,10 +1,15 @@
"""
PDF export functionality for pyPhotoAlbum
Uses multiprocessing to pre-process images in parallel for faster exports.
"""
import os
from typing import Any, List, Tuple, Optional, Union
from dataclasses import dataclass
from typing import Any, List, Tuple, Optional, Union, Dict
from dataclasses import dataclass, field
from concurrent.futures import ProcessPoolExecutor, as_completed
import multiprocessing
import io
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
from reportlab.lib.utils import ImageReader
@ -22,6 +27,103 @@ from pyPhotoAlbum.image_utils import (
)
@dataclass
class ImageTask:
"""Parameters needed to process an image in a worker process."""
task_id: str
image_path: str
pil_rotation_90: int
crop_info: Tuple[float, float, float, float]
crop_left: float
crop_right: float
target_width: float
target_height: float
target_width_px: int
target_height_px: int
corner_radius: float
def _process_image_task(task: ImageTask) -> Tuple[str, Optional[bytes], Optional[str]]:
"""
Process a single image task in a worker process.
This function runs in a separate process and handles all CPU-intensive
image operations: loading, rotation, cropping, resizing, and styling.
Args:
task: ImageTask with all parameters needed for processing
Returns:
Tuple of (task_id, processed_image_bytes or None, error_message or None)
"""
try:
# Validate image_path is a string before proceeding
if not isinstance(task.image_path, str):
return (task.task_id, None, f"Invalid image_path type: {type(task.image_path).__name__}, expected str")
# Import here to ensure fresh imports in worker process
from PIL import Image
# Load image first (before other imports that might cause issues)
img = Image.open(task.image_path)
# Now import the rest
from pyPhotoAlbum.image_utils import (
apply_pil_rotation,
convert_to_rgba,
calculate_center_crop_coords,
crop_image_to_coords,
apply_rounded_corners,
)
# Convert to RGBA
img = convert_to_rgba(img)
# Apply PIL-level rotation if needed
if task.pil_rotation_90 > 0:
img = apply_pil_rotation(img, task.pil_rotation_90)
# Calculate final crop bounds (combining element crop with split crop)
crop_x_min, crop_y_min, crop_x_max, crop_y_max = task.crop_info
final_crop_x_min = crop_x_min + (crop_x_max - crop_x_min) * task.crop_left
final_crop_x_max = crop_x_min + (crop_x_max - crop_x_min) * task.crop_right
# Calculate center crop coordinates
img_width, img_height = img.size
crop_coords = calculate_center_crop_coords(
img_width,
img_height,
task.target_width,
task.target_height,
(final_crop_x_min, crop_y_min, final_crop_x_max, crop_y_max),
)
# Crop the image
cropped_img = crop_image_to_coords(img, crop_coords)
# Downsample if needed
current_width, current_height = cropped_img.size
if current_width > task.target_width_px or current_height > task.target_height_px:
cropped_img = cropped_img.resize(
(task.target_width_px, task.target_height_px),
Image.Resampling.LANCZOS,
)
# Apply rounded corners if needed
if task.corner_radius > 0:
cropped_img = apply_rounded_corners(cropped_img, task.corner_radius)
# Serialize image to bytes (PNG for lossless with alpha)
buffer = io.BytesIO()
cropped_img.save(buffer, format="PNG", optimize=False)
return (task.task_id, buffer.getvalue(), None)
except Exception as e:
import traceback
return (task.task_id, None, f"{str(e)}\n{traceback.format_exc()}")
@dataclass
class RenderContext:
"""Parameters for rendering an image element"""
@ -60,7 +162,7 @@ class PDFExporter:
MM_TO_POINTS = 2.834645669 # 1mm = 2.834645669 points
SPLIT_THRESHOLD_RATIO = 0.002 # 1:500 threshold for tiny elements
def __init__(self, project, export_dpi: int = 300):
def __init__(self, project, export_dpi: int = 300, max_workers: Optional[int] = None):
"""
Initialize PDF exporter with a project.
@ -68,16 +170,24 @@ class PDFExporter:
project: The Project instance to export
export_dpi: Target DPI for images in the PDF (default 300 for print quality)
Use 300 for high-quality print, 150 for screen/draft
max_workers: Maximum number of worker processes for parallel image processing.
Defaults to number of CPU cores.
"""
self.project = project
self.export_dpi = export_dpi
self.warnings: List[str] = []
self.current_pdf_page = 1
self.max_workers = max_workers or multiprocessing.cpu_count()
# Cache for pre-processed images: task_id -> PIL Image
self._processed_images: Dict[str, Image.Image] = {}
def export(self, output_path: str, progress_callback=None) -> Tuple[bool, List[str]]:
"""
Export the project to PDF.
Uses multiprocessing to pre-process all images in parallel before
assembling the PDF sequentially.
Args:
output_path: Path where PDF should be saved
progress_callback: Optional callback(current, total, message) for progress updates
@ -87,6 +197,7 @@ class PDFExporter:
"""
self.warnings = []
self.current_pdf_page = 1
self._processed_images = {}
try:
# Calculate total pages for progress (cover counts as 1)
@ -101,42 +212,48 @@ class PDFExporter:
page_width_pt = page_width_mm * self.MM_TO_POINTS
page_height_pt = page_height_mm * self.MM_TO_POINTS
# Create PDF canvas
# Phase 1: Collect all image tasks and process in parallel
if progress_callback:
progress_callback(0, total_pages, "Collecting images for processing...")
image_tasks = self._collect_image_tasks(page_width_pt, page_height_pt)
if image_tasks:
if progress_callback:
progress_callback(0, total_pages, f"Processing {len(image_tasks)} images in parallel...")
self._preprocess_images_parallel(image_tasks, progress_callback, total_pages)
# Phase 2: Build PDF using pre-processed images
c = canvas.Canvas(output_path, pagesize=(page_width_pt, page_height_pt))
# Process each page
pages_processed = 0
for page in self.project.pages:
# Get display name for progress
page_name = self.project.get_page_display_name(page)
if progress_callback:
progress_callback(pages_processed, total_pages, f"Exporting {page_name}...")
progress_callback(pages_processed, total_pages, f"Assembling {page_name}...")
if page.is_cover:
# Export cover as single page with wrap-around design
self._export_cover(c, page, page_width_pt, page_height_pt)
pages_processed += 1
elif page.is_double_spread:
# Ensure spread starts on even page (left page of facing pair)
if self.current_pdf_page % 2 == 1:
# Insert blank page
c.showPage() # Finish current page
c.showPage()
self.current_pdf_page += 1
if progress_callback:
progress_callback(pages_processed, total_pages, f"Inserting blank page for alignment...")
progress_callback(pages_processed, total_pages, "Inserting blank page for alignment...")
# Export spread as two pages
self._export_spread(c, page, page_width_pt, page_height_pt)
pages_processed += 2
else:
# Export single page
self._export_single_page(c, page, page_width_pt, page_height_pt)
pages_processed += 1
# Save PDF
c.save()
# Clean up processed images cache
self._processed_images = {}
if progress_callback:
progress_callback(total_pages, total_pages, "Export complete!")
@ -146,6 +263,230 @@ class PDFExporter:
self.warnings.append(f"Export failed: {str(e)}")
return False, self.warnings
def _make_task_id(
self,
element: ImageData,
crop_left: float = 0.0,
crop_right: float = 1.0,
width_pt: float = 0.0,
height_pt: float = 0.0,
) -> str:
"""Generate a unique task ID for an image element with specific render params."""
return f"{id(element)}_{crop_left:.4f}_{crop_right:.4f}_{width_pt:.2f}_{height_pt:.2f}"
def _collect_image_tasks(self, page_width_pt: float, page_height_pt: float) -> List[ImageTask]:
"""
Collect all image processing tasks from the project.
Scans all pages and elements to build a list of ImageTask objects
that can be processed in parallel.
"""
tasks = []
dpi = self.project.working_dpi
for page in self.project.pages:
if page.is_cover:
cover_width_mm, cover_height_mm = page.layout.size
cover_width_pt = cover_width_mm * self.MM_TO_POINTS
cover_height_pt = cover_height_mm * self.MM_TO_POINTS
self._collect_page_tasks(tasks, page, 0, cover_width_pt, cover_height_pt)
elif page.is_double_spread:
# Collect tasks for both left and right pages of the spread
page_width_mm = self.project.page_size_mm[0]
center_mm = page_width_mm
self._collect_spread_tasks(tasks, page, page_width_pt, page_height_pt, center_mm)
else:
self._collect_page_tasks(tasks, page, 0, page_width_pt, page_height_pt)
return tasks
def _collect_page_tasks(
self,
tasks: List[ImageTask],
page,
x_offset_mm: float,
page_width_pt: float,
page_height_pt: float,
):
"""Collect image tasks from a single page."""
dpi = self.project.working_dpi
for element in page.layout.elements:
if not isinstance(element, ImageData):
continue
image_path = element.resolve_image_path()
if not image_path:
continue
# Calculate dimensions
element_x_px, element_y_px = element.position
element_width_px, element_height_px = element.size
element_width_mm = element_width_px * 25.4 / dpi
element_height_mm = element_height_px * 25.4 / dpi
width_pt = element_width_mm * self.MM_TO_POINTS
height_pt = element_height_mm * self.MM_TO_POINTS
target_width_px = int((width_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
target_height_px = int((height_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
task_id = self._make_task_id(element, 0.0, 1.0, width_pt, height_pt)
task = ImageTask(
task_id=task_id,
image_path=image_path,
pil_rotation_90=getattr(element, "pil_rotation_90", 0),
crop_info=element.crop_info,
crop_left=0.0,
crop_right=1.0,
target_width=width_pt,
target_height=height_pt,
target_width_px=max(1, target_width_px),
target_height_px=max(1, target_height_px),
corner_radius=element.style.corner_radius,
)
tasks.append(task)
def _collect_spread_tasks(
self,
tasks: List[ImageTask],
page,
page_width_pt: float,
page_height_pt: float,
center_mm: float,
):
"""Collect image tasks from a double-page spread, handling split elements."""
dpi = self.project.working_dpi
center_px = center_mm * dpi / 25.4
threshold_px = self.project.page_size_mm[0] * self.SPLIT_THRESHOLD_RATIO * dpi / 25.4
for element in page.layout.elements:
if not isinstance(element, ImageData):
continue
image_path = element.resolve_image_path()
if not image_path:
continue
element_x_px, element_y_px = element.position
element_width_px, element_height_px = element.size
element_x_mm = element_x_px * 25.4 / dpi
element_width_mm = element_width_px * 25.4 / dpi
element_height_mm = element_height_px * 25.4 / dpi
width_pt = element_width_mm * self.MM_TO_POINTS
height_pt = element_height_mm * self.MM_TO_POINTS
# Check if element spans the center
if element_x_px + element_width_px <= center_px + threshold_px:
# Entirely on left page
self._add_image_task(tasks, element, image_path, 0.0, 1.0, width_pt, height_pt)
elif element_x_px >= center_px - threshold_px:
# Entirely on right page
self._add_image_task(tasks, element, image_path, 0.0, 1.0, width_pt, height_pt)
else:
# Spanning element - create tasks for left and right portions
# Left portion
crop_width_mm_left = center_mm - element_x_mm
crop_right_left = crop_width_mm_left / element_width_mm
left_width_pt = crop_width_mm_left * self.MM_TO_POINTS
self._add_image_task(tasks, element, image_path, 0.0, crop_right_left, left_width_pt, height_pt)
# Right portion
crop_x_start_right = center_mm - element_x_mm
crop_left_right = crop_x_start_right / element_width_mm
right_width_pt = (element_width_mm - crop_x_start_right) * self.MM_TO_POINTS
self._add_image_task(tasks, element, image_path, crop_left_right, 1.0, right_width_pt, height_pt)
def _add_image_task(
self,
tasks: List[ImageTask],
element: ImageData,
image_path: str,
crop_left: float,
crop_right: float,
width_pt: float,
height_pt: float,
):
"""Helper to add an image task to the list."""
# Use original dimensions for aspect ratio calculation
original_width_pt = width_pt / (crop_right - crop_left) if crop_right != crop_left else width_pt
original_height_pt = height_pt
target_width_px = int((width_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
target_height_px = int((height_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
task_id = self._make_task_id(element, crop_left, crop_right, width_pt, height_pt)
task = ImageTask(
task_id=task_id,
image_path=image_path,
pil_rotation_90=getattr(element, "pil_rotation_90", 0),
crop_info=element.crop_info,
crop_left=crop_left,
crop_right=crop_right,
target_width=original_width_pt,
target_height=original_height_pt,
target_width_px=max(1, target_width_px),
target_height_px=max(1, target_height_px),
corner_radius=element.style.corner_radius,
)
tasks.append(task)
def _preprocess_images_parallel(
self,
tasks: List[ImageTask],
progress_callback,
total_pages: int,
):
"""
Process all image tasks in parallel using a process pool.
Results are stored in self._processed_images for use during PDF assembly.
"""
completed = 0
total_tasks = len(tasks)
with ProcessPoolExecutor(max_workers=self.max_workers) as executor:
future_to_task = {executor.submit(_process_image_task, task): task for task in tasks}
for future in as_completed(future_to_task):
task = future_to_task[future]
completed += 1
try:
task_id, image_bytes, error = future.result()
if error:
warning = f"Error processing image {task.image_path}: {error}"
print(f"WARNING: {warning}")
self.warnings.append(warning)
elif image_bytes:
# Deserialize the image from bytes
buffer = io.BytesIO(image_bytes)
img = Image.open(buffer)
# Force load the image data into memory
img.load()
# Store both image and buffer reference to prevent garbage collection
# Some PIL operations may still reference the source buffer
img._ppa_buffer = buffer # Keep buffer alive with image
self._processed_images[task_id] = img
except Exception as e:
warning = f"Error processing image {task.image_path}: {str(e)}"
print(f"WARNING: {warning}")
self.warnings.append(warning)
if progress_callback and completed % 5 == 0:
progress_callback(
0,
total_pages,
f"Processing images: {completed}/{total_tasks}...",
)
def _export_cover(self, c: canvas.Canvas, page, page_width_pt: float, page_height_pt: float):
"""
Export a cover page to PDF.
@ -428,74 +769,34 @@ class PDFExporter:
"""
Render an image element on the PDF canvas.
Uses pre-processed images from the cache when available (parallel processing),
otherwise falls back to processing the image on-demand.
Args:
ctx: RenderContext containing all rendering parameters
"""
# Resolve image path using ImageData's method
image_full_path = ctx.image_element.resolve_image_path()
# Check for pre-processed image in cache
task_id = self._make_task_id(
ctx.image_element, ctx.crop_left, ctx.crop_right, ctx.width_pt, ctx.height_pt
)
cropped_img = self._processed_images.get(task_id)
# Check if image exists
if not image_full_path:
warning = f"Page {ctx.page_number}: Image not found: {ctx.image_element.image_path}"
print(f"WARNING: {warning}")
self.warnings.append(warning)
return
if cropped_img is None:
# Fallback: process image on-demand (for backwards compatibility or cache miss)
cropped_img = self._process_image_fallback(ctx)
if cropped_img is None:
return
try:
# Load image using resolved path
img = Image.open(image_full_path)
img = convert_to_rgba(img)
# Apply PIL-level rotation if needed
if hasattr(ctx.image_element, "pil_rotation_90") and ctx.image_element.pil_rotation_90 > 0:
img = apply_pil_rotation(img, ctx.image_element.pil_rotation_90)
# Get element's crop_info and combine with split cropping if applicable
crop_x_min, crop_y_min, crop_x_max, crop_y_max = ctx.image_element.crop_info
final_crop_x_min = crop_x_min + (crop_x_max - crop_x_min) * ctx.crop_left
final_crop_x_max = crop_x_min + (crop_x_max - crop_x_min) * ctx.crop_right
# Determine target dimensions for aspect ratio
# Use original dimensions for split images to prevent stretching
if ctx.original_width_pt is not None and ctx.original_height_pt is not None:
target_width = ctx.original_width_pt
target_height = ctx.original_height_pt
else:
target_width = ctx.width_pt
target_height = ctx.height_pt
# Calculate center crop coordinates
img_width, img_height = img.size
crop_coords = calculate_center_crop_coords(
img_width,
img_height,
target_width,
target_height,
(final_crop_x_min, crop_y_min, final_crop_x_max, crop_y_max),
)
# Crop the image
cropped_img = crop_image_to_coords(img, crop_coords)
# Downsample image to target resolution based on export DPI
# This prevents embedding huge images and reduces PDF file size
# Calculate target dimensions in pixels based on physical size and export DPI
target_width_px = int((ctx.width_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
target_height_px = int((ctx.height_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
# Only downsample if current image is larger than target
# Don't upscale small images as that would reduce quality
current_width, current_height = cropped_img.size
if current_width > target_width_px or current_height > target_height_px:
# Use LANCZOS resampling for high quality downsampling
cropped_img = cropped_img.resize((target_width_px, target_height_px), Image.Resampling.LANCZOS)
# Note: Rotation is applied at the canvas level (below), not here
# to avoid double-rotation issues
style = ctx.image_element.style
# Save state for transformations
ctx.canvas.saveState()
# Draw drop shadow first (behind image)
if style.shadow_enabled:
self._draw_shadow_pdf(ctx)
# Apply rotation to canvas if needed
if ctx.image_element.rotation != 0:
# Move to element center
@ -520,6 +821,14 @@ class PDFExporter:
preserveAspectRatio=False,
)
# Draw border on top of image
if style.border_width > 0:
self._draw_border_pdf(ctx)
# Draw decorative frame if specified
if style.frame_style:
self._draw_frame_pdf(ctx)
ctx.canvas.restoreState()
except Exception as e:
@ -527,6 +836,165 @@ class PDFExporter:
print(f"WARNING: {warning}")
self.warnings.append(warning)
def _process_image_fallback(self, ctx: RenderContext) -> Optional[Image.Image]:
"""
Process an image on-demand when not found in the pre-processed cache.
This is a fallback for backwards compatibility or cache misses.
Returns:
Processed PIL Image or None if processing failed.
"""
# Resolve image path using ImageData's method
image_full_path = ctx.image_element.resolve_image_path()
# Check if image exists
if not image_full_path:
warning = f"Page {ctx.page_number}: Image not found: {ctx.image_element.image_path}"
print(f"WARNING: {warning}")
self.warnings.append(warning)
return None
try:
# Load image using resolved path
img = Image.open(image_full_path)
img = convert_to_rgba(img)
# Apply PIL-level rotation if needed
if hasattr(ctx.image_element, "pil_rotation_90") and ctx.image_element.pil_rotation_90 > 0:
img = apply_pil_rotation(img, ctx.image_element.pil_rotation_90)
# Get element's crop_info and combine with split cropping if applicable
crop_x_min, crop_y_min, crop_x_max, crop_y_max = ctx.image_element.crop_info
final_crop_x_min = crop_x_min + (crop_x_max - crop_x_min) * ctx.crop_left
final_crop_x_max = crop_x_min + (crop_x_max - crop_x_min) * ctx.crop_right
# Determine target dimensions for aspect ratio
if ctx.original_width_pt is not None and ctx.original_height_pt is not None:
target_width = ctx.original_width_pt
target_height = ctx.original_height_pt
else:
target_width = ctx.width_pt
target_height = ctx.height_pt
# Calculate center crop coordinates
img_width, img_height = img.size
crop_coords = calculate_center_crop_coords(
img_width,
img_height,
target_width,
target_height,
(final_crop_x_min, crop_y_min, final_crop_x_max, crop_y_max),
)
# Crop the image
cropped_img = crop_image_to_coords(img, crop_coords)
# Downsample image to target resolution based on export DPI
target_width_px = int((ctx.width_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
target_height_px = int((ctx.height_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
current_width, current_height = cropped_img.size
if current_width > target_width_px or current_height > target_height_px:
cropped_img = cropped_img.resize((target_width_px, target_height_px), Image.Resampling.LANCZOS)
# Apply styling to image (rounded corners)
style = ctx.image_element.style
if style.corner_radius > 0:
from pyPhotoAlbum.image_utils import apply_rounded_corners
cropped_img = apply_rounded_corners(cropped_img, style.corner_radius)
return cropped_img
except Exception as e:
warning = f"Page {ctx.page_number}: Error processing image {ctx.image_element.image_path}: {str(e)}"
print(f"WARNING: {warning}")
self.warnings.append(warning)
return None
def _draw_shadow_pdf(self, ctx: RenderContext):
"""Draw drop shadow in PDF output."""
style = ctx.image_element.style
# Convert shadow offset from mm to points
offset_x_pt = style.shadow_offset[0] * self.MM_TO_POINTS
offset_y_pt = -style.shadow_offset[1] * self.MM_TO_POINTS # Y is inverted in PDF
# Shadow color (normalize to 0-1)
r, g, b, a = style.shadow_color
shadow_alpha = a / 255.0
# Draw shadow rectangle
ctx.canvas.saveState()
ctx.canvas.setFillColorRGB(r / 255.0, g / 255.0, b / 255.0, shadow_alpha)
# Calculate corner radius in points
if style.corner_radius > 0:
shorter_side_pt = min(ctx.width_pt, ctx.height_pt)
radius_pt = shorter_side_pt * min(50, style.corner_radius) / 100
ctx.canvas.roundRect(
ctx.x_pt + offset_x_pt,
ctx.y_pt + offset_y_pt,
ctx.width_pt,
ctx.height_pt,
radius_pt,
stroke=0,
fill=1,
)
else:
ctx.canvas.rect(
ctx.x_pt + offset_x_pt,
ctx.y_pt + offset_y_pt,
ctx.width_pt,
ctx.height_pt,
stroke=0,
fill=1,
)
ctx.canvas.restoreState()
def _draw_border_pdf(self, ctx: RenderContext):
"""Draw styled border in PDF output."""
style = ctx.image_element.style
# Border width in points
border_width_pt = style.border_width * self.MM_TO_POINTS
# Border color (normalize to 0-1)
r, g, b = style.border_color
ctx.canvas.saveState()
ctx.canvas.setStrokeColorRGB(r / 255.0, g / 255.0, b / 255.0)
ctx.canvas.setLineWidth(border_width_pt)
# Calculate corner radius in points
if style.corner_radius > 0:
shorter_side_pt = min(ctx.width_pt, ctx.height_pt)
radius_pt = shorter_side_pt * min(50, style.corner_radius) / 100
ctx.canvas.roundRect(ctx.x_pt, ctx.y_pt, ctx.width_pt, ctx.height_pt, radius_pt, stroke=1, fill=0)
else:
ctx.canvas.rect(ctx.x_pt, ctx.y_pt, ctx.width_pt, ctx.height_pt, stroke=1, fill=0)
ctx.canvas.restoreState()
def _draw_frame_pdf(self, ctx: RenderContext):
"""Draw decorative frame in PDF output."""
from pyPhotoAlbum.frame_manager import get_frame_manager
style = ctx.image_element.style
frame_manager = get_frame_manager()
frame_manager.render_frame_pdf(
canvas=ctx.canvas,
frame_name=style.frame_style,
x_pt=ctx.x_pt,
y_pt=ctx.y_pt,
width_pt=ctx.width_pt,
height_pt=ctx.height_pt,
color=style.frame_color,
corners=style.frame_corners,
)
def _render_textbox(
self, c: canvas.Canvas, text_element: "TextBoxData", x_pt: float, y_pt: float, width_pt: float, height_pt: float
):

View File

@ -74,7 +74,7 @@ def build_ribbon_config(window_class: Type) -> Dict[str, Any]:
ribbon_config = {}
# Define tab order (tabs will appear in this order)
tab_order = ["Home", "Insert", "Layout", "Arrange", "View"]
tab_order = ["Home", "Insert", "Layout", "Arrange", "Style", "View"]
# Add tabs in the defined order, then add any remaining tabs
all_tabs = list(tabs.keys())
@ -93,6 +93,7 @@ def build_ribbon_config(window_class: Type) -> Dict[str, Any]:
"Insert": ["Media", "Snapping"],
"Layout": ["Page", "Templates"],
"Arrange": ["Align", "Distribute", "Size", "Order", "Transform"],
"Style": ["Corners", "Border", "Effects", "Frame", "Presets"],
"View": ["Zoom", "Guides"],
}

View File

@ -107,7 +107,8 @@ class RibbonWidget(QWidget):
# Connect to action
action_name = action_config.get("action")
if action_name:
button.clicked.connect(lambda: self._execute_action(action_name))
# Use default argument to capture action_name by value, not by reference
button.clicked.connect(lambda checked, name=action_name: self._execute_action(name))
return button

280
tests/test_frame_manager.py Normal file
View File

@ -0,0 +1,280 @@
"""
Unit tests for FrameManager class and frame definitions
"""
import pytest
from pyPhotoAlbum.frame_manager import (
FrameManager,
FrameDefinition,
FrameCategory,
FrameType,
get_frame_manager,
)
class TestFrameCategory:
"""Tests for FrameCategory enum"""
def test_modern_category_value(self):
"""Test MODERN category has correct value"""
assert FrameCategory.MODERN.value == "modern"
def test_vintage_category_value(self):
"""Test VINTAGE category has correct value"""
assert FrameCategory.VINTAGE.value == "vintage"
def test_geometric_category_value(self):
"""Test GEOMETRIC category has correct value"""
assert FrameCategory.GEOMETRIC.value == "geometric"
def test_custom_category_value(self):
"""Test CUSTOM category has correct value"""
assert FrameCategory.CUSTOM.value == "custom"
class TestFrameType:
"""Tests for FrameType enum"""
def test_corners_type_value(self):
"""Test CORNERS type has correct value"""
assert FrameType.CORNERS.value == "corners"
def test_full_type_value(self):
"""Test FULL type has correct value"""
assert FrameType.FULL.value == "full"
def test_edges_type_value(self):
"""Test EDGES type has correct value"""
assert FrameType.EDGES.value == "edges"
class TestFrameDefinition:
"""Tests for FrameDefinition dataclass"""
def test_basic_frame_definition(self):
"""Test creating a basic frame definition"""
frame = FrameDefinition(
name="test_frame",
display_name="Test Frame",
category=FrameCategory.MODERN,
frame_type=FrameType.FULL,
description="A test frame",
)
assert frame.name == "test_frame"
assert frame.display_name == "Test Frame"
assert frame.category == FrameCategory.MODERN
assert frame.frame_type == FrameType.FULL
assert frame.description == "A test frame"
def test_frame_definition_defaults(self):
"""Test frame definition default values"""
frame = FrameDefinition(
name="minimal",
display_name="Minimal",
category=FrameCategory.MODERN,
frame_type=FrameType.FULL,
)
assert frame.description == ""
assert frame.assets == {}
assert frame.colorizable is True
assert frame.default_thickness == 5.0
def test_corner_type_frame(self):
"""Test creating a corner-type frame"""
frame = FrameDefinition(
name="corners",
display_name="Corners",
category=FrameCategory.VINTAGE,
frame_type=FrameType.CORNERS,
default_thickness=8.0,
)
assert frame.frame_type == FrameType.CORNERS
assert frame.default_thickness == 8.0
class TestFrameManager:
"""Tests for FrameManager class"""
@pytest.fixture
def frame_manager(self):
"""Create a fresh FrameManager instance"""
return FrameManager()
def test_frame_manager_has_frames(self, frame_manager):
"""Test that FrameManager loads bundled frames"""
frames = frame_manager.get_all_frames()
assert len(frames) > 0
def test_get_frame_by_name(self, frame_manager):
"""Test getting a frame by name"""
frame = frame_manager.get_frame("simple_line")
assert frame is not None
assert frame.name == "simple_line"
assert frame.display_name == "Simple Line"
def test_get_nonexistent_frame(self, frame_manager):
"""Test getting a nonexistent frame returns None"""
frame = frame_manager.get_frame("nonexistent_frame")
assert frame is None
def test_get_frames_by_category_modern(self, frame_manager):
"""Test getting frames by MODERN category"""
frames = frame_manager.get_frames_by_category(FrameCategory.MODERN)
assert len(frames) > 0
for frame in frames:
assert frame.category == FrameCategory.MODERN
def test_get_frames_by_category_vintage(self, frame_manager):
"""Test getting frames by VINTAGE category"""
frames = frame_manager.get_frames_by_category(FrameCategory.VINTAGE)
assert len(frames) > 0
for frame in frames:
assert frame.category == FrameCategory.VINTAGE
def test_get_frames_by_category_geometric(self, frame_manager):
"""Test getting frames by GEOMETRIC category"""
frames = frame_manager.get_frames_by_category(FrameCategory.GEOMETRIC)
assert len(frames) > 0
for frame in frames:
assert frame.category == FrameCategory.GEOMETRIC
def test_get_frame_names(self, frame_manager):
"""Test getting list of frame names"""
names = frame_manager.get_frame_names()
assert isinstance(names, list)
assert "simple_line" in names
assert "double_line" in names
assert "leafy_corners" in names
def test_bundled_frames_exist(self, frame_manager):
"""Test that expected bundled frames exist"""
expected_frames = [
"simple_line",
"double_line",
"rounded_modern",
"geometric_corners",
"leafy_corners",
"ornate_flourish",
"victorian",
"art_nouveau",
]
for name in expected_frames:
frame = frame_manager.get_frame(name)
assert frame is not None, f"Expected frame '{name}' not found"
def test_modern_frames_are_full_type(self, frame_manager):
"""Test that modern frames are FULL type"""
modern_frames = ["simple_line", "double_line", "rounded_modern"]
for name in modern_frames:
frame = frame_manager.get_frame(name)
assert frame is not None
assert frame.frame_type == FrameType.FULL
def test_leafy_corners_is_corners_type(self, frame_manager):
"""Test that leafy_corners is CORNERS type"""
frame = frame_manager.get_frame("leafy_corners")
assert frame is not None
assert frame.frame_type == FrameType.CORNERS
def test_all_frames_are_colorizable(self, frame_manager):
"""Test that all bundled frames are colorizable"""
for frame in frame_manager.get_all_frames():
assert frame.colorizable is True
class TestGetFrameManager:
"""Tests for global frame manager instance"""
def test_get_frame_manager_returns_instance(self):
"""Test that get_frame_manager returns a FrameManager"""
manager = get_frame_manager()
assert isinstance(manager, FrameManager)
def test_get_frame_manager_returns_same_instance(self):
"""Test that get_frame_manager returns the same instance"""
manager1 = get_frame_manager()
manager2 = get_frame_manager()
assert manager1 is manager2
class TestFrameCategories:
"""Tests for frame category organization"""
@pytest.fixture
def frame_manager(self):
return FrameManager()
def test_modern_category_not_empty(self, frame_manager):
"""Test MODERN category has frames"""
frames = frame_manager.get_frames_by_category(FrameCategory.MODERN)
assert len(frames) >= 3 # simple_line, double_line, rounded_modern
def test_vintage_category_not_empty(self, frame_manager):
"""Test VINTAGE category has frames"""
frames = frame_manager.get_frames_by_category(FrameCategory.VINTAGE)
assert len(frames) >= 3 # leafy_corners, ornate_flourish, victorian, art_nouveau
def test_geometric_category_not_empty(self, frame_manager):
"""Test GEOMETRIC category has frames"""
frames = frame_manager.get_frames_by_category(FrameCategory.GEOMETRIC)
assert len(frames) >= 1 # geometric_corners
def test_all_frames_count(self, frame_manager):
"""Test total frame count"""
all_frames = frame_manager.get_all_frames()
# Should be at least 8 bundled frames
assert len(all_frames) >= 8
class TestFrameDescriptions:
"""Tests for frame descriptions"""
@pytest.fixture
def frame_manager(self):
return FrameManager()
def test_simple_line_has_description(self, frame_manager):
"""Test simple_line has a description"""
frame = frame_manager.get_frame("simple_line")
assert frame.description != ""
def test_leafy_corners_has_description(self, frame_manager):
"""Test leafy_corners has a description"""
frame = frame_manager.get_frame("leafy_corners")
assert frame.description != ""
def test_all_frames_have_descriptions(self, frame_manager):
"""Test all frames have non-empty descriptions"""
for frame in frame_manager.get_all_frames():
assert frame.description != "", f"Frame '{frame.name}' has no description"
def test_all_frames_have_display_names(self, frame_manager):
"""Test all frames have display names"""
for frame in frame_manager.get_all_frames():
assert frame.display_name != "", f"Frame '{frame.name}' has no display name"
class TestFrameThickness:
"""Tests for frame default thickness values"""
@pytest.fixture
def frame_manager(self):
return FrameManager()
def test_simple_line_thickness(self, frame_manager):
"""Test simple_line has thin default thickness"""
frame = frame_manager.get_frame("simple_line")
assert frame.default_thickness == 2.0
def test_vintage_frames_are_thicker(self, frame_manager):
"""Test vintage frames have thicker default"""
leafy = frame_manager.get_frame("leafy_corners")
victorian = frame_manager.get_frame("victorian")
assert leafy.default_thickness >= 8.0
assert victorian.default_thickness >= 10.0
def test_all_thicknesses_positive(self, frame_manager):
"""Test all frames have positive thickness"""
for frame in frame_manager.get_all_frames():
assert frame.default_thickness > 0

407
tests/test_image_style.py Normal file
View File

@ -0,0 +1,407 @@
"""
Unit tests for ImageStyle class and related styling functionality
"""
import pytest
from pyPhotoAlbum.models import ImageStyle, ImageData, PlaceholderData
class TestImageStyleInit:
"""Tests for ImageStyle initialization"""
def test_default_initialization(self):
"""Test ImageStyle with default values"""
style = ImageStyle()
assert style.corner_radius == 0.0
assert style.border_width == 0.0
assert style.border_color == (0, 0, 0)
assert style.shadow_enabled is False
assert style.shadow_offset == (2.0, 2.0)
assert style.shadow_blur == 3.0
assert style.shadow_color == (0, 0, 0, 128)
assert style.frame_style is None
assert style.frame_color == (0, 0, 0)
assert style.frame_corners == (True, True, True, True)
def test_custom_initialization(self):
"""Test ImageStyle with custom values"""
style = ImageStyle(
corner_radius=15.0,
border_width=2.5,
border_color=(255, 0, 0),
shadow_enabled=True,
shadow_offset=(3.0, 4.0),
shadow_blur=5.0,
shadow_color=(0, 0, 0, 200),
frame_style="leafy_corners",
frame_color=(0, 128, 0),
frame_corners=(True, False, True, False),
)
assert style.corner_radius == 15.0
assert style.border_width == 2.5
assert style.border_color == (255, 0, 0)
assert style.shadow_enabled is True
assert style.shadow_offset == (3.0, 4.0)
assert style.shadow_blur == 5.0
assert style.shadow_color == (0, 0, 0, 200)
assert style.frame_style == "leafy_corners"
assert style.frame_color == (0, 128, 0)
assert style.frame_corners == (True, False, True, False)
def test_frame_corners_none_defaults_to_all(self):
"""Test that frame_corners=None defaults to all corners"""
style = ImageStyle(frame_corners=None)
assert style.frame_corners == (True, True, True, True)
class TestImageStyleCopy:
"""Tests for ImageStyle.copy()"""
def test_copy_creates_identical_style(self):
"""Test that copy creates an identical style"""
original = ImageStyle(
corner_radius=10.0,
border_width=1.5,
border_color=(128, 128, 128),
shadow_enabled=True,
frame_style="simple_line",
frame_corners=(True, True, False, False),
)
copied = original.copy()
assert copied.corner_radius == original.corner_radius
assert copied.border_width == original.border_width
assert copied.border_color == original.border_color
assert copied.shadow_enabled == original.shadow_enabled
assert copied.frame_style == original.frame_style
assert copied.frame_corners == original.frame_corners
def test_copy_is_independent(self):
"""Test that modifying copy doesn't affect original"""
original = ImageStyle(corner_radius=5.0)
copied = original.copy()
copied.corner_radius = 20.0
assert original.corner_radius == 5.0
assert copied.corner_radius == 20.0
class TestImageStyleHasStyling:
"""Tests for ImageStyle.has_styling()"""
def test_default_style_has_no_styling(self):
"""Test that default style has no styling"""
style = ImageStyle()
assert style.has_styling() is False
def test_corner_radius_counts_as_styling(self):
"""Test that corner_radius > 0 counts as styling"""
style = ImageStyle(corner_radius=5.0)
assert style.has_styling() is True
def test_border_width_counts_as_styling(self):
"""Test that border_width > 0 counts as styling"""
style = ImageStyle(border_width=1.0)
assert style.has_styling() is True
def test_shadow_enabled_counts_as_styling(self):
"""Test that shadow_enabled counts as styling"""
style = ImageStyle(shadow_enabled=True)
assert style.has_styling() is True
def test_frame_style_counts_as_styling(self):
"""Test that frame_style counts as styling"""
style = ImageStyle(frame_style="simple_line")
assert style.has_styling() is True
class TestImageStyleSerialization:
"""Tests for ImageStyle.serialize() and deserialize()"""
def test_serialize_default_style(self):
"""Test serialization of default style"""
style = ImageStyle()
data = style.serialize()
assert data["corner_radius"] == 0.0
assert data["border_width"] == 0.0
assert data["border_color"] == [0, 0, 0]
assert data["shadow_enabled"] is False
assert data["frame_style"] is None
assert data["frame_corners"] == [True, True, True, True]
def test_serialize_custom_style(self):
"""Test serialization of custom style"""
style = ImageStyle(
corner_radius=10.0,
border_color=(255, 128, 0),
frame_style="ornate_flourish",
frame_corners=(False, True, False, True),
)
data = style.serialize()
assert data["corner_radius"] == 10.0
assert data["border_color"] == [255, 128, 0]
assert data["frame_style"] == "ornate_flourish"
assert data["frame_corners"] == [False, True, False, True]
def test_deserialize_creates_correct_style(self):
"""Test deserialization creates correct style"""
data = {
"corner_radius": 15.0,
"border_width": 2.0,
"border_color": [100, 100, 100],
"shadow_enabled": True,
"shadow_offset": [3.0, 3.0],
"shadow_blur": 4.0,
"shadow_color": [0, 0, 0, 150],
"frame_style": "leafy_corners",
"frame_color": [50, 50, 50],
"frame_corners": [True, False, True, False],
}
style = ImageStyle.deserialize(data)
assert style.corner_radius == 15.0
assert style.border_width == 2.0
assert style.border_color == (100, 100, 100)
assert style.shadow_enabled is True
assert style.shadow_offset == (3.0, 3.0)
assert style.shadow_blur == 4.0
assert style.shadow_color == (0, 0, 0, 150)
assert style.frame_style == "leafy_corners"
assert style.frame_color == (50, 50, 50)
assert style.frame_corners == (True, False, True, False)
def test_deserialize_none_returns_default(self):
"""Test that deserialize(None) returns default style"""
style = ImageStyle.deserialize(None)
assert style.corner_radius == 0.0
assert style.frame_style is None
def test_deserialize_empty_dict_returns_defaults(self):
"""Test that deserialize({}) returns default values"""
style = ImageStyle.deserialize({})
assert style.corner_radius == 0.0
assert style.border_width == 0.0
assert style.frame_style is None
assert style.frame_corners == (True, True, True, True)
def test_serialize_deserialize_roundtrip(self):
"""Test that serialize/deserialize are inverse operations"""
original = ImageStyle(
corner_radius=12.0,
border_width=1.5,
border_color=(200, 100, 50),
shadow_enabled=True,
shadow_offset=(2.5, 3.5),
shadow_blur=6.0,
shadow_color=(10, 20, 30, 180),
frame_style="victorian",
frame_color=(100, 150, 200),
frame_corners=(True, True, False, False),
)
data = original.serialize()
restored = ImageStyle.deserialize(data)
assert restored == original
def test_deserialize_handles_missing_frame_corners(self):
"""Test backward compatibility - missing frame_corners defaults correctly"""
data = {
"corner_radius": 5.0,
"border_width": 1.0,
"border_color": [0, 0, 0],
# No frame_corners key
}
style = ImageStyle.deserialize(data)
assert style.frame_corners == (True, True, True, True)
class TestImageStyleEquality:
"""Tests for ImageStyle.__eq__()"""
def test_identical_styles_are_equal(self):
"""Test that identical styles are equal"""
style1 = ImageStyle(corner_radius=10.0, frame_style="simple_line")
style2 = ImageStyle(corner_radius=10.0, frame_style="simple_line")
assert style1 == style2
def test_different_styles_are_not_equal(self):
"""Test that different styles are not equal"""
style1 = ImageStyle(corner_radius=10.0)
style2 = ImageStyle(corner_radius=20.0)
assert style1 != style2
def test_different_frame_corners_are_not_equal(self):
"""Test that different frame_corners make styles unequal"""
style1 = ImageStyle(frame_corners=(True, True, True, True))
style2 = ImageStyle(frame_corners=(True, False, True, False))
assert style1 != style2
def test_style_not_equal_to_non_style(self):
"""Test that style is not equal to non-style objects"""
style = ImageStyle()
assert style != "not a style"
assert style != 123
assert style != None
class TestImageDataWithStyle:
"""Tests for ImageData with styling"""
def test_image_data_has_default_style(self):
"""Test that ImageData has a default style"""
img = ImageData()
assert hasattr(img, "style")
assert isinstance(img.style, ImageStyle)
assert img.style.corner_radius == 0.0
def test_image_data_serialize_includes_style(self):
"""Test that ImageData serialization includes style"""
img = ImageData()
img.style.corner_radius = 15.0
img.style.frame_style = "ornate_flourish"
img.style.frame_corners = (True, False, False, True)
data = img.serialize()
assert "style" in data
assert data["style"]["corner_radius"] == 15.0
assert data["style"]["frame_style"] == "ornate_flourish"
assert data["style"]["frame_corners"] == [True, False, False, True]
def test_image_data_deserialize_restores_style(self):
"""Test that ImageData deserialization restores style"""
img = ImageData()
data = {
"style": {
"corner_radius": 20.0,
"border_width": 2.0,
"frame_style": "leafy_corners",
"frame_corners": [False, True, False, True],
}
}
img.deserialize(data)
assert img.style.corner_radius == 20.0
assert img.style.border_width == 2.0
assert img.style.frame_style == "leafy_corners"
assert img.style.frame_corners == (False, True, False, True)
def test_image_data_deserialize_without_style(self):
"""Test backward compatibility - deserialize without style"""
img = ImageData()
data = {"image_path": "test.jpg"}
img.deserialize(data)
# Should have default style
assert img.style.corner_radius == 0.0
assert img.style.frame_style is None
class TestPlaceholderDataWithStyle:
"""Tests for PlaceholderData with styling"""
def test_placeholder_has_default_style(self):
"""Test that PlaceholderData has a default style"""
placeholder = PlaceholderData()
assert hasattr(placeholder, "style")
assert isinstance(placeholder.style, ImageStyle)
def test_placeholder_serialize_includes_style(self):
"""Test that PlaceholderData serialization includes style"""
placeholder = PlaceholderData()
placeholder.style.corner_radius = 10.0
placeholder.style.shadow_enabled = True
placeholder.style.frame_corners = (True, True, False, False)
data = placeholder.serialize()
assert "style" in data
assert data["style"]["corner_radius"] == 10.0
assert data["style"]["shadow_enabled"] is True
def test_placeholder_deserialize_restores_style(self):
"""Test that PlaceholderData deserialization restores style"""
placeholder = PlaceholderData()
data = {
"style": {
"corner_radius": 5.0,
"border_width": 1.5,
"frame_style": "double_line",
}
}
placeholder.deserialize(data)
assert placeholder.style.corner_radius == 5.0
assert placeholder.style.border_width == 1.5
assert placeholder.style.frame_style == "double_line"
class TestFrameCorners:
"""Tests specifically for frame_corners functionality"""
def test_all_corners_true(self):
"""Test all corners enabled"""
style = ImageStyle(frame_corners=(True, True, True, True))
assert all(style.frame_corners)
def test_no_corners(self):
"""Test no corners enabled"""
style = ImageStyle(frame_corners=(False, False, False, False))
assert not any(style.frame_corners)
def test_diagonal_corners_only(self):
"""Test diagonal corners (TL, BR)"""
style = ImageStyle(frame_corners=(True, False, True, False))
tl, tr, br, bl = style.frame_corners
assert tl is True
assert tr is False
assert br is True
assert bl is False
def test_opposite_diagonal_corners(self):
"""Test opposite diagonal corners (TR, BL)"""
style = ImageStyle(frame_corners=(False, True, False, True))
tl, tr, br, bl = style.frame_corners
assert tl is False
assert tr is True
assert br is False
assert bl is True
def test_left_corners_only(self):
"""Test left side corners only"""
style = ImageStyle(frame_corners=(True, False, False, True))
tl, tr, br, bl = style.frame_corners
assert tl is True
assert tr is False
assert br is False
assert bl is True
def test_right_corners_only(self):
"""Test right side corners only"""
style = ImageStyle(frame_corners=(False, True, True, False))
tl, tr, br, bl = style.frame_corners
assert tl is False
assert tr is True
assert br is True
assert bl is False
def test_top_corners_only(self):
"""Test top corners only"""
style = ImageStyle(frame_corners=(True, True, False, False))
tl, tr, br, bl = style.frame_corners
assert tl is True
assert tr is True
assert br is False
assert bl is False
def test_bottom_corners_only(self):
"""Test bottom corners only"""
style = ImageStyle(frame_corners=(False, False, True, True))
tl, tr, br, bl = style.frame_corners
assert tl is False
assert tr is False
assert br is True
assert bl is True

View File

@ -0,0 +1,334 @@
"""
Unit tests for image_utils styling functions
"""
import pytest
from PIL import Image
from pyPhotoAlbum.image_utils import (
apply_rounded_corners,
apply_drop_shadow,
create_border_image,
)
class TestApplyRoundedCorners:
"""Tests for apply_rounded_corners function"""
def test_zero_radius_returns_same_image(self):
"""Test that 0% radius returns unchanged image"""
img = Image.new("RGB", (100, 100), color="red")
result = apply_rounded_corners(img, 0.0)
assert result.size == img.size
def test_negative_radius_returns_same_image(self):
"""Test that negative radius returns unchanged image"""
img = Image.new("RGB", (100, 100), color="blue")
result = apply_rounded_corners(img, -10.0)
assert result.size == img.size
def test_returns_rgba_image(self):
"""Test that result is RGBA mode"""
img = Image.new("RGB", (100, 100), color="green")
result = apply_rounded_corners(img, 10.0)
assert result.mode == "RGBA"
def test_preserves_size(self):
"""Test that image size is preserved"""
img = Image.new("RGBA", (200, 150), color="yellow")
result = apply_rounded_corners(img, 15.0)
assert result.size == (200, 150)
def test_corners_are_transparent(self):
"""Test that corners become transparent"""
img = Image.new("RGB", (100, 100), color="red")
result = apply_rounded_corners(img, 25.0)
# Top-left corner should be transparent
pixel = result.getpixel((0, 0))
assert pixel[3] == 0, "Top-left corner should be transparent"
# Top-right corner should be transparent
pixel = result.getpixel((99, 0))
assert pixel[3] == 0, "Top-right corner should be transparent"
# Bottom-left corner should be transparent
pixel = result.getpixel((0, 99))
assert pixel[3] == 0, "Bottom-left corner should be transparent"
# Bottom-right corner should be transparent
pixel = result.getpixel((99, 99))
assert pixel[3] == 0, "Bottom-right corner should be transparent"
def test_center_is_opaque(self):
"""Test that center remains opaque"""
img = Image.new("RGB", (100, 100), color="blue")
result = apply_rounded_corners(img, 10.0)
# Center pixel should be fully opaque
pixel = result.getpixel((50, 50))
assert pixel[3] == 255, "Center should be opaque"
def test_50_percent_radius_creates_ellipse(self):
"""Test that 50% radius creates elliptical result"""
img = Image.new("RGB", (100, 100), color="purple")
result = apply_rounded_corners(img, 50.0)
# Corner should be transparent
assert result.getpixel((0, 0))[3] == 0
def test_radius_clamped_to_50(self):
"""Test that radius > 50 is clamped"""
img = Image.new("RGB", (100, 100), color="orange")
result = apply_rounded_corners(img, 100.0) # Should be clamped to 50
# Should still work and not crash
assert result.size == (100, 100)
assert result.mode == "RGBA"
def test_preserves_existing_rgba(self):
"""Test that existing RGBA image alpha is preserved"""
img = Image.new("RGBA", (100, 100), (255, 0, 0, 128)) # Semi-transparent red
result = apply_rounded_corners(img, 10.0)
# Center should still be semi-transparent (original alpha combined with mask)
center_pixel = result.getpixel((50, 50))
assert center_pixel[3] == 128 # Original alpha preserved
class TestApplyDropShadow:
"""Tests for apply_drop_shadow function"""
def test_returns_rgba_image(self):
"""Test that result is RGBA mode"""
img = Image.new("RGB", (50, 50), color="red")
result = apply_drop_shadow(img)
assert result.mode == "RGBA"
def test_expand_increases_size(self):
"""Test that expand=True increases canvas size"""
img = Image.new("RGBA", (50, 50), color="blue")
result = apply_drop_shadow(img, offset=(5, 5), blur_radius=3, expand=True)
assert result.width > 50
assert result.height > 50
def test_no_expand_preserves_size(self):
"""Test that expand=False preserves size"""
img = Image.new("RGBA", (50, 50), color="green")
result = apply_drop_shadow(img, offset=(2, 2), blur_radius=3, expand=False)
assert result.size == (50, 50)
def test_shadow_has_correct_color(self):
"""Test that shadow uses specified color"""
# Create image with transparent background and opaque center
img = Image.new("RGBA", (20, 20), (0, 0, 0, 0))
img.paste((255, 0, 0, 255), (5, 5, 15, 15))
result = apply_drop_shadow(
img, offset=(10, 10), blur_radius=0, shadow_color=(0, 255, 0, 255), expand=True
)
# Shadow should be visible in the offset area
# The shadow color should be green
assert result.mode == "RGBA"
def test_zero_blur_radius(self):
"""Test that blur_radius=0 works"""
img = Image.new("RGBA", (30, 30), (255, 0, 0, 255))
result = apply_drop_shadow(img, blur_radius=0, expand=True)
assert result.mode == "RGBA"
assert result.width >= 30
assert result.height >= 30
def test_large_offset(self):
"""Test shadow with large offset"""
img = Image.new("RGBA", (50, 50), (0, 0, 255, 255))
result = apply_drop_shadow(img, offset=(20, 20), blur_radius=5, expand=True)
assert result.width > 50 + 20
assert result.height > 50 + 20
def test_negative_offset(self):
"""Test shadow with negative offset (shadow above/left of image)"""
img = Image.new("RGBA", (50, 50), (255, 255, 0, 255))
result = apply_drop_shadow(img, offset=(-10, -10), blur_radius=2, expand=True)
assert result.width > 50
assert result.height > 50
class TestCreateBorderImage:
"""Tests for create_border_image function"""
def test_returns_rgba_image(self):
"""Test that result is RGBA mode"""
result = create_border_image(100, 100, 5)
assert result.mode == "RGBA"
def test_correct_size(self):
"""Test that result has correct size"""
result = create_border_image(200, 150, 10)
assert result.size == (200, 150)
def test_zero_border_returns_transparent(self):
"""Test that 0 border width returns fully transparent image"""
result = create_border_image(100, 100, 0)
assert result.mode == "RGBA"
# All pixels should be transparent
for x in range(100):
for y in range(100):
assert result.getpixel((x, y))[3] == 0
def test_border_color_applied(self):
"""Test that border color is applied correctly"""
result = create_border_image(50, 50, 5, border_color=(255, 0, 0))
# Edge pixel should be red
edge_pixel = result.getpixel((0, 25)) # Left edge, middle
assert edge_pixel[:3] == (255, 0, 0)
assert edge_pixel[3] == 255 # Opaque
def test_center_is_transparent(self):
"""Test that center is transparent"""
result = create_border_image(100, 100, 10)
# Center pixel should be transparent
center_pixel = result.getpixel((50, 50))
assert center_pixel[3] == 0
def test_border_surrounds_image(self):
"""Test that border covers all edges"""
result = create_border_image(50, 50, 5, border_color=(0, 255, 0))
# Top edge
assert result.getpixel((25, 0))[3] == 255 # Opaque
# Bottom edge
assert result.getpixel((25, 49))[3] == 255
# Left edge
assert result.getpixel((0, 25))[3] == 255
# Right edge
assert result.getpixel((49, 25))[3] == 255
def test_with_corner_radius(self):
"""Test border with rounded corners"""
result = create_border_image(100, 100, 10, border_color=(0, 0, 255), corner_radius=20)
assert result.mode == "RGBA"
assert result.size == (100, 100)
def test_corner_radius_affects_transparency(self):
"""Test that corner radius creates rounded border"""
result = create_border_image(100, 100, 10, corner_radius=25)
# Outer corner should be transparent (outside the rounded border)
outer_corner = result.getpixel((0, 0))
assert outer_corner[3] == 0
def test_large_border_width(self):
"""Test with large border width"""
result = create_border_image(100, 100, 45) # Very thick border
assert result.mode == "RGBA"
# Center should still be transparent (just a small area)
center = result.getpixel((50, 50))
assert center[3] == 0
class TestStylingIntegration:
"""Integration tests combining multiple styling functions"""
def test_rounded_corners_then_shadow(self):
"""Test applying rounded corners then shadow"""
img = Image.new("RGB", (100, 100), color="red")
rounded = apply_rounded_corners(img, 15.0)
result = apply_drop_shadow(rounded, offset=(5, 5), blur_radius=3, expand=True)
assert result.mode == "RGBA"
assert result.width > 100
assert result.height > 100
def test_preserve_quality_through_chain(self):
"""Test that chaining operations preserves image quality"""
# Create a simple pattern
img = Image.new("RGB", (80, 80), color="blue")
img.paste((255, 0, 0), (20, 20, 60, 60)) # Red square in center
# Apply styling chain
result = apply_rounded_corners(img, 10.0)
result = apply_drop_shadow(result, expand=True)
assert result.mode == "RGBA"
def test_small_image_styling(self):
"""Test styling on small images"""
img = Image.new("RGB", (10, 10), color="green")
rounded = apply_rounded_corners(img, 20.0)
shadow = apply_drop_shadow(rounded, offset=(1, 1), blur_radius=1, expand=True)
assert shadow.mode == "RGBA"
assert shadow.width >= 10
assert shadow.height >= 10
def test_large_image_styling(self):
"""Test styling on larger images"""
img = Image.new("RGB", (1000, 800), color="purple")
rounded = apply_rounded_corners(img, 5.0)
assert rounded.mode == "RGBA"
assert rounded.size == (1000, 800)
# Corners should be transparent
assert rounded.getpixel((0, 0))[3] == 0
assert rounded.getpixel((999, 0))[3] == 0
def test_non_square_image_styling(self):
"""Test styling on non-square images"""
img = Image.new("RGB", (200, 50), color="orange")
rounded = apply_rounded_corners(img, 30.0)
assert rounded.mode == "RGBA"
assert rounded.size == (200, 50)
# Radius should be based on shorter side (50)
# 30% of 50 = 15 pixels radius
assert rounded.getpixel((0, 0))[3] == 0
class TestEdgeCases:
"""Tests for edge cases and boundary conditions"""
def test_1x1_image_rounded_corners(self):
"""Test rounded corners on 1x1 image"""
img = Image.new("RGB", (1, 1), color="white")
result = apply_rounded_corners(img, 50.0)
assert result.size == (1, 1)
def test_very_small_radius(self):
"""Test with very small radius percentage"""
img = Image.new("RGB", (100, 100), color="cyan")
result = apply_rounded_corners(img, 0.1)
assert result.mode == "RGBA"
def test_shadow_with_transparent_image(self):
"""Test shadow on fully transparent image"""
img = Image.new("RGBA", (50, 50), (0, 0, 0, 0))
result = apply_drop_shadow(img, expand=True)
assert result.mode == "RGBA"
def test_border_on_small_image(self):
"""Test border on small image (larger than border)"""
# Use a 10x10 image with 2px border (edge case with very small image)
result = create_border_image(10, 10, 2)
assert result.size == (10, 10)
assert result.mode == "RGBA"
def test_styling_preserves_pixel_data(self):
"""Test that styling preserves underlying pixel data"""
# Create image with known pattern
img = Image.new("RGB", (50, 50), (0, 0, 0))
img.putpixel((25, 25), (255, 255, 255))
result = apply_rounded_corners(img, 5.0)
# Center white pixel should still be white (with alpha)
pixel = result.getpixel((25, 25))
assert pixel[0] == 255
assert pixel[1] == 255
assert pixel[2] == 255
assert pixel[3] == 255 # Opaque in center