diff --git a/install.sh b/install.sh
index 9f1c3d5..93d7f7d 100755
--- a/install.sh
+++ b/install.sh
@@ -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
}
diff --git a/pyPhotoAlbum/commands.py b/pyPhotoAlbum/commands.py
index cff4fa1..a4decbe 100644
--- a/pyPhotoAlbum/commands.py
+++ b/pyPhotoAlbum/commands.py
@@ -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,
diff --git a/pyPhotoAlbum/dialogs/frame_picker_dialog.py b/pyPhotoAlbum/dialogs/frame_picker_dialog.py
new file mode 100644
index 0000000..3ffccee
--- /dev/null
+++ b/pyPhotoAlbum/dialogs/frame_picker_dialog.py
@@ -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
diff --git a/pyPhotoAlbum/dialogs/style_dialogs.py b/pyPhotoAlbum/dialogs/style_dialogs.py
new file mode 100644
index 0000000..8a76f04
--- /dev/null
+++ b/pyPhotoAlbum/dialogs/style_dialogs.py
@@ -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,
+ )
diff --git a/pyPhotoAlbum/frame_manager.py b/pyPhotoAlbum/frame_manager.py
new file mode 100644
index 0000000..e187e1f
--- /dev/null
+++ b/pyPhotoAlbum/frame_manager.py
@@ -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
diff --git a/pyPhotoAlbum/frames/CREDITS.txt b/pyPhotoAlbum/frames/CREDITS.txt
new file mode 100644
index 0000000..825f851
--- /dev/null
+++ b/pyPhotoAlbum/frames/CREDITS.txt
@@ -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/
diff --git a/pyPhotoAlbum/frames/corners/corner_decoration.svg b/pyPhotoAlbum/frames/corners/corner_decoration.svg
new file mode 100644
index 0000000..3218e27
--- /dev/null
+++ b/pyPhotoAlbum/frames/corners/corner_decoration.svg
@@ -0,0 +1,63 @@
+
+
+
+
diff --git a/pyPhotoAlbum/frames/corners/corner_ornament.svg b/pyPhotoAlbum/frames/corners/corner_ornament.svg
new file mode 100644
index 0000000..21f0844
--- /dev/null
+++ b/pyPhotoAlbum/frames/corners/corner_ornament.svg
@@ -0,0 +1,40 @@
+
+
+
+
diff --git a/pyPhotoAlbum/frames/corners/floral_corner.svg b/pyPhotoAlbum/frames/corners/floral_corner.svg
new file mode 100644
index 0000000..42291bc
--- /dev/null
+++ b/pyPhotoAlbum/frames/corners/floral_corner.svg
@@ -0,0 +1,6 @@
+
+
diff --git a/pyPhotoAlbum/frames/corners/floral_flourish.svg b/pyPhotoAlbum/frames/corners/floral_flourish.svg
new file mode 100644
index 0000000..f41d354
--- /dev/null
+++ b/pyPhotoAlbum/frames/corners/floral_flourish.svg
@@ -0,0 +1,522 @@
+
+
+
diff --git a/pyPhotoAlbum/frames/corners/ornate_corner.svg b/pyPhotoAlbum/frames/corners/ornate_corner.svg
new file mode 100644
index 0000000..e38af4e
--- /dev/null
+++ b/pyPhotoAlbum/frames/corners/ornate_corner.svg
@@ -0,0 +1,167 @@
+
+
+
+
diff --git a/pyPhotoAlbum/frames/corners/simple_corner.svg b/pyPhotoAlbum/frames/corners/simple_corner.svg
new file mode 100644
index 0000000..0a82cab
--- /dev/null
+++ b/pyPhotoAlbum/frames/corners/simple_corner.svg
@@ -0,0 +1,2999 @@
+
+
+
+
diff --git a/pyPhotoAlbum/gl_imports.py b/pyPhotoAlbum/gl_imports.py
index 3b4a28d..51051d1 100644
--- a/pyPhotoAlbum/gl_imports.py
+++ b/pyPhotoAlbum/gl_imports.py
@@ -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
diff --git a/pyPhotoAlbum/image_utils.py b/pyPhotoAlbum/image_utils.py
index 85481ea..ee9c5c2 100644
--- a/pyPhotoAlbum/image_utils.py
+++ b/pyPhotoAlbum/image_utils.py
@@ -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
diff --git a/pyPhotoAlbum/main.py b/pyPhotoAlbum/main.py
index 66f69ff..491fbfe 100644
--- a/pyPhotoAlbum/main.py
+++ b/pyPhotoAlbum/main.py
@@ -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.
diff --git a/pyPhotoAlbum/mixins/asset_drop.py b/pyPhotoAlbum/mixins/asset_drop.py
index 651322b..7640d0d 100644
--- a/pyPhotoAlbum/mixins/asset_drop.py
+++ b/pyPhotoAlbum/mixins/asset_drop.py
@@ -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:
diff --git a/pyPhotoAlbum/mixins/async_loading.py b/pyPhotoAlbum/mixins/async_loading.py
index 8f328fb..90c42e8 100644
--- a/pyPhotoAlbum/mixins/async_loading.py
+++ b/pyPhotoAlbum/mixins/async_loading.py
@@ -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):
"""
diff --git a/pyPhotoAlbum/mixins/operations/__init__.py b/pyPhotoAlbum/mixins/operations/__init__.py
index b597bd1..34ecf1c 100644
--- a/pyPhotoAlbum/mixins/operations/__init__.py
+++ b/pyPhotoAlbum/mixins/operations/__init__.py
@@ -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",
]
diff --git a/pyPhotoAlbum/mixins/operations/style_ops.py b/pyPhotoAlbum/mixins/operations/style_ops.py
new file mode 100644
index 0000000..be37e60
--- /dev/null
+++ b/pyPhotoAlbum/mixins/operations/style_ops.py
@@ -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")
diff --git a/pyPhotoAlbum/models.py b/pyPhotoAlbum/models.py
index d942e61..e8afc56 100644
--- a/pyPhotoAlbum/models.py
+++ b/pyPhotoAlbum/models.py
@@ -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):
diff --git a/pyPhotoAlbum/page_layout.py b/pyPhotoAlbum/page_layout.py
index 1b81d8e..9e11773 100644
--- a/pyPhotoAlbum/page_layout.py
+++ b/pyPhotoAlbum/page_layout.py
@@ -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"""
diff --git a/pyPhotoAlbum/pdf_exporter.py b/pyPhotoAlbum/pdf_exporter.py
index d609b80..f2f9d55 100644
--- a/pyPhotoAlbum/pdf_exporter.py
+++ b/pyPhotoAlbum/pdf_exporter.py
@@ -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
):
diff --git a/pyPhotoAlbum/ribbon_builder.py b/pyPhotoAlbum/ribbon_builder.py
index d60f862..e9797a8 100644
--- a/pyPhotoAlbum/ribbon_builder.py
+++ b/pyPhotoAlbum/ribbon_builder.py
@@ -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"],
}
diff --git a/pyPhotoAlbum/ribbon_widget.py b/pyPhotoAlbum/ribbon_widget.py
index 2f6067a..295a1f1 100644
--- a/pyPhotoAlbum/ribbon_widget.py
+++ b/pyPhotoAlbum/ribbon_widget.py
@@ -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
diff --git a/tests/test_frame_manager.py b/tests/test_frame_manager.py
new file mode 100644
index 0000000..cfb14ef
--- /dev/null
+++ b/tests/test_frame_manager.py
@@ -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
diff --git a/tests/test_image_style.py b/tests/test_image_style.py
new file mode 100644
index 0000000..c326d9b
--- /dev/null
+++ b/tests/test_image_style.py
@@ -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
diff --git a/tests/test_image_utils_styling.py b/tests/test_image_utils_styling.py
new file mode 100644
index 0000000..1e49299
--- /dev/null
+++ b/tests/test_image_utils_styling.py
@@ -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