From 54cc78783ade3c60bd65340bc14f42fbead388d6 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Thu, 1 Jan 2026 13:37:14 +0100 Subject: [PATCH] Added styling Improved pdf generation speed --- install.sh | 8 +- pyPhotoAlbum/commands.py | 28 + pyPhotoAlbum/dialogs/frame_picker_dialog.py | 352 ++ pyPhotoAlbum/dialogs/style_dialogs.py | 297 ++ pyPhotoAlbum/frame_manager.py | 925 +++++ pyPhotoAlbum/frames/CREDITS.txt | 23 + .../frames/corners/corner_decoration.svg | 63 + .../frames/corners/corner_ornament.svg | 40 + pyPhotoAlbum/frames/corners/floral_corner.svg | 6 + .../frames/corners/floral_flourish.svg | 522 +++ pyPhotoAlbum/frames/corners/ornate_corner.svg | 167 + pyPhotoAlbum/frames/corners/simple_corner.svg | 2999 +++++++++++++++++ pyPhotoAlbum/gl_imports.py | 3 +- pyPhotoAlbum/image_utils.py | 275 ++ pyPhotoAlbum/main.py | 2 + pyPhotoAlbum/mixins/asset_drop.py | 2 + pyPhotoAlbum/mixins/async_loading.py | 8 +- pyPhotoAlbum/mixins/operations/__init__.py | 2 + pyPhotoAlbum/mixins/operations/style_ops.py | 372 ++ pyPhotoAlbum/models.py | 341 +- pyPhotoAlbum/page_layout.py | 6 +- pyPhotoAlbum/pdf_exporter.py | 614 +++- pyPhotoAlbum/ribbon_builder.py | 3 +- pyPhotoAlbum/ribbon_widget.py | 3 +- tests/test_frame_manager.py | 280 ++ tests/test_image_style.py | 407 +++ tests/test_image_utils_styling.py | 334 ++ 27 files changed, 7984 insertions(+), 98 deletions(-) create mode 100644 pyPhotoAlbum/dialogs/frame_picker_dialog.py create mode 100644 pyPhotoAlbum/dialogs/style_dialogs.py create mode 100644 pyPhotoAlbum/frame_manager.py create mode 100644 pyPhotoAlbum/frames/CREDITS.txt create mode 100644 pyPhotoAlbum/frames/corners/corner_decoration.svg create mode 100644 pyPhotoAlbum/frames/corners/corner_ornament.svg create mode 100644 pyPhotoAlbum/frames/corners/floral_corner.svg create mode 100644 pyPhotoAlbum/frames/corners/floral_flourish.svg create mode 100644 pyPhotoAlbum/frames/corners/ornate_corner.svg create mode 100644 pyPhotoAlbum/frames/corners/simple_corner.svg create mode 100644 pyPhotoAlbum/mixins/operations/style_ops.py create mode 100644 tests/test_frame_manager.py create mode 100644 tests/test_image_style.py create mode 100644 tests/test_image_utils_styling.py 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 @@ + + + + + + + + image/svg+xml + + + + + + + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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