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
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:
parent
cf27d9ebee
commit
54cc78783a
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
352
pyPhotoAlbum/dialogs/frame_picker_dialog.py
Normal file
352
pyPhotoAlbum/dialogs/frame_picker_dialog.py
Normal 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
|
||||
297
pyPhotoAlbum/dialogs/style_dialogs.py
Normal file
297
pyPhotoAlbum/dialogs/style_dialogs.py
Normal 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,
|
||||
)
|
||||
925
pyPhotoAlbum/frame_manager.py
Normal file
925
pyPhotoAlbum/frame_manager.py
Normal 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
|
||||
23
pyPhotoAlbum/frames/CREDITS.txt
Normal file
23
pyPhotoAlbum/frames/CREDITS.txt
Normal 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/
|
||||
63
pyPhotoAlbum/frames/corners/corner_decoration.svg
Normal file
63
pyPhotoAlbum/frames/corners/corner_decoration.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 14 KiB |
40
pyPhotoAlbum/frames/corners/corner_ornament.svg
Normal file
40
pyPhotoAlbum/frames/corners/corner_ornament.svg
Normal 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 |
6
pyPhotoAlbum/frames/corners/floral_corner.svg
Normal file
6
pyPhotoAlbum/frames/corners/floral_corner.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 32 KiB |
522
pyPhotoAlbum/frames/corners/floral_flourish.svg
Normal file
522
pyPhotoAlbum/frames/corners/floral_flourish.svg
Normal 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 |
167
pyPhotoAlbum/frames/corners/ornate_corner.svg
Normal file
167
pyPhotoAlbum/frames/corners/ornate_corner.svg
Normal 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 |
2999
pyPhotoAlbum/frames/corners/simple_corner.svg
Normal file
2999
pyPhotoAlbum/frames/corners/simple_corner.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 138 KiB |
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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):
|
||||
"""
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
372
pyPhotoAlbum/mixins/operations/style_ops.py
Normal file
372
pyPhotoAlbum/mixins/operations/style_ops.py
Normal 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")
|
||||
@ -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):
|
||||
|
||||
@ -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"""
|
||||
|
||||
@ -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
|
||||
):
|
||||
|
||||
@ -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"],
|
||||
}
|
||||
|
||||
|
||||
@ -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
280
tests/test_frame_manager.py
Normal 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
407
tests/test_image_style.py
Normal 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
|
||||
334
tests/test_image_utils_styling.py
Normal file
334
tests/test_image_utils_styling.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user