Compare commits

...
26 Commits
Author SHA1 Message Date
dtourolle 15d8ca8c05 Fix ci more
Lint / lint (push) Successful in 35s
Tests / test (push) Successful in 52s
2026-06-20 14:54:22 +02:00
dtourolle 1fe44e7d8a More CI fixes and mypy fixes
Lint / lint (push) Successful in 20s
Tests / test (push) Successful in 10s
Python CI / test (push) Failing after 1m43s
2026-04-09 23:07:59 +02:00
dtourolle fd0758dd6c use x86 for CI
Lint / lint (push) Successful in 20s
Python CI / test (push) Failing after 1m45s
Tests / test (3.11) (push) Successful in 1m2s
Tests / test (3.12) (push) Failing after 58s
Tests / test (3.13) (push) Failing after 45s
Tests / test (3.14) (push) Failing after 45s
2026-04-09 22:52:16 +02:00
dtourolle 5763fa629e More fixes for CI
Lint / lint (push) Successful in 20s
Python CI / test (push) Failing after 1m42s
Tests / test (3.12) (push) Failing after 6s
Tests / test (3.13) (push) Failing after 6s
Tests / test (3.14) (push) Failing after 8s
Tests / test (3.11) (push) Failing after 37s
2026-04-09 22:39:15 +02:00
dtourolle 3092388226 Use docker image for CI
Lint / lint (push) Successful in 22s
Tests / test (3.11) (push) Failing after 15s
Tests / test (3.12) (push) Failing after 3s
Tests / test (3.13) (push) Failing after 4s
Tests / test (3.14) (push) Failing after 4s
Python CI / test (push) Failing after 1m42s
2026-04-09 22:35:31 +02:00
dtourolle cdd5215eb4 CI fix
Lint / lint (push) Successful in 25s
Tests / test (3.11) (push) Successful in 31s
Tests / test (3.12) (push) Successful in 29s
Python CI / test (push) Failing after 1m40s
Tests / test (3.13) (push) Successful in 28s
Tests / test (3.14) (push) Has been cancelled
2026-04-09 22:32:21 +02:00
dtourolle e112437372 Faster pdf export and test support
Lint / lint (push) Successful in 21s
Tests / test (3.11) (push) Successful in 19s
Tests / test (3.12) (push) Successful in 16s
Tests / test (3.13) (push) Successful in 16s
Tests / test (3.14) (push) Successful in 17s
Python CI / test (push) Failing after 1m44s
2026-04-09 22:30:36 +02:00
dtourolle f96200c799 Many improvements and stability fixes
Lint / lint (push) Successful in 1m47s
Tests / test (3.11) (push) Successful in 57s
Tests / test (3.12) (push) Successful in 55s
Tests / test (3.13) (push) Successful in 56s
Tests / test (3.14) (push) Successful in 1m3s
Python CI / test (push) Failing after 6m1s
2026-04-09 21:39:20 +02:00
dtourolle f0aa005d8c refactor to reduce complexity of nesting
Python CI / test (push) Successful in 1m41s
Lint / lint (push) Successful in 1m29s
Tests / test (3.11) (push) Successful in 1m46s
Tests / test (3.12) (push) Successful in 1m50s
Tests / test (3.13) (push) Successful in 1m43s
Tests / test (3.14) (push) Successful in 1m26s
2026-01-01 18:42:58 +01:00
dtourolle 293b568772 simplified deserialisation
Python CI / test (push) Successful in 1m40s
Lint / lint (push) Successful in 1m29s
Tests / test (3.11) (push) Successful in 1m48s
Tests / test (3.12) (push) Successful in 1m51s
Tests / test (3.13) (push) Successful in 1m46s
Tests / test (3.14) (push) Successful in 1m29s
2026-01-01 14:17:16 +01:00
dtourolle 3a2d8b05db fixed export issue
Python CI / test (push) Successful in 1m39s
Lint / lint (push) Successful in 1m30s
Tests / test (3.11) (push) Successful in 1m47s
Tests / test (3.12) (push) Successful in 1m51s
Tests / test (3.13) (push) Successful in 1m47s
Tests / test (3.14) (push) Successful in 1m27s
2026-01-01 13:50:17 +01:00
dtourolle 54cc78783a Added styling
Python CI / test (push) Successful in 1m44s
Lint / lint (push) Successful in 1m29s
Tests / test (3.11) (push) Successful in 1m49s
Tests / test (3.12) (push) Successful in 1m52s
Tests / test (3.13) (push) Successful in 1m45s
Tests / test (3.14) (push) Successful in 1m28s
Improved pdf generation speed
2026-01-01 13:37:14 +01:00
dtourolle cf27d9ebee fixed bug in font rendering when zooming
Python CI / test (push) Successful in 1m32s
Lint / lint (push) Successful in 1m18s
Tests / test (3.11) (push) Successful in 1m47s
Tests / test (3.12) (push) Successful in 1m48s
Tests / test (3.13) (push) Successful in 1m42s
Tests / test (3.14) (push) Successful in 1m18s
2025-12-31 13:19:36 +01:00
dtourolle 6a791b1397 Use md5 to only store unique content
Python CI / test (push) Successful in 3m12s
Lint / lint (push) Successful in 1m38s
Tests / test (3.11) (push) Successful in 2m26s
Tests / test (3.12) (push) Successful in 3m13s
Tests / test (3.13) (push) Successful in 3m9s
Tests / test (3.14) (push) Successful in 1m20s
2025-12-31 12:46:01 +01:00
dtourolle 254a95d83c fix tests
Python CI / test (push) Successful in 1m24s
Lint / lint (push) Successful in 1m13s
Tests / test (3.11) (push) Successful in 1m37s
Tests / test (3.12) (push) Successful in 1m41s
Tests / test (3.13) (push) Successful in 1m34s
Tests / test (3.14) (push) Successful in 1m12s
2025-12-13 17:33:12 +01:00
dtourolle c1ee894e7b Sort library tab, background saving
Python CI / test (push) Successful in 1m26s
Lint / lint (push) Successful in 1m13s
Tests / test (3.11) (push) Successful in 1m40s
Tests / test (3.12) (push) Successful in 1m46s
Tests / test (3.13) (push) Successful in 1m36s
Tests / test (3.14) (push) Successful in 1m13s
2025-12-13 16:53:43 +01:00
dtourolle 8f9f387848 Added gallery pane to easily add images on smaller screen devices
Python CI / test (push) Successful in 1m31s
Lint / lint (push) Successful in 1m10s
Tests / test (3.11) (push) Successful in 1m42s
Tests / test (3.12) (push) Successful in 1m43s
Tests / test (3.13) (push) Successful in 1m36s
Tests / test (3.14) (push) Successful in 1m14s
2025-12-13 15:30:37 +01:00
dtourolle c66724c190 Add debian installer and launcher
Python CI / test (push) Successful in 1m30s
Lint / lint (push) Successful in 1m8s
Tests / test (3.11) (push) Successful in 1m41s
Tests / test (3.12) (push) Successful in 1m42s
Tests / test (3.13) (push) Successful in 1m36s
Tests / test (3.14) (push) Successful in 1m17s
2025-12-07 18:14:19 +01:00
dtourolle a7f32e73b9 pan with two finger scroll
Python CI / test (push) Successful in 1m31s
Lint / lint (push) Successful in 1m8s
Tests / test (3.11) (push) Successful in 1m42s
Tests / test (3.12) (push) Successful in 1m43s
Tests / test (3.13) (push) Successful in 1m37s
Tests / test (3.14) (push) Successful in 1m16s
2025-12-02 20:57:37 +01:00
dtourolle 8f1e906884 justified test and two finger scrolling
Python CI / test (push) Successful in 1m27s
Lint / lint (push) Successful in 1m6s
Tests / test (3.11) (push) Successful in 1m42s
Tests / test (3.12) (push) Successful in 1m43s
Tests / test (3.13) (push) Successful in 1m37s
Tests / test (3.14) (push) Successful in 1m16s
2025-12-01 20:40:11 +01:00
dtourolle 80d7d291f3 Re-organised ribbons. Fixed bug in rendering.
Python CI / test (push) Successful in 1m31s
Lint / lint (push) Successful in 1m7s
Tests / test (3.11) (push) Successful in 1m43s
Tests / test (3.12) (push) Successful in 1m44s
Tests / test (3.13) (push) Successful in 1m38s
Tests / test (3.14) (push) Successful in 1m17s
2025-12-01 20:19:43 +01:00
dtourolle 2a087f0c9d update readme
Lint / lint (push) Successful in 1m7s
2025-11-28 20:41:38 +01:00
dtourolle b18a780a33 increase test coverage
Python CI / test (push) Successful in 1m28s
Lint / lint (push) Successful in 1m4s
Tests / test (3.11) (push) Successful in 1m41s
Tests / test (3.12) (push) Successful in 1m42s
Tests / test (3.13) (push) Successful in 1m35s
Tests / test (3.14) (push) Successful in 1m15s
2025-11-28 19:54:41 +01:00
dtourolle 558b7be01a additional test coverage and mypy fixes
Python CI / test (push) Successful in 1m18s
Lint / lint (push) Successful in 1m5s
Tests / test (3.11) (push) Successful in 1m29s
Tests / test (3.12) (push) Successful in 1m33s
Tests / test (3.13) (push) Successful in 1m26s
Tests / test (3.14) (push) Successful in 1m14s
2025-11-27 23:20:43 +01:00
dtourolle f6ed11b0bc black formatting
Python CI / test (push) Successful in 1m20s
Lint / lint (push) Successful in 1m4s
Tests / test (3.11) (push) Successful in 1m27s
Tests / test (3.12) (push) Successful in 2m25s
Tests / test (3.13) (push) Successful in 2m52s
Tests / test (3.14) (push) Successful in 1m9s
2025-11-27 23:07:16 +01:00
dtourolle 3b2301b547 removed sudo instal from ci
Python CI / test (push) Successful in 1m18s
Lint / lint (push) Successful in 1m31s
Tests / test (3.11) (push) Successful in 1m25s
Tests / test (3.13) (push) Has been cancelled
Tests / test (3.14) (push) Has been cancelled
Tests / test (3.12) (push) Has been cancelled
2025-11-27 23:02:06 +01:00
148 changed files with 28718 additions and 6244 deletions
+35
View File
@@ -0,0 +1,35 @@
# Virtual environments
venv/
.venv/
pyPhotoAlbum/venv/
# Python cache
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
dist/
eggs/
*.egg-info/
*.egg
# IDE
.idea/
.vscode/
*.swp
*.swo
# Git
.git/
# Test/coverage
.coverage
htmlcov/
.pytest_cache/
.tox/
# OS files
.DS_Store
Thumbs.db
-183
View File
@@ -1,183 +0,0 @@
name: Python CI
on:
push:
branches: [ main, master, develop ]
paths-ignore:
- 'coverage*.svg'
- 'README.md'
pull_request:
branches: [ main, master, develop ]
jobs:
test:
runs-on: self-hosted
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
# Install package in development mode with dev dependencies
pip install -e ".[dev]"
# Install additional test packages
pip install coverage-badge interrogate
- name: Download initial failed badges
run: |
echo "Downloading initial failed badges..."
# Create cov_info directory first
mkdir -p cov_info
# Download failed badges as defaults
curl -o cov_info/coverage.svg "https://img.shields.io/badge/coverage-failed-red.svg"
curl -o cov_info/coverage-docs.svg "https://img.shields.io/badge/docs-failed-red.svg"
echo "Initial failed badges created:"
ls -la cov_info/coverage*.svg
- name: Run tests with pytest
id: pytest
continue-on-error: true
run: |
# Run tests with coverage
# Check if xvfb-run is available, use it if present
if command -v xvfb-run &> /dev/null; then
echo "Using xvfb-run for headless Qt testing"
xvfb-run -a python -m pytest tests/ -v --cov=pyPhotoAlbum --cov-report=term-missing --cov-report=json --cov-report=html --cov-report=xml
else
echo "xvfb-run not found, running with QT_QPA_PLATFORM=offscreen only"
echo "For better Qt support, install: sudo apt-get install xvfb"
python -m pytest tests/ -v --cov=pyPhotoAlbum --cov-report=term-missing --cov-report=json --cov-report=html --cov-report=xml
fi
env:
QT_QPA_PLATFORM: offscreen
- name: Check documentation coverage
id: docs
continue-on-error: true
run: |
# Generate documentation coverage report
interrogate -v --ignore-init-method --ignore-init-module --ignore-magic --ignore-private --ignore-property-decorators --ignore-semiprivate --fail-under=80 pyPhotoAlbum/
- name: Lint with flake8
run: |
# Stop the build if there are Python syntax errors or undefined names
flake8 pyPhotoAlbum --count --select=E9,F63,F7,F82 --show-source --statistics
# Exit-zero treats all errors as warnings
flake8 pyPhotoAlbum --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Create coverage info directory
if: always()
run: |
mkdir -p cov_info
echo "Created cov_info directory for coverage data"
- name: Update test coverage badge on success
if: steps.pytest.outcome == 'success' && always()
run: |
echo "Tests passed! Generating successful coverage badge..."
if [ -f coverage.json ]; then
coverage-badge -o cov_info/coverage.svg -f
echo "✅ Test coverage badge updated with actual results"
else
echo "⚠️ No coverage.json found, keeping failed badge"
fi
- name: Update docs coverage badge on success
if: steps.docs.outcome == 'success' && always()
run: |
echo "Docs check passed! Generating successful docs badge..."
# Remove existing badge first to avoid overwrite error
rm -f cov_info/coverage-docs.svg
interrogate --generate-badge cov_info/coverage-docs.svg pyPhotoAlbum/
echo "✅ Docs coverage badge updated with actual results"
- name: Generate coverage reports
if: steps.pytest.outcome == 'success'
run: |
# Generate coverage summary for README
python -c "
import json
import os
# Read coverage data
if os.path.exists('coverage.json'):
with open('coverage.json', 'r') as f:
coverage_data = json.load(f)
total_coverage = round(coverage_data['totals']['percent_covered'], 1)
# Create coverage summary file in cov_info directory
with open('cov_info/coverage-summary.txt', 'w') as f:
f.write(f'{total_coverage}%')
print(f'Test Coverage: {total_coverage}%')
covered_lines = coverage_data['totals']['covered_lines']
total_lines = coverage_data['totals']['num_statements']
print(f'Lines Covered: {covered_lines}/{total_lines}')
else:
print('No coverage data found')
"
# Copy other coverage files to cov_info
if [ -f coverage.json ]; then cp coverage.json cov_info/; fi
if [ -f coverage.xml ]; then cp coverage.xml cov_info/; fi
if [ -d htmlcov ]; then cp -r htmlcov cov_info/; fi
- name: Final badge status
if: always()
run: |
echo "=== FINAL BADGE STATUS ==="
echo "Test outcome: ${{ steps.pytest.outcome }}"
echo "Docs outcome: ${{ steps.docs.outcome }}"
if [ -f cov_info/coverage.svg ]; then
echo "✅ Test coverage badge: $(ls -lh cov_info/coverage.svg)"
else
echo "❌ Test coverage badge: MISSING"
fi
if [ -f cov_info/coverage-docs.svg ]; then
echo "✅ Docs coverage badge: $(ls -lh cov_info/coverage-docs.svg)"
else
echo "❌ Docs coverage badge: MISSING"
fi
echo "Coverage info directory contents:"
ls -la cov_info/ 2>/dev/null || echo "No cov_info directory found"
- name: Upload coverage artifacts
uses: actions/upload-artifact@v3
with:
name: coverage-reports
path: |
cov_info/
- name: Commit badges to badges branch
if: github.ref == 'refs/heads/master'
run: |
git config --local user.email "action@gitea.local"
git config --local user.name "Gitea Action"
# Set the remote URL to use the token
git remote set-url origin https://${{ secrets.PUSH_TOKEN }}@gitea.tourolle.paris/dtourolle/pyPhotoAlbum.git
# Create a new orphan branch for badges (this discards any existing badges branch)
git checkout --orphan badges
# Remove all files except cov_info
find . -maxdepth 1 -not -name '.git' -not -name 'cov_info' -exec rm -rf {} + 2>/dev/null || true
# Add only the coverage info directory
git add -f cov_info/
# Always commit (force overwrite)
echo "Force updating badges branch with new coverage data..."
git commit -m "Update coverage badges [skip ci]"
git push -f origin badges
+3 -11
View File
@@ -4,22 +4,14 @@ on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/pyphotoalbum-ci:latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install flake8 black mypy
- name: Run flake8
run: |
# Stop the build if there are Python syntax errors or undefined names
+8 -29
View File
@@ -11,30 +11,16 @@ on:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.11', '3.12', '3.13', '3.14']
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/pyphotoalbum-ci:latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y xvfb libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 libegl1 libgl1-mesa-glx
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
pip install coverage-badge interrogate
- name: Install project
run: pip3 install -e . --no-deps --break-system-packages
- name: Download initial failed badges
run: |
@@ -46,7 +32,7 @@ jobs:
id: pytest
continue-on-error: true
run: |
xvfb-run -a pytest --cov=pyPhotoAlbum --cov-report=xml --cov-report=json --cov-report=html --cov-report=term-missing
pytest --cov=pyPhotoAlbum --cov-report=xml --cov-report=json --cov-report=html --cov-report=term-missing
env:
QT_QPA_PLATFORM: offscreen
@@ -74,7 +60,7 @@ jobs:
- name: Generate coverage reports
if: steps.pytest.outcome == 'success'
run: |
python -c "
python3 -c "
import json
import os
if os.path.exists('coverage.json'):
@@ -103,15 +89,8 @@ jobs:
name: coverage-reports
path: cov_info/
- name: Upload coverage reports to Codecov
if: matrix.python-version == '3.11'
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
fail_ci_if_error: false
- name: Commit badges to badges branch
if: github.ref == 'refs/heads/master' && matrix.python-version == '3.11'
if: github.ref == 'refs/heads/master'
run: |
git config --local user.email "action@gitea.local"
git config --local user.name "Gitea Action"
+67
View File
@@ -0,0 +1,67 @@
# CI test image for pyPhotoAlbum
# Build: docker build -f Dockerfile.ci -t gitea.tourolle.paris/dtourolle/pyphotoalbum-ci:latest .
# Push: docker push gitea.tourolle.paris/dtourolle/pyphotoalbum-ci:latest
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y \
--no-install-recommends \
# Python
python3 \
python3-pip \
# PyQt6 / OpenGL runtime deps
libgl1 \
libglib2.0-0 \
libegl1 \
libfontconfig1 \
libfreetype6 \
libdbus-1-3 \
# XCB / display (needed even with offscreen platform)
libx11-6 \
libx11-xcb1 \
libxcb1 \
libxcb-cursor0 \
libxcb-glx0 \
libxcb-icccm4 \
libxcb-image0 \
libxcb-keysyms1 \
libxcb-randr0 \
libxcb-render0 \
libxcb-render-util0 \
libxcb-shape0 \
libxcb-shm0 \
libxcb-sync1 \
libxcb-xfixes0 \
libxcb-xinerama0 \
libxcb-xkb1 \
libxkbcommon0 \
libxkbcommon-x11-0 \
# Misc tools used in workflows
curl \
git \
nodejs \
&& rm -rf /var/lib/apt/lists/*
# Install all Python dependencies so CI runs don't need to download anything
RUN pip3 install --break-system-packages --no-cache-dir \
# Runtime deps
PyQt6 \
PyOpenGL \
numpy \
Pillow \
reportlab \
lxml \
pypdf \
# Dev/test deps
pytest \
pytest-qt \
pytest-cov \
pytest-mock \
pdfplumber \
flake8 \
black \
mypy \
coverage-badge \
interrogate \
setuptools
+63
View File
@@ -0,0 +1,63 @@
# Dockerfile for testing pyPhotoAlbum installation on Debian
FROM debian:bookworm
# Avoid interactive prompts
ENV DEBIAN_FRONTEND=noninteractive
# Install system dependencies (same as install-debian.sh)
RUN apt-get update && apt-get install -y \
sudo \
curl \
python3 \
python3-venv \
python3-pip \
libgl1-mesa-dev \
libglu1-mesa-dev \
libxcb-xinerama0 \
libxcb-cursor0 \
libxkbcommon0 \
libdbus-1-3 \
libegl1 \
libfontconfig1 \
libfreetype6 \
libx11-6 \
libx11-xcb1 \
libxcb1 \
libxcb-glx0 \
libxcb-icccm4 \
libxcb-image0 \
libxcb-keysyms1 \
libxcb-randr0 \
libxcb-render0 \
libxcb-render-util0 \
libxcb-shape0 \
libxcb-shm0 \
libxcb-sync1 \
libxcb-xfixes0 \
libxcb-xkb1 \
libxkbcommon-x11-0 \
libglib2.0-0 \
libgtk-3-0 \
&& rm -rf /var/lib/apt/lists/*
# Create a non-root user for testing
RUN useradd -m -s /bin/bash testuser && \
echo "testuser ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
# Copy project files
WORKDIR /home/testuser/pyPhotoAlbum
COPY . .
# Fix ownership
RUN chown -R testuser:testuser /home/testuser/pyPhotoAlbum
# Switch to test user
USER testuser
# Create venv and install
RUN python3 -m venv venv && \
./venv/bin/pip install --upgrade pip && \
./venv/bin/pip install -e .
# Default command - run the app
CMD ["./launch-pyphotoalbum.sh"]
+33 -623
View File
@@ -1,14 +1,10 @@
# pyPhotoAlbum
| Badge | Description |
|-------|-------------|
| ![Test Coverage](https://gitea.tourolle.paris/dtourolle/pyPhotoAlbum/raw/branch/badges/cov_info/coverage.svg) | **Test Coverage** - Percentage of code covered by unit tests |
| ![Documentation Coverage](https://gitea.tourolle.paris/dtourolle/pyPhotoAlbum/raw/branch/badges/cov_info/coverage-docs.svg) | **Documentation Coverage** - Percentage of code with docstrings |
| ![License](https://img.shields.io/badge/license-MIT-blue.svg) | **License** - Project licensing information |
![Test Coverage](https://gitea.tourolle.paris/dtourolle/pyPhotoAlbum/raw/branch/badges/cov_info/coverage.svg)
![Documentation Coverage](https://gitea.tourolle.paris/dtourolle/pyPhotoAlbum/raw/branch/badges/cov_info/coverage-docs.svg)
![License](https://img.shields.io/badge/license-MIT-blue.svg)
> 📋 **Note**: Badges show results from the commit referenced in the URLs. Red "error" badges indicate build failures for that specific step.
A Python-based desktop application for designing photo albums with an intuitive interface and professional PDF export capabilities.
A desktop application for designing and creating professional photo albums with an intuitive drag-and-drop interface and high-quality PDF export.
## Overview
@@ -39,19 +35,8 @@ cd pyPhotoAlbum
./install.sh
```
**For GNOME users:** See [GNOME_QUICKSTART.md](GNOME_QUICKSTART.md) for desktop integration.
**For detailed instructions:** See [INSTALLATION.md](INSTALLATION.md)
### Requirements
- Python 3.9 or higher
- PyQt6
- PyOpenGL
- Pillow
- ReportLab
- lxml
### Distribution Packages
**Fedora (RPM):**
@@ -67,25 +52,12 @@ makepkg -si
See [INSTALLATION.md](INSTALLATION.md) for complete instructions.
### Install for Development
```bash
# Clone repository
git clone https://gitea.tourolle.paris/dtourolle/pyPhotoAlbum.git
cd pyPhotoAlbum
# Create virtual environment
python -m venv venv
source venv/bin/activate
# Install with development dependencies
pip install -e ".[dev]"
```
## Quick Start
### Running the Application
After installation, launch pyPhotoAlbum:
```bash
pyphotoalbum
```
@@ -96,600 +68,44 @@ Or run directly from source:
python pyPhotoAlbum/main.py
```
### Basic Usage Example
### Basic Workflow
```python
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
from pyPhotoAlbum.models import ImageData
1. **Create a New Project** - Choose your page size (A4, Letter, etc.) and DPI
2. **Add Pages** - Start with blank pages or use templates
3. **Add Images** - Drag and drop images from your file browser onto pages
4. **Arrange & Edit** - Move, resize, rotate, and crop images to your liking
5. **Save Your Work** - Projects are saved as .ppz files (ZIP archives)
6. **Export to PDF** - Generate high-quality PDFs ready for printing
# Create a new project
project = Project(name="My Photo Album")
project.page_size_mm = (210, 297) # A4 size
project.working_dpi = 300
## Using Templates
# Create a page with an image
layout = PageLayout(width=210, height=297)
image = ImageData(
image_path="photos/vacation.jpg",
x=10.0,
y=10.0,
width=190.0,
height=140.0
)
layout.add_element(image)
pyPhotoAlbum includes a template system to help you quickly create consistent layouts:
# Add page to project
page = Page(layout=layout, page_number=1)
project.add_page(page)
- **Built-in Templates**: Grid layouts, single large image, and more
- **Custom Templates**: Save your favorite layouts as templates
- **Flexible Application**: Apply templates to new or existing pages
# Save project
from pyPhotoAlbum.project_serializer import save_to_zip
success, error = save_to_zip(project, "my_album.ppz")
```
## Architecture Highlights
## Architecture
pyPhotoAlbum is built with clean, maintainable design patterns:
### GLWidget Mixin Architecture
### Mixin-Based Composition
The main OpenGL widget uses a **mixin-based architecture** for maintainability and testability. The monolithic 1,368-line `gl_widget.py` has been refactored into 9 focused mixins averaging 89 lines each:
The main OpenGL widget is composed of **12 specialized mixins** instead of one monolithic class:
- Each mixin handles a single responsibility (viewport, rendering, selection, etc.)
- Average ~90 lines per mixin for maintainability
- Easy to test in isolation with comprehensive unit tests
- Clean separation of concerns throughout the codebase
```python
class GLWidget(
ViewportMixin, # Zoom & pan state
RenderingMixin, # OpenGL rendering
AssetDropMixin, # Drag-and-drop
PageNavigationMixin, # Page detection
ImagePanMixin, # Image cropping
ElementManipulationMixin, # Resize & rotate
ElementSelectionMixin, # Hit detection
MouseInteractionMixin, # Event routing
UndoableInteractionMixin, # Undo/redo
QOpenGLWidget # Qt base class
):
"""Clean orchestration with minimal boilerplate"""
```
### Declarative UI with Decorators
**Benefits:**
- Each mixin has a single, clear responsibility
- 89 comprehensive unit tests with 69-97% coverage per mixin
- Easy to test in isolation with mock dependencies
- Clear separation of concerns
- Maintainable codebase (average 89 lines per mixin)
The ribbon interface is **auto-generated from decorator metadata**:
- `@ribbon_action` - Automatically creates ribbon buttons from method metadata
- `@undoable_operation` - Automatically captures state for undo/redo
- `@dialog_action` - Separates dialog presentation from business logic
- No manual UI wiring required - just add decorators to your methods
See [REFACTORING_COMPLETE.md](REFACTORING_COMPLETE.md) for details on the refactoring process.
### Core Components
#### Models (`models.py`)
Base classes for layout elements:
```python
# Image element with crop support
image = ImageData(
image_path="photo.jpg",
x=10, y=20,
width=200, height=150,
rotation=0,
z_index=0,
crop_info=(0, 0, 1, 1) # (x_min, y_min, x_max, y_max)
)
# Text box element
textbox = TextBoxData(
text_content="My Caption",
font_settings={"family": "Arial", "size": 14, "color": (0, 0, 0)},
alignment="center",
x=10, y=180,
width=200, height=30
)
# Placeholder for templates
placeholder = PlaceholderData(
placeholder_type="image",
x=10, y=10,
width=100, height=100
)
```
#### Project Structure (`project.py`)
```python
# Project contains multiple pages
project = Project(name="Album", folder_path="/path/to/project")
# Each page has a layout with elements
page = Page(layout=PageLayout(), page_number=1)
page.layout.add_element(image)
project.add_page(page)
```
#### Asset Management (`asset_manager.py`)
Automatic asset handling with reference counting:
```python
# Import an image into the project
asset_path = project.asset_manager.import_asset("photo.jpg")
# Returns: "assets/photo_001.jpg" (relative path)
# Assets are automatically copied to project folder
# Reference counting tracks usage across pages
# Cleanup happens automatically when elements are deleted
```
#### Command System (`commands.py`)
Undo/redo support for all operations:
```python
# Commands are automatically created for operations
from pyPhotoAlbum.commands import AddElementCommand, MoveElementCommand
# Add element (undoable)
cmd = AddElementCommand(page.layout, image, project.asset_manager)
project.history.execute(cmd)
# Move element (undoable)
cmd = MoveElementCommand(image, old_pos=(10, 10), new_pos=(20, 20))
project.history.execute(cmd)
# Undo/redo
project.history.undo()
project.history.redo()
```
### Layout System
#### PageLayout (`page_layout.py`)
Manages elements on a page:
```python
layout = PageLayout(width=210, height=297) # A4 in mm
# Add multiple elements
layout.add_element(image1)
layout.add_element(image2)
layout.add_element(textbox)
# Elements are rendered in z_index order
# Serialize/deserialize for saving
data = layout.serialize()
layout2 = PageLayout()
layout2.deserialize(data)
```
#### Alignment Tools (`alignment.py`)
Precise element positioning:
```python
from pyPhotoAlbum.alignment import AlignmentManager
# Align multiple elements to the left
changes = AlignmentManager.align_left(selected_elements)
for element, new_position in changes:
element.position = new_position
# Distribute elements evenly
changes = AlignmentManager.distribute_horizontally(selected_elements)
# Make elements the same size
changes = AlignmentManager.make_same_size(selected_elements)
for element, new_position, new_size in changes:
element.position = new_position
element.size = new_size
```
### Template System
#### Creating Templates
```python
from pyPhotoAlbum.template_manager import TemplateManager, Template
manager = TemplateManager()
# Create template from existing page
template = manager.create_template_from_page(
page=current_page,
name="My Grid Layout",
description="2x2 photo grid"
)
# Save template
manager.save_template(template)
```
#### Using Templates
```python
# List available templates
templates = manager.list_templates()
# Returns: ["Grid_2x2", "Single_Large", "My Grid Layout", ...]
# Create new page from template
new_page = manager.create_page_from_template(
template_name="Grid_2x2",
target_page_size=(210, 297),
page_number=5
)
# Apply template to existing page
manager.apply_template_to_page(
template=template,
target_page=existing_page,
mode="replace", # or "reflow"
scaling="proportional" # or "stretch" or "center"
)
```
### Project Serialization
#### Save/Load Projects
Projects are saved as ZIP archives (.ppz) containing:
- `project.json` - Project metadata and structure
- `assets/` - All referenced images
```python
from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip
# Save project
success, error = save_to_zip(project, "album.ppz")
if not success:
print(f"Error saving: {error}")
# Load project
try:
loaded_project = load_from_zip("album.ppz")
print(f"Loaded: {loaded_project.name}")
except Exception as error:
print(f"Error loading: {error}")
# Get project info without loading
from pyPhotoAlbum.project_serializer import get_project_info
info = get_project_info("album.ppz")
print(f"Name: {info['name']}, Pages: {info['page_count']}")
```
#### Project Structure
```python
{
"name": "My Album",
"serialization_version": "1.0",
"page_size_mm": [210, 297],
"working_dpi": 300,
"export_dpi": 300,
"pages": [
{
"page_number": 1,
"is_double_spread": false,
"layout": {
"width": 210,
"height": 297,
"elements": [...]
}
}
]
}
```
### PDF Export
```python
from pyPhotoAlbum.pdf_exporter import PDFExporter
# Create exporter
exporter = PDFExporter(project, export_dpi=300)
# Export with progress callback
def progress_callback(current, total):
print(f"Exporting page {current}/{total}")
success, errors = exporter.export(
output_path="album.pdf",
progress_callback=progress_callback
)
if success:
print("PDF exported successfully")
else:
print(f"Errors: {errors}")
```
## Testing
### Run Tests
```bash
# Run all tests
pytest
# Run with coverage
pytest --cov=pyPhotoAlbum --cov-report=html
# Run specific test file
pytest tests/test_models.py
# Run with verbose output
pytest -v
```
### Test Structure
```
tests/
├── __init__.py
├── conftest.py # Shared fixtures
├── test_models.py # Model serialization tests
├── test_project.py # Project and page tests
├── test_project_serialization.py # Save/load tests
├── test_page_renderer.py # Rendering tests
├── test_pdf_export.py # PDF export tests
├── test_gl_widget_fixtures.py # Shared GL widget test fixtures
├── test_viewport_mixin.py # Viewport mixin tests
├── test_element_selection_mixin.py # Selection mixin tests
├── test_element_manipulation_mixin.py # Manipulation mixin tests
├── test_image_pan_mixin.py # Image pan mixin tests
├── test_page_navigation_mixin.py # Page navigation mixin tests
└── test_asset_drop_mixin.py # Asset drop mixin tests
```
### Example Test Cases
From `tests/test_models.py`:
```python
def test_image_serialization():
"""Test ImageData serialization"""
img = ImageData(
image_path="test.jpg",
x=15.0, y=25.0,
width=180.0, height=120.0,
rotation=30.0,
z_index=3
)
# Serialize
data = img.serialize()
assert data["type"] == "image"
assert data["position"] == (15.0, 25.0)
# Deserialize
img2 = ImageData()
img2.deserialize(data)
assert img2.position == img.position
```
From `tests/test_project_serialization.py`:
```python
def test_save_and_load_project(tmp_path):
"""Test complete save/load cycle"""
# Create project with pages
project = Project(name="Test")
page = Page(layout=PageLayout(), page_number=1)
project.add_page(page)
# Save
zip_path = tmp_path / "project.ppz"
success, error = save_to_zip(project, str(zip_path))
assert success is True
# Load
loaded = load_from_zip(str(zip_path))
assert loaded.name == "Test"
assert len(loaded.pages) == 1
```
## Project Structure
```
pyPhotoAlbum/
├── __init__.py
├── main.py # Application entry point
├── models.py # Data models (ImageData, TextBoxData, etc.)
├── project.py # Project and Page classes
├── page_layout.py # Page layout management
├── page_renderer.py # OpenGL rendering
├── gl_widget.py # Main OpenGL widget (mixin orchestration)
├── project_serializer.py # Save/load functionality
├── asset_manager.py # Asset handling
├── commands.py # Undo/redo system
├── template_manager.py # Template system
├── pdf_exporter.py # PDF export
├── alignment.py # Alignment tools
├── snapping.py # Snapping system
├── decorators.py # UI decorators
├── ribbon_widget.py # Ribbon interface
├── ribbon_builder.py # Ribbon configuration
├── mixins/ # Mixin architecture
│ ├── __init__.py
│ ├── base.py # Base mixin class
│ ├── viewport.py # Zoom and pan management
│ ├── rendering.py # OpenGL rendering pipeline
│ ├── asset_drop.py # Drag-and-drop functionality
│ ├── page_navigation.py # Page detection and ghost pages
│ ├── image_pan.py # Image cropping within frames
│ ├── element_manipulation.py # Resize and rotate
│ ├── element_selection.py # Hit detection and selection
│ ├── mouse_interaction.py # Mouse event coordination
│ ├── interaction_undo.py # Undo/redo integration
│ └── operations/ # Operation mixins
│ ├── element_ops.py
│ ├── page_ops.py
│ ├── file_ops.py
│ ├── view_ops.py
│ ├── edit_ops.py
│ ├── template_ops.py
│ ├── alignment_ops.py
│ ├── distribution_ops.py
│ └── size_ops.py
└── templates/ # Built-in templates
├── Grid_2x2.json
└── Single_Large.json
tests/ # Unit tests (312 tests, 29% coverage)
examples/ # Usage examples
```
## Development
### Code Style
The project uses:
- **Black** for code formatting (line length: 120)
- **Flake8** for linting
- **MyPy** for type checking
```bash
# Format code
black pyPhotoAlbum tests
# Run linter
flake8 pyPhotoAlbum tests
# Type checking
mypy pyPhotoAlbum
```
### Continuous Integration
GitHub Actions / Gitea Actions workflows:
- Run tests on Python 3.9, 3.10, 3.11
- Check code quality with linters
- Generate coverage reports
### Contributing
1. Fork the repository
2. Create a feature branch
3. Write tests for new features
4. Ensure all tests pass
5. Submit a pull request
## Usage Examples
See the `examples/` directory for complete working examples:
- `basic_usage.py` - Creating projects and adding images
- `template_example.py` - Working with templates
- `generate_screenshots.py` - Creating documentation screenshots
Run examples:
```bash
cd examples
python basic_usage.py
```
## API Reference
### Key Classes
#### BaseLayoutElement (Abstract)
Base class for all layout elements.
**Methods:**
- `render()` - Render element using OpenGL
- `serialize() -> Dict` - Convert to dictionary
- `deserialize(data: Dict)` - Load from dictionary
**Attributes:**
- `position: Tuple[float, float]` - (x, y) in mm
- `size: Tuple[float, float]` - (width, height) in mm
- `rotation: float` - Rotation angle in degrees
- `z_index: int` - Layer order
#### ImageData
Image element with crop support.
**Constructor:**
```python
ImageData(
image_path: str = "",
crop_info: Tuple = (0, 0, 1, 1),
x: float = 0,
y: float = 0,
width: float = 100,
height: float = 100,
rotation: float = 0,
z_index: int = 0
)
```
#### TextBoxData
Text element with formatting.
**Constructor:**
```python
TextBoxData(
text_content: str = "",
font_settings: Dict = None,
alignment: str = "left",
x: float = 0,
y: float = 0,
width: float = 100,
height: float = 100
)
```
**Font Settings:**
```python
{
"family": "Arial",
"size": 12,
"color": (0, 0, 0) # RGB tuple
}
```
#### Project
Main project container.
**Methods:**
- `add_page(page: Page)` - Add page to project
- `remove_page(page: Page)` - Remove page
- `serialize() -> Dict` - Save to dictionary
- `deserialize(data: Dict)` - Load from dictionary
**Attributes:**
- `name: str` - Project name
- `pages: List[Page]` - List of pages
- `page_size_mm: Tuple[float, float]` - Page dimensions
- `working_dpi: int` - Display DPI
- `export_dpi: int` - Export DPI
- `asset_manager: AssetManager` - Asset handler
- `history: CommandHistory` - Undo/redo history
#### Page
Single page in project.
**Constructor:**
```python
Page(
layout: PageLayout = None,
page_number: int = 1,
is_double_spread: bool = False
)
```
#### PageLayout
Element container for a page.
**Methods:**
- `add_element(element: BaseLayoutElement)` - Add element
- `remove_element(element: BaseLayoutElement)` - Remove element
- `render(dpi: int)` - Render all elements
**Attributes:**
- `elements: List[BaseLayoutElement]` - Page elements
- `width: float` - Page width in mm
- `height: float` - Page height in mm
This approach keeps UI concerns separate from business logic and makes the codebase easier to maintain and extend.
## Keyboard Shortcuts
@@ -708,12 +124,6 @@ Element container for a page.
This project is licensed under the MIT License.
## Links
- Documentation: [Link to docs]
- Issue Tracker: [Link to issues]
- Changelog: [Link to changelog]
## Acknowledgments
Built with:
+265
View File
@@ -0,0 +1,265 @@
#!/bin/bash
# Debian/Ubuntu installation script for pyPhotoAlbum
# Creates a virtual environment and installs all dependencies
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
print_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
print_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
print_step() {
echo -e "${BLUE}[STEP]${NC} $1"
}
# Get the directory where this script is located
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VENV_DIR="$SCRIPT_DIR/venv"
INSTALL_DIR="$HOME/.local"
BIN_DIR="$INSTALL_DIR/bin"
echo "========================================"
echo " pyPhotoAlbum Debian Installation "
echo "========================================"
echo ""
# Check if running on Debian/Ubuntu
if [ -f /etc/os-release ]; then
. /etc/os-release
if [[ "$ID" != "debian" && "$ID" != "ubuntu" && "$ID_LIKE" != *"debian"* && "$ID_LIKE" != *"ubuntu"* ]]; then
print_warn "This script is designed for Debian/Ubuntu-based systems."
print_warn "Detected: $PRETTY_NAME"
read -p "Continue anyway? [y/N]: " continue_choice
if [[ ! "$continue_choice" =~ ^[Yy]$ ]]; then
exit 1
fi
else
print_info "Detected: $PRETTY_NAME"
fi
fi
# Check for required files
if [ ! -f "$SCRIPT_DIR/pyproject.toml" ]; then
print_error "pyproject.toml not found. Please run this script from the project root."
exit 1
fi
# Step 1: Install system dependencies
print_step "Installing system dependencies..."
echo ""
# Check if we need sudo
if [ "$(id -u)" -ne 0 ]; then
SUDO="sudo"
else
SUDO=""
fi
$SUDO apt update
# Install Python and venv support
print_info "Installing Python and venv support..."
$SUDO apt install -y python3 python3-venv python3-pip
# Install system libraries required for PyQt6 and OpenGL
print_info "Installing Qt6 and OpenGL libraries..."
$SUDO apt install -y \
libgl1-mesa-dev \
libglu1-mesa-dev \
libxcb-xinerama0 \
libxcb-cursor0 \
libxkbcommon0 \
libdbus-1-3 \
libegl1 \
libfontconfig1 \
libfreetype6 \
libx11-6 \
libx11-xcb1 \
libxcb1 \
libxcb-glx0 \
libxcb-icccm4 \
libxcb-image0 \
libxcb-keysyms1 \
libxcb-randr0 \
libxcb-render0 \
libxcb-render-util0 \
libxcb-shape0 \
libxcb-shm0 \
libxcb-sync1 \
libxcb-xfixes0 \
libxcb-xkb1 \
libxkbcommon-x11-0 \
libglib2.0-0 \
libgtk-3-0 || print_warn "Some packages may not be available, continuing..."
echo ""
# Step 2: Create virtual environment
print_step "Creating virtual environment..."
echo ""
if [ -d "$VENV_DIR" ]; then
print_warn "Virtual environment already exists at $VENV_DIR"
read -p "Remove and recreate? [y/N]: " recreate_choice
if [[ "$recreate_choice" =~ ^[Yy]$ ]]; then
print_info "Removing existing virtual environment..."
rm -rf "$VENV_DIR"
else
print_info "Using existing virtual environment..."
fi
fi
if [ ! -d "$VENV_DIR" ]; then
print_info "Creating virtual environment at $VENV_DIR..."
python3 -m venv "$VENV_DIR"
fi
# Activate virtual environment
source "$VENV_DIR/bin/activate"
# Upgrade pip
print_info "Upgrading pip..."
pip install --upgrade pip
echo ""
# Step 3: Install Python dependencies
print_step "Installing Python dependencies..."
echo ""
print_info "Installing pyPhotoAlbum and its dependencies..."
pip install -e "$SCRIPT_DIR"
echo ""
# Step 4: Create launcher script
print_step "Creating launcher script..."
echo ""
mkdir -p "$BIN_DIR"
cat > "$BIN_DIR/pyphotoalbum" << EOF
#!/bin/bash
# pyPhotoAlbum launcher script
# Activates the virtual environment and runs the application
SCRIPT_DIR="$SCRIPT_DIR"
VENV_DIR="$VENV_DIR"
# Activate venv and run
source "\$VENV_DIR/bin/activate"
exec python "\$SCRIPT_DIR/pyPhotoAlbum/main.py" "\$@"
EOF
chmod +x "$BIN_DIR/pyphotoalbum"
print_info "Launcher script created at $BIN_DIR/pyphotoalbum"
echo ""
# Step 5: Install desktop integration
print_step "Installing desktop integration..."
echo ""
DESKTOP_DIR="$HOME/.local/share/applications"
ICON_DIR="$HOME/.local/share/icons/hicolor"
mkdir -p "$DESKTOP_DIR"
mkdir -p "$ICON_DIR/256x256/apps"
# Create desktop file with correct path
cat > "$DESKTOP_DIR/pyphotoalbum.desktop" << EOF
[Desktop Entry]
Type=Application
Name=pyPhotoAlbum
GenericName=Photo Album Designer
Comment=Design photo albums and export them to PDF
Exec=$BIN_DIR/pyphotoalbum %F
Icon=pyphotoalbum
Terminal=false
Categories=Graphics;Photography;Qt;
Keywords=photo;album;pdf;design;layout;
MimeType=application/x-pyphotoalbum-project;
StartupNotify=true
StartupWMClass=pyPhotoAlbum
Actions=NewProject;
[Desktop Action NewProject]
Name=New Project
Exec=$BIN_DIR/pyphotoalbum --new
EOF
print_info "Desktop file created at $DESKTOP_DIR/pyphotoalbum.desktop"
# Copy icon
if [ -f "$SCRIPT_DIR/pyPhotoAlbum/icons/icon.png" ]; then
cp "$SCRIPT_DIR/pyPhotoAlbum/icons/icon.png" "$ICON_DIR/256x256/apps/pyphotoalbum.png"
print_info "Icon installed"
# Generate additional icon sizes if ImageMagick is available
if command -v convert &> /dev/null || command -v magick &> /dev/null; then
for size in 48 64 128; do
mkdir -p "$ICON_DIR/${size}x${size}/apps"
if command -v magick &> /dev/null; then
magick "$SCRIPT_DIR/pyPhotoAlbum/icons/icon.png" -resize ${size}x${size} "$ICON_DIR/${size}x${size}/apps/pyphotoalbum.png" 2>/dev/null
else
convert "$SCRIPT_DIR/pyPhotoAlbum/icons/icon.png" -resize ${size}x${size} "$ICON_DIR/${size}x${size}/apps/pyphotoalbum.png" 2>/dev/null
fi
done
print_info "Additional icon sizes generated"
fi
fi
# Update desktop database
if command -v update-desktop-database &> /dev/null; then
update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true
fi
# Update icon cache
if command -v gtk-update-icon-cache &> /dev/null; then
gtk-update-icon-cache -f "$ICON_DIR" 2>/dev/null || true
fi
echo ""
# Deactivate venv
deactivate
# Final message
echo "========================================"
echo -e "${GREEN} Installation complete!${NC}"
echo "========================================"
echo ""
echo "You can now run pyPhotoAlbum by:"
echo " 1) Running 'pyphotoalbum' in the terminal"
echo " 2) Finding 'pyPhotoAlbum' in your application menu"
echo ""
# Check if ~/.local/bin is in PATH
if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
print_warn "~/.local/bin is not in your PATH"
echo ""
echo "Add this to your ~/.bashrc or ~/.profile:"
echo " export PATH=\"\$HOME/.local/bin:\$PATH\""
echo ""
echo "Then run: source ~/.bashrc"
fi
echo ""
echo "To run directly from source directory:"
echo " $SCRIPT_DIR/launch-pyphotoalbum.sh"
echo ""
+4 -4
View File
@@ -84,19 +84,19 @@ install_package() {
case "$install_mode" in
system)
print_info "Installing pyPhotoAlbum system-wide..."
sudo pip install .
sudo pip install --upgrade .
;;
venv)
print_info "Installing pyPhotoAlbum in virtual environment..."
pip install .
pip install --upgrade .
;;
user-force)
print_info "Installing pyPhotoAlbum for current user (forcing --user)..."
pip install --user .
pip install --user --upgrade .
;;
*)
print_info "Installing pyPhotoAlbum for current user..."
pip install --user .
pip install --user --upgrade .
;;
esac
}
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
# pyPhotoAlbum launch script
# Runs the application from the project directory using the local venv
# Get the directory where this script is located
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VENV_DIR="$SCRIPT_DIR/venv"
# Check if venv exists
if [ ! -d "$VENV_DIR" ]; then
echo "Error: Virtual environment not found at $VENV_DIR"
echo "Please run install-debian.sh first to set up the environment."
exit 1
fi
# Activate venv and run the application
source "$VENV_DIR/bin/activate"
exec python "$SCRIPT_DIR/pyPhotoAlbum/main.py" "$@"
+43 -21
View File
@@ -65,14 +65,10 @@ class ElementMaximizer:
# Calculate distances between rectangles
horizontal_gap = max(
other_x - (x + w), # Other is to the right
x - (other_x + other_w) # Other is to the left
other_x - (x + w), x - (other_x + other_w) # Other is to the right # Other is to the left
)
vertical_gap = max(
other_y - (y + h), # Other is below
y - (other_y + other_h) # Other is above
)
vertical_gap = max(other_y - (y + h), y - (other_y + other_h)) # Other is below # Other is above
# If rectangles overlap or are too close in both dimensions
if horizontal_gap < self.min_gap and vertical_gap < self.min_gap:
@@ -80,8 +76,14 @@ class ElementMaximizer:
return False
def find_max_scale(self, elem_idx: int, current_scale: float, max_search_scale: float = 3.0,
tolerance: float = 0.001, max_iterations: int = 20) -> float:
def find_max_scale(
self,
elem_idx: int,
current_scale: float,
max_search_scale: float = 3.0,
tolerance: float = 0.001,
max_iterations: int = 20,
) -> float:
"""
Use binary search to find the maximum scale factor for an element.
@@ -171,8 +173,10 @@ class ElementMaximizer:
ow, oh = other.size
# Check if rectangles overlap (with min_gap consideration)
if (abs((x + w/2) - (ox + ow/2)) < (w + ow)/2 + self.min_gap and
abs((y + h/2) - (oy + oh/2)) < (h + oh)/2 + self.min_gap):
if (
abs((x + w / 2) - (ox + ow / 2)) < (w + ow) / 2 + self.min_gap
and abs((y + h / 2) - (oy + oh / 2)) < (h + oh) / 2 + self.min_gap
):
return True
return False
@@ -233,7 +237,9 @@ class ElementMaximizer:
self.center_element_horizontally(elem)
self.center_element_vertically(elem)
def maximize(self, max_iterations: int = 100, growth_rate: float = 0.05) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
def maximize(
self, max_iterations: int = 100, growth_rate: float = 0.05
) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
"""
Execute the maximization algorithm.
@@ -365,7 +371,9 @@ class AlignmentManager:
return changes
@staticmethod
def align_horizontal_center(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
def align_horizontal_center(
elements: List[BaseLayoutElement],
) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
"""
Align all elements to horizontal center.
@@ -413,7 +421,9 @@ class AlignmentManager:
return changes
@staticmethod
def make_same_size(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
def make_same_size(
elements: List[BaseLayoutElement],
) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
"""
Make all elements the same size as the first element.
@@ -435,7 +445,9 @@ class AlignmentManager:
return changes
@staticmethod
def make_same_width(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
def make_same_width(
elements: List[BaseLayoutElement],
) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
"""
Make all elements the same width as the first element.
@@ -457,7 +469,9 @@ class AlignmentManager:
return changes
@staticmethod
def make_same_height(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
def make_same_height(
elements: List[BaseLayoutElement],
) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
"""
Make all elements the same height as the first element.
@@ -479,7 +493,9 @@ class AlignmentManager:
return changes
@staticmethod
def distribute_horizontally(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
def distribute_horizontally(
elements: List[BaseLayoutElement],
) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
"""
Distribute elements evenly across horizontal span.
@@ -613,7 +629,9 @@ class AlignmentManager:
return changes
@staticmethod
def fit_to_page_width(element: BaseLayoutElement, page_width: float) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
def fit_to_page_width(
element: BaseLayoutElement, page_width: float
) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
"""
Resize element to fit page width while maintaining aspect ratio.
@@ -638,7 +656,9 @@ class AlignmentManager:
return (element, old_pos, old_size)
@staticmethod
def fit_to_page_height(element: BaseLayoutElement, page_height: float) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
def fit_to_page_height(
element: BaseLayoutElement, page_height: float
) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
"""
Resize element to fit page height while maintaining aspect ratio.
@@ -663,7 +683,9 @@ class AlignmentManager:
return (element, old_pos, old_size)
@staticmethod
def fit_to_page(element: BaseLayoutElement, page_width: float, page_height: float) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
def fit_to_page(
element: BaseLayoutElement, page_width: float, page_height: float
) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
"""
Resize element to fit within page dimensions while maintaining aspect ratio.
@@ -702,7 +724,7 @@ class AlignmentManager:
page_size: Tuple[float, float],
min_gap: float = 2.0,
max_iterations: int = 100,
growth_rate: float = 0.05
growth_rate: float = 0.05,
) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
"""
Maximize element sizes using a crystal growth algorithm.
@@ -729,7 +751,7 @@ class AlignmentManager:
element: BaseLayoutElement,
page_size: Tuple[float, float],
other_elements: List[BaseLayoutElement],
min_gap: float = 10.0
min_gap: float = 10.0,
) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
"""
Expand a single element until it is min_gap away from page edges or other elements.
+12 -8
View File
@@ -6,9 +6,16 @@ import os
import shutil
from typing import List, Dict, Set
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QListWidget, QListWidgetItem, QFileDialog, QGroupBox,
QMessageBox
QDialog,
QVBoxLayout,
QHBoxLayout,
QLabel,
QPushButton,
QListWidget,
QListWidgetItem,
QFileDialog,
QGroupBox,
QMessageBox,
)
from PyQt6.QtCore import Qt
@@ -124,10 +131,7 @@ class AssetHealDialog(QDialog):
def _add_search_path(self):
"""Add a search path"""
directory = QFileDialog.getExistingDirectory(
self,
"Select Search Path for Assets",
"",
QFileDialog.Option.ShowDirsOnly
self, "Select Search Path for Assets", "", QFileDialog.Option.ShowDirsOnly
)
if directory:
@@ -203,7 +207,7 @@ class AssetHealDialog(QDialog):
# Check if the found file needs to be imported
# (i.e., it's not already in the assets folder)
needs_import = True
if not os.path.isabs(asset_path) and asset_path.startswith('assets/'):
if not os.path.isabs(asset_path) and asset_path.startswith("assets/"):
# It's already a relative assets path, just missing from disk
# Copy it to the correct location
dest_path = os.path.join(self.project.folder_path, asset_path)
+279 -2
View File
@@ -2,12 +2,37 @@
Asset management system for pyPhotoAlbum with automatic reference counting
"""
import hashlib
import os
import shutil
from typing import Dict, Optional
from typing import Dict, List, Optional, Tuple
from pathlib import Path
def compute_file_md5(file_path: str) -> Optional[str]:
"""
Compute MD5 hash of a file.
Args:
file_path: Path to the file
Returns:
MD5 hash as hex string, or None if file doesn't exist
"""
if not os.path.exists(file_path):
return None
hash_md5 = hashlib.md5()
try:
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
hash_md5.update(chunk)
return hash_md5.hexdigest()
except Exception as e:
print(f"AssetManager: Error computing MD5 for {file_path}: {e}")
return None
class AssetManager:
"""Manages project assets with automatic reference counting and cleanup"""
@@ -21,6 +46,7 @@ class AssetManager:
self.project_folder = project_folder
self.assets_folder = os.path.join(project_folder, "assets")
self.reference_counts: Dict[str, int] = {} # {relative_path: count}
self.asset_hashes: Dict[str, str] = {} # {relative_path: md5_hash}
# Create assets folder if it doesn't exist
os.makedirs(self.assets_folder, exist_ok=True)
@@ -144,10 +170,261 @@ class AssetManager:
def serialize(self) -> Dict:
"""Serialize asset manager state"""
return {
"reference_counts": self.reference_counts
"reference_counts": self.reference_counts,
"asset_hashes": self.asset_hashes,
}
def deserialize(self, data: Dict):
"""Deserialize asset manager state"""
self.reference_counts = data.get("reference_counts", {})
self.asset_hashes = data.get("asset_hashes", {})
print(f"AssetManager: Loaded {len(self.reference_counts)} asset references")
def compute_asset_hash(self, asset_path: str) -> Optional[str]:
"""
Compute and cache the MD5 hash for an asset.
Args:
asset_path: Relative path to the asset
Returns:
MD5 hash as hex string, or None if computation fails
"""
full_path = self.get_absolute_path(asset_path)
md5_hash = compute_file_md5(full_path)
if md5_hash:
self.asset_hashes[asset_path] = md5_hash
return md5_hash
def compute_all_hashes(self) -> Dict[str, str]:
"""
Compute MD5 hashes for all assets in the assets folder.
Returns:
Dictionary mapping relative paths to MD5 hashes
"""
self.asset_hashes.clear()
if not os.path.exists(self.assets_folder):
return self.asset_hashes
for root, dirs, files in os.walk(self.assets_folder):
for filename in files:
file_path = os.path.join(root, filename)
relative_path = os.path.relpath(file_path, self.project_folder)
md5_hash = compute_file_md5(file_path)
if md5_hash:
self.asset_hashes[relative_path] = md5_hash
print(f"AssetManager: Computed hashes for {len(self.asset_hashes)} assets")
return self.asset_hashes
def find_duplicates(self) -> Dict[str, List[str]]:
"""
Find duplicate assets based on MD5 hash.
Returns:
Dictionary mapping MD5 hash to list of asset paths with that hash.
Only includes hashes with more than one file.
"""
# Compute hashes if not already done
if not self.asset_hashes:
self.compute_all_hashes()
# Group assets by hash
hash_to_paths: Dict[str, List[str]] = {}
for path, md5_hash in self.asset_hashes.items():
if md5_hash not in hash_to_paths:
hash_to_paths[md5_hash] = []
hash_to_paths[md5_hash].append(path)
# Filter to only duplicates (more than one file with same hash)
duplicates = {h: paths for h, paths in hash_to_paths.items() if len(paths) > 1}
if duplicates:
total_dups = sum(len(paths) - 1 for paths in duplicates.values())
print(f"AssetManager: Found {total_dups} duplicate files in {len(duplicates)} groups")
return duplicates
def deduplicate_assets(self, update_references_callback=None) -> Tuple[int, int]:
"""
Remove duplicate assets, keeping one canonical copy and updating references.
Args:
update_references_callback: Optional callback function that takes
(old_path, new_path) to update external references (e.g., ImageData elements)
Returns:
Tuple of (files_removed, bytes_saved)
"""
duplicates = self.find_duplicates()
if not duplicates:
print("AssetManager: No duplicates found")
return (0, 0)
files_removed = 0
bytes_saved = 0
for md5_hash, paths in duplicates.items():
# Sort paths to get consistent canonical path (first alphabetically)
paths.sort()
canonical_path = paths[0]
# Remove duplicates and update references
for dup_path in paths[1:]:
full_dup_path = self.get_absolute_path(dup_path)
# Get file size before deletion
try:
file_size = os.path.getsize(full_dup_path)
except OSError:
file_size = 0
# Update references if callback provided
if update_references_callback:
update_references_callback(dup_path, canonical_path)
# Transfer reference count to canonical path
if dup_path in self.reference_counts:
dup_refs = self.reference_counts[dup_path]
if canonical_path in self.reference_counts:
self.reference_counts[canonical_path] += dup_refs
else:
self.reference_counts[canonical_path] = dup_refs
del self.reference_counts[dup_path]
# Delete the duplicate file
try:
if os.path.exists(full_dup_path):
os.remove(full_dup_path)
files_removed += 1
bytes_saved += file_size
print(f"AssetManager: Removed duplicate {dup_path} (kept {canonical_path})")
except Exception as e:
print(f"AssetManager: Error removing duplicate {dup_path}: {e}")
# Remove from hash tracking
if dup_path in self.asset_hashes:
del self.asset_hashes[dup_path]
print(f"AssetManager: Deduplication complete - removed {files_removed} files, saved {bytes_saved} bytes")
return (files_removed, bytes_saved)
def get_duplicate_stats(self) -> Tuple[int, int, int]:
"""
Get statistics about duplicate assets without modifying anything.
Returns:
Tuple of (duplicate_groups, total_duplicate_files, estimated_bytes_to_save)
"""
duplicates = self.find_duplicates()
if not duplicates:
return (0, 0, 0)
duplicate_groups = len(duplicates)
total_duplicate_files = sum(len(paths) - 1 for paths in duplicates.values())
# Calculate bytes that would be saved
bytes_to_save = 0
for paths in duplicates.values():
# Skip the first (canonical) file, count size of the rest
for dup_path in paths[1:]:
full_path = self.get_absolute_path(dup_path)
try:
bytes_to_save += os.path.getsize(full_path)
except OSError:
pass
return (duplicate_groups, total_duplicate_files, bytes_to_save)
def find_unused_assets(self) -> List[str]:
"""
Find assets that exist in the assets folder but have no references.
Returns:
List of relative paths to unused assets
"""
unused: list[str] = []
if not os.path.exists(self.assets_folder):
return unused
# Get all files in assets folder
for root, dirs, files in os.walk(self.assets_folder):
for filename in files:
file_path = os.path.join(root, filename)
relative_path = os.path.relpath(file_path, self.project_folder)
# Check if this asset has any references
ref_count = self.reference_counts.get(relative_path, 0)
if ref_count <= 0:
unused.append(relative_path)
if unused:
print(f"AssetManager: Found {len(unused)} unused assets")
return unused
def get_unused_stats(self) -> Tuple[int, int]:
"""
Get statistics about unused assets without modifying anything.
Returns:
Tuple of (unused_file_count, total_bytes)
"""
unused = self.find_unused_assets()
if not unused:
return (0, 0)
total_bytes = 0
for asset_path in unused:
full_path = self.get_absolute_path(asset_path)
try:
total_bytes += os.path.getsize(full_path)
except OSError:
pass
return (len(unused), total_bytes)
def remove_unused_assets(self) -> Tuple[int, int]:
"""
Remove all unused assets from the assets folder.
Returns:
Tuple of (files_removed, bytes_freed)
"""
unused = self.find_unused_assets()
if not unused:
print("AssetManager: No unused assets to remove")
return (0, 0)
files_removed = 0
bytes_freed = 0
for asset_path in unused:
full_path = self.get_absolute_path(asset_path)
try:
file_size = os.path.getsize(full_path)
except OSError:
file_size = 0
try:
if os.path.exists(full_path):
os.remove(full_path)
files_removed += 1
bytes_freed += file_size
print(f"AssetManager: Removed unused asset {asset_path}")
# Clean up tracking
if asset_path in self.reference_counts:
del self.reference_counts[asset_path]
if asset_path in self.asset_hashes:
del self.asset_hashes[asset_path]
except Exception as e:
print(f"AssetManager: Error removing unused asset {asset_path}: {e}")
print(f"AssetManager: Removed {files_removed} unused assets, freed {bytes_freed} bytes")
return (files_removed, bytes_freed)
+142 -99
View File
@@ -12,9 +12,10 @@ import asyncio
import logging
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from enum import Enum
from enum import IntEnum
from pathlib import Path
from typing import Optional, Callable, Dict, Any, Tuple
from typing import Optional, Callable, Dict, Any, Tuple, Union
from concurrent.futures import Future
from collections import OrderedDict
import threading
@@ -26,18 +27,16 @@ from pyPhotoAlbum.image_utils import convert_to_rgba, resize_to_fit
logger = logging.getLogger(__name__)
class LoadPriority(Enum):
class LoadPriority(IntEnum):
"""Priority levels for load requests."""
LOW = 0 # Offscreen, not visible
NORMAL = 1 # Potentially visible soon
HIGH = 2 # Visible on screen
URGENT = 3 # User is actively interacting with
LOW = 0 # Offscreen, not visible
NORMAL = 1 # Potentially visible soon
HIGH = 2 # Visible on screen
URGENT = 3 # User is actively interacting with
def get_image_dimensions(
image_path: str,
max_size: Optional[int] = None
) -> Optional[Tuple[int, int]]:
def get_image_dimensions(image_path: str, max_size: Optional[int] = None) -> Optional[Tuple[int, int]]:
"""
Extract image dimensions without loading the full image.
@@ -78,6 +77,7 @@ def get_image_dimensions(
@dataclass(order=True)
class LoadRequest:
"""Request to load and process an image."""
priority: LoadPriority = field(compare=True)
request_id: int = field(compare=True) # Tie-breaker for same priority
path: Path = field(compare=False)
@@ -111,7 +111,7 @@ class ImageCache:
"""Estimate memory size of PIL image in bytes."""
# PIL images are typically width * height * bytes_per_pixel
# RGBA = 4 bytes, RGB = 3 bytes, L = 1 byte
mode_sizes = {'RGBA': 4, 'RGB': 3, 'L': 1, 'LA': 2}
mode_sizes = {"RGBA": 4, "RGB": 3, "L": 1, "LA": 2}
bytes_per_pixel = mode_sizes.get(img.mode, 4)
return img.width * img.height * bytes_per_pixel
@@ -164,8 +164,7 @@ class ImageCache:
self.current_memory_bytes -= old_size
# Evict LRU items if needed
while (self.current_memory_bytes + img_size > self.max_memory_bytes
and len(self._cache) > 0):
while self.current_memory_bytes + img_size > self.max_memory_bytes and len(self._cache) > 0:
evicted_key, (evicted_img, evicted_size) = self._cache.popitem(last=False)
self.current_memory_bytes -= evicted_size
logger.debug(f"Cache EVICT: {evicted_key} ({evicted_size / 1024 / 1024:.1f}MB)")
@@ -174,10 +173,12 @@ class ImageCache:
self._cache[key] = (img.copy(), img_size)
self.current_memory_bytes += img_size
logger.debug(f"Cache PUT: {key} ({img_size / 1024 / 1024:.1f}MB) "
f"[Total: {self.current_memory_bytes / 1024 / 1024:.1f}MB / "
f"{self.max_memory_bytes / 1024 / 1024:.1f}MB, "
f"Items: {len(self._cache)}]")
logger.debug(
f"Cache PUT: {key} ({img_size / 1024 / 1024:.1f}MB) "
f"[Total: {self.current_memory_bytes / 1024 / 1024:.1f}MB / "
f"{self.max_memory_bytes / 1024 / 1024:.1f}MB, "
f"Items: {len(self._cache)}]"
)
def clear(self):
"""Clear entire cache."""
@@ -190,10 +191,10 @@ class ImageCache:
"""Get cache statistics."""
with self._lock:
return {
'items': len(self._cache),
'memory_mb': self.current_memory_bytes / 1024 / 1024,
'max_memory_mb': self.max_memory_bytes / 1024 / 1024,
'utilization': (self.current_memory_bytes / self.max_memory_bytes) * 100
"items": len(self._cache),
"memory_mb": self.current_memory_bytes / 1024 / 1024,
"max_memory_mb": self.max_memory_bytes / 1024 / 1024,
"utilization": (self.current_memory_bytes / self.max_memory_bytes) * 100,
}
@@ -213,7 +214,7 @@ class AsyncImageLoader(QObject):
# Signals for Qt integration
image_loaded = pyqtSignal(object, object, object) # (path, image, user_data)
load_failed = pyqtSignal(object, str, object) # (path, error_msg, user_data)
load_failed = pyqtSignal(object, str, object) # (path, error_msg, user_data)
def __init__(self, cache: Optional[ImageCache] = None, max_workers: int = 4):
"""
@@ -227,11 +228,10 @@ class AsyncImageLoader(QObject):
self.cache = cache or ImageCache()
self.max_workers = max_workers
self.executor = ThreadPoolExecutor(max_workers=max_workers,
thread_name_prefix="ImageLoader")
self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="ImageLoader")
# Priority queue and tracking
self._queue: asyncio.PriorityQueue = None # Created when event loop starts
self._queue: Optional[asyncio.PriorityQueue[Any]] = None # Created when event loop starts
self._pending_requests: Dict[Path, LoadRequest] = {}
self._active_tasks: Dict[Path, asyncio.Task] = {}
self._next_request_id = 0
@@ -251,9 +251,9 @@ class AsyncImageLoader(QObject):
return
self._shutdown = False
self._loop_thread = threading.Thread(target=self._run_event_loop,
daemon=True,
name="AsyncImageLoader-EventLoop")
self._loop_thread = threading.Thread(
target=self._run_event_loop, daemon=True, name="AsyncImageLoader-EventLoop"
)
self._loop_thread.start()
logger.info("AsyncImageLoader event loop started")
@@ -265,9 +265,14 @@ class AsyncImageLoader(QObject):
logger.info("Stopping AsyncImageLoader...")
self._shutdown = True
# Cancel all active tasks
# Cancel all active tasks and wait for them to finish
if self._loop and not self._loop.is_closed():
asyncio.run_coroutine_threadsafe(self._cancel_all_tasks(), self._loop)
future = asyncio.run_coroutine_threadsafe(self._cancel_all_tasks(), self._loop)
try:
# Wait for cancellation to complete with timeout
future.result(timeout=2.0)
except Exception as e:
logger.warning(f"Error during task cancellation: {e}")
# Stop the event loop
self._loop.call_soon_threadsafe(self._loop.stop)
@@ -345,6 +350,10 @@ class AsyncImageLoader(QObject):
target_size = request.target_size
try:
# Check if shutting down
if self._shutdown:
return
# Check cache first
cached_img = self.cache.get(path, target_size)
if cached_img is not None:
@@ -354,12 +363,11 @@ class AsyncImageLoader(QObject):
# Load in thread pool (I/O bound)
loop = asyncio.get_event_loop()
img = await loop.run_in_executor(
self.executor,
self._load_and_process_image,
path,
target_size
)
img = await loop.run_in_executor(self.executor, self._load_and_process_image, path, target_size)
# Check again if shutting down before emitting
if self._shutdown:
return
# Cache result
self.cache.put(path, img, target_size)
@@ -369,9 +377,16 @@ class AsyncImageLoader(QObject):
logger.debug(f"Loaded: {path} (size: {img.size})")
except asyncio.CancelledError:
# Task was cancelled during shutdown - this is expected
logger.debug(f"Load cancelled for {path}")
raise # Re-raise to properly cancel the task
except Exception as e:
logger.error(f"Failed to load {path}: {e}", exc_info=True)
self._emit_failed(path, str(e), request.user_data)
# Only emit error if not shutting down
if not self._shutdown:
logger.error(f"Failed to load {path}: {e}", exc_info=True)
self._emit_failed(path, str(e), request.user_data)
finally:
# Cleanup tracking
@@ -379,7 +394,7 @@ class AsyncImageLoader(QObject):
self._pending_requests.pop(path, None)
self._active_tasks.pop(path, None)
def _load_and_process_image(self, path: Path, target_size: Optional[Tuple[int, int]]) -> Image.Image:
def _load_and_process_image(self, path: Path, target_size: Optional[Tuple[int, int]]) -> "Image.Image":
"""
Load image from disk and process (runs in thread pool).
@@ -390,7 +405,7 @@ class AsyncImageLoader(QObject):
Returns:
Processed PIL Image
"""
img = Image.open(path)
img: Image.Image = Image.open(path)
img = convert_to_rgba(img)
# Downsample if target size specified (preserving aspect ratio)
@@ -405,17 +420,33 @@ class AsyncImageLoader(QObject):
def _emit_loaded(self, path: Path, img: Image.Image, user_data: Any):
"""Emit image_loaded signal (thread-safe)."""
self.image_loaded.emit(path, img, user_data)
# Check if object is still valid before emitting
if self._shutdown:
return
try:
self.image_loaded.emit(path, img, user_data)
except RuntimeError as e:
# Object was deleted - log but don't crash
logger.debug(f"Could not emit image_loaded for {path}: {e}")
def _emit_failed(self, path: Path, error_msg: str, user_data: Any):
"""Emit load_failed signal (thread-safe)."""
self.load_failed.emit(path, error_msg, user_data)
# Check if object is still valid before emitting
if self._shutdown:
return
try:
self.load_failed.emit(path, error_msg, user_data)
except RuntimeError as e:
# Object was deleted - log but don't crash
logger.debug(f"Could not emit load_failed for {path}: {e}")
def request_load(self,
path: Path,
priority: LoadPriority = LoadPriority.NORMAL,
target_size: Optional[Tuple[int, int]] = None,
user_data: Any = None) -> bool:
def request_load(
self,
path: Path,
priority: LoadPriority = LoadPriority.NORMAL,
target_size: Optional[Tuple[int, int]] = None,
user_data: Any = None,
) -> bool:
"""
Request image load with specified priority.
@@ -446,7 +477,7 @@ class AsyncImageLoader(QObject):
request_id=self._next_request_id,
path=path,
target_size=target_size,
user_data=user_data
user_data=user_data,
)
self._next_request_id += 1
@@ -454,10 +485,8 @@ class AsyncImageLoader(QObject):
self._pending_requests[path] = request
# Submit to queue (thread-safe)
asyncio.run_coroutine_threadsafe(
self._queue.put(request),
self._loop
)
assert self._queue is not None
asyncio.run_coroutine_threadsafe(self._queue.put(request), self._loop)
logger.debug(f"Queued load: {path} (priority: {priority.name})")
return True
@@ -494,9 +523,9 @@ class AsyncImageLoader(QObject):
"""Get loader statistics."""
with self._lock:
return {
'pending': len(self._pending_requests),
'active': len(self._active_tasks),
'cache': self.cache.get_stats()
"pending": len(self._pending_requests),
"active": len(self._active_tasks),
"cache": self.cache.get_stats(),
}
@@ -517,8 +546,8 @@ class AsyncPDFGenerator(QObject):
# Signals for Qt integration
progress_updated = pyqtSignal(int, int, str) # (current, total, message)
export_complete = pyqtSignal(bool, list) # (success, warnings)
export_failed = pyqtSignal(str) # (error_message)
export_complete = pyqtSignal(bool, list) # (success, warnings)
export_failed = pyqtSignal(str) # (error_message)
def __init__(self, image_cache: Optional[ImageCache] = None, max_workers: int = 2):
"""
@@ -532,13 +561,12 @@ class AsyncPDFGenerator(QObject):
self.image_cache = image_cache or ImageCache()
self.max_workers = max_workers
self.executor = ThreadPoolExecutor(max_workers=max_workers,
thread_name_prefix="PDFGenerator")
self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="PDFGenerator")
# Export state
self._current_export: Optional[asyncio.Task] = None
self._current_export: Optional[Future[Any]] = None
self._cancel_requested = False
self._lock = threading.Lock()
self._lock = threading.RLock() # Use RLock to allow re-entrant locking
self._shutdown = False
# Event loop for async operations
@@ -554,9 +582,9 @@ class AsyncPDFGenerator(QObject):
return
self._shutdown = False
self._loop_thread = threading.Thread(target=self._run_event_loop,
daemon=True,
name="AsyncPDFGenerator-EventLoop")
self._loop_thread = threading.Thread(
target=self._run_event_loop, daemon=True, name="AsyncPDFGenerator-EventLoop"
)
self._loop_thread.start()
logger.info("AsyncPDFGenerator event loop started")
@@ -621,8 +649,7 @@ class AsyncPDFGenerator(QObject):
# Submit export task
self._current_export = asyncio.run_coroutine_threadsafe(
self._export_pdf_async(project, output_path, export_dpi),
self._loop
self._export_pdf_async(project, output_path, export_dpi), self._loop
)
logger.info(f"PDF export started: {output_path}")
@@ -654,41 +681,53 @@ class AsyncPDFGenerator(QObject):
# Progress callback wrapper
def progress_callback(current, total, message):
if self._cancel_requested:
if self._cancel_requested or self._shutdown:
return False # Signal cancellation
self.progress_updated.emit(current, total, message)
try:
self.progress_updated.emit(current, total, message)
except RuntimeError as e:
# Object was deleted - log but don't crash
logger.debug(f"Could not emit progress_updated: {e}")
return False
return True
# Run export in thread pool
loop = asyncio.get_event_loop()
success, warnings = await loop.run_in_executor(
self.executor,
self._export_with_cache,
exporter,
output_path,
progress_callback
self.executor, self._export_with_cache, exporter, output_path, progress_callback
)
# Emit completion signal
if not self._cancel_requested:
self.export_complete.emit(success, warnings)
logger.info(f"PDF export completed: {output_path} (warnings: {len(warnings)})")
if not self._cancel_requested and not self._shutdown:
try:
self.export_complete.emit(success, warnings)
logger.info(f"PDF export completed: {output_path} (warnings: {len(warnings)})")
except RuntimeError as e:
logger.debug(f"Could not emit export_complete: {e}")
else:
logger.info("PDF export cancelled")
except asyncio.CancelledError:
logger.info("PDF export cancelled by user")
self.export_failed.emit("Export cancelled")
if not self._shutdown:
try:
self.export_failed.emit("Export cancelled")
except RuntimeError as e:
logger.debug(f"Could not emit export_failed: {e}")
except Exception as e:
logger.error(f"PDF export failed: {e}", exc_info=True)
self.export_failed.emit(str(e))
if not self._shutdown:
try:
self.export_failed.emit(str(e))
except RuntimeError as e:
logger.debug(f"Could not emit export_failed: {e}")
finally:
with self._lock:
self._current_export = None
def _export_with_cache(self, exporter, output_path: str, progress_callback) -> Tuple[bool, list]:
def _export_with_cache(self, exporter: Any, output_path: str, progress_callback: Any) -> Tuple[bool, list[Any]]:
"""
Run PDF export with image cache integration.
@@ -707,23 +746,31 @@ class AsyncPDFGenerator(QObject):
# Patch Image.open to use cache
def cached_open(path, *args, **kwargs):
# Try cache first
# Note: We cache the unrotated image so rotation can be applied per-element
cached_img = self.image_cache.get(Path(path))
if cached_img:
logger.debug(f"PDF using cached image: {path}")
return cached_img
# Only use cache for file paths, not BytesIO or other file-like objects
is_file_path = isinstance(path, (str, Path))
# Load and cache (unrotated - rotation is applied per-element)
img = original_open(path, *args, **kwargs)
img = convert_to_rgba(img)
self.image_cache.put(Path(path), img)
return img
if is_file_path:
# Try cache first
# Note: We cache the unrotated image so rotation can be applied per-element
path_obj = Path(path) if isinstance(path, str) else path
cached_img = self.image_cache.get(path_obj)
if cached_img:
logger.debug(f"PDF using cached image: {path}")
return cached_img
# Load and cache (unrotated - rotation is applied per-element)
img = original_open(path, *args, **kwargs)
img = convert_to_rgba(img)
self.image_cache.put(path_obj, img)
return img
else:
# For BytesIO and other file-like objects, just use original open
return original_open(path, *args, **kwargs)
# Temporarily patch Image.open
try:
Image.open = cached_open
return exporter.export(output_path, progress_callback)
Image.open = cached_open # type: ignore[assignment]
return exporter.export(output_path, progress_callback) # type: ignore[no-any-return]
finally:
# Restore original
Image.open = original_open
@@ -731,13 +778,9 @@ class AsyncPDFGenerator(QObject):
def is_exporting(self) -> bool:
"""Check if export is currently in progress."""
with self._lock:
return (self._current_export is not None
and not self._current_export.done())
return self._current_export is not None and not self._current_export.done()
def get_stats(self) -> Dict[str, Any]:
"""Get generator statistics."""
with self._lock:
return {
'exporting': self.is_exporting(),
'cache': self.image_cache.get_stats()
}
return {"exporting": self.is_exporting(), "cache": self.image_cache.get_stats()}
+14 -17
View File
@@ -18,7 +18,7 @@ from pyPhotoAlbum.version_manager import (
CURRENT_DATA_VERSION,
check_version_compatibility,
VersionCompatibility,
DataMigration
DataMigration,
)
@@ -78,7 +78,7 @@ class AsyncProjectLoader(QThread):
self.progress_updated.emit(10, 100, "Extracting project files...")
# Extract ZIP contents with progress
with zipfile.ZipFile(self.zip_path, 'r') as zipf:
with zipfile.ZipFile(self.zip_path, "r") as zipf:
file_list = zipf.namelist()
total_files = len(file_list)
@@ -91,10 +91,7 @@ class AsyncProjectLoader(QThread):
# Update progress every 10 files or on last file
if i % 10 == 0 or i == total_files - 1:
progress = 10 + int((i / total_files) * 30) # 10-40%
self.progress_updated.emit(
progress, 100,
f"Extracting files... ({i + 1}/{total_files})"
)
self.progress_updated.emit(progress, 100, f"Extracting files... ({i + 1}/{total_files})")
if self._cancelled:
return
@@ -103,12 +100,12 @@ class AsyncProjectLoader(QThread):
self.progress_updated.emit(45, 100, "Loading project data...")
# Load project.json
project_json_path = os.path.join(extract_to, 'project.json')
project_json_path = os.path.join(extract_to, "project.json")
if not os.path.exists(project_json_path):
self.load_failed.emit("Invalid project file: project.json not found")
return
with open(project_json_path, 'r') as f:
with open(project_json_path, "r") as f:
project_data = json.load(f)
if self._cancelled:
@@ -118,7 +115,7 @@ class AsyncProjectLoader(QThread):
self.progress_updated.emit(55, 100, "Checking version compatibility...")
# Check version compatibility
file_version = project_data.get('data_version', project_data.get('serialization_version', '1.0'))
file_version = project_data.get("data_version", project_data.get("serialization_version", "1.0"))
is_compatible, error_msg = check_version_compatibility(file_version, self.zip_path)
if not is_compatible:
@@ -141,7 +138,7 @@ class AsyncProjectLoader(QThread):
self.progress_updated.emit(70, 100, "Creating project...")
# Create new project
project_name = project_data.get('name', 'Untitled Project')
project_name = project_data.get("name", "Untitled Project")
project = Project(name=project_name, folder_path=extract_to)
# Deserialize project data
@@ -197,14 +194,14 @@ class AsyncProjectLoader(QThread):
original_path = element.image_path
# Skip if already a simple relative path
if not os.path.isabs(original_path) and not original_path.startswith('./projects/'):
if not os.path.isabs(original_path) and not original_path.startswith("./projects/"):
continue
# Pattern 1: "./projects/XXX/assets/filename.jpg" -> "assets/filename.jpg"
if '/assets/' in original_path:
parts = original_path.split('/assets/')
if "/assets/" in original_path:
parts = original_path.split("/assets/")
if len(parts) == 2:
new_path = os.path.join('assets', parts[1])
new_path = os.path.join("assets", parts[1])
element.image_path = new_path
normalized_count += 1
continue
@@ -222,9 +219,9 @@ class AsyncProjectLoader(QThread):
print(f"Normalized {normalized_count} asset paths")
def load_from_zip_async(zip_path: str, extract_to: Optional[str] = None,
progress_callback=None, complete_callback=None,
error_callback=None) -> AsyncProjectLoader:
def load_from_zip_async(
zip_path: str, extract_to: Optional[str] = None, progress_callback=None, complete_callback=None, error_callback=None
) -> AsyncProjectLoader:
"""
Load a project from a ZIP file asynchronously.
+13 -13
View File
@@ -10,7 +10,7 @@ import json
import shutil
from pathlib import Path
from datetime import datetime, timedelta
from typing import Optional, List, Tuple
from typing import Dict, Optional, List, Tuple
from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip
@@ -86,11 +86,11 @@ class AutosaveManager:
"project_name": project.name,
"timestamp": datetime.now().isoformat(),
"checkpoint_path": str(checkpoint_path),
"original_path": getattr(project, 'file_path', None),
"original_path": getattr(project, "file_path", None),
}
metadata_path = checkpoint_path.with_suffix('.json')
with open(metadata_path, 'w') as f:
metadata_path = checkpoint_path.with_suffix(".json")
with open(metadata_path, "w") as f:
json.dump(metadata, f, indent=2)
def list_checkpoints(self, project_name: Optional[str] = None) -> List[Tuple[Path, dict]]:
@@ -106,23 +106,23 @@ class AutosaveManager:
checkpoints = []
for checkpoint_file in self.CHECKPOINT_DIR.glob(f"{self.CHECKPOINT_PREFIX}*{self.CHECKPOINT_EXTENSION}"):
metadata_file = checkpoint_file.with_suffix('.json')
metadata_file = checkpoint_file.with_suffix(".json")
# Try to load metadata
metadata = {}
if metadata_file.exists():
try:
with open(metadata_file, 'r') as f:
with open(metadata_file, "r") as f:
metadata = json.load(f)
except:
pass
# Filter by project name if specified
if project_name is None or metadata.get('project_name') == project_name:
if project_name is None or metadata.get("project_name") == project_name:
checkpoints.append((checkpoint_file, metadata))
# Sort by timestamp (newest first)
checkpoints.sort(key=lambda x: x[1].get('timestamp', ''), reverse=True)
checkpoints.sort(key=lambda x: x[1].get("timestamp", ""), reverse=True)
return checkpoints
def load_checkpoint(self, checkpoint_path: Path):
@@ -157,7 +157,7 @@ class AutosaveManager:
checkpoint_path.unlink()
# Delete metadata file
metadata_path = checkpoint_path.with_suffix('.json')
metadata_path = checkpoint_path.with_suffix(".json")
if metadata_path.exists():
metadata_path.unlink()
@@ -186,11 +186,11 @@ class AutosaveManager:
max_count: Maximum number of checkpoints to keep per project
"""
now = datetime.now()
checkpoints_by_project = {}
checkpoints_by_project: Dict[str, List[Tuple[Path, dict]]] = {}
# Group checkpoints by project
for checkpoint_path, metadata in self.list_checkpoints():
project_name = metadata.get('project_name', 'unknown')
project_name = metadata.get("project_name", "unknown")
if project_name not in checkpoints_by_project:
checkpoints_by_project[project_name] = []
checkpoints_by_project[project_name].append((checkpoint_path, metadata))
@@ -198,11 +198,11 @@ class AutosaveManager:
# Clean up each project's checkpoints
for project_name, checkpoints in checkpoints_by_project.items():
# Sort by timestamp (newest first)
checkpoints.sort(key=lambda x: x[1].get('timestamp', ''), reverse=True)
checkpoints.sort(key=lambda x: x[1].get("timestamp", ""), reverse=True)
for idx, (checkpoint_path, metadata) in enumerate(checkpoints):
# Delete if too old
timestamp_str = metadata.get('timestamp')
timestamp_str = metadata.get("timestamp")
if timestamp_str:
try:
timestamp = datetime.fromisoformat(timestamp_str)
+112 -188
View File
@@ -27,6 +27,35 @@ def _normalize_asset_path(image_path: str, asset_manager) -> str:
return image_path
def _deserialize_element(elem_data: Dict[str, Any]) -> BaseLayoutElement:
"""
Deserialize element data into the appropriate element type.
Args:
elem_data: Dictionary containing serialized element data with 'type' key
Returns:
Deserialized element instance (ImageData, PlaceholderData, or TextBoxData)
Raises:
ValueError: If element type is unknown
"""
elem_type = elem_data.get("type")
element: BaseLayoutElement
if elem_type == "image":
element = ImageData()
elif elem_type == "placeholder":
element = PlaceholderData()
elif elem_type == "textbox":
element = TextBoxData()
else:
raise ValueError(f"Unknown element type: {elem_type}")
element.deserialize(elem_data)
return element
class Command(ABC):
"""Abstract base class for all commands"""
@@ -52,7 +81,7 @@ class Command(ABC):
@staticmethod
@abstractmethod
def deserialize(data: Dict[str, Any], project) -> 'Command':
def deserialize(data: Dict[str, Any], project) -> "Command":
"""Deserialize command from dictionary"""
pass
@@ -89,32 +118,13 @@ class AddElementCommand(Command):
def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary"""
return {
"type": "add_element",
"element": self.element.serialize(),
"executed": self.executed
}
return {"type": "add_element", "element": self.element.serialize(), "executed": self.executed}
@staticmethod
def deserialize(data: Dict[str, Any], project) -> 'AddElementCommand':
def deserialize(data: Dict[str, Any], project) -> "AddElementCommand":
"""Deserialize from dictionary"""
# Reconstruct element from serialized data
elem_data = data["element"]
elem_type = elem_data.get("type")
if elem_type == "image":
element = ImageData()
elif elem_type == "placeholder":
element = PlaceholderData()
elif elem_type == "textbox":
element = TextBoxData()
else:
raise ValueError(f"Unknown element type: {elem_type}")
element.deserialize(elem_data)
# Note: We need to find the correct page_layout
# This will be handled by the CommandHistory deserializer
element = _deserialize_element(data["element"])
# Note: page_layout will be handled by the CommandHistory deserializer
cmd = AddElementCommand(None, element)
cmd.executed = data.get("executed", False)
return cmd
@@ -152,29 +162,12 @@ class DeleteElementCommand(Command):
def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary"""
return {
"type": "delete_element",
"element": self.element.serialize(),
"executed": self.executed
}
return {"type": "delete_element", "element": self.element.serialize(), "executed": self.executed}
@staticmethod
def deserialize(data: Dict[str, Any], project) -> 'DeleteElementCommand':
def deserialize(data: Dict[str, Any], project) -> "DeleteElementCommand":
"""Deserialize from dictionary"""
elem_data = data["element"]
elem_type = elem_data.get("type")
if elem_type == "image":
element = ImageData()
elif elem_type == "placeholder":
element = PlaceholderData()
elif elem_type == "textbox":
element = TextBoxData()
else:
raise ValueError(f"Unknown element type: {elem_type}")
element.deserialize(elem_data)
element = _deserialize_element(data["element"])
cmd = DeleteElementCommand(None, element)
cmd.executed = data.get("executed", False)
return cmd
@@ -206,38 +199,22 @@ class MoveElementCommand(Command):
"type": "move_element",
"element": self.element.serialize(),
"old_position": self.old_position,
"new_position": self.new_position
"new_position": self.new_position,
}
@staticmethod
def deserialize(data: Dict[str, Any], project) -> 'MoveElementCommand':
def deserialize(data: Dict[str, Any], project) -> "MoveElementCommand":
"""Deserialize from dictionary"""
elem_data = data["element"]
elem_type = elem_data.get("type")
if elem_type == "image":
element = ImageData()
elif elem_type == "placeholder":
element = PlaceholderData()
elif elem_type == "textbox":
element = TextBoxData()
else:
raise ValueError(f"Unknown element type: {elem_type}")
element.deserialize(elem_data)
return MoveElementCommand(
element,
tuple(data["old_position"]),
tuple(data["new_position"])
)
element = _deserialize_element(data["element"])
return MoveElementCommand(element, tuple(data["old_position"]), tuple(data["new_position"]))
class ResizeElementCommand(Command):
"""Command for resizing an element"""
def __init__(self, element: BaseLayoutElement, old_position: tuple, old_size: tuple,
new_position: tuple, new_size: tuple):
def __init__(
self, element: BaseLayoutElement, old_position: tuple, old_size: tuple, new_position: tuple, new_size: tuple
):
self.element = element
self.old_position = old_position
self.old_size = old_size
@@ -266,32 +243,19 @@ class ResizeElementCommand(Command):
"old_position": self.old_position,
"old_size": self.old_size,
"new_position": self.new_position,
"new_size": self.new_size
"new_size": self.new_size,
}
@staticmethod
def deserialize(data: Dict[str, Any], project) -> 'ResizeElementCommand':
def deserialize(data: Dict[str, Any], project) -> "ResizeElementCommand":
"""Deserialize from dictionary"""
elem_data = data["element"]
elem_type = elem_data.get("type")
if elem_type == "image":
element = ImageData()
elif elem_type == "placeholder":
element = PlaceholderData()
elif elem_type == "textbox":
element = TextBoxData()
else:
raise ValueError(f"Unknown element type: {elem_type}")
element.deserialize(elem_data)
element = _deserialize_element(data["element"])
return ResizeElementCommand(
element,
tuple(data["old_position"]),
tuple(data["old_size"]),
tuple(data["new_position"]),
tuple(data["new_size"])
tuple(data["new_size"]),
)
@@ -308,7 +272,7 @@ class RotateElementCommand(Command):
self.old_size = element.size
# For ImageData, store the old PIL rotation state
if hasattr(element, 'pil_rotation_90'):
if hasattr(element, "pil_rotation_90"):
self.old_pil_rotation = element.pil_rotation_90
else:
self.old_pil_rotation = None
@@ -344,9 +308,9 @@ class RotateElementCommand(Command):
self.element.position = (center_x - h / 2, center_y - w / 2)
# Clear the texture so it will be reloaded with the new rotation
if hasattr(self.element, '_texture_id'):
if hasattr(self.element, "_texture_id"):
del self.element._texture_id
if hasattr(self.element, '_async_load_requested'):
if hasattr(self.element, "_async_load_requested"):
self.element._async_load_requested = False
# Keep visual rotation at 0
@@ -376,7 +340,7 @@ class RotateElementCommand(Command):
# For ImageData, restore PIL rotation and clear texture
if isinstance(self.element, ImageData) and self.old_pil_rotation is not None:
self.element.pil_rotation_90 = self.old_pil_rotation
if hasattr(self.element, '_texture_id'):
if hasattr(self.element, "_texture_id"):
self.element._texture_id = None
self.element._async_load_requested = False
@@ -390,31 +354,14 @@ class RotateElementCommand(Command):
"type": "rotate_element",
"element": self.element.serialize(),
"old_rotation": self.old_rotation,
"new_rotation": self.new_rotation
"new_rotation": self.new_rotation,
}
@staticmethod
def deserialize(data: Dict[str, Any], project) -> 'RotateElementCommand':
def deserialize(data: Dict[str, Any], project) -> "RotateElementCommand":
"""Deserialize from dictionary"""
elem_data = data["element"]
elem_type = elem_data.get("type")
if elem_type == "image":
element = ImageData()
elif elem_type == "placeholder":
element = PlaceholderData()
elif elem_type == "textbox":
element = TextBoxData()
else:
raise ValueError(f"Unknown element type: {elem_type}")
element.deserialize(elem_data)
return RotateElementCommand(
element,
data["old_rotation"],
data["new_rotation"]
)
element = _deserialize_element(data["element"])
return RotateElementCommand(element, data["old_rotation"], data["new_rotation"])
class AdjustImageCropCommand(Command):
@@ -443,21 +390,17 @@ class AdjustImageCropCommand(Command):
"type": "adjust_image_crop",
"element": self.element.serialize(),
"old_crop_info": self.old_crop_info,
"new_crop_info": self.new_crop_info
"new_crop_info": self.new_crop_info,
}
@staticmethod
def deserialize(data: Dict[str, Any], project) -> 'AdjustImageCropCommand':
def deserialize(data: Dict[str, Any], project) -> "AdjustImageCropCommand":
"""Deserialize from dictionary"""
elem_data = data["element"]
element = ImageData()
element.deserialize(elem_data)
return AdjustImageCropCommand(
element,
tuple(data["old_crop_info"]),
tuple(data["new_crop_info"])
)
return AdjustImageCropCommand(element, tuple(data["old_crop_info"]), tuple(data["new_crop_info"]))
class AlignElementsCommand(Command):
@@ -493,36 +436,20 @@ class AlignElementsCommand(Command):
"""Serialize to dictionary"""
return {
"type": "align_elements",
"changes": [
{
"element": elem.serialize(),
"old_position": old_pos
}
for elem, old_pos in self.changes
]
"changes": [{"element": elem.serialize(), "old_position": old_pos} for elem, old_pos in self.changes],
}
@staticmethod
def deserialize(data: Dict[str, Any], project) -> 'AlignElementsCommand':
def deserialize(data: Dict[str, Any], project) -> "AlignElementsCommand":
"""Deserialize from dictionary"""
changes = []
for change_data in data.get("changes", []):
elem_data = change_data["element"]
elem_type = elem_data.get("type")
if elem_type == "image":
element = ImageData()
elif elem_type == "placeholder":
element = PlaceholderData()
elif elem_type == "textbox":
element = TextBoxData()
else:
try:
element = _deserialize_element(change_data["element"])
old_position = tuple(change_data["old_position"])
changes.append((element, old_position))
except ValueError:
continue
element.deserialize(elem_data)
old_position = tuple(change_data["old_position"])
changes.append((element, old_position))
return AlignElementsCommand(changes)
@@ -558,37 +485,23 @@ class ResizeElementsCommand(Command):
return {
"type": "resize_elements",
"changes": [
{
"element": elem.serialize(),
"old_position": old_pos,
"old_size": old_size
}
{"element": elem.serialize(), "old_position": old_pos, "old_size": old_size}
for elem, old_pos, old_size in self.changes
]
],
}
@staticmethod
def deserialize(data: Dict[str, Any], project) -> 'ResizeElementsCommand':
def deserialize(data: Dict[str, Any], project) -> "ResizeElementsCommand":
"""Deserialize from dictionary"""
changes = []
for change_data in data.get("changes", []):
elem_data = change_data["element"]
elem_type = elem_data.get("type")
if elem_type == "image":
element = ImageData()
elif elem_type == "placeholder":
element = PlaceholderData()
elif elem_type == "textbox":
element = TextBoxData()
else:
try:
element = _deserialize_element(change_data["element"])
old_position = tuple(change_data["old_position"])
old_size = tuple(change_data["old_size"])
changes.append((element, old_position, old_size))
except ValueError:
continue
element.deserialize(elem_data)
old_position = tuple(change_data["old_position"])
old_size = tuple(change_data["old_size"])
changes.append((element, old_position, old_size))
return ResizeElementsCommand(changes)
@@ -625,31 +538,15 @@ class ChangeZOrderCommand(Command):
"type": "change_zorder",
"element": self.element.serialize(),
"old_index": self.old_index,
"new_index": self.new_index
"new_index": self.new_index,
}
@staticmethod
def deserialize(data: Dict[str, Any], project) -> 'ChangeZOrderCommand':
def deserialize(data: Dict[str, Any], project) -> "ChangeZOrderCommand":
"""Deserialize from dictionary"""
elem_data = data["element"]
elem_type = elem_data.get("type")
if elem_type == "image":
element = ImageData()
elif elem_type == "placeholder":
element = PlaceholderData()
elif elem_type == "textbox":
element = TextBoxData()
else:
raise ValueError(f"Unknown element type: {elem_type}")
element.deserialize(elem_data)
element = _deserialize_element(data["element"])
return ChangeZOrderCommand(
None, # page_layout will be set by CommandHistory
element,
data["old_index"],
data["new_index"]
None, element, data["old_index"], data["new_index"] # page_layout will be set by CommandHistory
)
@@ -691,13 +588,10 @@ class StateChangeCommand(Command):
"""Serialize to dictionary"""
# For now, state change commands are not serialized
# This could be enhanced later if needed
return {
"type": "state_change",
"description": self.description
}
return {"type": "state_change", "description": self.description}
@staticmethod
def deserialize(data: Dict[str, Any], project) -> 'StateChangeCommand':
def deserialize(data: Dict[str, Any], project) -> "StateChangeCommand":
"""Deserialize from dictionary"""
# Not implemented - would need to serialize state
raise NotImplementedError("StateChangeCommand deserialization not yet supported")
@@ -801,7 +695,7 @@ class CommandHistory:
return {
"undo_stack": [cmd.serialize() for cmd in self.undo_stack],
"redo_stack": [cmd.serialize() for cmd in self.redo_stack],
"max_history": self.max_history
"max_history": self.max_history,
}
def deserialize(self, data: Dict[str, Any], project):
@@ -813,6 +707,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
@@ -820,8 +716,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,
@@ -838,6 +760,8 @@ class CommandHistory:
def _deserialize_command(self, data: Dict[str, Any], project) -> Optional[Command]:
"""Deserialize a single command using registry pattern"""
cmd_type = data.get("type")
if cmd_type is None:
return None
deserializer = self._COMMAND_DESERIALIZERS.get(cmd_type)
if not deserializer:
+45 -49
View File
@@ -4,7 +4,7 @@ Decorator system for pyPhotoAlbum ribbon UI
import copy
from functools import wraps
from typing import Optional, Callable
from typing import Any, Optional, Callable
class RibbonAction:
@@ -37,7 +37,7 @@ class RibbonAction:
shortcut: Optional[str] = None,
requires_page: bool = False,
requires_selection: bool = False,
min_selection: int = 0
min_selection: int = 0,
):
"""
Initialize the ribbon action decorator.
@@ -73,22 +73,23 @@ class RibbonAction:
Returns:
The decorated function with metadata attached
"""
@wraps(func)
def wrapper(*args, **kwargs):
def wrapper(*args: Any, **kwargs: Any) -> Any:
return func(*args, **kwargs)
# Store metadata on wrapper function
wrapper._ribbon_action = {
'label': self.label,
'tooltip': self.tooltip,
'tab': self.tab,
'group': self.group,
'icon': self.icon,
'shortcut': self.shortcut,
'action': func.__name__,
'requires_page': self.requires_page,
'requires_selection': self.requires_selection,
'min_selection': self.min_selection
wrapper._ribbon_action = { # type: ignore[attr-defined]
"label": self.label,
"tooltip": self.tooltip,
"tab": self.tab,
"group": self.group,
"icon": self.icon,
"shortcut": self.shortcut,
"action": func.__name__,
"requires_page": self.requires_page,
"requires_selection": self.requires_selection,
"min_selection": self.min_selection,
}
return wrapper
@@ -103,7 +104,7 @@ def ribbon_action(
shortcut: Optional[str] = None,
requires_page: bool = False,
requires_selection: bool = False,
min_selection: int = 0
min_selection: int = 0,
) -> Callable:
"""
Convenience function for the RibbonAction decorator.
@@ -133,7 +134,7 @@ def ribbon_action(
shortcut=shortcut,
requires_page=requires_page,
requires_selection=requires_selection,
min_selection=min_selection
min_selection=min_selection,
)
@@ -175,14 +176,13 @@ class NumericalInput:
Returns:
The decorated function with metadata attached
"""
@wraps(func)
def wrapper(*args, **kwargs):
def wrapper(*args: Any, **kwargs: Any) -> Any:
return func(*args, **kwargs)
# Store metadata on wrapper function
wrapper._numerical_input = {
'fields': self.fields
}
wrapper._numerical_input = {"fields": self.fields} # type: ignore[attr-defined]
return wrapper
@@ -218,7 +218,7 @@ class UndoableOperation:
# Decorator handles undo/redo automatically
"""
def __init__(self, capture: str = 'page_elements', description: str = None):
def __init__(self, capture: str = "page_elements", description: Optional[str] = None):
"""
Initialize the undoable operation decorator.
@@ -241,10 +241,11 @@ class UndoableOperation:
Returns:
The decorated function
"""
@wraps(func)
def wrapper(self_instance, *args, **kwargs):
# Get description
description = self.description or func.__name__.replace('_', ' ').title()
description = self.description or func.__name__.replace("_", " ").title()
# Capture before state
before_state = self._capture_state(self_instance, self.capture)
@@ -259,14 +260,15 @@ class UndoableOperation:
def restore_state(state):
self._restore_state(self_instance, self.capture, state)
# Update view after restoring
if hasattr(self_instance, 'update_view'):
if hasattr(self_instance, "update_view"):
self_instance.update_view()
# Create and execute command
from pyPhotoAlbum.commands import StateChangeCommand
cmd = StateChangeCommand(description, restore_state, before_state, after_state)
if hasattr(self_instance, 'project') and hasattr(self_instance.project, 'history'):
if hasattr(self_instance, "project") and hasattr(self_instance.project, "history"):
self_instance.project.history.execute(cmd)
print(f"Undoable operation '{description}' executed")
@@ -276,9 +278,9 @@ class UndoableOperation:
def _capture_state(self, instance, capture_type: str):
"""Capture current state based on capture type"""
if capture_type == 'page_elements':
if capture_type == "page_elements":
# Capture elements from current page
current_page = instance.get_current_page() if hasattr(instance, 'get_current_page') else None
current_page = instance.get_current_page() if hasattr(instance, "get_current_page") else None
if current_page:
# Deep copy elements
return [copy.deepcopy(elem.serialize()) for elem in current_page.layout.elements]
@@ -288,22 +290,24 @@ class UndoableOperation:
def _restore_state(self, instance, capture_type: str, state):
"""Restore state based on capture type"""
if capture_type == 'page_elements':
if capture_type == "page_elements":
# Restore elements to current page
current_page = instance.get_current_page() if hasattr(instance, 'get_current_page') else None
current_page = instance.get_current_page() if hasattr(instance, "get_current_page") else None
if current_page and state is not None:
# Clear existing elements
current_page.layout.elements.clear()
# Restore elements from serialized state
from pyPhotoAlbum.models import ImageData, PlaceholderData, TextBoxData
from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData
for elem_data in state:
elem_type = elem_data.get('type')
if elem_type == 'image':
elem_type = elem_data.get("type")
elem: BaseLayoutElement
if elem_type == "image":
elem = ImageData()
elif elem_type == 'placeholder':
elif elem_type == "placeholder":
elem = PlaceholderData()
elif elem_type == 'textbox':
elif elem_type == "textbox":
elem = TextBoxData()
else:
continue
@@ -312,7 +316,7 @@ class UndoableOperation:
current_page.layout.add_element(elem)
def undoable_operation(capture: str = 'page_elements', description: str = None) -> Callable:
def undoable_operation(capture: str = "page_elements", description: Optional[str] = None) -> Callable:
"""
Convenience function for the UndoableOperation decorator.
@@ -343,11 +347,7 @@ class DialogAction:
self.apply_page_setup(values)
"""
def __init__(
self,
dialog_class: type,
requires_pages: bool = True
):
def __init__(self, dialog_class: type, requires_pages: bool = True):
"""
Initialize the dialog action decorator.
@@ -368,6 +368,7 @@ class DialogAction:
Returns:
The decorated function
"""
@wraps(func)
def wrapper(self_instance, *args, **kwargs):
# Check preconditions
@@ -376,7 +377,7 @@ class DialogAction:
# Get initial page index if available
initial_page_index = 0
if hasattr(self_instance, '_get_most_visible_page_index'):
if hasattr(self_instance, "_get_most_visible_page_index"):
initial_page_index = self_instance._get_most_visible_page_index()
# Create and show dialog
@@ -384,17 +385,15 @@ class DialogAction:
# Create dialog
dialog = self.dialog_class(
parent=self_instance,
project=self_instance.project,
initial_page_index=initial_page_index,
**kwargs
parent=self_instance, project=self_instance.project, initial_page_index=initial_page_index, **kwargs
)
# Show dialog and get result
from PyQt6.QtWidgets import QDialog
if dialog.exec() == QDialog.DialogCode.Accepted:
# Get values from dialog
if hasattr(dialog, 'get_values'):
if hasattr(dialog, "get_values"):
values = dialog.get_values()
# Call the decorated function with values
return func(self_instance, values, *args, **kwargs)
@@ -406,10 +405,7 @@ class DialogAction:
return wrapper
def dialog_action(
dialog_class: type,
requires_pages: bool = True
) -> Callable:
def dialog_action(dialog_class: type, requires_pages: bool = True) -> Callable:
"""
Convenience function for the DialogAction decorator.
+2 -1
View File
@@ -6,5 +6,6 @@ UI presentation logic separately from business logic.
"""
from .page_setup_dialog import PageSetupDialog
from .print_settings_dialog import PrintSettingsDialog
__all__ = ['PageSetupDialog']
__all__ = ["PageSetupDialog", "PrintSettingsDialog"]
+352
View File
@@ -0,0 +1,352 @@
"""
Frame picker dialog for pyPhotoAlbum
Dialog for selecting decorative frames to apply to images.
"""
from typing import Optional, Tuple
from PyQt6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QLabel,
QPushButton,
QTabWidget,
QWidget,
QGridLayout,
QScrollArea,
QFrame,
QGroupBox,
QCheckBox,
)
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QPainter, QColor, QPen
from pyPhotoAlbum.frame_manager import get_frame_manager, FrameCategory, FrameDefinition, FrameType
class FramePreviewWidget(QFrame):
"""Widget that shows a preview of a frame"""
clicked = pyqtSignal(str) # Emits frame name when clicked
def __init__(self, frame: FrameDefinition, parent=None):
super().__init__(parent)
self.frame = frame
self.selected = False
self.setFixedSize(100, 100)
self.setFrameStyle(QFrame.Shape.Box)
self.setCursor(Qt.CursorShape.PointingHandCursor)
def paintEvent(self, event):
super().paintEvent(event)
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# Background
if self.selected:
painter.fillRect(self.rect(), QColor(200, 220, 255))
else:
painter.fillRect(self.rect(), QColor(245, 245, 245))
# Draw a simple preview of the frame style
margin = 15
rect = self.rect().adjusted(margin, margin, -margin, -margin)
# Draw "photo" placeholder
painter.fillRect(rect, QColor(180, 200, 220))
# Draw frame preview based on type
pen = QPen(QColor(80, 80, 80))
pen.setWidth(2)
painter.setPen(pen)
if self.frame.frame_type.value == "corners":
# Draw corner decorations
corner_size = 12
x, y, w, h = rect.x(), rect.y(), rect.width(), rect.height()
# Top-left
painter.drawLine(x, y + corner_size, x, y)
painter.drawLine(x, y, x + corner_size, y)
# Top-right
painter.drawLine(x + w - corner_size, y, x + w, y)
painter.drawLine(x + w, y, x + w, y + corner_size)
# Bottom-right
painter.drawLine(x + w, y + h - corner_size, x + w, y + h)
painter.drawLine(x + w, y + h, x + w - corner_size, y + h)
# Bottom-left
painter.drawLine(x + corner_size, y + h, x, y + h)
painter.drawLine(x, y + h, x, y + h - corner_size)
else:
# Draw full border
painter.drawRect(rect.adjusted(-3, -3, 3, 3))
painter.drawRect(rect)
# Draw frame name
painter.setPen(QColor(0, 0, 0))
text_rect = self.rect().adjusted(0, 0, 0, 0)
text_rect.setTop(self.rect().bottom() - 20)
painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, self.frame.display_name)
def mousePressEvent(self, event):
self.clicked.emit(self.frame.name)
def set_selected(self, selected: bool):
self.selected = selected
self.update()
class FramePickerDialog(QDialog):
"""Dialog for selecting a decorative frame"""
def __init__(
self,
parent,
current_frame: Optional[str] = None,
current_color: Tuple[int, int, int] = (0, 0, 0),
current_corners: Tuple[bool, bool, bool, bool] = (True, True, True, True),
):
super().__init__(parent)
self.setWindowTitle("Select Frame")
self.setMinimumSize(500, 500)
self.selected_frame: Optional[str] = current_frame
self.frame_color = current_color
self.frame_corners = current_corners # (TL, TR, BR, BL)
self.frame_widgets: dict[str, FramePreviewWidget] = {}
self._setup_ui()
def _setup_ui(self):
layout = QVBoxLayout(self)
# Tab widget for categories
self.tab_widget = QTabWidget()
# All frames tab
all_tab = self._create_category_tab(None)
self.tab_widget.addTab(all_tab, "All")
# Category tabs
for category in FrameCategory:
tab = self._create_category_tab(category)
self.tab_widget.addTab(tab, category.value.title())
layout.addWidget(self.tab_widget)
# Selected frame info
info_group = QGroupBox("Selected Frame")
info_layout = QVBoxLayout(info_group)
# Frame name and color row
name_color_layout = QHBoxLayout()
self.selected_label = QLabel("None")
name_color_layout.addWidget(self.selected_label)
# Color button
from pyPhotoAlbum.dialogs.style_dialogs import ColorButton
name_color_layout.addWidget(QLabel("Color:"))
self.color_btn = ColorButton(self.frame_color)
name_color_layout.addWidget(self.color_btn)
name_color_layout.addStretch()
info_layout.addLayout(name_color_layout)
# Corner selection (for corner-type frames)
self.corners_group = QGroupBox("Corner Decorations")
corners_layout = QGridLayout(self.corners_group)
# Create a visual grid for corner checkboxes
self.corner_tl = QCheckBox("Top-Left")
self.corner_tl.setChecked(self.frame_corners[0])
self.corner_tl.stateChanged.connect(self._update_corners)
self.corner_tr = QCheckBox("Top-Right")
self.corner_tr.setChecked(self.frame_corners[1])
self.corner_tr.stateChanged.connect(self._update_corners)
self.corner_br = QCheckBox("Bottom-Right")
self.corner_br.setChecked(self.frame_corners[2])
self.corner_br.stateChanged.connect(self._update_corners)
self.corner_bl = QCheckBox("Bottom-Left")
self.corner_bl.setChecked(self.frame_corners[3])
self.corner_bl.stateChanged.connect(self._update_corners)
corners_layout.addWidget(self.corner_tl, 0, 0)
corners_layout.addWidget(self.corner_tr, 0, 1)
corners_layout.addWidget(self.corner_bl, 1, 0)
corners_layout.addWidget(self.corner_br, 1, 1)
# Quick selection buttons
quick_btns_layout = QHBoxLayout()
all_btn = QPushButton("All")
all_btn.clicked.connect(self._select_all_corners)
none_btn = QPushButton("None")
none_btn.clicked.connect(self._select_no_corners)
diag_btn = QPushButton("Diagonal")
diag_btn.clicked.connect(self._select_diagonal_corners)
quick_btns_layout.addWidget(all_btn)
quick_btns_layout.addWidget(none_btn)
quick_btns_layout.addWidget(diag_btn)
quick_btns_layout.addStretch()
corners_layout.addLayout(quick_btns_layout, 2, 0, 1, 2)
info_layout.addWidget(self.corners_group)
layout.addWidget(info_group)
# Update corners group visibility based on frame type
self._update_corners_visibility()
# Buttons
button_layout = QHBoxLayout()
clear_btn = QPushButton("No Frame")
clear_btn.clicked.connect(self._clear_selection)
button_layout.addWidget(clear_btn)
button_layout.addStretch()
ok_btn = QPushButton("OK")
ok_btn.clicked.connect(self.accept)
button_layout.addWidget(ok_btn)
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.reject)
button_layout.addWidget(cancel_btn)
layout.addLayout(button_layout)
# Update selection display
self._update_selection_display()
def _create_category_tab(self, category: Optional[FrameCategory]) -> QWidget:
"""Create a tab for a frame category"""
widget = QWidget()
layout = QVBoxLayout(widget)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
content = QWidget()
grid = QGridLayout(content)
grid.setSpacing(10)
frame_manager = get_frame_manager()
if category:
frames = frame_manager.get_frames_by_category(category)
else:
frames = frame_manager.get_all_frames()
row, col = 0, 0
max_cols = 4
for frame in frames:
preview = FramePreviewWidget(frame)
preview.clicked.connect(self._on_frame_clicked)
if frame.name == self.selected_frame:
preview.set_selected(True)
grid.addWidget(preview, row, col)
self.frame_widgets[frame.name] = preview
col += 1
if col >= max_cols:
col = 0
row += 1
# Add stretch at the bottom
grid.setRowStretch(row + 1, 1)
scroll.setWidget(content)
layout.addWidget(scroll)
return widget
def _on_frame_clicked(self, frame_name: str):
"""Handle frame selection"""
# Deselect previous
if self.selected_frame and self.selected_frame in self.frame_widgets:
self.frame_widgets[self.selected_frame].set_selected(False)
# Select new
self.selected_frame = frame_name
if frame_name in self.frame_widgets:
self.frame_widgets[frame_name].set_selected(True)
self._update_selection_display()
self._update_corners_visibility()
def _clear_selection(self):
"""Clear frame selection"""
if self.selected_frame and self.selected_frame in self.frame_widgets:
self.frame_widgets[self.selected_frame].set_selected(False)
self.selected_frame = None
self._update_selection_display()
self._update_corners_visibility()
def _update_selection_display(self):
"""Update the selected frame label"""
if self.selected_frame:
frame = get_frame_manager().get_frame(self.selected_frame)
if frame:
self.selected_label.setText(f"{frame.display_name} - {frame.description}")
else:
self.selected_label.setText(self.selected_frame)
else:
self.selected_label.setText("None")
def _update_corners(self):
"""Update corner selection from checkboxes"""
self.frame_corners = (
self.corner_tl.isChecked(),
self.corner_tr.isChecked(),
self.corner_br.isChecked(),
self.corner_bl.isChecked(),
)
def _update_corners_visibility(self):
"""Show/hide corners group based on selected frame type"""
if self.selected_frame:
frame = get_frame_manager().get_frame(self.selected_frame)
if frame and frame.frame_type == FrameType.CORNERS:
self.corners_group.setVisible(True)
return
self.corners_group.setVisible(False)
def _select_all_corners(self):
"""Select all corners"""
self.corner_tl.setChecked(True)
self.corner_tr.setChecked(True)
self.corner_br.setChecked(True)
self.corner_bl.setChecked(True)
self._update_corners()
def _select_no_corners(self):
"""Deselect all corners"""
self.corner_tl.setChecked(False)
self.corner_tr.setChecked(False)
self.corner_br.setChecked(False)
self.corner_bl.setChecked(False)
self._update_corners()
def _select_diagonal_corners(self):
"""Select diagonal corners (TL and BR)"""
self.corner_tl.setChecked(True)
self.corner_tr.setChecked(False)
self.corner_br.setChecked(True)
self.corner_bl.setChecked(False)
self._update_corners()
def get_values(self) -> Tuple[Optional[str], Tuple[int, int, int], Tuple[bool, bool, bool, bool]]:
"""Get selected frame name, color, and corner configuration"""
return self.selected_frame, self.color_btn.get_color(), self.frame_corners
+50 -40
View File
@@ -8,9 +8,18 @@ separating presentation from business logic.
import math
from typing import Optional, Dict, Any
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox,
QComboBox, QCheckBox
QDialog,
QVBoxLayout,
QHBoxLayout,
QLabel,
QDoubleSpinBox,
QSpinBox,
QPushButton,
QGroupBox,
QComboBox,
QCheckBox,
QRadioButton,
QButtonGroup,
)
from pyPhotoAlbum.project import Project
@@ -23,12 +32,7 @@ class PageSetupDialog(QDialog):
including page size, DPI settings, and cover configuration.
"""
def __init__(
self,
parent,
project: Project,
initial_page_index: int = 0
):
def __init__(self, parent, project: Project, initial_page_index: int = 0):
"""
Initialize the page setup dialog.
@@ -105,9 +109,7 @@ class PageSetupDialog(QDialog):
# Cover checkbox
self.cover_checkbox = QCheckBox("Designate as Cover")
self.cover_checkbox.setToolTip(
"Mark this page as the book cover with wrap-around front/spine/back"
)
self.cover_checkbox.setToolTip("Mark this page as the book cover with wrap-around front/spine/back")
layout.addWidget(self.cover_checkbox)
# Paper thickness
@@ -136,9 +138,7 @@ class PageSetupDialog(QDialog):
# Calculated spine width display
self.spine_info_label = QLabel()
self.spine_info_label.setStyleSheet(
"font-size: 9pt; color: #0066cc; padding: 5px;"
)
self.spine_info_label.setStyleSheet("font-size: 9pt; color: #0066cc; padding: 5px;")
self.spine_info_label.setWordWrap(True)
layout.addWidget(self.spine_info_label)
@@ -168,12 +168,23 @@ class PageSetupDialog(QDialog):
height_layout.addWidget(self.height_spinbox)
layout.addLayout(height_layout)
# Set as default checkbox
self.set_default_checkbox = QCheckBox("Set as default for new pages")
self.set_default_checkbox.setToolTip(
"Update project default page size for future pages"
)
layout.addWidget(self.set_default_checkbox)
# Apply scope radio buttons
scope_label = QLabel("Apply to:")
layout.addWidget(scope_label)
self._apply_scope_group = QButtonGroup(self)
self.scope_page_only = QRadioButton("This page only")
self.scope_non_manual = QRadioButton("All non-manual pages")
self.scope_all_pages = QRadioButton("All pages (override manual sizing)")
self.scope_page_only.setChecked(True)
self._apply_scope_group.addButton(self.scope_page_only, 0)
self._apply_scope_group.addButton(self.scope_non_manual, 1)
self._apply_scope_group.addButton(self.scope_all_pages, 2)
layout.addWidget(self.scope_page_only)
layout.addWidget(self.scope_non_manual)
layout.addWidget(self.scope_all_pages)
group.setLayout(layout)
return group
@@ -248,7 +259,7 @@ class PageSetupDialog(QDialog):
return
selected_page = self.project.pages[index]
is_first_page = (index == 0)
is_first_page = index == 0
# Show/hide cover settings based on page selection
self._cover_group.setVisible(is_first_page)
@@ -265,7 +276,7 @@ class PageSetupDialog(QDialog):
elif selected_page.is_double_spread:
display_width = (
selected_page.layout.base_width
if hasattr(selected_page.layout, 'base_width')
if hasattr(selected_page.layout, "base_width")
else selected_page.layout.size[0] / 2
)
else:
@@ -278,22 +289,21 @@ class PageSetupDialog(QDialog):
is_cover = selected_page.is_cover
self.width_spinbox.setEnabled(not is_cover)
self.height_spinbox.setEnabled(not is_cover)
self.set_default_checkbox.setEnabled(not is_cover)
self.scope_non_manual.setEnabled(not is_cover)
self.scope_all_pages.setEnabled(not is_cover)
if is_cover:
self.scope_page_only.setChecked(True)
def _update_spine_info(self):
"""Update the spine information display."""
if self.cover_checkbox.isChecked():
# Calculate spine width with current settings
content_pages = sum(
p.get_page_count() for p in self.project.pages if not p.is_cover
)
content_pages = sum(p.get_page_count() for p in self.project.pages if not p.is_cover)
sheets = math.ceil(content_pages / 4)
spine_width = sheets * self.thickness_spinbox.value() * 2
page_width = self.project.page_size_mm[0]
total_width = (
(page_width * 2) + spine_width + (self.bleed_spinbox.value() * 2)
)
total_width = (page_width * 2) + spine_width + (self.bleed_spinbox.value() * 2)
self.spine_info_label.setText(
f"Cover Layout: Front ({page_width:.0f}mm) + "
@@ -317,14 +327,14 @@ class PageSetupDialog(QDialog):
selected_page = self.project.pages[selected_index]
return {
'selected_index': selected_index,
'selected_page': selected_page,
'is_cover': self.cover_checkbox.isChecked(),
'paper_thickness_mm': self.thickness_spinbox.value(),
'cover_bleed_mm': self.bleed_spinbox.value(),
'width_mm': self.width_spinbox.value(),
'height_mm': self.height_spinbox.value(),
'working_dpi': self.working_dpi_spinbox.value(),
'export_dpi': self.export_dpi_spinbox.value(),
'set_as_default': self.set_default_checkbox.isChecked()
"selected_index": selected_index,
"selected_page": selected_page,
"is_cover": self.cover_checkbox.isChecked(),
"paper_thickness_mm": self.thickness_spinbox.value(),
"cover_bleed_mm": self.bleed_spinbox.value(),
"width_mm": self.width_spinbox.value(),
"height_mm": self.height_spinbox.value(),
"working_dpi": self.working_dpi_spinbox.value(),
"export_dpi": self.export_dpi_spinbox.value(),
"apply_scope": self._apply_scope_group.checkedId(),
}
@@ -0,0 +1,90 @@
"""
Print Settings Dialog for pyPhotoAlbum
Project-level bleed and safe-area configuration applied uniformly to all pages.
"""
from typing import Any, Dict
from PyQt6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QLabel,
QDoubleSpinBox,
QPushButton,
QGroupBox,
)
from pyPhotoAlbum.project import Project
class PrintSettingsDialog(QDialog):
"""Dialog for configuring project-wide print settings (bleed and safe area)."""
def __init__(self, parent, project: Project):
super().__init__(parent)
self.project = project
self._setup_ui()
def _setup_ui(self):
self.setWindowTitle("Print Settings")
self.setMinimumWidth(340)
layout = QVBoxLayout()
group = QGroupBox("Bleed && Safe Area (applied to all pages)")
group_layout = QVBoxLayout()
# Bleed
bleed_layout = QHBoxLayout()
bleed_layout.addWidget(QLabel("Bleed Margin:"))
self.bleed_spinbox = QDoubleSpinBox()
self.bleed_spinbox.setRange(0.0, 20.0)
self.bleed_spinbox.setSingleStep(0.5)
self.bleed_spinbox.setDecimals(1)
self.bleed_spinbox.setSuffix(" mm")
self.bleed_spinbox.setValue(self.project.page_bleed_mm)
self.bleed_spinbox.setToolTip(
"Extra white space added around each page in the exported PDF.\n"
"The printer cuts here — 3 mm is standard."
)
bleed_layout.addWidget(self.bleed_spinbox)
group_layout.addLayout(bleed_layout)
# Safe area
safe_layout = QHBoxLayout()
safe_layout.addWidget(QLabel("Safe Area:"))
self.safe_spinbox = QDoubleSpinBox()
self.safe_spinbox.setRange(0.0, 50.0)
self.safe_spinbox.setSingleStep(0.5)
self.safe_spinbox.setDecimals(1)
self.safe_spinbox.setSuffix(" mm")
self.safe_spinbox.setValue(self.project.page_safe_area_mm)
self.safe_spinbox.setToolTip(
"Keep text and important content inside this distance from the cut/trim line.\n"
"Shown as a red guide in the editor."
)
safe_layout.addWidget(self.safe_spinbox)
group_layout.addLayout(safe_layout)
group.setLayout(group_layout)
layout.addWidget(group)
# Buttons
btn_layout = QHBoxLayout()
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.reject)
ok_btn = QPushButton("OK")
ok_btn.clicked.connect(self.accept)
ok_btn.setDefault(True)
btn_layout.addStretch()
btn_layout.addWidget(cancel_btn)
btn_layout.addWidget(ok_btn)
layout.addLayout(btn_layout)
self.setLayout(layout)
def get_values(self) -> Dict[str, Any]:
return {
"page_bleed_mm": self.bleed_spinbox.value(),
"page_safe_area_mm": self.safe_spinbox.value(),
}
+297
View File
@@ -0,0 +1,297 @@
"""
Style dialogs for pyPhotoAlbum
Dialogs for configuring image styling options:
- Corner radius
- Border (width and color)
- Drop shadow
"""
from typing import Tuple
from PyQt6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QLabel,
QSlider,
QSpinBox,
QDoubleSpinBox,
QPushButton,
QCheckBox,
QColorDialog,
QGroupBox,
QFormLayout,
QWidget,
)
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QColor
class CornerRadiusDialog(QDialog):
"""Dialog for setting corner radius"""
def __init__(self, parent, current_radius: float = 0.0):
super().__init__(parent)
self.setWindowTitle("Corner Radius")
self.setMinimumWidth(300)
layout = QVBoxLayout(self)
# Slider with label
slider_layout = QHBoxLayout()
slider_layout.addWidget(QLabel("Radius:"))
self.slider = QSlider(Qt.Orientation.Horizontal)
self.slider.setMinimum(0)
self.slider.setMaximum(50)
self.slider.setValue(int(current_radius))
self.slider.valueChanged.connect(self._on_slider_changed)
slider_layout.addWidget(self.slider)
self.value_label = QLabel(f"{int(current_radius)}%")
self.value_label.setMinimumWidth(40)
slider_layout.addWidget(self.value_label)
layout.addLayout(slider_layout)
# Preset buttons
preset_layout = QHBoxLayout()
for value, label in [(0, "None"), (5, "Slight"), (15, "Medium"), (25, "Large"), (50, "Circle")]:
btn = QPushButton(label)
btn.clicked.connect(lambda checked, v=value: self.slider.setValue(v))
preset_layout.addWidget(btn)
layout.addLayout(preset_layout)
# OK/Cancel buttons
button_layout = QHBoxLayout()
ok_btn = QPushButton("OK")
ok_btn.clicked.connect(self.accept)
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.reject)
button_layout.addStretch()
button_layout.addWidget(ok_btn)
button_layout.addWidget(cancel_btn)
layout.addLayout(button_layout)
def _on_slider_changed(self, value):
self.value_label.setText(f"{value}%")
def get_value(self) -> float:
return float(self.slider.value())
class ColorButton(QPushButton):
"""Button that shows a color and opens color picker on click"""
def __init__(self, color: Tuple[int, int, int], parent=None):
super().__init__(parent)
self.setFixedSize(40, 25)
self._color = color
self._update_style()
self.clicked.connect(self._pick_color)
def _update_style(self):
r, g, b = self._color
self.setStyleSheet(f"background-color: rgb({r}, {g}, {b}); border: 1px solid #666;")
def _pick_color(self):
r, g, b = self._color
initial = QColor(r, g, b)
color = QColorDialog.getColor(initial, self, "Select Color")
if color.isValid():
self._color = (color.red(), color.green(), color.blue())
self._update_style()
def get_color(self) -> Tuple[int, int, int]:
return self._color
class BorderDialog(QDialog):
"""Dialog for configuring border"""
def __init__(
self,
parent,
current_width: float = 0.0,
current_color: Tuple[int, int, int] = (0, 0, 0),
):
super().__init__(parent)
self.setWindowTitle("Border Settings")
self.setMinimumWidth(300)
layout = QVBoxLayout(self)
# Border width
width_layout = QHBoxLayout()
width_layout.addWidget(QLabel("Width (mm):"))
self.width_spin = QDoubleSpinBox()
self.width_spin.setRange(0, 20)
self.width_spin.setSingleStep(0.5)
self.width_spin.setValue(current_width)
self.width_spin.setDecimals(1)
width_layout.addWidget(self.width_spin)
layout.addLayout(width_layout)
# Border color
color_layout = QHBoxLayout()
color_layout.addWidget(QLabel("Color:"))
self.color_btn = ColorButton(current_color)
color_layout.addWidget(self.color_btn)
color_layout.addStretch()
layout.addLayout(color_layout)
# Preset buttons
preset_layout = QHBoxLayout()
presets = [
("None", 0, (0, 0, 0)),
("Thin Black", 0.5, (0, 0, 0)),
("White", 2, (255, 255, 255)),
("Gold", 1.5, (212, 175, 55)),
]
for label, width, color in presets:
btn = QPushButton(label)
btn.clicked.connect(lambda checked, w=width, c=color: self._apply_preset(w, c))
preset_layout.addWidget(btn)
layout.addLayout(preset_layout)
# OK/Cancel buttons
button_layout = QHBoxLayout()
ok_btn = QPushButton("OK")
ok_btn.clicked.connect(self.accept)
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.reject)
button_layout.addStretch()
button_layout.addWidget(ok_btn)
button_layout.addWidget(cancel_btn)
layout.addLayout(button_layout)
def _apply_preset(self, width, color):
self.width_spin.setValue(width)
self.color_btn._color = color
self.color_btn._update_style()
def get_values(self) -> Tuple[float, Tuple[int, int, int]]:
return self.width_spin.value(), self.color_btn.get_color()
class ShadowDialog(QDialog):
"""Dialog for configuring drop shadow"""
def __init__(
self,
parent,
enabled: bool = False,
offset: Tuple[float, float] = (2.0, 2.0),
blur: float = 3.0,
color: Tuple[int, int, int, int] = (0, 0, 0, 128),
):
super().__init__(parent)
self.setWindowTitle("Shadow Settings")
self.setMinimumWidth(350)
layout = QVBoxLayout(self)
# Enable checkbox
self.enabled_check = QCheckBox("Enable Drop Shadow")
self.enabled_check.setChecked(enabled)
self.enabled_check.stateChanged.connect(self._update_controls)
layout.addWidget(self.enabled_check)
# Settings group
self.settings_group = QGroupBox("Shadow Settings")
form = QFormLayout(self.settings_group)
# Offset X
self.offset_x = QDoubleSpinBox()
self.offset_x.setRange(-20, 20)
self.offset_x.setSingleStep(0.5)
self.offset_x.setValue(offset[0])
self.offset_x.setDecimals(1)
form.addRow("Offset X (mm):", self.offset_x)
# Offset Y
self.offset_y = QDoubleSpinBox()
self.offset_y.setRange(-20, 20)
self.offset_y.setSingleStep(0.5)
self.offset_y.setValue(offset[1])
self.offset_y.setDecimals(1)
form.addRow("Offset Y (mm):", self.offset_y)
# Blur
self.blur_spin = QDoubleSpinBox()
self.blur_spin.setRange(0, 20)
self.blur_spin.setSingleStep(0.5)
self.blur_spin.setValue(blur)
self.blur_spin.setDecimals(1)
form.addRow("Blur (mm):", self.blur_spin)
# Color
color_widget = QWidget()
color_layout = QHBoxLayout(color_widget)
color_layout.setContentsMargins(0, 0, 0, 0)
self.color_btn = ColorButton(color[:3])
color_layout.addWidget(self.color_btn)
color_layout.addStretch()
form.addRow("Color:", color_widget)
# Opacity
self.opacity_slider = QSlider(Qt.Orientation.Horizontal)
self.opacity_slider.setRange(0, 255)
self.opacity_slider.setValue(color[3] if len(color) > 3 else 128)
opacity_layout = QHBoxLayout()
opacity_layout.addWidget(self.opacity_slider)
self.opacity_label = QLabel(f"{self.opacity_slider.value()}")
self.opacity_label.setMinimumWidth(30)
opacity_layout.addWidget(self.opacity_label)
self.opacity_slider.valueChanged.connect(lambda v: self.opacity_label.setText(str(v)))
form.addRow("Opacity:", opacity_layout)
layout.addWidget(self.settings_group)
# Preset buttons
preset_layout = QHBoxLayout()
presets = [
("Subtle", True, (1.0, 1.0), 2.0, (0, 0, 0, 60)),
("Normal", True, (2.0, 2.0), 3.0, (0, 0, 0, 100)),
("Strong", True, (3.0, 3.0), 5.0, (0, 0, 0, 150)),
]
for label, en, off, bl, col in presets:
btn = QPushButton(label)
btn.clicked.connect(lambda checked, e=en, o=off, b=bl, c=col: self._apply_preset(e, o, b, c))
preset_layout.addWidget(btn)
layout.addLayout(preset_layout)
# OK/Cancel buttons
button_layout = QHBoxLayout()
ok_btn = QPushButton("OK")
ok_btn.clicked.connect(self.accept)
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.reject)
button_layout.addStretch()
button_layout.addWidget(ok_btn)
button_layout.addWidget(cancel_btn)
layout.addLayout(button_layout)
self._update_controls()
def _update_controls(self):
self.settings_group.setEnabled(self.enabled_check.isChecked())
def _apply_preset(self, enabled, offset, blur, color):
self.enabled_check.setChecked(enabled)
self.offset_x.setValue(offset[0])
self.offset_y.setValue(offset[1])
self.blur_spin.setValue(blur)
self.color_btn._color = color[:3]
self.color_btn._update_style()
self.opacity_slider.setValue(color[3] if len(color) > 3 else 128)
def get_values(self) -> Tuple[bool, Tuple[float, float], float, Tuple[int, int, int, int]]:
color_rgb = self.color_btn.get_color()
color_rgba = color_rgb + (self.opacity_slider.value(),)
return (
self.enabled_check.isChecked(),
(self.offset_x.value(), self.offset_y.value()),
self.blur_spin.value(),
color_rgba,
)
+943
View File
@@ -0,0 +1,943 @@
"""
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,
)
# Validate png_data type
if not isinstance(png_data, bytes):
print(f"Warning: cairosvg returned {type(png_data)} instead of bytes")
return None
# Load as PIL Image from bytes buffer
buffer = io.BytesIO(png_data)
img: Image.Image = Image.open(buffer)
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:
import traceback
print(f"Error loading SVG {svg_path}: {e}")
traceback.print_exc()
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:
try:
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
except Exception as e:
import traceback
print(f"Error getting corner image for {frame.name}: {e}")
traceback.print_exc()
return None
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.Transpose.FLIP_LEFT_RIGHT)
# Flip vertically if v position differs
if asset_v != target_v:
img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
return img
# Note: PDF Y-axis is bottom-up, so corners are positioned differently
# Top-left in screen coordinates = high Y in PDF
if tl:
img = get_flipped_image("tl")
img_reader = ImageReader(img)
canvas.drawImage(img_reader, x, y + h - cs, cs, cs, mask="auto")
# Top-right
if tr:
img = get_flipped_image("tr")
img_reader = ImageReader(img)
canvas.drawImage(img_reader, x + w - cs, y + h - cs, cs, cs, mask="auto")
# Bottom-right
if br:
img = get_flipped_image("br")
img_reader = ImageReader(img)
canvas.drawImage(img_reader, x + w - cs, y, cs, cs, mask="auto")
# Bottom-left
if bl:
img = get_flipped_image("bl")
img_reader = ImageReader(img)
canvas.drawImage(img_reader, x, y, cs, cs, mask="auto")
return True
def _render_corner_frame_pdf(
self,
canvas,
x: float,
y: float,
w: float,
h: float,
thickness: float,
frame_name: str,
corners: Tuple[bool, bool, bool, bool] = (True, True, True, True),
):
"""Render corner-style frame on PDF (programmatic fallback)."""
corner_size = thickness * 2
tl, tr, br, bl = corners
path = canvas.beginPath()
if tl:
path.moveTo(x, y + h - corner_size)
path.lineTo(x, y + h)
path.lineTo(x + corner_size, y + h)
if tr:
path.moveTo(x + w - corner_size, y + h)
path.lineTo(x + w, y + h)
path.lineTo(x + w, y + h - corner_size)
if br:
path.moveTo(x + w, y + corner_size)
path.lineTo(x + w, y)
path.lineTo(x + w - corner_size, y)
if bl:
path.moveTo(x + corner_size, y)
path.lineTo(x, y)
path.lineTo(x, y + corner_size)
canvas.drawPath(path, stroke=1, fill=0)
def _render_full_frame_pdf(self, canvas, x: float, y: float, w: float, h: float, thickness: float):
"""Render full-border frame on PDF"""
canvas.rect(
x - thickness * 0.5,
y - thickness * 0.5,
w + thickness,
h + thickness,
stroke=1,
fill=0,
)
canvas.rect(
x + thickness * 0.3,
y + thickness * 0.3,
w - thickness * 0.6,
h - thickness * 0.6,
stroke=1,
fill=0,
)
# Global frame manager instance
_frame_manager: Optional[FrameManager] = None
def get_frame_manager() -> FrameManager:
"""Get the global frame manager instance"""
global _frame_manager
if _frame_manager is None:
_frame_manager = FrameManager()
return _frame_manager
+23
View File
@@ -0,0 +1,23 @@
Decorative Frame Assets - Credits and Licenses
===============================================
All decorative corner SVG assets in this directory are sourced from FreeSVG.org
and are released under the Creative Commons Zero (CC0) Public Domain license.
This means you can copy, modify, distribute, and use them for commercial purposes,
all without asking permission or providing attribution.
However, we gratefully acknowledge the following sources:
Corner Decorations
------------------
- corner_decoration.svg - FreeSVG.org (OpenClipart)
- corner_ornament.svg - FreeSVG.org (RebeccaRead/OpenClipart)
- floral_corner.svg - FreeSVG.org (OpenClipart)
- floral_flourish.svg - FreeSVG.org (OpenClipart)
- ornate_corner.svg - FreeSVG.org (OpenClipart)
- simple_corner.svg - FreeSVG.org (OpenClipart)
Source: https://freesvg.org
License: CC0 1.0 Universal (Public Domain)
License URL: https://creativecommons.org/publicdomain/zero/1.0/
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

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

After

Width:  |  Height:  |  Size: 4.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 32 KiB

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

After

Width:  |  Height:  |  Size: 43 KiB

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

After

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 138 KiB

+5 -10
View File
@@ -22,18 +22,16 @@ try:
glVertex2f,
GL_QUADS,
GL_LINE_LOOP,
GL_LINE_STRIP,
GL_LINES,
GL_TRIANGLE_FAN,
# Colors
glColor3f,
glColor4f,
# Line state
glLineWidth,
glLineStipple,
GL_LINE_STIPPLE,
# General state
glEnable,
glDisable,
@@ -42,7 +40,6 @@ try:
GL_SRC_ALPHA,
GL_ONE_MINUS_SRC_ALPHA,
glBlendFunc,
# Textures
glGenTextures,
glBindTexture,
@@ -56,7 +53,6 @@ try:
GL_TEXTURE_MAG_FILTER,
GL_LINEAR,
glTexCoord2f,
# Matrix operations
glPushMatrix,
glPopMatrix,
@@ -64,24 +60,23 @@ try:
glTranslatef,
glLoadIdentity,
glRotatef,
# Clear operations
glClear,
glClearColor,
glFlush,
GL_COLOR_BUFFER_BIT,
GL_DEPTH_BUFFER_BIT,
# Viewport
glViewport,
glMatrixMode,
glOrtho,
GL_PROJECTION,
GL_MODELVIEW,
# Info/debug
glGetString,
GL_VERSION,
)
GL_AVAILABLE = True
except ImportError:
@@ -100,12 +95,12 @@ except ImportError:
glTexParameteri = glDeleteTextures = glTexCoord2f = _gl_stub
glPushMatrix = glPopMatrix = glScalef = glTranslatef = _gl_stub
glLoadIdentity = glRotatef = _gl_stub
glClear = glClearColor = _gl_stub
glClear = glClearColor = glFlush = _gl_stub
glViewport = glMatrixMode = glOrtho = _gl_stub
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
+230 -7
View File
@@ -34,7 +34,7 @@ class GLWidget(
MouseInteractionMixin,
UndoableInteractionMixin,
KeyboardNavigationMixin,
QOpenGLWidget
QOpenGLWidget,
):
"""OpenGL widget for pyPhotoAlbum rendering and user interaction
@@ -53,11 +53,21 @@ class GLWidget(
def __init__(self, parent=None):
super().__init__(parent)
# Store reference to main window for accessing project
self._main_window = parent
# Initialize async loading system
self._init_async_loading()
# Initialize OpenGL
self.setFormat(self.format())
# Set up OpenGL surface format with explicit double buffering
from PyQt6.QtGui import QSurfaceFormat
fmt = QSurfaceFormat()
fmt.setSwapBehavior(QSurfaceFormat.SwapBehavior.DoubleBuffer)
fmt.setSwapInterval(1) # Enable vsync
self.setFormat(fmt)
# Force full redraws to ensure viewport updates
self.setUpdateBehavior(QOpenGLWidget.UpdateBehavior.NoPartialUpdate)
# Enable mouse tracking and drag-drop
@@ -68,6 +78,26 @@ class GLWidget(
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.setFocus()
# Enable gesture support for pinch-to-zoom
self.grabGesture(Qt.GestureType.PinchGesture)
# Track pinch gesture state
self._pinch_scale_factor = 1.0
def window(self):
"""Override window() to return stored main_window reference.
This fixes the Qt widget hierarchy issue where window() returns None
because the GL widget is nested in container widgets.
"""
return self._main_window if hasattr(self, "_main_window") else super().window()
def update(self):
"""Override update to force immediate repaint"""
super().update()
# Force immediate processing of paint events
self.repaint()
def closeEvent(self, event):
"""Handle widget close event."""
# Cleanup async loading
@@ -77,8 +107,8 @@ class GLWidget(
def _get_project_folder(self):
"""Override AssetPathMixin to access project via main window."""
main_window = self.window()
if hasattr(main_window, 'project') and main_window.project:
return getattr(main_window.project, 'folder_path', None)
if hasattr(main_window, "project") and main_window.project:
return getattr(main_window.project, "folder_path", None)
return None
def keyPressEvent(self, event):
@@ -86,7 +116,7 @@ class GLWidget(
if event.key() == Qt.Key.Key_Delete or event.key() == Qt.Key.Key_Backspace:
if self.selected_element:
main_window = self.window()
if hasattr(main_window, 'delete_selected_element'):
if hasattr(main_window, "delete_selected_element"):
main_window.delete_selected_element()
elif event.key() == Qt.Key.Key_Escape:
@@ -99,7 +129,7 @@ class GLWidget(
if self.selected_element:
self.rotation_mode = not self.rotation_mode
main_window = self.window()
if hasattr(main_window, 'show_status'):
if hasattr(main_window, "show_status"):
mode_text = "Rotation Mode" if self.rotation_mode else "Move/Resize Mode"
main_window.show_status(f"Switched to {mode_text}", 2000)
print(f"Rotation mode: {self.rotation_mode}")
@@ -131,3 +161,196 @@ class GLWidget(
else:
super().keyPressEvent(event)
def event(self, event):
"""Handle gesture events for pinch-to-zoom"""
from PyQt6.QtCore import QEvent, Qt as QtCore
from PyQt6.QtWidgets import QPinchGesture
from PyQt6.QtGui import QNativeGestureEvent
# Handle native touchpad gestures (Linux, macOS)
if event.type() == QEvent.Type.NativeGesture:
native_event = event
gesture_type = native_event.gestureType()
print(f"DEBUG: Native gesture detected - type: {gesture_type}")
# Check for zoom/pinch gesture
if gesture_type == QtCore.NativeGestureType.ZoomNativeGesture:
# Get zoom value (typically a delta around 0)
value = native_event.value()
print(f"DEBUG: Zoom value: {value}")
# Convert to scale factor (value is typically small, like -0.1 to 0.1)
# Positive value = zoom in, negative = zoom out
scale_factor = 1.0 + value
# Get the position of the gesture
pos = native_event.position()
mouse_x = pos.x()
mouse_y = pos.y()
self._apply_zoom_at_point(mouse_x, mouse_y, scale_factor)
return True
# Check for pan gesture (two-finger drag)
elif gesture_type == QtCore.NativeGestureType.PanNativeGesture:
# Get the pan delta
delta = native_event.delta()
dx = delta.x()
dy = delta.y()
print(f"DEBUG: Pan delta: dx={dx}, dy={dy}")
# Apply pan
self.pan_offset[0] += dx
self.pan_offset[1] += dy
# Clamp pan offset to content bounds
if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset()
self.update()
# Update scrollbars if available
main_window = self.window()
if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars()
return True
# Handle Qt gesture events (fallback for other platforms)
elif event.type() == QEvent.Type.Gesture:
print("DEBUG: Qt Gesture event detected")
gesture_event = event
pinch = gesture_event.gesture(Qt.GestureType.PinchGesture)
if pinch:
print(f"DEBUG: Pinch gesture detected - state: {pinch.state()}, scale: {pinch.totalScaleFactor()}")
self._handle_pinch_gesture(pinch)
return True
return super().event(event)
def _handle_pinch_gesture(self, pinch):
"""Handle pinch gesture for zooming"""
from PyQt6.QtCore import Qt as QtCore
# Check gesture state
state = pinch.state()
if state == QtCore.GestureState.GestureStarted:
# Reset scale factor at gesture start
self._pinch_scale_factor = 1.0
return
elif state == QtCore.GestureState.GestureUpdated:
# Get current total scale factor
current_scale = pinch.totalScaleFactor()
# Calculate incremental change from last update
if current_scale > 0:
scale_change = current_scale / self._pinch_scale_factor
self._pinch_scale_factor = current_scale
# Get the center point of the pinch gesture
center_point = pinch.centerPoint()
mouse_x = center_point.x()
mouse_y = center_point.y()
# Calculate world coordinates at the pinch center
world_x = (mouse_x - self.pan_offset[0]) / self.zoom_level
world_y = (mouse_y - self.pan_offset[1]) / self.zoom_level
# Apply incremental zoom change
new_zoom = self.zoom_level * scale_change
# Clamp zoom level to reasonable bounds
if 0.1 <= new_zoom <= 5.0:
old_pan_x = self.pan_offset[0]
old_pan_y = self.pan_offset[1]
self.zoom_level = new_zoom
# Adjust pan offset to keep the pinch center point fixed
self.pan_offset[0] = mouse_x - world_x * self.zoom_level
self.pan_offset[1] = mouse_y - world_y * self.zoom_level
# If dragging, adjust drag_start_pos to account for pan_offset change
if (
hasattr(self, "is_dragging")
and self.is_dragging
and hasattr(self, "drag_start_pos")
and self.drag_start_pos
):
pan_delta_x = self.pan_offset[0] - old_pan_x
pan_delta_y = self.pan_offset[1] - old_pan_y
self.drag_start_pos = (
self.drag_start_pos[0] + pan_delta_x,
self.drag_start_pos[1] + pan_delta_y,
)
# Clamp pan offset to content bounds
if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset()
self.update()
# Update status bar
main_window = self.window()
if hasattr(main_window, "status_bar"):
main_window.status_bar.showMessage(f"Zoom: {int(self.zoom_level * 100)}%", 2000)
# Update scrollbars if available
if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars()
elif state == QtCore.GestureState.GestureFinished or state == QtCore.GestureState.GestureCanceled:
# Reset on gesture end
self._pinch_scale_factor = 1.0
def _apply_zoom_at_point(self, mouse_x, mouse_y, scale_factor):
"""Apply zoom centered at a specific point"""
# Calculate world coordinates at the zoom center
world_x = (mouse_x - self.pan_offset[0]) / self.zoom_level
world_y = (mouse_y - self.pan_offset[1]) / self.zoom_level
# Apply zoom
new_zoom = self.zoom_level * scale_factor
# Clamp zoom level to reasonable bounds
if 0.1 <= new_zoom <= 5.0:
old_pan_x = self.pan_offset[0]
old_pan_y = self.pan_offset[1]
self.zoom_level = new_zoom
# Adjust pan offset to keep the zoom center point fixed
self.pan_offset[0] = mouse_x - world_x * self.zoom_level
self.pan_offset[1] = mouse_y - world_y * self.zoom_level
# If dragging, adjust drag_start_pos to account for pan_offset change
if (
hasattr(self, "is_dragging")
and self.is_dragging
and hasattr(self, "drag_start_pos")
and self.drag_start_pos
):
pan_delta_x = self.pan_offset[0] - old_pan_x
pan_delta_y = self.pan_offset[1] - old_pan_y
self.drag_start_pos = (self.drag_start_pos[0] + pan_delta_x, self.drag_start_pos[1] + pan_delta_y)
# Clamp pan offset to content bounds
if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset()
self.update()
# Update status bar
main_window = self.window()
if hasattr(main_window, "status_bar"):
main_window.status_bar.showMessage(f"Zoom: {int(self.zoom_level * 100)}%", 2000)
# Update scrollbars if available
if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars()
+282 -14
View File
@@ -8,11 +8,11 @@ across models.py, pdf_exporter.py, and async_backend.py.
from typing import Tuple
from PIL import Image
# =============================================================================
# Image Processing Utilities
# =============================================================================
def apply_pil_rotation(image: Image.Image, pil_rotation_90: int) -> Image.Image:
"""
Apply 90-degree rotation increments to a PIL image.
@@ -29,11 +29,11 @@ def apply_pil_rotation(image: Image.Image, pil_rotation_90: int) -> Image.Image:
angle = pil_rotation_90 * 90
if angle == 90:
return image.transpose(Image.ROTATE_270) # CCW 90 = rotate right
return image.transpose(Image.Transpose.ROTATE_270) # CCW 90 = rotate right
elif angle == 180:
return image.transpose(Image.ROTATE_180)
return image.transpose(Image.Transpose.ROTATE_180)
elif angle == 270:
return image.transpose(Image.ROTATE_90) # CCW 270 = rotate left
return image.transpose(Image.Transpose.ROTATE_90) # CCW 270 = rotate left
return image
@@ -48,8 +48,8 @@ def convert_to_rgba(image: Image.Image) -> Image.Image:
Returns:
PIL Image in RGBA mode
"""
if image.mode != 'RGBA':
return image.convert('RGBA')
if image.mode != "RGBA":
return image.convert("RGBA")
return image
@@ -58,7 +58,7 @@ def calculate_center_crop_coords(
img_height: int,
target_width: float,
target_height: float,
crop_info: Tuple[float, float, float, float] = (0, 0, 1, 1)
crop_info: Tuple[float, float, float, float] = (0, 0, 1, 1),
) -> Tuple[float, float, float, float]:
"""
Calculate texture/crop coordinates for center-crop fitting an image to a target aspect ratio.
@@ -113,10 +113,7 @@ def calculate_center_crop_coords(
return (tx_min, ty_min, tx_max, ty_max)
def crop_image_to_coords(
image: Image.Image,
coords: Tuple[float, float, float, float]
) -> Image.Image:
def crop_image_to_coords(image: Image.Image, coords: Tuple[float, float, float, float]) -> Image.Image:
"""
Crop an image using normalized texture coordinates.
@@ -139,9 +136,7 @@ def crop_image_to_coords(
def resize_to_fit(
image: Image.Image,
max_size: int,
resample: Image.Resampling = Image.Resampling.LANCZOS
image: Image.Image, max_size: int, resample: Image.Resampling = Image.Resampling.LANCZOS
) -> Image.Image:
"""
Resize image to fit within max_size while preserving aspect ratio.
@@ -162,3 +157,276 @@ 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
+3 -3
View File
@@ -5,7 +5,7 @@ Displays loading progress in the lower-right corner of the window.
"""
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QProgressBar
from PyQt6.QtCore import Qt, QPropertyAnimation, QEasingCurve, pyqtProperty
from PyQt6.QtCore import Qt, QPropertyAnimation, QEasingCurve, pyqtProperty # type: ignore[attr-defined]
from PyQt6.QtGui import QPalette, QColor
@@ -96,8 +96,8 @@ class LoadingWidget(QWidget):
"""Get opacity for animation"""
return self._opacity
@opacity.setter
def opacity(self, value):
@opacity.setter # type: ignore[no-redef]
def opacity(self, value: float) -> None:
"""Set opacity for animation"""
self._opacity = value
self.setWindowOpacity(value)
+38 -21
View File
@@ -9,8 +9,14 @@ import sys
from datetime import datetime
from pathlib import Path
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QVBoxLayout, QWidget,
QStatusBar, QScrollBar, QHBoxLayout, QMessageBox
QApplication,
QMainWindow,
QVBoxLayout,
QWidget,
QStatusBar,
QScrollBar,
QHBoxLayout,
QMessageBox,
)
from PyQt6.QtCore import Qt, QSize, QTimer
from PyQt6.QtGui import QIcon
@@ -21,6 +27,7 @@ from pyPhotoAlbum.ribbon_widget import RibbonWidget
from pyPhotoAlbum.ribbon_builder import build_ribbon_config, print_ribbon_summary
from pyPhotoAlbum.gl_widget import GLWidget
from pyPhotoAlbum.autosave_manager import AutosaveManager
from pyPhotoAlbum.thumbnail_browser import ThumbnailBrowserDock
# Import mixins
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
@@ -37,6 +44,7 @@ from pyPhotoAlbum.mixins.operations import (
SizeOperationsMixin,
ZOrderOperationsMixin,
MergeOperationsMixin,
StyleOperationsMixin,
)
@@ -55,6 +63,7 @@ class MainWindow(
SizeOperationsMixin,
ZOrderOperationsMixin,
MergeOperationsMixin,
StyleOperationsMixin,
):
"""
Main application window using mixin architecture.
@@ -85,7 +94,7 @@ class MainWindow(
self._autosave_timer.start(5 * 60 * 1000) # 5 minutes in milliseconds
# Add a sample page for demonstration
#self._add_sample_page()
# self._add_sample_page()
def _init_state(self):
"""Initialize shared application state"""
@@ -94,6 +103,7 @@ class MainWindow(
# Set asset resolution context
from pyPhotoAlbum.models import set_asset_resolution_context
set_asset_resolution_context(self._project.folder_path)
# Initialize template manager
@@ -167,6 +177,11 @@ class MainWindow(
self.setCentralWidget(main_widget)
# Create thumbnail browser dock
self._thumbnail_browser = ThumbnailBrowserDock(self)
self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self._thumbnail_browser)
self._thumbnail_browser.hide() # Initially hidden
# Create status bar
self._status_bar = QStatusBar()
self.setStatusBar(self._status_bar)
@@ -206,8 +221,8 @@ class MainWindow(
viewport_width = self._gl_widget.width()
viewport_height = self._gl_widget.height()
content_height = bounds['height']
content_width = bounds['width']
content_height = bounds["height"]
content_width = bounds["width"]
# Vertical scrollbar
# Scrollbar value 0 = top of content
@@ -257,7 +272,7 @@ class MainWindow(
print(f"Registered shortcut: {shortcut_str} -> {method_name}")
# Register additional Ctrl+Shift+Z shortcut for redo
if hasattr(self, 'redo'):
if hasattr(self, "redo"):
redo_shortcut = QShortcut(QKeySequence("Ctrl+Shift+Z"), self)
redo_shortcut.activated.connect(self.redo)
print("Registered shortcut: Ctrl+Shift+Z -> redo")
@@ -265,7 +280,7 @@ class MainWindow(
def resizeEvent(self, event):
"""Handle window resize to reposition loading widget"""
super().resizeEvent(event)
if hasattr(self, '_loading_widget'):
if hasattr(self, "_loading_widget"):
self._loading_widget.resizeParent()
def _add_sample_page(self):
@@ -315,8 +330,8 @@ class MainWindow(
return
checkpoint_path, metadata = checkpoint_info
project_name = metadata.get('project_name', 'Unknown')
timestamp_str = metadata.get('timestamp', 'Unknown time')
project_name = metadata.get("project_name", "Unknown")
timestamp_str = metadata.get("timestamp", "Unknown time")
# Parse timestamp for better display
try:
@@ -334,7 +349,7 @@ class MainWindow(
f"Time: {time_display}\n\n"
f"Would you like to recover this checkpoint?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.Yes
QMessageBox.StandardButton.Yes,
)
if reply == QMessageBox.StandardButton.Yes:
@@ -343,7 +358,7 @@ class MainWindow(
if success:
# Replace current project with recovered one
if hasattr(self, '_project') and self._project:
if hasattr(self, "_project") and self._project:
self._project.cleanup()
self._project = result
@@ -365,19 +380,21 @@ class MainWindow(
self,
"Unsaved Changes",
"You have unsaved changes. Would you like to save before exiting?",
QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Save
| QMessageBox.StandardButton.Discard
| QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Save,
)
if reply == QMessageBox.StandardButton.Save:
# Trigger save
self.save_project()
# Check if save was successful (project should be clean now)
if self.project.is_dirty():
# User cancelled save dialog or save failed
event.ignore()
return
# Save is async — ignore event and let on_complete trigger close
self._pending_close = True
save_started = self.save_project()
if not save_started:
# User cancelled the file dialog
self._pending_close = False
event.ignore()
return
elif reply == QMessageBox.StandardButton.Cancel:
# User cancelled exit
event.ignore()
@@ -390,7 +407,7 @@ class MainWindow(
self.project.cleanup()
# Stop autosave timer
if hasattr(self, '_autosave_timer'):
if hasattr(self, "_autosave_timer"):
self._autosave_timer.stop()
# Cleanup old checkpoints
+17 -12
View File
@@ -3,9 +3,21 @@ Merge dialog for resolving project conflicts visually
"""
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
QListWidget, QListWidgetItem, QSplitter, QWidget, QScrollArea,
QRadioButton, QButtonGroup, QTextEdit, QComboBox, QGroupBox
QDialog,
QVBoxLayout,
QHBoxLayout,
QPushButton,
QLabel,
QListWidget,
QListWidgetItem,
QSplitter,
QWidget,
QScrollArea,
QRadioButton,
QButtonGroup,
QTextEdit,
QComboBox,
QGroupBox,
)
from PyQt6.QtCore import Qt, QSize, pyqtSignal
from PyQt6.QtGui import QPixmap, QPainter, QColor, QFont, QPen
@@ -22,10 +34,7 @@ class PagePreviewWidget(QWidget):
super().__init__(parent)
self.page_data = page_data
self.setMinimumSize(200, 280)
self.setSizePolicy(
self.sizePolicy().Policy.Expanding,
self.sizePolicy().Policy.Expanding
)
self.setSizePolicy(self.sizePolicy().Policy.Expanding, self.sizePolicy().Policy.Expanding)
def paintEvent(self, event):
"""Render the page preview"""
@@ -356,8 +365,4 @@ class MergeDialog(QDialog):
Returns:
Merged project data dictionary
"""
return self.merge_manager.apply_resolutions(
self.our_project_data,
self.their_project_data,
self.resolutions
)
return self.merge_manager.apply_resolutions(self.our_project_data, self.their_project_data, self.resolutions)
+83 -126
View File
@@ -16,6 +16,7 @@ from datetime import datetime, timezone
class ConflictType(Enum):
"""Types of merge conflicts"""
# Page-level conflicts
PAGE_MODIFIED_BOTH = "page_modified_both" # Page modified in both versions
PAGE_DELETED_ONE = "page_deleted_one" # Page deleted in one version, modified in other
@@ -31,6 +32,7 @@ class ConflictType(Enum):
class MergeStrategy(Enum):
"""Automatic merge resolution strategies"""
LATEST_WINS = "latest_wins" # Most recent last_modified wins
OURS = "ours" # Always use our version
THEIRS = "theirs" # Always use their version
@@ -40,6 +42,7 @@ class MergeStrategy(Enum):
@dataclass
class ConflictInfo:
"""Information about a single merge conflict"""
conflict_type: ConflictType
page_uuid: Optional[str] # UUID of the page (if page-level conflict)
element_uuid: Optional[str] # UUID of the element (if element-level conflict)
@@ -76,12 +79,10 @@ class MergeManager:
print("MergeManager: One or both projects lack project_id, assuming concatenation")
return False
return project_a_id == project_b_id
return bool(project_a_id == project_b_id)
def detect_conflicts(
self,
our_project_data: Dict[str, Any],
their_project_data: Dict[str, Any]
self, our_project_data: Dict[str, Any], their_project_data: Dict[str, Any]
) -> List[ConflictInfo]:
"""
Detect conflicts between two versions of the same project.
@@ -103,16 +104,18 @@ class MergeManager:
return self.conflicts
def _detect_project_settings_conflicts(
self,
our_data: Dict[str, Any],
their_data: Dict[str, Any]
):
def _detect_project_settings_conflicts(self, our_data: Dict[str, Any], their_data: Dict[str, Any]):
"""Detect conflicts in project-level settings."""
# Settings that can conflict
settings_keys = [
"name", "page_size_mm", "working_dpi", "export_dpi",
"has_cover", "paper_thickness_mm", "cover_bleed_mm", "binding_type"
"name",
"page_size_mm",
"working_dpi",
"export_dpi",
"has_cover",
"paper_thickness_mm",
"cover_bleed_mm",
"binding_type",
]
our_modified = our_data.get("last_modified")
@@ -124,20 +127,18 @@ class MergeManager:
# If values differ, it's a conflict
if our_value != their_value:
self.conflicts.append(ConflictInfo(
conflict_type=ConflictType.SETTINGS_MODIFIED_BOTH,
page_uuid=None,
element_uuid=None,
our_version={key: our_value, "last_modified": our_modified},
their_version={key: their_value, "last_modified": their_modified},
description=f"Project setting '{key}' modified in both versions"
))
self.conflicts.append(
ConflictInfo(
conflict_type=ConflictType.SETTINGS_MODIFIED_BOTH,
page_uuid=None,
element_uuid=None,
our_version={key: our_value, "last_modified": our_modified},
their_version={key: their_value, "last_modified": their_modified},
description=f"Project setting '{key}' modified in both versions",
)
)
def _detect_page_conflicts(
self,
our_data: Dict[str, Any],
their_data: Dict[str, Any]
):
def _detect_page_conflicts(self, our_data: Dict[str, Any], their_data: Dict[str, Any]):
"""Detect conflicts at page level."""
our_pages = {page["uuid"]: page for page in our_data.get("pages", [])}
their_pages = {page["uuid"]: page for page in their_data.get("pages", [])}
@@ -164,12 +165,7 @@ class MergeManager:
# Unless we deleted it
pass
def _detect_page_modification_conflicts(
self,
page_uuid: str,
our_page: Dict[str, Any],
their_page: Dict[str, Any]
):
def _detect_page_modification_conflicts(self, page_uuid: str, our_page: Dict[str, Any], their_page: Dict[str, Any]):
"""Detect conflicts in a specific page."""
our_modified = our_page.get("last_modified")
their_modified = their_page.get("last_modified")
@@ -180,14 +176,16 @@ class MergeManager:
# Check if one deleted, one modified
if our_page.get("deleted") != their_page.get("deleted"):
self.conflicts.append(ConflictInfo(
conflict_type=ConflictType.PAGE_DELETED_ONE,
page_uuid=page_uuid,
element_uuid=None,
our_version=our_page,
their_version=their_page,
description=f"Page deleted in one version but modified in the other"
))
self.conflicts.append(
ConflictInfo(
conflict_type=ConflictType.PAGE_DELETED_ONE,
page_uuid=page_uuid,
element_uuid=None,
our_version=our_page,
their_version=their_page,
description=f"Page deleted in one version but modified in the other",
)
)
return
# Check page-level properties
@@ -201,24 +199,21 @@ class MergeManager:
# Only flag as conflict if properties differ AND timestamps are identical
# (See element conflict detection for detailed explanation of this strategy)
if page_modified and our_modified == their_modified:
self.conflicts.append(ConflictInfo(
conflict_type=ConflictType.PAGE_MODIFIED_BOTH,
page_uuid=page_uuid,
element_uuid=None,
our_version=our_page,
their_version=their_page,
description=f"Page properties modified with same timestamp (possible conflict)"
))
self.conflicts.append(
ConflictInfo(
conflict_type=ConflictType.PAGE_MODIFIED_BOTH,
page_uuid=page_uuid,
element_uuid=None,
our_version=our_page,
their_version=their_page,
description=f"Page properties modified with same timestamp (possible conflict)",
)
)
# Check element-level conflicts
self._detect_element_conflicts(page_uuid, our_page, their_page)
def _detect_element_conflicts(
self,
page_uuid: str,
our_page: Dict[str, Any],
their_page: Dict[str, Any]
):
def _detect_element_conflicts(self, page_uuid: str, our_page: Dict[str, Any], their_page: Dict[str, Any]):
"""Detect conflicts in elements within a page."""
our_layout = our_page.get("layout", {})
their_layout = their_page.get("layout", {})
@@ -238,16 +233,10 @@ class MergeManager:
continue
# Element exists in both - check for modifications
self._detect_element_modification_conflicts(
page_uuid, elem_uuid, our_elem, their_elem
)
self._detect_element_modification_conflicts(page_uuid, elem_uuid, our_elem, their_elem)
def _detect_element_modification_conflicts(
self,
page_uuid: str,
elem_uuid: str,
our_elem: Dict[str, Any],
their_elem: Dict[str, Any]
self, page_uuid: str, elem_uuid: str, our_elem: Dict[str, Any], their_elem: Dict[str, Any]
):
"""Detect conflicts in a specific element."""
our_modified = our_elem.get("last_modified")
@@ -259,14 +248,16 @@ class MergeManager:
# Check if one deleted, one modified
if our_elem.get("deleted") != their_elem.get("deleted"):
self.conflicts.append(ConflictInfo(
conflict_type=ConflictType.ELEMENT_DELETED_ONE,
page_uuid=page_uuid,
element_uuid=elem_uuid,
our_version=our_elem,
their_version=their_elem,
description=f"Element deleted in one version but modified in the other"
))
self.conflicts.append(
ConflictInfo(
conflict_type=ConflictType.ELEMENT_DELETED_ONE,
page_uuid=page_uuid,
element_uuid=elem_uuid,
our_version=our_elem,
their_version=their_elem,
description=f"Element deleted in one version but modified in the other",
)
)
return
# Check element properties
@@ -298,22 +289,21 @@ class MergeManager:
# Properties differ but timestamps match - this is unusual and might indicate
# that both versions modified it at exactly the same time, or there's data corruption.
# Flag as conflict to be safe.
self.conflicts.append(ConflictInfo(
conflict_type=ConflictType.ELEMENT_MODIFIED_BOTH,
page_uuid=page_uuid,
element_uuid=elem_uuid,
our_version=our_elem,
their_version=their_elem,
description=f"Element modified with same timestamp (possible conflict)"
))
self.conflicts.append(
ConflictInfo(
conflict_type=ConflictType.ELEMENT_MODIFIED_BOTH,
page_uuid=page_uuid,
element_uuid=elem_uuid,
our_version=our_elem,
their_version=their_elem,
description=f"Element modified with same timestamp (possible conflict)",
)
)
# Note: If timestamps differ, we assume one version modified it and the other didn't.
# The _merge_non_conflicting_changes method will automatically use the newer version.
def auto_resolve_conflicts(
self,
strategy: MergeStrategy = MergeStrategy.LATEST_WINS
) -> Dict[str, Any]:
def auto_resolve_conflicts(self, strategy: MergeStrategy = MergeStrategy.LATEST_WINS) -> Dict[int, str]:
"""
Automatically resolve conflicts based on a strategy.
@@ -353,10 +343,7 @@ class MergeManager:
return None
def apply_resolutions(
self,
our_project_data: Dict[str, Any],
their_project_data: Dict[str, Any],
resolutions: Dict[int, str]
self, our_project_data: Dict[str, Any], their_project_data: Dict[str, Any], resolutions: Dict[int, str]
) -> Dict[str, Any]:
"""
Apply conflict resolutions to create merged project.
@@ -415,20 +402,12 @@ class MergeManager:
break
break
def _merge_non_conflicting_changes(
self,
merged_data: Dict[str, Any],
their_data: Dict[str, Any]
):
def _merge_non_conflicting_changes(self, merged_data: Dict[str, Any], their_data: Dict[str, Any]):
"""Add non-conflicting pages and elements from their version."""
self._add_missing_pages(merged_data, their_data)
self._merge_page_elements(merged_data, their_data)
def _add_missing_pages(
self,
merged_data: Dict[str, Any],
their_data: Dict[str, Any]
):
def _add_missing_pages(self, merged_data: Dict[str, Any], their_data: Dict[str, Any]):
"""Add pages that exist only in their version."""
our_page_uuids = {page["uuid"] for page in merged_data.get("pages", [])}
@@ -436,11 +415,7 @@ class MergeManager:
if their_page["uuid"] not in our_page_uuids:
merged_data["pages"].append(their_page)
def _merge_page_elements(
self,
merged_data: Dict[str, Any],
their_data: Dict[str, Any]
):
def _merge_page_elements(self, merged_data: Dict[str, Any], their_data: Dict[str, Any]):
"""For pages that exist in both versions, merge their elements."""
their_pages = {page["uuid"]: page for page in their_data.get("pages", [])}
@@ -449,25 +424,15 @@ class MergeManager:
if not their_page:
continue
our_elements = {
elem["uuid"]: elem
for elem in our_page.get("layout", {}).get("elements", [])
}
our_elements = {elem["uuid"]: elem for elem in our_page.get("layout", {}).get("elements", [])}
for their_elem in their_page.get("layout", {}).get("elements", []):
self._merge_element(
our_page=our_page,
page_uuid=our_page["uuid"],
their_elem=their_elem,
our_elements=our_elements
our_page=our_page, page_uuid=our_page["uuid"], their_elem=their_elem, our_elements=our_elements
)
def _merge_element(
self,
our_page: Dict[str, Any],
page_uuid: str,
their_elem: Dict[str, Any],
our_elements: Dict[str, Any]
self, our_page: Dict[str, Any], page_uuid: str, their_elem: Dict[str, Any], our_elements: Dict[str, Any]
):
"""Merge a single element from their version into our page."""
elem_uuid = their_elem["uuid"]
@@ -486,17 +451,10 @@ class MergeManager:
def _is_element_in_conflict(self, elem_uuid: str, page_uuid: str) -> bool:
"""Check if element was part of a conflict that was already resolved."""
return any(
c.element_uuid == elem_uuid and c.page_uuid == page_uuid
for c in self.conflicts
)
return any(c.element_uuid == elem_uuid and c.page_uuid == page_uuid for c in self.conflicts)
def _merge_by_timestamp(
self,
our_page: Dict[str, Any],
elem_uuid: str,
their_elem: Dict[str, Any],
our_elem: Dict[str, Any]
self, our_page: Dict[str, Any], elem_uuid: str, their_elem: Dict[str, Any], our_elem: Dict[str, Any]
):
"""Use the more recently modified version of an element."""
our_modified = our_elem.get("last_modified")
@@ -513,10 +471,7 @@ class MergeManager:
break
def concatenate_projects(
project_a_data: Dict[str, Any],
project_b_data: Dict[str, Any]
) -> Dict[str, Any]:
def concatenate_projects(project_a_data: Dict[str, Any], project_b_data: Dict[str, Any]) -> Dict[str, Any]:
"""
Concatenate two projects with different project_ids.
@@ -542,6 +497,8 @@ def concatenate_projects(
# Update last_modified to now
merged_data["last_modified"] = datetime.now(timezone.utc).isoformat()
print(f"Concatenated projects: {len(project_a_data.get('pages', []))} + {len(project_b_data.get('pages', []))} = {len(merged_data['pages'])} pages")
print(
f"Concatenated projects: {len(project_a_data.get('pages', []))} + {len(project_b_data.get('pages', []))} = {len(merged_data['pages'])} pages"
)
return merged_data
+1 -1
View File
@@ -5,4 +5,4 @@ Mixin modules for pyPhotoAlbum
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
from pyPhotoAlbum.mixins.dialog_mixin import DialogMixin
__all__ = ['ApplicationStateMixin', 'DialogMixin']
__all__ = ["ApplicationStateMixin", "DialogMixin"]
+12 -20
View File
@@ -14,7 +14,7 @@ class AssetDropMixin:
or updating ImageData elements.
"""
IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp']
IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp"]
def dragEnterEvent(self, event):
"""Handle drag enter events"""
@@ -66,7 +66,7 @@ class AssetDropMixin:
def _handle_drop_on_element(self, image_path, target_element):
"""Handle dropping an image onto an existing element"""
main_window = self.window()
if not (hasattr(main_window, 'project') and main_window.project):
if not (hasattr(main_window, "project") and main_window.project):
return
try:
@@ -89,7 +89,9 @@ class AssetDropMixin:
y=placeholder.position[1],
width=placeholder.size[0],
height=placeholder.size[1],
z_index=placeholder.z_index
z_index=placeholder.z_index,
# Inherit styling from placeholder (for templatable styles)
style=placeholder.style.copy(),
)
if not main_window.project.pages:
@@ -104,7 +106,7 @@ class AssetDropMixin:
def _handle_drop_on_empty_space(self, image_path, x, y):
"""Handle dropping an image onto empty space"""
main_window = self.window()
if not (hasattr(main_window, 'project') and main_window.project and main_window.project.pages):
if not (hasattr(main_window, "project") and main_window.project and main_window.project.pages):
return
target_page, page_index, page_renderer = self._get_page_at(x, y)
@@ -120,8 +122,7 @@ class AssetDropMixin:
img_width, img_height = self._calculate_image_dimensions(full_asset_path)
self._add_new_image_to_page(
asset_path, target_page, page_index, page_renderer,
x, y, img_width, img_height, main_window
asset_path, target_page, page_index, page_renderer, x, y, img_width, img_height, main_window
)
except Exception as e:
print(f"Error importing dropped image: {e}")
@@ -138,27 +139,18 @@ class AssetDropMixin:
# Fallback dimensions if image cannot be read
return 200, 150
def _add_new_image_to_page(self, asset_path, target_page, page_index,
page_renderer, x, y, img_width, img_height, main_window):
def _add_new_image_to_page(
self, asset_path, target_page, page_index, page_renderer, x, y, img_width, img_height, main_window
):
"""Add a new image element to the target page (asset already imported)"""
if page_index >= 0:
self.current_page_index = page_index
page_local_x, page_local_y = page_renderer.screen_to_page(x, y)
new_image = ImageData(
image_path=asset_path,
x=page_local_x,
y=page_local_y,
width=img_width,
height=img_height
)
new_image = ImageData(image_path=asset_path, x=page_local_x, y=page_local_y, width=img_width, height=img_height)
cmd = AddElementCommand(
target_page.layout,
new_image,
asset_manager=main_window.project.asset_manager
)
cmd = AddElementCommand(target_page.layout, new_image, asset_manager=main_window.project.asset_manager)
main_window.project.history.execute(cmd)
print(f"Added new image to page {page_index + 1} at ({page_local_x:.1f}, {page_local_y:.1f}): {asset_path}")
+2 -2
View File
@@ -63,6 +63,6 @@ class AssetPathMixin:
Override this method if the project is accessed differently.
Default implementation uses self.project.folder_path.
"""
if hasattr(self, 'project') and self.project:
return getattr(self.project, 'folder_path', None)
if hasattr(self, "project") and self.project:
return getattr(self.project, "folder_path", None)
return None
+43 -36
View File
@@ -4,17 +4,32 @@ Async loading mixin for non-blocking image loading and PDF generation.
import os
from pathlib import Path
from typing import Optional
from typing import TYPE_CHECKING, Optional, cast
import logging
from PyQt6.QtCore import QObject
from PyQt6.QtWidgets import QProgressDialog
from pyPhotoAlbum.async_backend import AsyncImageLoader, AsyncPDFGenerator, ImageCache, LoadPriority
if TYPE_CHECKING:
from PyQt6.QtWidgets import QMainWindow
logger = logging.getLogger(__name__)
class AsyncLoadingMixin:
# Type hints for expected attributes from mixing class
_pdf_progress_dialog: Optional[QProgressDialog]
def update(self) -> None: # type: ignore[empty-body]
"""Expected from QWidget"""
...
def window(self) -> "QMainWindow": # type: ignore[empty-body]
"""Expected from QWidget"""
...
"""
Mixin to add async loading capabilities to GLWidget.
@@ -48,13 +63,13 @@ class AsyncLoadingMixin:
"""Cleanup async loading components."""
logger.info("Cleaning up async loading system...")
if hasattr(self, 'async_image_loader'):
if hasattr(self, "async_image_loader"):
self.async_image_loader.stop()
if hasattr(self, 'async_pdf_generator'):
if hasattr(self, "async_pdf_generator"):
self.async_pdf_generator.stop()
if hasattr(self, 'image_cache'):
if hasattr(self, "image_cache"):
self.image_cache.clear()
logger.info("Async loading system cleaned up")
@@ -70,7 +85,7 @@ class AsyncLoadingMixin:
"""
logger.debug(f"Image loaded callback: {path}")
if user_data and hasattr(user_data, '_on_async_image_loaded'):
if user_data and hasattr(user_data, "_on_async_image_loaded"):
user_data._on_async_image_loaded(image)
# Trigger re-render to show newly loaded image
@@ -87,7 +102,7 @@ class AsyncLoadingMixin:
"""
logger.warning(f"Image load failed: {path} - {error_msg}")
if user_data and hasattr(user_data, '_on_async_image_load_failed'):
if user_data and hasattr(user_data, "_on_async_image_load_failed"):
user_data._on_async_image_load_failed(error_msg)
def _on_pdf_progress(self, current: int, total: int, message: str):
@@ -102,9 +117,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):
"""
@@ -117,19 +134,16 @@ class AsyncLoadingMixin:
logger.info(f"PDF export complete: success={success}, warnings={len(warnings)}")
# Close progress dialog
if hasattr(self, '_pdf_progress_dialog') and self._pdf_progress_dialog:
if hasattr(self, "_pdf_progress_dialog") and self._pdf_progress_dialog:
self._pdf_progress_dialog.close()
self._pdf_progress_dialog = None
# Show completion message
main_window = self.window()
if hasattr(main_window, 'show_status'):
if hasattr(main_window, "show_status"):
if success:
if warnings:
main_window.show_status(
f"PDF exported successfully with {len(warnings)} warnings",
5000
)
main_window.show_status(f"PDF exported successfully with {len(warnings)} warnings", 5000)
else:
main_window.show_status("PDF exported successfully", 3000)
else:
@@ -145,13 +159,13 @@ class AsyncLoadingMixin:
logger.error(f"PDF export failed: {error_msg}")
# Close progress dialog
if hasattr(self, '_pdf_progress_dialog') and self._pdf_progress_dialog:
if hasattr(self, "_pdf_progress_dialog") and self._pdf_progress_dialog:
self._pdf_progress_dialog.close()
self._pdf_progress_dialog = None
# Show error message
main_window = self.window()
if hasattr(main_window, 'show_status'):
if hasattr(main_window, "show_status"):
main_window.show_status(f"PDF export failed: {error_msg}", 5000)
def request_image_load(self, image_data, priority: LoadPriority = LoadPriority.NORMAL):
@@ -162,7 +176,7 @@ class AsyncLoadingMixin:
image_data: ImageData element to load
priority: Load priority level
"""
if not hasattr(self, 'async_image_loader'):
if not hasattr(self, "async_image_loader"):
logger.warning("Async image loader not initialized")
return
@@ -188,7 +202,7 @@ class AsyncLoadingMixin:
Path(image_full_path),
priority=priority,
target_size=target_size,
user_data=image_data # Pass element for callback
user_data=image_data, # Pass element for callback
)
def export_pdf_async(self, project, output_path: str, export_dpi: int = 300):
@@ -200,7 +214,7 @@ class AsyncLoadingMixin:
output_path: Output PDF file path
export_dpi: Export DPI (default 300)
"""
if not hasattr(self, 'async_pdf_generator'):
if not hasattr(self, "async_pdf_generator"):
logger.warning("Async PDF generator not initialized")
return False
@@ -208,18 +222,11 @@ class AsyncLoadingMixin:
from PyQt6.QtWidgets import QProgressDialog
from PyQt6.QtCore import Qt
total_pages = sum(
1 if page.is_cover else (2 if page.is_double_spread else 1)
for page in project.pages
)
total_pages = sum(1 if page.is_cover else (2 if page.is_double_spread else 1) for page in project.pages)
self._pdf_progress_dialog = QProgressDialog(
"Exporting to PDF...",
"Cancel",
0,
total_pages,
self
)
from PyQt6.QtWidgets import QWidget
self._pdf_progress_dialog = QProgressDialog("Exporting to PDF...", "Cancel", 0, total_pages, cast(QWidget, self))
self._pdf_progress_dialog.setWindowModality(Qt.WindowModality.WindowModal)
self._pdf_progress_dialog.setWindowTitle("PDF Export")
self._pdf_progress_dialog.canceled.connect(self._on_pdf_cancel)
@@ -232,17 +239,17 @@ class AsyncLoadingMixin:
"""Handle PDF export cancellation."""
logger.info("User requested PDF export cancellation")
if hasattr(self, 'async_pdf_generator'):
if hasattr(self, "async_pdf_generator"):
self.async_pdf_generator.cancel_export()
def get_async_stats(self) -> dict:
"""Get async loading system statistics."""
stats = {}
if hasattr(self, 'async_image_loader'):
stats['image_loader'] = self.async_image_loader.get_stats()
if hasattr(self, "async_image_loader"):
stats["image_loader"] = self.async_image_loader.get_stats()
if hasattr(self, 'async_pdf_generator'):
stats['pdf_generator'] = self.async_pdf_generator.get_stats()
if hasattr(self, "async_pdf_generator"):
stats["pdf_generator"] = self.async_pdf_generator.get_stats()
return stats
+15 -18
View File
@@ -2,8 +2,8 @@
Base mixin providing shared application state access
"""
from typing import Optional
from PyQt6.QtWidgets import QStatusBar, QMessageBox
from typing import Any, Optional, cast
from PyQt6.QtWidgets import QStatusBar, QMessageBox, QWidget
class ApplicationStateMixin:
@@ -23,7 +23,7 @@ class ApplicationStateMixin:
@property
def project(self):
"""Access to current project"""
if not hasattr(self, '_project'):
if not hasattr(self, "_project"):
raise AttributeError("MainWindow must set _project attribute")
return self._project
@@ -35,21 +35,21 @@ class ApplicationStateMixin:
@property
def gl_widget(self):
"""Access to GL rendering widget"""
if not hasattr(self, '_gl_widget'):
if not hasattr(self, "_gl_widget"):
raise AttributeError("MainWindow must set _gl_widget attribute")
return self._gl_widget
@property
def status_bar(self) -> QStatusBar:
"""Access to status bar"""
if not hasattr(self, '_status_bar'):
if not hasattr(self, "_status_bar"):
raise AttributeError("MainWindow must set _status_bar attribute")
return self._status_bar
return cast(QStatusBar, self._status_bar)
@property
def template_manager(self):
"""Access to template manager"""
if not hasattr(self, '_template_manager'):
if not hasattr(self, "_template_manager"):
raise AttributeError("MainWindow must set _template_manager attribute")
return self._template_manager
@@ -62,7 +62,7 @@ class ApplicationStateMixin:
Returns:
int: Index of the most visible page
"""
if not hasattr(self.gl_widget, '_page_renderers') or not self.gl_widget._page_renderers:
if not hasattr(self.gl_widget, "_page_renderers") or not self.gl_widget._page_renderers:
return self.gl_widget.current_page_index
# Get viewport dimensions
@@ -70,7 +70,7 @@ class ApplicationStateMixin:
viewport_center_y = viewport_height / 2
# Find which page's center is closest to viewport center
min_distance = float('inf')
min_distance = float("inf")
best_page_index = self.gl_widget.current_page_index
for renderer, page in self.gl_widget._page_renderers:
@@ -117,7 +117,7 @@ class ApplicationStateMixin:
"""
if not self.project or not self.project.pages:
return -1
return self.gl_widget.current_page_index
return int(self.gl_widget.current_page_index)
def show_status(self, message: str, timeout: int = 2000):
"""
@@ -138,7 +138,7 @@ class ApplicationStateMixin:
title: Dialog title
message: Error message
"""
QMessageBox.critical(self, title, message)
QMessageBox.critical(cast(QWidget, self), title, message)
def show_warning(self, title: str, message: str):
"""
@@ -148,7 +148,7 @@ class ApplicationStateMixin:
title: Dialog title
message: Warning message
"""
QMessageBox.warning(self, title, message)
QMessageBox.warning(cast(QWidget, self), title, message)
def show_info(self, title: str, message: str):
"""
@@ -158,7 +158,7 @@ class ApplicationStateMixin:
title: Dialog title
message: Information message
"""
QMessageBox.information(self, title, message)
QMessageBox.information(cast(QWidget, self), title, message)
def require_page(self, show_warning: bool = True) -> bool:
"""
@@ -197,10 +197,7 @@ class ApplicationStateMixin:
if min_count == 1:
self.show_info("No Selection", "Please select an element.")
else:
self.show_info(
"Selection Required",
f"Please select at least {min_count} elements."
)
self.show_info("Selection Required", f"Please select at least {min_count} elements.")
return False
return True
@@ -211,5 +208,5 @@ class ApplicationStateMixin:
self.gl_widget.update()
# Update scrollbars to reflect new content
if hasattr(self, 'update_scrollbars'):
if hasattr(self, "update_scrollbars"):
self.update_scrollbars()
+3 -13
View File
@@ -16,12 +16,7 @@ class DialogMixin:
making it easier to create, test, and maintain complex dialogs.
"""
def create_dialog(
self,
dialog_class: type,
title: Optional[str] = None,
**kwargs
) -> Optional[Any]:
def create_dialog(self, dialog_class: type, title: Optional[str] = None, **kwargs) -> Optional[Any]:
"""
Create and show a dialog, handling the result.
@@ -43,18 +38,13 @@ class DialogMixin:
# Show dialog and handle result
if dialog.exec() == QDialog.DialogCode.Accepted:
# Check if dialog has a get_values method
if hasattr(dialog, 'get_values'):
if hasattr(dialog, "get_values"):
return dialog.get_values()
return True
return None
def show_dialog(
self,
dialog_class: type,
on_accept: Optional[Callable] = None,
**kwargs
) -> bool:
def show_dialog(self, dialog_class: type, on_accept: Optional[Callable] = None, **kwargs) -> bool:
"""
Show a dialog and execute callback on acceptance.
+34 -20
View File
@@ -2,18 +2,31 @@
Element manipulation mixin for GLWidget - handles element transformations
"""
from typing import Optional, Tuple
from typing import TYPE_CHECKING, Any, Optional, Tuple
if TYPE_CHECKING:
from pyPhotoAlbum.models import BaseLayoutElement
from PyQt6.QtWidgets import QMainWindow
class ElementManipulationMixin:
"""
Mixin providing element transformation functionality.
# Type hints for expected attributes from mixing class
selected_element: Optional["BaseLayoutElement"]
drag_start_pos: Optional[Tuple[float, float]]
drag_start_element_pos: Optional[Tuple[float, float]]
This mixin handles resizing, rotating, and moving elements, including
snapping support and cross-page element transfers.
"""
def window(self) -> "QMainWindow": # type: ignore[empty-body]
"""Expected from QWidget"""
...
def __init__(self, *args, **kwargs):
"""
Initialize element manipulation mixin.
This mixin provides element transformation functionality including
resizing, rotating, moving elements, snapping support and cross-page
element transfers.
"""
super().__init__(*args, **kwargs)
# Resize state
@@ -27,11 +40,7 @@ class ElementManipulationMixin:
self.rotation_snap_angle: int = 15 # Default snap angle in degrees
# Snap state tracking
self.snap_state = {
'is_snapped': False,
'last_position': None,
'last_size': None
}
self.snap_state = {"is_snapped": False, "last_position": None, "last_size": None}
def _resize_element(self, dx: float, dy: float):
"""
@@ -49,19 +58,20 @@ class ElementManipulationMixin:
# Get the snapping system from the element's parent page
main_window = self.window()
if not hasattr(self.selected_element, '_parent_page'):
if not hasattr(self.selected_element, "_parent_page"):
self._resize_element_no_snap(dx, dy)
return
parent_page = self.selected_element._parent_page
parent_page = self.selected_element._parent_page # type: ignore[attr-defined]
snap_sys = parent_page.layout.snapping_system
# Get page size
page_size = parent_page.layout.size
dpi = main_window.project.working_dpi
dpi = main_window.project.working_dpi # type: ignore[attr-defined]
# Apply snapping to resize
from pyPhotoAlbum.snapping import SnapResizeParams
params = SnapResizeParams(
position=self.resize_start_pos,
size=self.resize_start_size,
@@ -70,7 +80,7 @@ class ElementManipulationMixin:
resize_handle=self.resize_handle,
page_size=page_size,
dpi=dpi,
project=main_window.project
project=main_window.project, # type: ignore[attr-defined]
)
new_pos, new_size = snap_sys.snap_resize(params)
@@ -96,20 +106,22 @@ class ElementManipulationMixin:
"""
if not self.resize_start_pos or not self.resize_start_size:
return
if self.selected_element is None:
return
start_x, start_y = self.resize_start_pos
start_w, start_h = self.resize_start_size
if self.resize_handle == 'nw':
if self.resize_handle == "nw":
self.selected_element.position = (start_x + dx, start_y + dy)
self.selected_element.size = (start_w - dx, start_h - dy)
elif self.resize_handle == 'ne':
elif self.resize_handle == "ne":
self.selected_element.position = (start_x, start_y + dy)
self.selected_element.size = (start_w + dx, start_h - dy)
elif self.resize_handle == 'sw':
elif self.resize_handle == "sw":
self.selected_element.position = (start_x + dx, start_y)
self.selected_element.size = (start_w - dx, start_h + dy)
elif self.resize_handle == 'se':
elif self.resize_handle == "se":
self.selected_element.size = (start_w + dx, start_h + dy)
# Ensure minimum size
@@ -121,7 +133,9 @@ class ElementManipulationMixin:
w, _ = self.selected_element.size
self.selected_element.size = (w, min_size)
def _transfer_element_to_page(self, element, source_page, target_page, mouse_x: float, mouse_y: float, target_renderer):
def _transfer_element_to_page(
self, element, source_page, target_page, mouse_x: float, mouse_y: float, target_renderer
):
"""
Transfer an element from one page to another during drag operation.
+23 -12
View File
@@ -2,11 +2,22 @@
Element selection mixin for GLWidget - handles element selection and hit detection
"""
from typing import Optional, Set
from typing import Any, TYPE_CHECKING, Optional, Set
if TYPE_CHECKING:
from PyQt6.QtWidgets import QMainWindow
from pyPhotoAlbum.models import BaseLayoutElement
class ElementSelectionMixin:
# Type hints for expected attributes from mixing class
_page_renderers: list
def window(self) -> "QMainWindow": # type: ignore[empty-body]
"""Expected from QWidget"""
...
"""
Mixin providing element selection and hit detection functionality.
@@ -54,7 +65,7 @@ class ElementSelectionMixin:
Returns:
BaseLayoutElement or None: The topmost element at the position, or None
"""
if not hasattr(self, '_page_renderers') or not self._page_renderers:
if not hasattr(self, "_page_renderers") or not self._page_renderers:
return None
# Check each page from top to bottom (reverse z-order)
@@ -73,9 +84,9 @@ class ElementSelectionMixin:
# Simple bounds check (no rotation transformation needed - images are already rotated)
if ex <= page_x <= ex + ew and ey <= page_y <= ey + eh:
# Store the renderer with the element for later use
element._page_renderer = renderer
element._parent_page = page
return element
element._page_renderer = renderer # type: ignore[attr-defined]
element._parent_page = page # type: ignore[attr-defined]
return element # type: ignore[no-any-return]
return None
@@ -96,14 +107,14 @@ class ElementSelectionMixin:
return None
main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return None
# Get the PageRenderer for this element (stored when element was selected)
if not hasattr(self.selected_element, '_page_renderer'):
if not hasattr(self.selected_element, "_page_renderer"):
return None
renderer = self.selected_element._page_renderer
renderer: Any = self.selected_element._page_renderer # type: ignore[attr-defined]
# Get element position and size in page-local coordinates
elem_x, elem_y = self.selected_element.position
@@ -117,10 +128,10 @@ class ElementSelectionMixin:
# Check handles (no rotation transformation needed - images are already rotated)
handles = {
'nw': (ex - handle_size/2, ey - handle_size/2),
'ne': (ex + ew - handle_size/2, ey - handle_size/2),
'sw': (ex - handle_size/2, ey + eh - handle_size/2),
'se': (ex + ew - handle_size/2, ey + eh - handle_size/2),
"nw": (ex - handle_size / 2, ey - handle_size / 2),
"ne": (ex + ew - handle_size / 2, ey - handle_size / 2),
"sw": (ex - handle_size / 2, ey + eh - handle_size / 2),
"se": (ex + ew - handle_size / 2, ey + eh - handle_size / 2),
}
for name, (hx, hy) in handles.items():
+7 -2
View File
@@ -2,11 +2,16 @@
Image pan mixin for GLWidget - handles panning images within frames
"""
from typing import Optional, Tuple
from typing import TYPE_CHECKING, Optional, Tuple
from pyPhotoAlbum.models import ImageData
class ImagePanMixin:
# Type hints for expected attributes from mixing class
drag_start_pos: Optional[Tuple[float, float]]
zoom_level: float
"""
Mixin providing image panning functionality.
@@ -21,7 +26,7 @@ class ImagePanMixin:
self.image_pan_mode: bool = False # True when Control+dragging an ImageData element
self.image_pan_start_crop: Optional[Tuple[float, float, float, float]] = None # Starting crop_info
def _handle_image_pan_move(self, x: float, y: float, element: ImageData):
def _handle_image_pan_move(self, x: float, y: float, element: "ImageData"):
"""
Handle image panning within a frame during mouse move.
@@ -59,7 +59,7 @@ class MoveCommandBuilder(CommandBuilder):
def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
"""Check if position changed significantly."""
old_pos = start_state.get('position')
old_pos = start_state.get("position")
if old_pos is None:
return False
@@ -68,7 +68,7 @@ class MoveCommandBuilder(CommandBuilder):
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
"""Build a MoveElementCommand."""
old_pos = start_state.get('position')
old_pos = start_state.get("position")
if old_pos is None:
return None
@@ -79,6 +79,7 @@ class MoveCommandBuilder(CommandBuilder):
return None
from pyPhotoAlbum.commands import MoveElementCommand
command = MoveElementCommand(element, old_pos, new_pos)
self.log_command("Move", f"{old_pos}{new_pos}")
@@ -90,8 +91,8 @@ class ResizeCommandBuilder(CommandBuilder):
def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
"""Check if position or size changed significantly."""
old_pos = start_state.get('position')
old_size = start_state.get('size')
old_pos = start_state.get("position")
old_size = start_state.get("size")
if old_pos is None or old_size is None:
return False
@@ -106,8 +107,8 @@ class ResizeCommandBuilder(CommandBuilder):
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
"""Build a ResizeElementCommand."""
old_pos = start_state.get('position')
old_size = start_state.get('size')
old_pos = start_state.get("position")
old_size = start_state.get("size")
if old_pos is None or old_size is None:
return None
@@ -119,6 +120,7 @@ class ResizeCommandBuilder(CommandBuilder):
return None
from pyPhotoAlbum.commands import ResizeElementCommand
command = ResizeElementCommand(element, old_pos, old_size, new_pos, new_size)
self.log_command("Resize", f"{old_size}{new_size}")
@@ -130,7 +132,7 @@ class RotateCommandBuilder(CommandBuilder):
def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
"""Check if rotation changed significantly."""
old_rotation = start_state.get('rotation')
old_rotation = start_state.get("rotation")
if old_rotation is None:
return False
@@ -139,7 +141,7 @@ class RotateCommandBuilder(CommandBuilder):
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
"""Build a RotateElementCommand."""
old_rotation = start_state.get('rotation')
old_rotation = start_state.get("rotation")
if old_rotation is None:
return None
@@ -150,6 +152,7 @@ class RotateCommandBuilder(CommandBuilder):
return None
from pyPhotoAlbum.commands import RotateElementCommand
command = RotateElementCommand(element, old_rotation, new_rotation)
self.log_command("Rotation", f"{old_rotation:.1f}° → {new_rotation:.1f}°")
@@ -166,7 +169,7 @@ class ImagePanCommandBuilder(CommandBuilder):
if not isinstance(element, ImageData):
return False
old_crop = start_state.get('crop_info')
old_crop = start_state.get("crop_info")
if old_crop is None:
return False
@@ -181,7 +184,7 @@ class ImagePanCommandBuilder(CommandBuilder):
if not isinstance(element, ImageData):
return None
old_crop = start_state.get('crop_info')
old_crop = start_state.get("crop_info")
if old_crop is None:
return None
@@ -193,6 +196,7 @@ class ImagePanCommandBuilder(CommandBuilder):
return None
from pyPhotoAlbum.commands import AdjustImageCropCommand
command = AdjustImageCropCommand(element, old_crop, new_crop)
self.log_command("Image pan", f"{old_crop}{new_crop}")
@@ -12,7 +12,7 @@ from .interaction_command_builders import (
MoveCommandBuilder,
ResizeCommandBuilder,
RotateCommandBuilder,
ImagePanCommandBuilder
ImagePanCommandBuilder,
)
@@ -31,10 +31,10 @@ class InteractionCommandFactory:
def _register_default_builders(self):
"""Register the default command builders."""
self.register_builder('move', MoveCommandBuilder())
self.register_builder('resize', ResizeCommandBuilder())
self.register_builder('rotate', RotateCommandBuilder())
self.register_builder('image_pan', ImagePanCommandBuilder())
self.register_builder("move", MoveCommandBuilder())
self.register_builder("resize", ResizeCommandBuilder())
self.register_builder("rotate", RotateCommandBuilder())
self.register_builder("image_pan", ImagePanCommandBuilder())
def register_builder(self, interaction_type: str, builder: CommandBuilder):
"""
@@ -46,11 +46,9 @@ class InteractionCommandFactory:
"""
self._builders[interaction_type] = builder
def create_command(self,
interaction_type: str,
element: BaseLayoutElement,
start_state: dict,
**kwargs) -> Optional[Any]:
def create_command(
self, interaction_type: str, element: BaseLayoutElement, start_state: dict, **kwargs
) -> Optional[Any]:
"""
Create a command based on interaction type and state changes.
@@ -91,13 +89,15 @@ class InteractionState:
the code more maintainable.
"""
def __init__(self,
element: Optional[BaseLayoutElement] = None,
interaction_type: Optional[str] = None,
position: Optional[tuple] = None,
size: Optional[tuple] = None,
rotation: Optional[float] = None,
crop_info: Optional[tuple] = None):
def __init__(
self,
element: Optional[BaseLayoutElement] = None,
interaction_type: Optional[str] = None,
position: Optional[tuple] = None,
size: Optional[tuple] = None,
rotation: Optional[float] = None,
crop_info: Optional[tuple] = None,
):
"""
Initialize interaction state.
@@ -123,15 +123,15 @@ class InteractionState:
Returns:
Dict with non-None state values
"""
state = {}
state: Dict[str, Any] = {}
if self.position is not None:
state['position'] = self.position
state["position"] = self.position
if self.size is not None:
state['size'] = self.size
state["size"] = self.size
if self.rotation is not None:
state['rotation'] = self.rotation
state["rotation"] = self.rotation
if self.crop_info is not None:
state['crop_info'] = self.crop_info
state["crop_info"] = self.crop_info
return state
def is_valid(self) -> bool:
+7 -6
View File
@@ -33,7 +33,7 @@ class UndoableInteractionMixin:
element: The element being moved
"""
self._interaction_state.element = element
self._interaction_state.interaction_type = 'move'
self._interaction_state.interaction_type = "move"
self._interaction_state.position = element.position
def _begin_resize(self, element: BaseLayoutElement):
@@ -44,7 +44,7 @@ class UndoableInteractionMixin:
element: The element being resized
"""
self._interaction_state.element = element
self._interaction_state.interaction_type = 'resize'
self._interaction_state.interaction_type = "resize"
self._interaction_state.position = element.position
self._interaction_state.size = element.size
@@ -56,7 +56,7 @@ class UndoableInteractionMixin:
element: The element being rotated
"""
self._interaction_state.element = element
self._interaction_state.interaction_type = 'rotate'
self._interaction_state.interaction_type = "rotate"
self._interaction_state.rotation = element.rotation
def _begin_image_pan(self, element):
@@ -67,11 +67,12 @@ class UndoableInteractionMixin:
element: The ImageData element being panned
"""
from pyPhotoAlbum.models import ImageData
if not isinstance(element, ImageData):
return
self._interaction_state.element = element
self._interaction_state.interaction_type = 'image_pan'
self._interaction_state.interaction_type = "image_pan"
self._interaction_state.crop_info = element.crop_info
def _end_interaction(self):
@@ -88,7 +89,7 @@ class UndoableInteractionMixin:
# Get main window to access project history
main_window = self.window()
if not hasattr(main_window, 'project'):
if not hasattr(main_window, "project"):
self._clear_interaction_state()
return
@@ -96,7 +97,7 @@ class UndoableInteractionMixin:
command = self._command_factory.create_command(
interaction_type=self._interaction_state.interaction_type,
element=self._interaction_state.element,
start_state=self._interaction_state.to_dict()
start_state=self._interaction_state.to_dict(),
)
# Execute the command through history if one was created
+31 -38
View File
@@ -16,6 +16,7 @@ def significant_change(threshold: float = 0.1):
Returns:
None if change is insignificant, otherwise returns the command builder result
"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
@@ -23,7 +24,9 @@ def significant_change(threshold: float = 0.1):
if result is None:
return None
return result
return wrapper
return decorator
@@ -31,9 +34,9 @@ class ChangeValidator:
"""Validates whether changes are significant enough to create commands."""
@staticmethod
def position_changed(old_pos: Optional[Tuple[float, float]],
new_pos: Optional[Tuple[float, float]],
threshold: float = 0.1) -> bool:
def position_changed(
old_pos: Optional[Tuple[float, float]], new_pos: Optional[Tuple[float, float]], threshold: float = 0.1
) -> bool:
"""Check if position changed significantly."""
if old_pos is None or new_pos is None:
return False
@@ -43,9 +46,9 @@ class ChangeValidator:
return dx > threshold or dy > threshold
@staticmethod
def size_changed(old_size: Optional[Tuple[float, float]],
new_size: Optional[Tuple[float, float]],
threshold: float = 0.1) -> bool:
def size_changed(
old_size: Optional[Tuple[float, float]], new_size: Optional[Tuple[float, float]], threshold: float = 0.1
) -> bool:
"""Check if size changed significantly."""
if old_size is None or new_size is None:
return False
@@ -55,9 +58,7 @@ class ChangeValidator:
return dw > threshold or dh > threshold
@staticmethod
def rotation_changed(old_rotation: Optional[float],
new_rotation: Optional[float],
threshold: float = 0.1) -> bool:
def rotation_changed(old_rotation: Optional[float], new_rotation: Optional[float], threshold: float = 0.1) -> bool:
"""Check if rotation changed significantly."""
if old_rotation is None or new_rotation is None:
return False
@@ -65,9 +66,11 @@ class ChangeValidator:
return abs(new_rotation - old_rotation) > threshold
@staticmethod
def crop_changed(old_crop: Optional[Tuple[float, float, float, float]],
new_crop: Optional[Tuple[float, float, float, float]],
threshold: float = 0.001) -> bool:
def crop_changed(
old_crop: Optional[Tuple[float, float, float, float]],
new_crop: Optional[Tuple[float, float, float, float]],
threshold: float = 0.001,
) -> bool:
"""Check if crop info changed significantly."""
if old_crop is None or new_crop is None:
return False
@@ -85,8 +88,7 @@ class InteractionChangeDetector:
self.threshold = threshold
self.validator = ChangeValidator()
def detect_position_change(self, old_pos: Tuple[float, float],
new_pos: Tuple[float, float]) -> Optional[dict]:
def detect_position_change(self, old_pos: Tuple[float, float], new_pos: Tuple[float, float]) -> Optional[dict]:
"""
Detect position change and return change info.
@@ -97,14 +99,13 @@ class InteractionChangeDetector:
return None
return {
'old_position': old_pos,
'new_position': new_pos,
'delta_x': new_pos[0] - old_pos[0],
'delta_y': new_pos[1] - old_pos[1]
"old_position": old_pos,
"new_position": new_pos,
"delta_x": new_pos[0] - old_pos[0],
"delta_y": new_pos[1] - old_pos[1],
}
def detect_size_change(self, old_size: Tuple[float, float],
new_size: Tuple[float, float]) -> Optional[dict]:
def detect_size_change(self, old_size: Tuple[float, float], new_size: Tuple[float, float]) -> Optional[dict]:
"""
Detect size change and return change info.
@@ -115,14 +116,13 @@ class InteractionChangeDetector:
return None
return {
'old_size': old_size,
'new_size': new_size,
'delta_width': new_size[0] - old_size[0],
'delta_height': new_size[1] - old_size[1]
"old_size": old_size,
"new_size": new_size,
"delta_width": new_size[0] - old_size[0],
"delta_height": new_size[1] - old_size[1],
}
def detect_rotation_change(self, old_rotation: float,
new_rotation: float) -> Optional[dict]:
def detect_rotation_change(self, old_rotation: float, new_rotation: float) -> Optional[dict]:
"""
Detect rotation change and return change info.
@@ -132,14 +132,11 @@ class InteractionChangeDetector:
if not self.validator.rotation_changed(old_rotation, new_rotation, self.threshold):
return None
return {
'old_rotation': old_rotation,
'new_rotation': new_rotation,
'delta_angle': new_rotation - old_rotation
}
return {"old_rotation": old_rotation, "new_rotation": new_rotation, "delta_angle": new_rotation - old_rotation}
def detect_crop_change(self, old_crop: Tuple[float, float, float, float],
new_crop: Tuple[float, float, float, float]) -> Optional[dict]:
def detect_crop_change(
self, old_crop: Tuple[float, float, float, float], new_crop: Tuple[float, float, float, float]
) -> Optional[dict]:
"""
Detect crop change and return change info.
@@ -149,8 +146,4 @@ class InteractionChangeDetector:
if not self.validator.crop_changed(old_crop, new_crop, threshold=0.001):
return None
return {
'old_crop': old_crop,
'new_crop': new_crop,
'delta': tuple(new_crop[i] - old_crop[i] for i in range(4))
}
return {"old_crop": old_crop, "new_crop": new_crop, "delta": tuple(new_crop[i] - old_crop[i] for i in range(4))}
+13 -13
View File
@@ -16,7 +16,7 @@ class KeyboardNavigationMixin:
def _navigate_to_next_page(self):
"""Navigate to the next page using Page Down key"""
main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return
current_index = main_window._get_most_visible_page_index()
@@ -24,14 +24,14 @@ class KeyboardNavigationMixin:
next_page = main_window.project.pages[current_index + 1]
self._scroll_to_page(next_page, current_index + 1)
if hasattr(main_window, 'show_status'):
if hasattr(main_window, "show_status"):
page_name = main_window.project.get_page_display_name(next_page)
main_window.show_status(f"Navigated to {page_name}", 2000)
def _navigate_to_previous_page(self):
"""Navigate to the previous page using Page Up key"""
main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return
current_index = main_window._get_most_visible_page_index()
@@ -39,7 +39,7 @@ class KeyboardNavigationMixin:
prev_page = main_window.project.pages[current_index - 1]
self._scroll_to_page(prev_page, current_index - 1)
if hasattr(main_window, 'show_status'):
if hasattr(main_window, "show_status"):
page_name = main_window.project.get_page_display_name(prev_page)
main_window.show_status(f"Navigated to {page_name}", 2000)
@@ -52,7 +52,7 @@ class KeyboardNavigationMixin:
page_index: The index of the page in the project
"""
main_window = self.window()
if not hasattr(main_window, 'project'):
if not hasattr(main_window, "project"):
return
dpi = main_window.project.working_dpi
@@ -79,14 +79,14 @@ class KeyboardNavigationMixin:
self.pan_offset[1] = target_pan_y
# Clamp pan offset to content bounds
if hasattr(self, 'clamp_pan_offset'):
if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset()
self.update()
# Update scrollbars if available
main_window = self.window()
if hasattr(main_window, 'update_scrollbars'):
if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars()
def _move_viewport_with_arrow_keys(self, key):
@@ -109,14 +109,14 @@ class KeyboardNavigationMixin:
self.pan_offset[0] -= move_amount
# Clamp pan offset to content bounds
if hasattr(self, 'clamp_pan_offset'):
if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset()
self.update()
# Update scrollbars if available
main_window = self.window()
if hasattr(main_window, 'update_scrollbars'):
if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars()
def _move_selected_elements_with_arrow_keys(self, key):
@@ -127,7 +127,7 @@ class KeyboardNavigationMixin:
key: The Qt key code (Up, Down, Left, Right)
"""
main_window = self.window()
if not hasattr(main_window, 'project'):
if not hasattr(main_window, "project"):
return
# Movement amount in mm
@@ -151,7 +151,7 @@ class KeyboardNavigationMixin:
new_y = current_y + dy
# Apply snapping if element has a parent page
if hasattr(element, '_parent_page') and element._parent_page:
if hasattr(element, "_parent_page") and element._parent_page:
page = element._parent_page
snap_sys = page.layout.snapping_system
page_size = page.layout.size
@@ -162,7 +162,7 @@ class KeyboardNavigationMixin:
size=element.size,
page_size=page_size,
dpi=dpi,
project=main_window.project
project=main_window.project,
)
element.position = snapped_pos
else:
@@ -170,7 +170,7 @@ class KeyboardNavigationMixin:
self.update()
if hasattr(main_window, 'show_status'):
if hasattr(main_window, "show_status"):
count = len(self.selected_elements)
elem_text = "element" if count == 1 else "elements"
main_window.show_status(f"Moved {count} {elem_text}", 1000)
+262 -201
View File
@@ -3,9 +3,14 @@ Mouse interaction mixin for GLWidget - coordinates all mouse events
"""
import math
from typing import TYPE_CHECKING, Any, Optional, Set
from PyQt6.QtCore import Qt
from pyPhotoAlbum.models import ImageData
from PyQt6.QtGui import QCursor
from pyPhotoAlbum.models import ImageData, BaseLayoutElement
if TYPE_CHECKING:
from PyQt6.QtWidgets import QMainWindow
class MouseInteractionMixin:
@@ -16,6 +21,39 @@ class MouseInteractionMixin:
the current interaction state.
"""
# Type declarations for attributes provided by other mixins/base classes
selected_elements: Set[BaseLayoutElement]
selected_element: Optional[BaseLayoutElement]
rotation_mode: bool
rotation_snap_angle: int
rotation_start_angle: Optional[float]
pan_offset: list
zoom_level: float
image_pan_mode: bool
current_page_index: int
_page_renderers: list
resize_start_pos: Optional[Any]
resize_start_size: Optional[Any]
if TYPE_CHECKING:
def window(self) -> "QMainWindow": ...
def update(self) -> None: ...
def setCursor(self, cursor: Any) -> None: ...
def setFocus(self, *args: Any) -> None: ...
def _begin_rotate(self, element: Any) -> None: ...
def _begin_resize(self, element: Any) -> None: ...
def _begin_image_pan(self, element: Any) -> None: ...
def _begin_move(self, element: Any) -> None: ...
def _end_interaction(self) -> None: ...
def _resize_element(self, dx: float, dy: float) -> None: ...
def _get_page_at(self, x: float, y: float) -> Any: ...
def _get_element_at(self, x: float, y: float) -> Optional[BaseLayoutElement]: ...
def _get_resize_handle_at(self, x: float, y: float) -> Optional[str]: ...
def _check_ghost_page_click(self, x: float, y: float) -> bool: ...
def _transfer_element_to_page(self, element: Any, source: Any, target: Any, x: float, y: float, renderer: Any) -> None: ...
def _handle_image_pan_move(self, x: float, y: float, element: Any) -> None: ...
def _update_page_status(self, x: float, y: float) -> None: ...
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -25,206 +63,229 @@ class MouseInteractionMixin:
self.is_dragging = False
self.is_panning = False
def _handle_rotation_start(self, x: float, y: float):
"""Start rotation interaction for selected element."""
assert self.selected_element is not None
self._begin_rotate(self.selected_element)
self.drag_start_pos = (x, y)
self.rotation_start_angle = self.selected_element.rotation
self.is_dragging = True
def _handle_resize_start(self, x: float, y: float, handle):
"""Start resize interaction for selected element."""
assert self.selected_element is not None
self._begin_resize(self.selected_element)
self.resize_handle = handle
self.drag_start_pos = (x, y)
self.resize_start_pos = self.selected_element.position
self.resize_start_size = self.selected_element.size
self.is_dragging = True
def _handle_image_pan_start(self, x: float, y: float, element):
"""Start image pan mode for an ImageData element."""
self.selected_elements = {element}
self.drag_start_pos = (x, y)
self.image_pan_mode = True
self.image_pan_start_crop = element.crop_info
self._begin_image_pan(element)
self.is_dragging = True
self.setCursor(Qt.CursorShape.SizeAllCursor)
def _handle_multi_select(self, element):
"""Toggle element in multi-selection."""
if element in self.selected_elements:
self.selected_elements.remove(element)
else:
self.selected_elements.add(element)
def _handle_element_drag_start(self, x: float, y: float, element):
"""Start dragging an element."""
self.selected_elements = {element}
self.drag_start_pos = (x, y)
self.drag_start_element_pos = element.position
if not self.rotation_mode:
self._begin_move(element)
self.is_dragging = True
def mousePressEvent(self, event):
"""Handle mouse press events"""
# Ensure widget has focus for keyboard events
self.setFocus()
if event.button() == Qt.MouseButton.LeftButton:
x, y = event.position().x(), event.position().y()
ctrl_pressed = event.modifiers() & Qt.KeyboardModifier.ControlModifier
shift_pressed = event.modifiers() & Qt.KeyboardModifier.ShiftModifier
# Check if clicking on ghost page button
if self._check_ghost_page_click(x, y):
return
# Update current_page_index based on where user clicked
page, page_index, renderer = self._get_page_at(x, y)
if page_index >= 0:
self.current_page_index = page_index
if len(self.selected_elements) == 1 and self.selected_element:
if self.rotation_mode:
# In rotation mode, start rotation tracking
self._begin_rotate(self.selected_element)
self.drag_start_pos = (x, y)
self.rotation_start_angle = self.selected_element.rotation
self.is_dragging = True
return
else:
# In normal mode, check for resize handles
handle = self._get_resize_handle_at(x, y)
if handle:
self._begin_resize(self.selected_element)
self.resize_handle = handle
self.drag_start_pos = (x, y)
self.resize_start_pos = self.selected_element.position
self.resize_start_size = self.selected_element.size
self.is_dragging = True
return
element = self._get_element_at(x, y)
if element:
print(f"DEBUG: Clicked on element: {element}, ctrl_pressed: {ctrl_pressed}, shift_pressed: {shift_pressed}")
# Check if Ctrl is pressed and element is ImageData - enter image pan mode
if ctrl_pressed and isinstance(element, ImageData) and not self.rotation_mode:
# Enter image pan mode - pan image within frame
self.selected_elements = {element}
self.drag_start_pos = (x, y)
self.image_pan_mode = True
self.image_pan_start_crop = element.crop_info
self._begin_image_pan(element)
self.is_dragging = True
self.setCursor(Qt.CursorShape.SizeAllCursor)
print(f"Entered image pan mode for {element}")
elif ctrl_pressed:
# Multi-select mode (for non-ImageData elements or when Ctrl is pressed)
print(f"DEBUG: Multi-select mode triggered")
if element in self.selected_elements:
print(f"DEBUG: Removing element from selection")
self.selected_elements.remove(element)
else:
print(f"DEBUG: Adding element to selection. Current count: {len(self.selected_elements)}")
self.selected_elements.add(element)
print(f"DEBUG: Total selected elements: {len(self.selected_elements)}")
elif shift_pressed:
# Shift can be used for multi-select as well
if element in self.selected_elements:
self.selected_elements.remove(element)
else:
self.selected_elements.add(element)
else:
# Normal drag mode
print(f"DEBUG: Normal drag mode - single selection")
self.selected_elements = {element}
self.drag_start_pos = (x, y)
self.drag_start_element_pos = element.position
if not self.rotation_mode:
self._begin_move(element)
self.is_dragging = True
else:
if not ctrl_pressed:
self.selected_elements.clear()
self.update()
self._handle_left_click(event)
elif event.button() == Qt.MouseButton.MiddleButton:
self.is_panning = True
self.drag_start_pos = (event.position().x(), event.position().y())
self.setCursor(Qt.CursorShape.ClosedHandCursor)
def _handle_left_click(self, event):
"""Handle left mouse button click."""
x, y = event.position().x(), event.position().y()
ctrl_pressed = event.modifiers() & Qt.KeyboardModifier.ControlModifier
shift_pressed = event.modifiers() & Qt.KeyboardModifier.ShiftModifier
# Check if clicking on ghost page button
if self._check_ghost_page_click(x, y):
return
# Update current_page_index based on where user clicked
page, page_index, renderer = self._get_page_at(x, y)
if page_index >= 0:
self.current_page_index = page_index
# Handle interaction with already-selected element
if len(self.selected_elements) == 1 and self.selected_element:
if self.rotation_mode:
self._handle_rotation_start(x, y)
return
else:
handle = self._get_resize_handle_at(x, y)
if handle:
self._handle_resize_start(x, y, handle)
return
# Handle click on element
element = self._get_element_at(x, y)
if element:
if ctrl_pressed and isinstance(element, ImageData) and not self.rotation_mode:
self._handle_image_pan_start(x, y, element)
elif ctrl_pressed or shift_pressed:
self._handle_multi_select(element)
else:
self._handle_element_drag_start(x, y, element)
else:
if not ctrl_pressed:
self.selected_elements.clear()
self.update()
def _handle_canvas_pan(self, x: float, y: float):
"""Handle canvas panning with middle mouse button."""
dx = x - self.drag_start_pos[0]
dy = y - self.drag_start_pos[1]
self.pan_offset[0] += dx
self.pan_offset[1] += dy
if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset()
self.drag_start_pos = (x, y)
self.update()
main_window = self.window()
if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars()
def _handle_rotation_move(self, x: float, y: float):
"""Handle element rotation during drag."""
if self.selected_element is None or not hasattr(self.selected_element, "_page_renderer"):
return
renderer = self.selected_element._page_renderer # type: ignore[attr-defined]
elem_x, elem_y = self.selected_element.position
elem_w, elem_h = self.selected_element.size
center_page_x = elem_x + elem_w / 2
center_page_y = elem_y + elem_h / 2
screen_center_x, screen_center_y = renderer.page_to_screen(center_page_x, center_page_y)
dx = x - screen_center_x
dy = y - screen_center_y
angle = math.degrees(math.atan2(dy, dx))
angle = round(angle / self.rotation_snap_angle) * self.rotation_snap_angle
angle = angle % 360
self.selected_element.rotation = angle
main_window = self.window()
if hasattr(main_window, "show_status"):
main_window.show_status(f"Rotation: {angle:.1f}°", 100)
def _handle_resize_move(self, x: float, y: float):
"""Handle element resize during drag."""
screen_dx = x - self.drag_start_pos[0]
screen_dy = y - self.drag_start_pos[1]
total_dx = screen_dx / self.zoom_level
total_dy = screen_dy / self.zoom_level
self._resize_element(total_dx, total_dy)
def _handle_element_move(self, x: float, y: float):
"""Handle element movement during drag, including page transfer."""
assert self.selected_element is not None
current_page, current_page_index, current_renderer = self._get_page_at(x, y)
if current_page and hasattr(self.selected_element, "_parent_page"):
source_page = self.selected_element._parent_page # type: ignore[attr-defined]
if current_page is not source_page:
self._transfer_element_to_page(self.selected_element, source_page, current_page, x, y, current_renderer)
else:
self._move_element_within_page(x, y, source_page)
else:
# No page context - simple move without snapping
total_dx = (x - self.drag_start_pos[0]) / self.zoom_level
total_dy = (y - self.drag_start_pos[1]) / self.zoom_level
new_x = self.drag_start_element_pos[0] + total_dx
new_y = self.drag_start_element_pos[1] + total_dy
self.selected_element.position = (new_x, new_y)
def _move_element_within_page(self, x: float, y: float, page):
"""Move element within its current page with snapping."""
total_dx = (x - self.drag_start_pos[0]) / self.zoom_level
total_dy = (y - self.drag_start_pos[1]) / self.zoom_level
new_x = self.drag_start_element_pos[0] + total_dx
new_y = self.drag_start_element_pos[1] + total_dy
assert self.selected_element is not None
main_window = self.window()
snap_sys = page.layout.snapping_system
page_size = page.layout.size
dpi = main_window.project.working_dpi # type: ignore[attr-defined]
snapped_pos = snap_sys.snap_position(
position=(new_x, new_y),
size=self.selected_element.size,
page_size=page_size,
dpi=dpi,
project=main_window.project, # type: ignore[attr-defined]
)
self.selected_element.position = snapped_pos
def mouseMoveEvent(self, event):
"""Handle mouse move events"""
x, y = event.position().x(), event.position().y()
# Update status bar with page information
self._update_page_status(x, y)
# Canvas panning (middle mouse button)
if self.is_panning and self.drag_start_pos:
dx = x - self.drag_start_pos[0]
dy = y - self.drag_start_pos[1]
self.pan_offset[0] += dx
self.pan_offset[1] += dy
# Clamp pan offset to content bounds
if hasattr(self, 'clamp_pan_offset'):
self.clamp_pan_offset()
self.drag_start_pos = (x, y)
self.update()
# Update scrollbars if available
main_window = self.window()
if hasattr(main_window, 'update_scrollbars'):
main_window.update_scrollbars()
self._handle_canvas_pan(x, y)
return
if not self.is_dragging or not self.drag_start_pos:
return
if self.selected_element:
if self.image_pan_mode:
# Image pan mode - delegate to ImagePanMixin
self._handle_image_pan_move(x, y, self.selected_element)
if not self.selected_element:
return
elif self.rotation_mode:
# Rotation mode
if not hasattr(self.selected_element, '_page_renderer'):
return
# Dispatch to appropriate handler based on interaction mode
if self.image_pan_mode:
self._handle_image_pan_move(x, y, self.selected_element)
elif self.rotation_mode:
self._handle_rotation_move(x, y)
elif self.resize_handle:
self._handle_resize_move(x, y)
else:
self._handle_element_move(x, y)
renderer = self.selected_element._page_renderer
elem_x, elem_y = self.selected_element.position
elem_w, elem_h = self.selected_element.size
center_page_x = elem_x + elem_w / 2
center_page_y = elem_y + elem_h / 2
screen_center_x, screen_center_y = renderer.page_to_screen(center_page_x, center_page_y)
dx = x - screen_center_x
dy = y - screen_center_y
angle = math.degrees(math.atan2(dy, dx))
angle = round(angle / self.rotation_snap_angle) * self.rotation_snap_angle
angle = angle % 360
self.selected_element.rotation = angle
main_window = self.window()
if hasattr(main_window, 'show_status'):
main_window.show_status(f"Rotation: {angle:.1f}°", 100)
elif self.resize_handle:
# Resize mode
screen_dx = x - self.drag_start_pos[0]
screen_dy = y - self.drag_start_pos[1]
total_dx = screen_dx / self.zoom_level
total_dy = screen_dy / self.zoom_level
self._resize_element(total_dx, total_dy)
else:
# Move mode
current_page, current_page_index, current_renderer = self._get_page_at(x, y)
if current_page and hasattr(self.selected_element, '_parent_page'):
source_page = self.selected_element._parent_page
if current_page is not source_page:
self._transfer_element_to_page(self.selected_element, source_page, current_page, x, y, current_renderer)
else:
total_dx = (x - self.drag_start_pos[0]) / self.zoom_level
total_dy = (y - self.drag_start_pos[1]) / self.zoom_level
new_x = self.drag_start_element_pos[0] + total_dx
new_y = self.drag_start_element_pos[1] + total_dy
main_window = self.window()
snap_sys = source_page.layout.snapping_system
page_size = source_page.layout.size
dpi = main_window.project.working_dpi
snapped_pos = snap_sys.snap_position(
position=(new_x, new_y),
size=self.selected_element.size,
page_size=page_size,
dpi=dpi,
project=main_window.project
)
self.selected_element.position = snapped_pos
else:
total_dx = (x - self.drag_start_pos[0]) / self.zoom_level
total_dy = (y - self.drag_start_pos[1]) / self.zoom_level
new_x = self.drag_start_element_pos[0] + total_dx
new_y = self.drag_start_element_pos[1] + total_dy
self.selected_element.position = (new_x, new_y)
self.update()
self.update()
def mouseReleaseEvent(self, event):
"""Handle mouse release events"""
@@ -238,11 +299,7 @@ class MouseInteractionMixin:
self.rotation_start_angle = None
self.image_pan_mode = False
self.image_pan_start_crop = None
self.snap_state = {
'is_snapped': False,
'last_position': None,
'last_size': None
}
self.snap_state = {"is_snapped": False, "last_position": None, "last_size": None}
self.setCursor(Qt.CursorShape.ArrowCursor)
elif event.button() == Qt.MouseButton.MiddleButton:
@@ -257,6 +314,7 @@ class MouseInteractionMixin:
element = self._get_element_at(x, y)
from pyPhotoAlbum.models import TextBoxData
if isinstance(element, TextBoxData):
self._edit_text_element(element)
return
@@ -293,47 +351,50 @@ class MouseInteractionMixin:
if self.is_dragging and self.drag_start_pos:
pan_delta_x = self.pan_offset[0] - old_pan_x
pan_delta_y = self.pan_offset[1] - old_pan_y
self.drag_start_pos = (
self.drag_start_pos[0] + pan_delta_x,
self.drag_start_pos[1] + pan_delta_y
)
self.drag_start_pos = (self.drag_start_pos[0] + pan_delta_x, self.drag_start_pos[1] + pan_delta_y)
# Clamp pan offset to content bounds
if hasattr(self, 'clamp_pan_offset'):
if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset()
self.update()
main_window = self.window()
if hasattr(main_window, 'status_bar'):
if hasattr(main_window, "status_bar"):
main_window.status_bar.showMessage(f"Zoom: {int(self.zoom_level * 100)}%", 2000)
# Update scrollbars if available
if hasattr(main_window, 'update_scrollbars'):
if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars()
else:
# Regular wheel: Vertical scroll
scroll_amount = delta * 0.5
# Regular wheel: Two-finger scroll (vertical and horizontal)
delta_x = event.angleDelta().x()
delta_y = event.angleDelta().y()
scroll_amount_x = delta_x * 0.5
scroll_amount_y = delta_y * 0.5
old_pan_x = self.pan_offset[0]
old_pan_y = self.pan_offset[1]
self.pan_offset[1] += scroll_amount
self.pan_offset[0] += scroll_amount_x
self.pan_offset[1] += scroll_amount_y
# Clamp pan offset to content bounds
if hasattr(self, 'clamp_pan_offset'):
if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset()
# If dragging, adjust drag_start_pos to account for pan_offset change
if self.is_dragging and self.drag_start_pos:
pan_delta_x = self.pan_offset[0] - old_pan_x
pan_delta_y = self.pan_offset[1] - old_pan_y
self.drag_start_pos = (
self.drag_start_pos[0],
self.drag_start_pos[1] + pan_delta_y
)
self.drag_start_pos = (self.drag_start_pos[0] + pan_delta_x, self.drag_start_pos[1] + pan_delta_y)
self.update()
# Update scrollbars if available
main_window = self.window()
if hasattr(main_window, 'update_scrollbars'):
if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars()
def _edit_text_element(self, text_element):
@@ -344,9 +405,9 @@ class MouseInteractionMixin:
if dialog.exec() == TextEditDialog.DialogCode.Accepted:
values = dialog.get_values()
text_element.text_content = values['text_content']
text_element.font_settings = values['font_settings']
text_element.alignment = values['alignment']
text_element.text_content = values["text_content"]
text_element.font_settings = values["font_settings"]
text_element.alignment = values["alignment"]
self.update()
+13 -11
View File
@@ -13,17 +13,19 @@ 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',
'EditOperationsMixin',
'ElementOperationsMixin',
'PageOperationsMixin',
'TemplateOperationsMixin',
'ViewOperationsMixin',
'AlignmentOperationsMixin',
'DistributionOperationsMixin',
'SizeOperationsMixin',
'ZOrderOperationsMixin',
'MergeOperationsMixin',
"FileOperationsMixin",
"EditOperationsMixin",
"ElementOperationsMixin",
"PageOperationsMixin",
"TemplateOperationsMixin",
"ViewOperationsMixin",
"AlignmentOperationsMixin",
"DistributionOperationsMixin",
"SizeOperationsMixin",
"ZOrderOperationsMixin",
"MergeOperationsMixin",
"StyleOperationsMixin",
]
+32 -67
View File
@@ -14,26 +14,36 @@ class AlignmentOperationsMixin:
"""Get list of selected elements for alignment operations"""
return list(self.gl_widget.selected_elements) if self.gl_widget.selected_elements else []
def _execute_alignment(self, alignment_func, status_msg: str):
"""
Execute an alignment operation with common boilerplate.
Args:
alignment_func: AlignmentManager method to call with elements
status_msg: Status message format string (will receive element count)
"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2): # type: ignore[attr-defined]
return
changes = alignment_func(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd) # type: ignore[attr-defined]
self.update_view() # type: ignore[attr-defined]
self.show_status(status_msg.format(len(elements)), 2000) # type: ignore[attr-defined]
@ribbon_action(
label="Align Left",
tooltip="Align selected elements to the left",
tab="Arrange",
group="Align",
requires_selection=True,
min_selection=2
min_selection=2,
)
def align_left(self):
"""Align selected elements to the left"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2):
return
changes = AlignmentManager.align_left(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Aligned {len(elements)} elements to left", 2000)
self._execute_alignment(AlignmentManager.align_left, "Aligned {} elements to left")
@ribbon_action(
label="Align Right",
@@ -41,20 +51,11 @@ class AlignmentOperationsMixin:
tab="Arrange",
group="Align",
requires_selection=True,
min_selection=2
min_selection=2,
)
def align_right(self):
"""Align selected elements to the right"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2):
return
changes = AlignmentManager.align_right(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Aligned {len(elements)} elements to right", 2000)
self._execute_alignment(AlignmentManager.align_right, "Aligned {} elements to right")
@ribbon_action(
label="Align Top",
@@ -62,20 +63,11 @@ class AlignmentOperationsMixin:
tab="Arrange",
group="Align",
requires_selection=True,
min_selection=2
min_selection=2,
)
def align_top(self):
"""Align selected elements to the top"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2):
return
changes = AlignmentManager.align_top(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Aligned {len(elements)} elements to top", 2000)
self._execute_alignment(AlignmentManager.align_top, "Aligned {} elements to top")
@ribbon_action(
label="Align Bottom",
@@ -83,20 +75,11 @@ class AlignmentOperationsMixin:
tab="Arrange",
group="Align",
requires_selection=True,
min_selection=2
min_selection=2,
)
def align_bottom(self):
"""Align selected elements to the bottom"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2):
return
changes = AlignmentManager.align_bottom(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Aligned {len(elements)} elements to bottom", 2000)
self._execute_alignment(AlignmentManager.align_bottom, "Aligned {} elements to bottom")
@ribbon_action(
label="Align H-Center",
@@ -104,20 +87,11 @@ class AlignmentOperationsMixin:
tab="Arrange",
group="Align",
requires_selection=True,
min_selection=2
min_selection=2,
)
def align_horizontal_center(self):
"""Align selected elements to horizontal center"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2):
return
changes = AlignmentManager.align_horizontal_center(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Aligned {len(elements)} elements to horizontal center", 2000)
self._execute_alignment(AlignmentManager.align_horizontal_center, "Aligned {} elements to horizontal center")
@ribbon_action(
label="Align V-Center",
@@ -125,20 +99,11 @@ class AlignmentOperationsMixin:
tab="Arrange",
group="Align",
requires_selection=True,
min_selection=2
min_selection=2,
)
def align_vertical_center(self):
"""Align selected elements to vertical center"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2):
return
changes = AlignmentManager.align_vertical_center(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Aligned {len(elements)} elements to vertical center", 2000)
self._execute_alignment(AlignmentManager.align_vertical_center, "Aligned {} elements to vertical center")
@ribbon_action(
label="Maximize Pattern",
@@ -146,7 +111,7 @@ class AlignmentOperationsMixin:
tab="Arrange",
group="Size",
requires_selection=True,
min_selection=1
min_selection=1,
)
def maximize_pattern(self):
"""Maximize selected elements until they are close to borders or each other"""
@@ -14,26 +14,36 @@ class DistributionOperationsMixin:
"""Get list of selected elements for distribution operations"""
return list(self.gl_widget.selected_elements) if self.gl_widget.selected_elements else []
def _execute_distribution(self, distribution_func, status_msg: str):
"""
Execute a distribution operation with common boilerplate.
Args:
distribution_func: AlignmentManager method to call with elements
status_msg: Status message format string (will receive element count)
"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=3): # type: ignore[attr-defined]
return
changes = distribution_func(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd) # type: ignore[attr-defined]
self.update_view() # type: ignore[attr-defined]
self.show_status(status_msg.format(len(elements)), 2000) # type: ignore[attr-defined]
@ribbon_action(
label="Distribute H",
tooltip="Distribute selected elements evenly horizontally",
tab="Arrange",
group="Distribute",
requires_selection=True,
min_selection=3
min_selection=3,
)
def distribute_horizontally(self):
"""Distribute selected elements evenly horizontally"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=3):
return
changes = AlignmentManager.distribute_horizontally(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Distributed {len(elements)} elements horizontally", 2000)
self._execute_distribution(AlignmentManager.distribute_horizontally, "Distributed {} elements horizontally")
@ribbon_action(
label="Distribute V",
@@ -41,20 +51,11 @@ class DistributionOperationsMixin:
tab="Arrange",
group="Distribute",
requires_selection=True,
min_selection=3
min_selection=3,
)
def distribute_vertically(self):
"""Distribute selected elements evenly vertically"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=3):
return
changes = AlignmentManager.distribute_vertically(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Distributed {len(elements)} elements vertically", 2000)
self._execute_distribution(AlignmentManager.distribute_vertically, "Distributed {} elements vertically")
@ribbon_action(
label="Space H",
@@ -62,20 +63,11 @@ class DistributionOperationsMixin:
tab="Arrange",
group="Distribute",
requires_selection=True,
min_selection=3
min_selection=3,
)
def space_horizontally(self):
"""Space selected elements equally horizontally"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=3):
return
changes = AlignmentManager.space_horizontally(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Spaced {len(elements)} elements horizontally", 2000)
self._execute_distribution(AlignmentManager.space_horizontally, "Spaced {} elements horizontally")
@ribbon_action(
label="Space V",
@@ -83,17 +75,8 @@ class DistributionOperationsMixin:
tab="Arrange",
group="Distribute",
requires_selection=True,
min_selection=3
min_selection=3,
)
def space_vertically(self):
"""Space selected elements equally vertically"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=3):
return
changes = AlignmentManager.space_vertically(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Spaced {len(elements)} elements vertically", 2000)
self._execute_distribution(AlignmentManager.space_vertically, "Spaced {} elements vertically")
+10 -24
View File
@@ -9,13 +9,7 @@ from pyPhotoAlbum.commands import DeleteElementCommand, RotateElementCommand
class EditOperationsMixin:
"""Mixin providing edit-related operations"""
@ribbon_action(
label="Undo",
tooltip="Undo last action (Ctrl+Z)",
tab="Home",
group="Edit",
shortcut="Ctrl+Z"
)
@ribbon_action(label="Undo", tooltip="Undo last action (Ctrl+Z)", tab="Home", group="Edit", shortcut="Ctrl+Z")
def undo(self):
"""Undo last action"""
if self.project.history.undo():
@@ -27,11 +21,7 @@ class EditOperationsMixin:
print("Nothing to undo")
@ribbon_action(
label="Redo",
tooltip="Redo last action (Ctrl+Y or Ctrl+Shift+Z)",
tab="Home",
group="Edit",
shortcut="Ctrl+Y"
label="Redo", tooltip="Redo last action (Ctrl+Y or Ctrl+Shift+Z)", tab="Home", group="Edit", shortcut="Ctrl+Y"
)
def redo(self):
"""Redo last action"""
@@ -49,7 +39,7 @@ class EditOperationsMixin:
tab="Home",
group="Edit",
shortcut="Delete",
requires_selection=True
requires_selection=True,
)
def delete_selected_element(self):
"""Delete the currently selected element"""
@@ -65,11 +55,7 @@ class EditOperationsMixin:
selected_element = next(iter(self.gl_widget.selected_elements))
try:
cmd = DeleteElementCommand(
current_page.layout,
selected_element,
asset_manager=self.project.asset_manager
)
cmd = DeleteElementCommand(current_page.layout, selected_element, asset_manager=self.project.asset_manager)
self.project.history.execute(cmd)
# Clear selection
@@ -88,9 +74,9 @@ class EditOperationsMixin:
@ribbon_action(
label="Rotate Left",
tooltip="Rotate selected element 90° counter-clockwise",
tab="Home",
tab="Arrange",
group="Transform",
requires_selection=True
requires_selection=True,
)
def rotate_left(self):
"""Rotate selected element 90 degrees counter-clockwise"""
@@ -111,9 +97,9 @@ class EditOperationsMixin:
@ribbon_action(
label="Rotate Right",
tooltip="Rotate selected element 90° clockwise",
tab="Home",
tab="Arrange",
group="Transform",
requires_selection=True
requires_selection=True,
)
def rotate_right(self):
"""Rotate selected element 90 degrees clockwise"""
@@ -134,9 +120,9 @@ class EditOperationsMixin:
@ribbon_action(
label="Reset Rotation",
tooltip="Reset selected element rotation to 0°",
tab="Home",
tab="Arrange",
group="Transform",
requires_selection=True
requires_selection=True,
)
def reset_rotation(self):
"""Reset selected element rotation to 0 degrees"""
+8 -39
View File
@@ -13,11 +13,7 @@ class ElementOperationsMixin:
"""Mixin providing element creation and manipulation operations"""
@ribbon_action(
label="Image",
tooltip="Add an image to the current page",
tab="Insert",
group="Media",
requires_page=True
label="Image", tooltip="Add an image to the current page", tab="Insert", group="Media", requires_page=True
)
def add_image(self):
"""Add an image to the current page"""
@@ -25,10 +21,7 @@ class ElementOperationsMixin:
return
file_path, _ = QFileDialog.getOpenFileName(
self,
"Select Image",
"",
"Image Files (*.jpg *.jpeg *.png *.gif *.bmp *.tiff *.webp);;All Files (*)"
self, "Select Image", "", "Image Files (*.jpg *.jpeg *.png *.gif *.bmp *.tiff *.webp);;All Files (*)"
)
if not file_path:
@@ -59,20 +52,10 @@ class ElementOperationsMixin:
x = (page_width_mm - img_width) / 2
y = (page_height_mm - img_height) / 2
new_image = ImageData(
image_path=asset_path,
x=x,
y=y,
width=img_width,
height=img_height
)
new_image = ImageData(image_path=asset_path, x=x, y=y, width=img_width, height=img_height)
# Add element using command pattern for undo/redo
cmd = AddElementCommand(
current_page.layout,
new_image,
asset_manager=self.project.asset_manager
)
cmd = AddElementCommand(current_page.layout, new_image, asset_manager=self.project.asset_manager)
self.project.history.execute(cmd)
self.update_view()
@@ -84,11 +67,7 @@ class ElementOperationsMixin:
print(f"Error adding image: {e}")
@ribbon_action(
label="Text",
tooltip="Add a text box to the current page",
tab="Insert",
group="Media",
requires_page=True
label="Text", tooltip="Add a text box to the current page", tab="Insert", group="Media", requires_page=True
)
def add_text(self):
"""Add text to the current page"""
@@ -110,13 +89,7 @@ class ElementOperationsMixin:
x = (page_width_mm - text_width) / 2
y = (page_height_mm - text_height) / 2
new_text = TextBoxData(
text_content="New Text",
x=x,
y=y,
width=text_width,
height=text_height
)
new_text = TextBoxData(text_content="New Text", x=x, y=y, width=text_width, height=text_height)
current_page.layout.add_element(new_text)
self.update_view()
@@ -128,7 +101,7 @@ class ElementOperationsMixin:
tooltip="Add a placeholder to the current page",
tab="Insert",
group="Media",
requires_page=True
requires_page=True,
)
def add_placeholder(self):
"""Add a placeholder to the current page"""
@@ -151,11 +124,7 @@ class ElementOperationsMixin:
y = (page_height_mm - placeholder_height) / 2
new_placeholder = PlaceholderData(
placeholder_type="image",
x=x,
y=y,
width=placeholder_width,
height=placeholder_height
placeholder_type="image", x=x, y=y, width=placeholder_width, height=placeholder_height
)
current_page.layout.add_element(new_placeholder)
+324 -105
View File
@@ -3,32 +3,64 @@ File operations mixin for pyPhotoAlbum
"""
import os
from typing import TYPE_CHECKING, Optional, cast
from PyQt6.QtCore import QObject, pyqtSignal
from PyQt6.QtWidgets import (
QFileDialog, QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox, QRadioButton,
QButtonGroup, QLineEdit, QTextEdit
QFileDialog,
QDialog,
QVBoxLayout,
QHBoxLayout,
QLabel,
QDoubleSpinBox,
QSpinBox,
QPushButton,
QGroupBox,
QRadioButton,
QButtonGroup,
QLineEdit,
QTextEdit,
QWidget,
QMessageBox,
)
from pyPhotoAlbum.decorators import ribbon_action, numerical_input
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.async_project_loader import AsyncProjectLoader
from pyPhotoAlbum.loading_widget import LoadingWidget
from pyPhotoAlbum.project_serializer import save_to_zip
from pyPhotoAlbum.project_serializer import save_to_zip, save_to_zip_async
from pyPhotoAlbum.models import set_asset_resolution_context
from pyPhotoAlbum.version_manager import format_version_info, CURRENT_DATA_VERSION
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
class _SaveBridge(QObject):
"""Thread-safe signal bridge for async save callbacks.
Signals can be safely emitted from any thread; connected slots run on
the main (GUI) thread via Qt's queued connection.
"""
progress = pyqtSignal(int, str)
finished = pyqtSignal(bool, str)
class FileOperationsMixin:
"""Mixin providing file-related operations"""
@ribbon_action(
label="New",
tooltip="Create a new project",
tab="Home",
group="File",
shortcut="Ctrl+N"
)
# Type hints for expected attributes from mixing class
def show_status(self, message: str, timeout: int = 0) -> None:
"""Expected from ApplicationStateMixin"""
...
def show_error(self, title: str, message: str) -> None:
"""Expected from ApplicationStateMixin"""
...
def resolve_asset_path(self, path: str) -> Optional[str]:
"""Expected from asset path mixin"""
...
@ribbon_action(label="New", tooltip="Create a new project", tab="Home", group="File", shortcut="Ctrl+N")
def new_project(self):
"""Create a new project with initial setup dialog"""
# Create new project setup dialog
@@ -153,7 +185,7 @@ class FileOperationsMixin:
export_dpi = export_dpi_spinbox.value()
# Cleanup old project if it exists
if hasattr(self, 'project') and self.project:
if hasattr(self, "project") and self.project:
self.project.cleanup()
# Create project with custom settings
@@ -174,27 +206,18 @@ class FileOperationsMixin:
# User cancelled - keep current project
print("New project creation cancelled")
@ribbon_action(
label="Open",
tooltip="Open an existing project",
tab="Home",
group="File",
shortcut="Ctrl+O"
)
@ribbon_action(label="Open", tooltip="Open an existing project", tab="Home", group="File", shortcut="Ctrl+O")
def open_project(self):
"""Open an existing project with async loading and progress bar"""
file_path, _ = QFileDialog.getOpenFileName(
self,
"Open Project",
"",
"pyPhotoAlbum Projects (*.ppz);;All Files (*)"
self, "Open Project", "", "pyPhotoAlbum Projects (*.ppz);;All Files (*)"
)
if file_path:
print(f"Opening project: {file_path}")
# Create loading widget if not exists
if not hasattr(self, '_loading_widget'):
if not hasattr(self, "_loading_widget"):
self._loading_widget = LoadingWidget(self)
# Show loading widget
@@ -214,29 +237,29 @@ class FileOperationsMixin:
def _on_load_progress(self, current: int, total: int, message: str):
"""Handle loading progress updates"""
if hasattr(self, '_loading_widget'):
if hasattr(self, "_loading_widget"):
self._loading_widget.set_progress(current, total)
self._loading_widget.set_status(message)
def _on_load_complete(self, project):
"""Handle successful project load"""
# Cleanup old project if it exists
if hasattr(self, 'project') and self.project:
if hasattr(self, "project") and self.project:
self.project.cleanup()
# Set new project
self.project = project
# Set file path and mark as clean
if hasattr(self, '_opening_file_path'):
if hasattr(self, "_opening_file_path"):
self.project.file_path = self._opening_file_path
delattr(self, '_opening_file_path')
delattr(self, "_opening_file_path")
self.project.mark_clean()
self.gl_widget.current_page_index = 0 # Reset to first page
# Hide loading widget
if hasattr(self, '_loading_widget'):
if hasattr(self, "_loading_widget"):
self._loading_widget.hide_loading()
# Update view (this will trigger progressive image loading)
@@ -254,7 +277,7 @@ class FileOperationsMixin:
def _on_load_failed(self, error_msg: str):
"""Handle project load failure"""
# Hide loading widget
if hasattr(self, '_loading_widget'):
if hasattr(self, "_loading_widget"):
self._loading_widget.hide_loading()
error_msg = f"Failed to open project: {error_msg}"
@@ -262,48 +285,89 @@ class FileOperationsMixin:
self.show_error("Load Failed", error_msg)
print(error_msg)
@ribbon_action(
label="Save",
tooltip="Save the current project",
tab="Home",
group="File",
shortcut="Ctrl+S"
)
def save_project(self):
"""Save the current project"""
@ribbon_action(label="Save", tooltip="Save the current project", tab="Home", group="File", shortcut="Ctrl+S")
def save_project(self) -> bool:
"""Save the current project asynchronously with progress feedback.
Returns True if an async save was started, False if the user cancelled.
"""
# Prevent concurrent saves — only one background save at a time
if getattr(self, "_save_in_progress", False):
self.show_status("Save already in progress...")
return False
# If project has a file path, use it; otherwise prompt for location
file_path = self.project.file_path if hasattr(self.project, 'file_path') and self.project.file_path else None
file_path = self.project.file_path if hasattr(self.project, "file_path") and self.project.file_path else None
if not file_path:
file_path, _ = QFileDialog.getSaveFileName(
self,
"Save Project",
"",
"pyPhotoAlbum Projects (*.ppz);;All Files (*)"
self, "Save Project", "", "pyPhotoAlbum Projects (*.ppz);;All Files (*)" # type: ignore[arg-type]
)
if file_path:
print(f"Saving project to: {file_path}")
if not file_path:
return False
# Save project to ZIP
success, error = save_to_zip(self.project, file_path)
self._save_in_progress = True
print(f"Saving project to: {file_path}")
# Create loading widget if not exists
if not hasattr(self, "_loading_widget"):
self._loading_widget = LoadingWidget(self)
# Show loading widget
self._loading_widget.show_loading("Saving project...")
# Bridge object: signals are thread-safe so background thread can
# emit them and slots always run on the main (GUI) thread.
bridge = _SaveBridge(parent=self) # type: ignore[arg-type]
def _on_progress(progress: int, message: str):
if hasattr(self, "_loading_widget"):
try:
self._loading_widget.set_progress(progress, 100)
self._loading_widget.set_status(message)
except RuntimeError:
pass
def _on_finished(success: bool, error: str):
self._save_in_progress = False
try:
if hasattr(self, "_loading_widget"):
self._loading_widget.hide_loading()
except RuntimeError:
pass
if success:
self.project.file_path = file_path
self.project.mark_clean()
self.show_status(f"Project saved: {file_path}")
print(f"Successfully saved project to: {file_path}")
if getattr(self, "_pending_close", False):
self._pending_close = False
self.close() # type: ignore[attr-defined]
else:
self._pending_close = False
error_msg = f"Failed to save project: {error}"
self.show_status(error_msg)
self.show_error("Save Failed", error_msg)
print(error_msg)
@ribbon_action(
label="Heal Assets",
tooltip="Reconnect missing image assets",
tab="Home",
group="File"
)
bridge.progress.connect(_on_progress)
bridge.finished.connect(_on_finished)
# Start async save — callbacks emit signals (thread-safe)
save_to_zip_async(
self.project,
file_path,
on_complete=lambda ok, err: bridge.finished.emit(ok, err or ""),
on_progress=lambda p, m: bridge.progress.emit(p, m),
)
# Show immediate feedback
self.show_status("Saving project in background...", 2000)
return True
@ribbon_action(label="Heal Assets", tooltip="Reconnect missing image assets", tab="Home", group="File")
def heal_assets(self):
"""Open the asset healing dialog to reconnect missing images"""
dialog = AssetHealDialog(self.project, self)
@@ -343,34 +407,30 @@ class FileOperationsMixin:
asset_list = "\n".join(f"{path}" for path in missing_assets[:5])
asset_list += f"\n ... and {len(missing_assets) - 5} more"
msg = QMessageBox(self)
msg = QMessageBox(cast(QWidget, self))
msg.setIcon(QMessageBox.Icon.Warning)
msg.setWindowTitle("Missing Assets")
msg.setText(f"{len(missing_assets)} image(s) could not be found in the assets folder:")
msg.setInformativeText(asset_list)
msg.setDetailedText("These images need to be reconnected using the 'Heal Assets' feature.\n\n"
"Go to: Home → Heal Assets\n\n"
"Add search paths where the original images might be located, "
"then click 'Attempt Healing' to find and import them.")
msg.setDetailedText(
"These images need to be reconnected using the 'Heal Assets' feature.\n\n"
"Go to: Home → Heal Assets\n\n"
"Add search paths where the original images might be located, "
"then click 'Attempt Healing' to find and import them."
)
msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Open)
msg.button(QMessageBox.StandardButton.Open).setText("Open Heal Assets")
btn = msg.button(QMessageBox.StandardButton.Open)
if btn is not None:
btn.setText("Open Heal Assets")
result = msg.exec()
if result == QMessageBox.StandardButton.Open:
self.heal_assets()
@ribbon_action(
label="Project Settings",
tooltip="Configure project-wide page size and defaults",
tab="Home",
group="File"
)
@numerical_input(
fields=[
('width', 'Width', 'mm', 10, 1000),
('height', 'Height', 'mm', 10, 1000)
]
label="Project Settings", tooltip="Configure project-wide page size and defaults", tab="Home", group="File"
)
@numerical_input(fields=[("width", "Width", "mm", 10, 1000), ("height", "Height", "mm", 10, 1000)])
def project_settings(self):
"""Configure project-wide settings including default page size"""
# Create dialog
@@ -436,14 +496,24 @@ class FileOperationsMixin:
scaling_group = None
scaling_buttons = None
scope_buttons = None
if self.project.pages:
scaling_group = QGroupBox("Apply to Existing Pages")
scaling_layout = QVBoxLayout()
info_label = QLabel("How should existing content be adjusted?\n(Pages with manual sizing will not be affected)")
info_label.setWordWrap(True)
scaling_layout.addWidget(info_label)
# Scope: which pages to update
scaling_layout.addWidget(QLabel("Pages to update:"))
scope_buttons = QButtonGroup()
scope_non_manual = QRadioButton("Non-manual pages only")
scope_non_manual.setChecked(True)
scope_all = QRadioButton("All pages (override manual sizing)")
scope_buttons.addButton(scope_non_manual, 0)
scope_buttons.addButton(scope_all, 1)
scaling_layout.addWidget(scope_non_manual)
scaling_layout.addWidget(scope_all)
# Content scaling
scaling_layout.addWidget(QLabel("Content adjustment:"))
scaling_buttons = QButtonGroup()
proportional_radio = QRadioButton("Resize proportionally (fit to smallest axis)")
@@ -493,12 +563,15 @@ class FileOperationsMixin:
new_working_dpi = working_dpi_spinbox.value()
new_export_dpi = export_dpi_spinbox.value()
# Determine scaling mode
scaling_mode = 'none'
# Determine scaling mode and scope
scaling_mode = "none"
include_manual = False
if scaling_buttons:
selected_id = scaling_buttons.checkedId()
modes = {0: 'proportional', 1: 'stretch', 2: 'reposition', 3: 'none'}
scaling_mode = modes.get(selected_id, 'none')
modes = {0: "proportional", 1: "stretch", 2: "reposition", 3: "none"}
scaling_mode = modes.get(selected_id, "none")
if scope_buttons:
include_manual = scope_buttons.checkedId() == 1
# Apply settings
old_size = self.project.page_size_mm
@@ -506,22 +579,23 @@ class FileOperationsMixin:
self.project.working_dpi = new_working_dpi
self.project.export_dpi = new_export_dpi
# Update existing pages (exclude manually sized ones)
# Update existing pages
if self.project.pages and old_size != (new_width, new_height):
self._apply_page_size_to_project(old_size, (new_width, new_height), scaling_mode)
self._apply_page_size_to_project(old_size, (new_width, new_height), scaling_mode, include_manual)
self.update_view()
self.show_status(f"Project settings updated: {new_width}×{new_height} mm", 2000)
print(f"Project settings updated: {new_width}×{new_height} mm, scaling mode: {scaling_mode}")
def _apply_page_size_to_project(self, old_size, new_size, scaling_mode):
def _apply_page_size_to_project(self, old_size, new_size, scaling_mode, include_manual=False):
"""
Apply new page size to all non-manually-sized pages
Apply new page size to existing pages.
Args:
old_size: Old page size (width, height) in mm
new_size: New page size (width, height) in mm
scaling_mode: 'proportional', 'stretch', 'reposition', or 'none'
include_manual: If True, also resize manually-sized pages
"""
old_width, old_height = old_size
new_width, new_height = new_size
@@ -530,8 +604,9 @@ class FileOperationsMixin:
height_ratio = new_height / old_height if old_height > 0 else 1.0
for page in self.project.pages:
# Skip manually sized pages
if page.manually_sized:
if page.is_cover:
continue
if page.manually_sized and not include_manual:
continue
# Update page size
@@ -544,14 +619,14 @@ class FileOperationsMixin:
page.layout.size = (new_width, new_height)
# Apply content scaling based on mode
if scaling_mode == 'proportional':
if scaling_mode == "proportional":
# Use smallest ratio to fit content
scale = min(width_ratio, height_ratio)
self._scale_page_elements(page, scale, scale)
elif scaling_mode == 'stretch':
elif scaling_mode == "stretch":
# Scale independently on each axis
self._scale_page_elements(page, width_ratio, height_ratio)
elif scaling_mode == 'reposition':
elif scaling_mode == "reposition":
# Keep size, center content
self._reposition_page_elements(page, old_size, new_size)
# 'none' - do nothing to elements
@@ -593,12 +668,7 @@ class FileOperationsMixin:
x, y = element.position
element.position = (x + x_offset, y + y_offset)
@ribbon_action(
label="Export PDF",
tooltip="Export project to PDF",
tab="Export",
group="Export"
)
@ribbon_action(label="Export PDF", tooltip="Export project to PDF", tab="Home", group="File")
def export_pdf(self):
"""Export project to PDF using async backend (non-blocking)"""
# Check if we have pages to export
@@ -607,33 +677,182 @@ class FileOperationsMixin:
return
# Show file save dialog
file_path, _ = QFileDialog.getSaveFileName(
self,
"Export to PDF",
"",
"PDF Files (*.pdf);;All Files (*)"
)
file_path, _ = QFileDialog.getSaveFileName(self, "Export to PDF", "", "PDF Files (*.pdf);;All Files (*)")
if not file_path:
return
# Ensure .pdf extension
if not file_path.lower().endswith('.pdf'):
file_path += '.pdf'
if not file_path.lower().endswith(".pdf"):
file_path += ".pdf"
# Use async PDF export (non-blocking, UI stays responsive)
success = self.gl_widget.export_pdf_async(self.project, file_path, export_dpi=300)
success = self.gl_widget.export_pdf_async(self.project, file_path, export_dpi=self.project.export_dpi)
if success:
self.show_status("PDF export started...", 2000)
else:
self.show_status("PDF export failed to start", 3000)
@ribbon_action(
label="About",
tooltip="About pyPhotoAlbum and data format version",
tab="Home",
group="File"
label="Clean Assets", tooltip="Find and remove duplicate or unused image files", tab="Home", group="File"
)
def clean_assets(self):
"""Find and remove duplicate and unused asset files to save space"""
from PyQt6.QtWidgets import QProgressDialog, QCheckBox
from PyQt6.QtCore import Qt
# Helper to format bytes
def format_bytes(num_bytes):
if num_bytes >= 1024 * 1024:
return f"{num_bytes / (1024 * 1024):.1f} MB"
elif num_bytes >= 1024:
return f"{num_bytes / 1024:.1f} KB"
else:
return f"{num_bytes} bytes"
# Scan for issues with progress dialog
progress = QProgressDialog("Scanning assets...", "Cancel", 0, 100, self)
progress.setWindowTitle("Clean Assets")
progress.setWindowModality(Qt.WindowModality.WindowModal)
progress.setValue(10)
# Compute hashes for duplicate detection
self.project.asset_manager.compute_all_hashes()
progress.setValue(40)
if progress.wasCanceled():
return
# Get duplicate stats
dup_groups, dup_files, dup_bytes = self.project.asset_manager.get_duplicate_stats()
progress.setValue(60)
# Get unused stats
unused_files, unused_bytes = self.project.asset_manager.get_unused_stats()
progress.setValue(80)
progress.close()
# Check if there's anything to clean
if dup_files == 0 and unused_files == 0:
QMessageBox.information(
self, "Assets Clean", "No duplicate or unused files were found in your project assets."
)
return
# Build dialog with checkboxes for each cleanup type
dialog = QDialog(self)
dialog.setWindowTitle("Clean Assets")
dialog.setMinimumWidth(450)
layout = QVBoxLayout()
# Info label
info_label = QLabel("Select which cleanup operations to perform:")
layout.addWidget(info_label)
# Duplicates checkbox
dup_checkbox = None
if dup_files > 0:
dup_checkbox = QCheckBox(
f"Remove {dup_files} duplicate file(s) in {dup_groups} group(s) " f"(saves {format_bytes(dup_bytes)})"
)
dup_checkbox.setChecked(True)
dup_checkbox.setToolTip(
"Duplicate files have identical content but different names.\n"
"Image references will be automatically updated to use the kept file."
)
layout.addWidget(dup_checkbox)
# Unused checkbox
unused_checkbox = None
if unused_files > 0:
unused_checkbox = QCheckBox(f"Remove {unused_files} unused file(s) (saves {format_bytes(unused_bytes)})")
unused_checkbox.setChecked(True)
unused_checkbox.setToolTip(
"Unused files exist in the assets folder but are not referenced\n"
"by any image element in your project."
)
layout.addWidget(unused_checkbox)
# Summary
total_files = dup_files + unused_files
total_bytes = dup_bytes + unused_bytes
summary_label = QLabel(f"\nTotal potential savings: {format_bytes(total_bytes)} from {total_files} file(s)")
summary_label.setStyleSheet("font-weight: bold;")
layout.addWidget(summary_label)
# Buttons
button_layout = QHBoxLayout()
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(dialog.reject)
clean_btn = QPushButton("Clean Selected")
clean_btn.clicked.connect(dialog.accept)
clean_btn.setDefault(True)
button_layout.addStretch()
button_layout.addWidget(cancel_btn)
button_layout.addWidget(clean_btn)
layout.addLayout(button_layout)
dialog.setLayout(layout)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
# Perform selected cleanups
total_removed = 0
total_saved = 0
# Remove duplicates if selected
if dup_checkbox and dup_checkbox.isChecked():
def update_image_references(old_path: str, new_path: str):
"""Update all ImageData elements that reference the old path"""
from pyPhotoAlbum.models import ImageData
for page in self.project.pages:
for element in page.layout.elements:
if isinstance(element, ImageData) and element.image_path == old_path:
element.image_path = new_path
element.mark_modified()
print(f"Updated image reference: {old_path} -> {new_path}")
removed, saved = self.project.asset_manager.deduplicate_assets(
update_references_callback=update_image_references
)
total_removed += removed
total_saved += saved
# Remove unused if selected
if unused_checkbox and unused_checkbox.isChecked():
removed, saved = self.project.asset_manager.remove_unused_assets()
total_removed += removed
total_saved += saved
if total_removed > 0:
# Mark project as dirty since we modified it
self.project.mark_dirty()
# Update view
self.update_view()
# Show result
QMessageBox.information(
self,
"Cleanup Complete",
f"Removed {total_removed} file(s).\n\n"
f"Saved {format_bytes(total_saved)} of disk space.\n\n"
f"Remember to save your project to preserve these changes.",
)
self.show_status(
f"Asset cleanup complete: removed {total_removed} files, saved {format_bytes(total_saved)}"
)
else:
self.show_status("No files were removed")
@ribbon_action(label="About", tooltip="About pyPhotoAlbum and data format version", tab="Home", group="File")
def show_about(self):
"""Show about dialog with version information"""
dialog = QDialog(self)
+15 -27
View File
@@ -19,8 +19,8 @@ class MergeOperationsMixin:
@ribbon_action(
label="Merge Projects",
tooltip="Merge another project file with the current project",
tab="File",
group="Import/Export"
tab="Home",
group="File",
)
def merge_projects(self):
"""
@@ -35,22 +35,19 @@ class MergeOperationsMixin:
self,
"Unsaved Changes",
"You have unsaved changes in the current project. Save before merging?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel,
)
if reply == QMessageBox.StandardButton.Cancel:
return
elif reply == QMessageBox.StandardButton.Yes:
# Save current project first
if hasattr(self, 'save_project'):
if hasattr(self, "save_project"):
self.save_project()
# Select file to merge
file_path, _ = QFileDialog.getOpenFileName(
self,
"Select Project to Merge",
"",
"Photo Album Projects (*.ppz);;All Files (*)"
self, "Select Project to Merge", "", "Photo Album Projects (*.ppz);;All Files (*)"
)
if not file_path:
@@ -58,7 +55,7 @@ class MergeOperationsMixin:
try:
# Disable autosave during merge
if hasattr(self, '_autosave_timer'):
if hasattr(self, "_autosave_timer"):
self._autosave_timer.stop()
# Load the other project
@@ -82,14 +79,10 @@ class MergeOperationsMixin:
self._perform_concatenation(our_data, their_data)
except Exception as e:
QMessageBox.critical(
self,
"Merge Error",
f"Failed to merge projects:\n{str(e)}"
)
QMessageBox.critical(self, "Merge Error", f"Failed to merge projects:\n{str(e)}")
finally:
# Re-enable autosave
if hasattr(self, '_autosave_timer'):
if hasattr(self, "_autosave_timer"):
self._autosave_timer.start()
def _perform_merge_with_conflicts(self, our_data, their_data):
@@ -104,7 +97,7 @@ class MergeOperationsMixin:
self,
"No Conflicts",
"No conflicts detected. Merge projects automatically?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
@@ -117,11 +110,7 @@ class MergeOperationsMixin:
dialog = MergeDialog(our_data, their_data, self)
if dialog.exec() != QMessageBox.DialogCode.Accepted:
QMessageBox.information(
self,
"Merge Cancelled",
"Merge operation cancelled."
)
QMessageBox.information(self, "Merge Cancelled", "Merge operation cancelled.")
return
# Get merged data from dialog
@@ -135,7 +124,7 @@ class MergeOperationsMixin:
"Merge Complete",
f"Projects merged successfully.\n"
f"Total pages: {len(merged_data.get('pages', []))}\n"
f"Resolved conflicts: {len(conflicts)}"
f"Resolved conflicts: {len(conflicts)}",
)
def _perform_concatenation(self, our_data, their_data):
@@ -147,7 +136,7 @@ class MergeOperationsMixin:
f"{our_data.get('name', 'Untitled')}\n"
f"{their_data.get('name', 'Untitled')}\n\n"
f"Concatenate them (combine all pages)?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
@@ -162,8 +151,7 @@ class MergeOperationsMixin:
QMessageBox.information(
self,
"Concatenation Complete",
f"Projects concatenated successfully.\n"
f"Total pages: {len(merged_data.get('pages', []))}"
f"Projects concatenated successfully.\n" f"Total pages: {len(merged_data.get('pages', []))}",
)
def _apply_merged_data(self, merged_data):
@@ -182,9 +170,9 @@ class MergeOperationsMixin:
new_project.mark_dirty()
# Update UI
if hasattr(self, 'gl_widget'):
if hasattr(self, "gl_widget"):
self.gl_widget.set_project(new_project)
self.gl_widget.update()
if hasattr(self, 'status_bar'):
if hasattr(self, "status_bar"):
self.status_bar.showMessage("Merge completed successfully", 3000)
+35 -40
View File
@@ -14,12 +14,7 @@ class PageOperationsMixin:
# Note: Previous/Next page navigation removed - now using scrollable multi-page view
# User can scroll through all pages vertically
@ribbon_action(
label="Add Page",
tooltip="Add a new page to the project",
tab="Layout",
group="Page"
)
@ribbon_action(label="Add Page", tooltip="Add a new page to the project", tab="Layout", group="Page")
def add_page(self):
"""Add a new page to the project after the current page"""
# Get the most visible page in viewport to determine insertion point
@@ -73,12 +68,7 @@ class PageOperationsMixin:
new_page_name = self.project.get_page_display_name(new_page)
print(f"Added {new_page_name} at position {insert_index + 1} with size {width_mm}×{height_mm} mm")
@ribbon_action(
label="Page Setup",
tooltip="Configure page size and settings",
tab="Layout",
group="Page"
)
@ribbon_action(label="Page Setup", tooltip="Configure page size and settings", tab="Layout", group="Page")
@dialog_action(dialog_class=PageSetupDialog, requires_pages=True)
def page_setup(self, values):
"""
@@ -90,17 +80,17 @@ class PageOperationsMixin:
Args:
values: Dictionary of values from the dialog
"""
selected_page = values['selected_page']
selected_index = values['selected_index']
selected_page = values["selected_page"]
selected_index = values["selected_index"]
# Update project cover settings
self.project.paper_thickness_mm = values['paper_thickness_mm']
self.project.cover_bleed_mm = values['cover_bleed_mm']
self.project.paper_thickness_mm = values["paper_thickness_mm"]
self.project.cover_bleed_mm = values["cover_bleed_mm"]
# Handle cover designation (only for first page)
if selected_index == 0:
was_cover = selected_page.is_cover
is_cover = values['is_cover']
is_cover = values["is_cover"]
if was_cover != is_cover:
selected_page.is_cover = is_cover
@@ -116,8 +106,8 @@ class PageOperationsMixin:
print(f"Cover removed from page 1")
# Get new values
width_mm = values['width_mm']
height_mm = values['height_mm']
width_mm = values["width_mm"]
height_mm = values["height_mm"]
# Don't allow manual size changes for covers
if not selected_page.is_cover:
@@ -126,11 +116,11 @@ class PageOperationsMixin:
if selected_page.is_double_spread:
old_base_width = (
selected_page.layout.base_width
if hasattr(selected_page.layout, 'base_width')
if hasattr(selected_page.layout, "base_width")
else selected_page.layout.size[0] / 2
)
old_height = selected_page.layout.size[1]
size_changed = (old_base_width != width_mm or old_height != height_mm)
size_changed = old_base_width != width_mm or old_height != height_mm
if size_changed:
# Update double spread
@@ -143,7 +133,7 @@ class PageOperationsMixin:
)
else:
old_size = selected_page.layout.size
size_changed = (old_size != (width_mm, height_mm))
size_changed = old_size != (width_mm, height_mm)
if size_changed:
# Update single page
@@ -151,19 +141,32 @@ class PageOperationsMixin:
selected_page.layout.base_width = width_mm
selected_page.manually_sized = True
print(
f"{self.project.get_page_display_name(selected_page)} "
f"updated to {width_mm}×{height_mm} mm"
f"{self.project.get_page_display_name(selected_page)} " f"updated to {width_mm}×{height_mm} mm"
)
# Update DPI settings
self.project.working_dpi = values['working_dpi']
self.project.export_dpi = values['export_dpi']
self.project.working_dpi = values["working_dpi"]
self.project.export_dpi = values["export_dpi"]
# Set as default if checkbox is checked
if values['set_as_default']:
# Apply to other pages based on scope
# 0 = page only, 1 = non-manual pages, 2 = all pages
apply_scope = values.get("apply_scope", 0)
if apply_scope in (1, 2):
self.project.page_size_mm = (width_mm, height_mm)
print(f"Project default page size set to {width_mm}×{height_mm} mm")
for page in self.project.pages:
if page is selected_page or page.is_cover:
continue
if apply_scope == 1 and page.manually_sized:
continue
if page.is_double_spread:
page.layout.base_width = width_mm
page.layout.size = (width_mm * 2, height_mm)
else:
page.layout.size = (width_mm, height_mm)
page.layout.base_width = width_mm
self.update_view()
# Build status message
@@ -172,15 +175,12 @@ class PageOperationsMixin:
status_msg = f"{page_name} updated"
else:
status_msg = f"{page_name} size: {width_mm}×{height_mm} mm"
if values['set_as_default']:
if values.get("apply_scope", 0) in (1, 2):
status_msg += " (set as default)"
self.show_status(status_msg, 2000)
@ribbon_action(
label="Toggle Spread",
tooltip="Toggle double page spread for current page",
tab="Layout",
group="Page"
label="Toggle Spread", tooltip="Toggle double page spread for current page", tab="Layout", group="Page"
)
def toggle_double_spread(self):
"""Toggle double spread for the current page"""
@@ -208,7 +208,7 @@ class PageOperationsMixin:
current_height = current_page.layout.size[1]
# Get base width (might already be doubled)
if hasattr(current_page.layout, 'base_width'):
if hasattr(current_page.layout, "base_width"):
base_width = current_page.layout.base_width
else:
# Assume current width is single if not marked as facing
@@ -228,12 +228,7 @@ class PageOperationsMixin:
self.show_status(f"{page_name}: Double spread {status}, width = {new_width:.0f}mm", 2000)
print(f"{page_name}: Double spread {status}, width = {new_width}mm")
@ribbon_action(
label="Remove Page",
tooltip="Remove the currently selected page",
tab="Layout",
group="Page"
)
@ribbon_action(label="Remove Page", tooltip="Remove the currently selected page", tab="Layout", group="Page")
def remove_page(self):
"""Remove the currently selected page"""
if len(self.project.pages) <= 1:
+68 -104
View File
@@ -14,26 +14,61 @@ class SizeOperationsMixin:
"""Get list of selected elements for size operations"""
return list(self.gl_widget.selected_elements) if self.gl_widget.selected_elements else []
def _execute_resize(self, resize_func, status_msg: str):
"""
Execute a resize operation on multiple elements.
Args:
resize_func: AlignmentManager method to call with elements
status_msg: Status message format string (will receive element count)
"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2): # type: ignore[attr-defined]
return
changes = resize_func(elements)
if changes:
cmd = ResizeElementsCommand(changes)
self.project.history.execute(cmd) # type: ignore[attr-defined]
self.update_view() # type: ignore[attr-defined]
self.show_status(status_msg.format(len(elements)), 2000) # type: ignore[attr-defined]
def _execute_fit_to_page(self, fit_func, status_msg: str):
"""
Execute a fit-to-page operation on a single element.
Args:
fit_func: Function that takes (element, page) and returns a change tuple
status_msg: Status message to display on success
"""
if not self.require_selection(min_count=1): # type: ignore[attr-defined]
return
page = self.get_current_page() # type: ignore[attr-defined]
if not page:
self.show_warning("No Page", "Please create a page first.") # type: ignore[attr-defined]
return
element = next(iter(self.gl_widget.selected_elements)) # type: ignore[attr-defined]
change = fit_func(element, page)
if change:
cmd = ResizeElementsCommand([change])
self.project.history.execute(cmd) # type: ignore[attr-defined]
self.update_view() # type: ignore[attr-defined]
self.show_status(status_msg, 2000) # type: ignore[attr-defined]
@ribbon_action(
label="Same Size",
tooltip="Make all selected elements the same size",
tab="Arrange",
group="Size",
requires_selection=True,
min_selection=2
min_selection=2,
)
def make_same_size(self):
"""Make all selected elements the same size"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2):
return
changes = AlignmentManager.make_same_size(elements)
if changes:
cmd = ResizeElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Resized {len(elements)} elements to same size", 2000)
self._execute_resize(AlignmentManager.make_same_size, "Resized {} elements to same size")
@ribbon_action(
label="Same Width",
@@ -41,20 +76,11 @@ class SizeOperationsMixin:
tab="Arrange",
group="Size",
requires_selection=True,
min_selection=2
min_selection=2,
)
def make_same_width(self):
"""Make all selected elements the same width"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2):
return
changes = AlignmentManager.make_same_width(elements)
if changes:
cmd = ResizeElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Resized {len(elements)} elements to same width", 2000)
self._execute_resize(AlignmentManager.make_same_width, "Resized {} elements to same width")
@ribbon_action(
label="Same Height",
@@ -62,20 +88,11 @@ class SizeOperationsMixin:
tab="Arrange",
group="Size",
requires_selection=True,
min_selection=2
min_selection=2,
)
def make_same_height(self):
"""Make all selected elements the same height"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2):
return
changes = AlignmentManager.make_same_height(elements)
if changes:
cmd = ResizeElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Resized {len(elements)} elements to same height", 2000)
self._execute_resize(AlignmentManager.make_same_height, "Resized {} elements to same height")
@ribbon_action(
label="Fit Width",
@@ -83,30 +100,14 @@ class SizeOperationsMixin:
tab="Arrange",
group="Size",
requires_selection=True,
min_selection=1
min_selection=1,
)
def fit_to_width(self):
"""Fit selected element to page width"""
if not self.require_selection(min_count=1):
return
page = self.get_current_page()
if not page:
self.show_warning("No Page", "Please create a page first.")
return
# Get the first selected element
element = next(iter(self.gl_widget.selected_elements))
# Fit to page width
page_width = page.layout.size[0]
change = AlignmentManager.fit_to_page_width(element, page_width)
if change:
cmd = ResizeElementsCommand([change])
self.project.history.execute(cmd)
self.update_view()
self.show_status("Fitted element to page width", 2000)
self._execute_fit_to_page(
lambda elem, page: AlignmentManager.fit_to_page_width(elem, page.layout.size[0]),
"Fitted element to page width",
)
@ribbon_action(
label="Fit Height",
@@ -114,30 +115,14 @@ class SizeOperationsMixin:
tab="Arrange",
group="Size",
requires_selection=True,
min_selection=1
min_selection=1,
)
def fit_to_height(self):
"""Fit selected element to page height"""
if not self.require_selection(min_count=1):
return
page = self.get_current_page()
if not page:
self.show_warning("No Page", "Please create a page first.")
return
# Get the first selected element
element = next(iter(self.gl_widget.selected_elements))
# Fit to page height
page_height = page.layout.size[1]
change = AlignmentManager.fit_to_page_height(element, page_height)
if change:
cmd = ResizeElementsCommand([change])
self.project.history.execute(cmd)
self.update_view()
self.show_status("Fitted element to page height", 2000)
self._execute_fit_to_page(
lambda elem, page: AlignmentManager.fit_to_page_height(elem, page.layout.size[1]),
"Fitted element to page height",
)
@ribbon_action(
label="Fit to Page",
@@ -145,30 +130,14 @@ class SizeOperationsMixin:
tab="Arrange",
group="Size",
requires_selection=True,
min_selection=1
min_selection=1,
)
def fit_to_page(self):
"""Fit selected element to page dimensions"""
if not self.require_selection(min_count=1):
return
page = self.get_current_page()
if not page:
self.show_warning("No Page", "Please create a page first.")
return
# Get the first selected element
element = next(iter(self.gl_widget.selected_elements))
# Fit to page
page_width, page_height = page.layout.size
change = AlignmentManager.fit_to_page(element, page_width, page_height)
if change:
cmd = ResizeElementsCommand([change])
self.project.history.execute(cmd)
self.update_view()
self.show_status("Fitted element to page", 2000)
self._execute_fit_to_page(
lambda elem, page: AlignmentManager.fit_to_page(elem, page.layout.size[0], page.layout.size[1]),
"Fitted element to page",
)
@ribbon_action(
label="Expand Image",
@@ -176,7 +145,7 @@ class SizeOperationsMixin:
tab="Arrange",
group="Size",
requires_selection=True,
min_selection=1
min_selection=1,
)
def expand_image(self):
"""Expand selected image to fill available space"""
@@ -195,16 +164,11 @@ class SizeOperationsMixin:
other_elements = [e for e in page.layout.elements if e is not element]
# Use configurable min_gap (grid spacing from snapping system, default 10mm)
min_gap = getattr(page.layout.snapping_system, 'grid_spacing', 10.0)
min_gap = getattr(page.layout.snapping_system, "grid_spacing", 10.0)
# Expand to bounds
page_width, page_height = page.layout.size
change = AlignmentManager.expand_to_bounds(
element,
(page_width, page_height),
other_elements,
min_gap
)
change = AlignmentManager.expand_to_bounds(element, (page_width, page_height), other_elements, min_gap)
if change:
cmd = ResizeElementsCommand([change])
+373
View File
@@ -0,0 +1,373 @@
"""
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) # type: ignore[attr-defined]
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) # type: ignore[attr-defined]
self.update_view() # type: ignore[attr-defined]
self.show_status(f"{description} applied to {len(images)} image(s)", 2000) # type: ignore[attr-defined]
# =========================================================================
# 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) # type: ignore[attr-defined]
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) # type: ignore[attr-defined]
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) # type: ignore[attr-defined]
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) # type: ignore[attr-defined]
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) # type: ignore[attr-defined]
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")
+22 -35
View File
@@ -3,9 +3,16 @@ Template operations mixin for pyPhotoAlbum
"""
from PyQt6.QtWidgets import (
QInputDialog, QDialog, QVBoxLayout, QLabel, QComboBox,
QRadioButton, QButtonGroup, QPushButton, QHBoxLayout,
QDoubleSpinBox
QInputDialog,
QDialog,
QVBoxLayout,
QLabel,
QComboBox,
QRadioButton,
QButtonGroup,
QPushButton,
QHBoxLayout,
QDoubleSpinBox,
)
from pyPhotoAlbum.decorators import ribbon_action, undoable_operation
@@ -18,7 +25,7 @@ class TemplateOperationsMixin:
tooltip="Save current page as a reusable template",
tab="Layout",
group="Templates",
requires_page=True
requires_page=True,
)
def save_page_as_template(self):
"""Save current page as a template"""
@@ -36,37 +43,26 @@ class TemplateOperationsMixin:
self,
"Save Template",
"Enter template name:",
text=f"Template_{len(self.template_manager.list_templates()) + 1}"
text=f"Template_{len(self.template_manager.list_templates()) + 1}",
)
if not ok or not name:
return
# Ask for optional description
description, ok = QInputDialog.getText(
self,
"Template Description",
"Enter description (optional):"
)
description, ok = QInputDialog.getText(self, "Template Description", "Enter description (optional):")
if not ok:
description = ""
try:
# Create template from page
template = self.template_manager.create_template_from_page(
current_page,
name,
description
)
template = self.template_manager.create_template_from_page(current_page, name, description)
# Save template
self.template_manager.save_template(template)
self.show_info(
"Template Saved",
f"Template '{name}' has been saved successfully."
)
self.show_info("Template Saved", f"Template '{name}' has been saved successfully.")
print(f"Saved template: {name}")
@@ -75,10 +71,7 @@ class TemplateOperationsMixin:
print(f"Error saving template: {e}")
@ribbon_action(
label="New from Template",
tooltip="Create a new page from a template",
tab="Layout",
group="Templates"
label="New from Template", tooltip="Create a new page from a template", tab="Layout", group="Templates"
)
def new_page_from_template(self):
"""Create a new page from a template"""
@@ -87,8 +80,7 @@ class TemplateOperationsMixin:
if not templates:
self.show_info(
"No Templates",
"No templates available. Create a template first by using 'Save as Template'."
"No Templates", "No templates available. Create a template first by using 'Save as Template'."
)
return
@@ -178,7 +170,7 @@ class TemplateOperationsMixin:
page_number=new_page_number,
target_size_mm=self.project.page_size_mm,
scale_mode=scale_mode,
margin_percent=margin_percent
margin_percent=margin_percent,
)
# Add to project
@@ -200,9 +192,9 @@ class TemplateOperationsMixin:
tooltip="Apply a template layout to current page",
tab="Layout",
group="Templates",
requires_page=True
requires_page=True,
)
@undoable_operation(capture='page_elements', description='Apply Template')
@undoable_operation(capture="page_elements", description="Apply Template")
def apply_template_to_page(self):
"""Apply a template to the current page"""
current_page = self.get_current_page()
@@ -214,8 +206,7 @@ class TemplateOperationsMixin:
if not templates:
self.show_info(
"No Templates",
"No templates available. Create a template first by using 'Save as Template'."
"No Templates", "No templates available. Create a template first by using 'Save as Template'."
)
return
@@ -320,11 +311,7 @@ class TemplateOperationsMixin:
# Apply template to page
self.template_manager.apply_template_to_page(
template,
current_page,
mode=mode,
scale_mode=scale_mode,
margin_percent=margin_percent
template, current_page, mode=mode, scale_mode=scale_mode, margin_percent=margin_percent
)
# Update display
+69 -133
View File
@@ -3,18 +3,13 @@ View operations mixin for pyPhotoAlbum
"""
from pyPhotoAlbum.decorators import ribbon_action
from pyPhotoAlbum.dialogs import PrintSettingsDialog
class ViewOperationsMixin:
"""Mixin providing view-related operations"""
@ribbon_action(
label="Zoom In",
tooltip="Zoom in",
tab="View",
group="Zoom",
shortcut="Ctrl++"
)
@ribbon_action(label="Zoom In", tooltip="Zoom in", tab="View", group="Zoom", shortcut="Ctrl++")
def zoom_in(self):
"""Zoom in"""
self.gl_widget.zoom_level *= 1.2
@@ -23,13 +18,7 @@ class ViewOperationsMixin:
self.update_view()
self.show_status(f"Zoom: {int(self.gl_widget.zoom_level * 100)}%", 2000)
@ribbon_action(
label="Zoom Out",
tooltip="Zoom out",
tab="View",
group="Zoom",
shortcut="Ctrl+-"
)
@ribbon_action(label="Zoom Out", tooltip="Zoom out", tab="View", group="Zoom", shortcut="Ctrl+-")
def zoom_out(self):
"""Zoom out"""
self.gl_widget.zoom_level /= 1.2
@@ -38,13 +27,7 @@ class ViewOperationsMixin:
self.update_view()
self.show_status(f"Zoom: {int(self.gl_widget.zoom_level * 100)}%", 2000)
@ribbon_action(
label="Fit to Window",
tooltip="Fit page to window",
tab="View",
group="Zoom",
shortcut="Ctrl+0"
)
@ribbon_action(label="Fit to Window", tooltip="Fit page to window", tab="View", group="Zoom", shortcut="Ctrl+0")
def zoom_fit(self):
"""Fit page to window"""
if not self.project.pages:
@@ -73,10 +56,11 @@ class ViewOperationsMixin:
self.show_status(f"Zoom: {int(self.gl_widget.zoom_level * 100)}%", 2000)
@ribbon_action(
label="Toggle Grid Snap",
tooltip="Toggle snapping to grid",
tab="View",
group="Snapping"
label="Grid Snap",
tooltip="Enable/disable snapping to grid (Ctrl+G)",
tab="Insert",
group="Snapping",
shortcut="Ctrl+G",
)
def toggle_grid_snap(self):
"""Toggle grid snapping"""
@@ -91,10 +75,11 @@ class ViewOperationsMixin:
print(f"Grid snapping {status}")
@ribbon_action(
label="Toggle Edge Snap",
tooltip="Toggle snapping to page edges",
tab="View",
group="Snapping"
label="Edge Snap",
tooltip="Enable/disable snapping to page edges (Ctrl+E)",
tab="Insert",
group="Snapping",
shortcut="Ctrl+E",
)
def toggle_edge_snap(self):
"""Toggle edge snapping"""
@@ -108,12 +93,7 @@ class ViewOperationsMixin:
self.show_status(f"Edge snapping {status}", 2000)
print(f"Edge snapping {status}")
@ribbon_action(
label="Toggle Guide Snap",
tooltip="Toggle snapping to guides",
tab="View",
group="Snapping"
)
@ribbon_action(label="Guide Snap", tooltip="Enable/disable snapping to guides", tab="Insert", group="Snapping")
def toggle_guide_snap(self):
"""Toggle guide snapping"""
if not self.project:
@@ -126,12 +106,7 @@ class ViewOperationsMixin:
self.show_status(f"Guide snapping {status}", 2000)
print(f"Guide snapping {status}")
@ribbon_action(
label="Show Grid",
tooltip="Toggle visibility of grid lines",
tab="View",
group="Snapping"
)
@ribbon_action(label="Show Grid", tooltip="Toggle visibility of grid lines", tab="Insert", group="Snapping")
def toggle_show_grid(self):
"""Toggle grid visibility"""
if not self.project:
@@ -145,11 +120,38 @@ class ViewOperationsMixin:
print(f"Grid {status}")
@ribbon_action(
label="Show Guides",
tooltip="Toggle visibility of guide lines",
tab="View",
group="Snapping"
label="Print Settings...", tooltip="Configure bleed and safe area for all pages", tab="View", group="Guides"
)
def open_print_settings(self):
"""Open the print settings dialog (bleed and safe area)"""
if not self.project:
return
dialog = PrintSettingsDialog(self, self.project)
if dialog.exec():
values = dialog.get_values()
self.project.page_bleed_mm = values["page_bleed_mm"]
self.project.page_safe_area_mm = values["page_safe_area_mm"]
self.update_view()
self.show_status(
f"Bleed: {values['page_bleed_mm']:.1f}mm, Safe area: {values['page_safe_area_mm']:.1f}mm", 2000
)
@ribbon_action(
label="Print Guides", tooltip="Toggle bleed/cut/safe-area guide lines in the editor", tab="View", group="Guides"
)
def toggle_print_guides(self):
"""Toggle print guide lines (bleed/cut/safe area)"""
if not self.project:
return
self.project.show_print_guides = not self.project.show_print_guides
status = "visible" if self.project.show_print_guides else "hidden"
self.update_view()
self.show_status(f"Print guides {status}", 2000)
@ribbon_action(label="Show Guides", tooltip="Toggle visibility of guide lines", tab="Insert", group="Snapping")
def toggle_snap_lines(self):
"""Toggle guide lines visibility"""
if not self.project:
@@ -162,12 +164,7 @@ class ViewOperationsMixin:
self.show_status(f"Guides {status}", 2000)
print(f"Guides {status}")
@ribbon_action(
label="Add H Guide",
tooltip="Add horizontal guide at page center",
tab="View",
group="Guides"
)
@ribbon_action(label="Add H Guide", tooltip="Add horizontal guide at page center", tab="View", group="Guides")
def add_horizontal_guide(self):
"""Add a horizontal guide at page center"""
current_page = self.get_current_page()
@@ -176,18 +173,13 @@ class ViewOperationsMixin:
# Add guide at vertical center (in mm)
center_y = current_page.layout.size[1] / 2.0
current_page.layout.snapping_system.add_guide(center_y, 'horizontal')
current_page.layout.snapping_system.add_guide(center_y, "horizontal")
self.update_view()
self.show_status(f"Added horizontal guide at {center_y:.1f} mm", 2000)
print(f"Added horizontal guide at {center_y:.1f} mm")
@ribbon_action(
label="Add V Guide",
tooltip="Add vertical guide at page center",
tab="View",
group="Guides"
)
@ribbon_action(label="Add V Guide", tooltip="Add vertical guide at page center", tab="View", group="Guides")
def add_vertical_guide(self):
"""Add a vertical guide at page center"""
current_page = self.get_current_page()
@@ -196,18 +188,13 @@ class ViewOperationsMixin:
# Add guide at horizontal center (in mm)
center_x = current_page.layout.size[0] / 2.0
current_page.layout.snapping_system.add_guide(center_x, 'vertical')
current_page.layout.snapping_system.add_guide(center_x, "vertical")
self.update_view()
self.show_status(f"Added vertical guide at {center_x:.1f} mm", 2000)
print(f"Added vertical guide at {center_x:.1f} mm")
@ribbon_action(
label="Clear Guides",
tooltip="Clear all guides from current page",
tab="View",
group="Guides"
)
@ribbon_action(label="Clear Guides", tooltip="Clear all guides from current page", tab="View", group="Guides")
def clear_guides(self):
"""Clear all guides from current page"""
current_page = self.get_current_page()
@@ -222,10 +209,24 @@ class ViewOperationsMixin:
print(f"Cleared {guide_count} guides")
@ribbon_action(
label="Set Grid Size...",
tooltip="Configure grid spacing for snapping",
label="Image Browser",
tooltip="Show/hide the image browser panel",
tab="View",
group="Snapping"
group="Panels",
shortcut="Ctrl+B",
)
def toggle_image_browser(self):
"""Toggle the thumbnail browser visibility"""
if hasattr(self, "_thumbnail_browser"):
if self._thumbnail_browser.isVisible():
self._thumbnail_browser.hide()
self.show_status("Image browser hidden", 2000)
else:
self._thumbnail_browser.show()
self.show_status("Image browser shown", 2000)
@ribbon_action(
label="Grid Settings...", tooltip="Configure grid size and snap threshold", tab="Insert", group="Snapping"
)
def set_grid_size(self):
"""Open dialog to set grid size"""
@@ -295,68 +296,3 @@ class ViewOperationsMixin:
self.update_view()
self.show_status(f"Grid size: {new_grid_size:.1f}mm, Threshold: {new_threshold:.1f}mm", 2000)
print(f"Updated grid settings - Size: {new_grid_size:.1f}mm, Threshold: {new_threshold:.1f}mm")
# ===== Layout Tab Snapping Controls =====
# These provide easy access to snapping features during layout work
@ribbon_action(
label="Grid Snap",
tooltip="Enable/disable snapping to grid (Ctrl+G)",
tab="Layout",
group="Snapping",
shortcut="Ctrl+G"
)
def layout_toggle_grid_snap(self):
"""Toggle grid snapping (Layout tab)"""
self.toggle_grid_snap()
@ribbon_action(
label="Edge Snap",
tooltip="Enable/disable snapping to page edges (Ctrl+E)",
tab="Layout",
group="Snapping",
shortcut="Ctrl+E"
)
def layout_toggle_edge_snap(self):
"""Toggle edge snapping (Layout tab)"""
self.toggle_edge_snap()
@ribbon_action(
label="Guide Snap",
tooltip="Enable/disable snapping to guides",
tab="Layout",
group="Snapping"
)
def layout_toggle_guide_snap(self):
"""Toggle guide snapping (Layout tab)"""
self.toggle_guide_snap()
@ribbon_action(
label="Show Grid",
tooltip="Toggle visibility of grid lines",
tab="Layout",
group="Snapping"
)
def layout_toggle_show_grid(self):
"""Toggle grid visibility (Layout tab)"""
self.toggle_show_grid()
@ribbon_action(
label="Show Guides",
tooltip="Toggle visibility of guide lines",
tab="Layout",
group="Snapping"
)
def layout_toggle_snap_lines(self):
"""Toggle guide lines visibility (Layout tab)"""
self.toggle_snap_lines()
@ribbon_action(
label="Grid Settings...",
tooltip="Configure grid size and snap threshold",
tab="Layout",
group="Snapping"
)
def layout_set_grid_size(self):
"""Open grid settings dialog (Layout tab)"""
self.set_grid_size()
+5 -5
View File
@@ -15,7 +15,7 @@ class ZOrderOperationsMixin:
tab="Arrange",
group="Order",
shortcut="Ctrl+Shift+]",
requires_selection=True
requires_selection=True,
)
def bring_to_front(self):
"""Bring selected element to front (end of list)"""
@@ -53,7 +53,7 @@ class ZOrderOperationsMixin:
tab="Arrange",
group="Order",
shortcut="Ctrl+Shift+[",
requires_selection=True
requires_selection=True,
)
def send_to_back(self):
"""Send selected element to back (start of list)"""
@@ -91,7 +91,7 @@ class ZOrderOperationsMixin:
tab="Arrange",
group="Order",
shortcut="Ctrl+]",
requires_selection=True
requires_selection=True,
)
def bring_forward(self):
"""Move selected element forward one position in list"""
@@ -129,7 +129,7 @@ class ZOrderOperationsMixin:
tab="Arrange",
group="Order",
shortcut="Ctrl+[",
requires_selection=True
requires_selection=True,
)
def send_backward(self):
"""Move selected element backward one position in list"""
@@ -168,7 +168,7 @@ class ZOrderOperationsMixin:
group="Order",
shortcut="Ctrl+Shift+X",
requires_selection=True,
min_selection=2
min_selection=2,
)
def swap_order(self):
"""Swap the z-order of two selected elements"""
+51 -26
View File
@@ -2,10 +2,25 @@
Page navigation mixin for GLWidget - handles page detection and ghost pages
"""
from typing import Optional, Tuple, List
from typing import TYPE_CHECKING, Optional, Tuple, List
if TYPE_CHECKING:
from PyQt6.QtWidgets import QMainWindow
class PageNavigationMixin:
# Type hints for expected attributes from mixing class
pan_offset: Tuple[float, float]
zoom_level: float
def update(self) -> None: # type: ignore[empty-body]
"""Expected from QWidget"""
...
def window(self) -> "QMainWindow": # type: ignore[empty-body]
"""Expected from QWidget"""
...
"""
Mixin providing page navigation and ghost page functionality.
@@ -33,11 +48,11 @@ class PageNavigationMixin:
Returns:
Tuple of (page, page_index, renderer) or (None, -1, None) if no page at coordinates
"""
if not hasattr(self, '_page_renderers') or not self._page_renderers:
if not hasattr(self, "_page_renderers") or not self._page_renderers:
return None, -1, None
main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return None, -1, None
# Check each page to find which one contains the coordinates
@@ -56,15 +71,23 @@ class PageNavigationMixin:
Returns:
List of tuples (page_type, page_or_ghost_data, y_offset)
"""
main_window = self.window()
if not hasattr(main_window, 'project'):
# Use stored reference to main window
main_window = getattr(self, "_main_window", None)
if main_window is None:
main_window = self.window()
try:
project = main_window.project
if not project:
return []
except (AttributeError, TypeError):
return []
dpi = main_window.project.working_dpi
dpi = project.working_dpi
# Use project's page_spacing_mm setting (default is 10mm = 1cm)
# Convert to pixels at working DPI
spacing_mm = main_window.project.page_spacing_mm
spacing_mm = project.page_spacing_mm
spacing_px = spacing_mm * dpi / 25.4
# Start with a small top margin (5mm)
@@ -75,9 +98,9 @@ class PageNavigationMixin:
current_y = top_margin_px # Initial top offset in pixels (not screen pixels)
# First, render cover if it exists
for page in main_window.project.pages:
for page in project.pages:
if page.is_cover:
result.append(('page', page, current_y))
result.append(("page", page, current_y))
# Calculate cover height in pixels
page_height_mm = page.layout.size[1]
@@ -88,10 +111,10 @@ class PageNavigationMixin:
break # Only one cover allowed
# Get page layout with ghosts from project (this excludes cover)
layout_with_ghosts = main_window.project.calculate_page_layout_with_ghosts()
layout_with_ghosts = project.calculate_page_layout_with_ghosts()
for page_type, page_obj, logical_pos in layout_with_ghosts:
if page_type == 'page':
if page_type == "page":
# Regular page (single or double spread)
result.append((page_type, page_obj, current_y))
@@ -103,9 +126,9 @@ class PageNavigationMixin:
# Move to next position (add height + spacing)
current_y += page_height_px + spacing_px
elif page_type == 'ghost':
elif page_type == "ghost":
# Ghost page - use default page size
page_size_mm = main_window.project.page_size_mm
page_size_mm = project.page_size_mm
from pyPhotoAlbum.models import GhostPageData
# Create ghost page data with correct size
@@ -131,11 +154,11 @@ class PageNavigationMixin:
Returns:
bool: True if a ghost page was clicked and a new page was created
"""
if not hasattr(self, '_page_renderers'):
if not hasattr(self, "_page_renderers"):
return False
main_window = self.window()
if not hasattr(main_window, 'project'):
if not hasattr(main_window, "project"):
return False
# Get page positions which includes ghosts
@@ -144,7 +167,7 @@ class PageNavigationMixin:
# Check each position for ghost pages
for idx, (page_type, page_or_ghost, y_offset) in enumerate(page_positions):
# Skip non-ghost pages
if page_type != 'ghost':
if page_type != "ghost":
continue
ghost = page_or_ghost
@@ -156,20 +179,21 @@ class PageNavigationMixin:
screen_y = (y_offset * self.zoom_level) + self.pan_offset[1]
from pyPhotoAlbum.page_renderer import PageRenderer
renderer = PageRenderer(
page_width_mm=ghost_width_mm,
page_height_mm=ghost_height_mm,
screen_x=screen_x,
screen_y=screen_y,
dpi=dpi,
zoom=self.zoom_level
zoom=self.zoom_level,
)
# Check if click is anywhere on the ghost page (entire page is clickable)
if renderer.is_point_in_page(x, y):
# User clicked the ghost page!
# Calculate the insertion index (count real pages before this ghost in page_positions)
insert_index = sum(1 for i, (pt, _, _) in enumerate(page_positions) if i < idx and pt == 'page')
insert_index = sum(1 for i, (pt, _, _) in enumerate(page_positions) if i < idx and pt == "page")
print(f"Ghost page clicked at index {insert_index} - inserting new page in place")
@@ -181,10 +205,9 @@ class PageNavigationMixin:
new_page_number = insert_index + 1
new_page = Page(
layout=PageLayout(
width=main_window.project.page_size_mm[0],
height=main_window.project.page_size_mm[1]
width=main_window.project.page_size_mm[0], height=main_window.project.page_size_mm[1]
),
page_number=new_page_number
page_number=new_page_number,
)
# Insert the page at the correct position
@@ -209,10 +232,10 @@ class PageNavigationMixin:
y: Screen Y coordinate
"""
main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return
if not hasattr(self, '_page_renderers') or not self._page_renderers:
if not hasattr(self, "_page_renderers") or not self._page_renderers:
return
# Get total page count (accounting for double spreads = 2 pages each)
@@ -228,7 +251,7 @@ class PageNavigationMixin:
if page.is_double_spread:
side = renderer.get_sub_page_at(x, is_facing_page=True)
page_nums = page.get_page_numbers()
if side == 'left':
if side == "left":
current_page_info = f"Page {page_nums[0]}"
else:
current_page_info = f"Page {page_nums[1]}"
@@ -237,8 +260,10 @@ class PageNavigationMixin:
break
# Update status bar
if hasattr(main_window, 'status_bar'):
if hasattr(main_window, "status_bar"):
if current_page_info:
main_window.status_bar.showMessage(f"{current_page_info} of {total_pages} | Zoom: {int(self.zoom_level * 100)}%")
main_window.status_bar.showMessage(
f"{current_page_info} of {total_pages} | Zoom: {int(self.zoom_level * 100)}%"
)
else:
main_window.status_bar.showMessage(f"Total pages: {total_pages} | Zoom: {int(self.zoom_level * 100)}%")
+146 -39
View File
@@ -25,8 +25,23 @@ class RenderingMixin:
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
glLoadIdentity()
main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
# Use stored reference to main window
main_window = getattr(self, "_main_window", None)
if main_window is None:
# Fallback to window() if _main_window not set
main_window = self.window()
if main_window is None:
return
try:
project = main_window.project
if not project:
return
if not project.pages:
return
except AttributeError:
# Project not yet initialized
return
# Set initial zoom and center the page if not done yet
@@ -36,11 +51,10 @@ class RenderingMixin:
self.initial_zoom_set = True
# Update scrollbars now that we have content bounds
main_window = self.window()
if hasattr(main_window, 'update_scrollbars'):
main_window.update_scrollbars()
if hasattr(self, "_main_window") and hasattr(self._main_window, "update_scrollbars"):
self._main_window.update_scrollbars()
dpi = main_window.project.working_dpi
dpi = project.working_dpi
# Calculate page positions with ghosts
page_positions = self._get_page_positions()
@@ -52,10 +66,11 @@ class RenderingMixin:
PAGE_MARGIN = 50
# Render all pages
pages_rendered = 0
for page_info in page_positions:
page_type, page_or_ghost, y_offset = page_info
if page_type == 'page':
if page_type == "page":
page = page_or_ghost
page_width_mm, page_height_mm = page.layout.size
@@ -68,7 +83,7 @@ class RenderingMixin:
screen_x=screen_x,
screen_y=screen_y,
dpi=dpi,
zoom=self.zoom_level
zoom=self.zoom_level,
)
self._page_renderers.append((renderer, page))
@@ -76,10 +91,15 @@ class RenderingMixin:
renderer.begin_render()
# Pass widget reference for async loading
page.layout._parent_widget = self
page.layout.render(dpi=dpi, project=main_window.project)
page.layout.render(dpi=dpi, project=project)
renderer.end_render()
elif page_type == 'ghost':
# Draw bleed/cut/safe-area guides for this page
self._draw_page_print_guides(renderer, project)
pages_rendered += 1
elif page_type == "ghost":
ghost = page_or_ghost
ghost_width_mm, ghost_height_mm = ghost.page_size
@@ -92,14 +112,14 @@ class RenderingMixin:
screen_x=screen_x,
screen_y=screen_y,
dpi=dpi,
zoom=self.zoom_level
zoom=self.zoom_level,
)
self._render_ghost_page(ghost, renderer)
# Update PageRenderer references for selected elements
for element in self.selected_elements:
if hasattr(element, '_parent_page'):
if hasattr(element, "_parent_page"):
for renderer, page in self._page_renderers:
if page is element._parent_page:
element._page_renderer = renderer
@@ -109,7 +129,8 @@ class RenderingMixin:
for element in self.selected_elements:
self._draw_selection_handles(element)
# Render text overlays
# Render text overlays using QPainter
# Qt will handle OpenGL/QPainter coordination automatically
self._render_text_overlays()
def _draw_selection_handles(self, element):
@@ -118,10 +139,10 @@ class RenderingMixin:
return
main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return
if not hasattr(element, '_page_renderer'):
if not hasattr(element, "_page_renderer"):
return
renderer = element._page_renderer
@@ -188,10 +209,10 @@ class RenderingMixin:
glEnd()
else:
handles = [
(x - handle_size/2, y - handle_size/2),
(x + w - handle_size/2, y - handle_size/2),
(x - handle_size/2, y + h - handle_size/2),
(x + w - handle_size/2, y + h - handle_size/2),
(x - handle_size / 2, y - handle_size / 2),
(x + w - handle_size / 2, y - handle_size / 2),
(x - handle_size / 2, y + h - handle_size / 2),
(x + w - handle_size / 2, y + h - handle_size / 2),
]
glColor3f(1.0, 1.0, 1.0)
@@ -214,7 +235,7 @@ class RenderingMixin:
def _render_text_overlays(self):
"""Render text content for TextBoxData elements using QPainter overlay"""
if not hasattr(self, '_page_renderers') or not self._page_renderers:
if not hasattr(self, "_page_renderers") or not self._page_renderers:
return
painter = QPainter(self)
@@ -236,41 +257,44 @@ class RenderingMixin:
screen_w = w * renderer.zoom
screen_h = h * renderer.zoom
font_family = element.font_settings.get('family', 'Arial')
font_size = int(element.font_settings.get('size', 12) * renderer.zoom)
font_family = element.font_settings.get("family", "Arial")
# Use base font size without zoom - zoom is applied via painter transform
font_size = int(element.font_settings.get("size", 12))
font = QFont(font_family, font_size)
painter.setFont(font)
font_color = element.font_settings.get('color', (0, 0, 0))
font_color = element.font_settings.get("color", (0, 0, 0))
if all(isinstance(c, int) and c > 1 for c in font_color):
color = QColor(*font_color)
else:
color = QColor(int(font_color[0] * 255), int(font_color[1] * 255), int(font_color[2] * 255))
painter.setPen(QPen(color))
# Apply zoom via painter transform so font scales consistently with page
painter.save()
painter.translate(screen_x, screen_y)
painter.scale(renderer.zoom, renderer.zoom)
if element.rotation != 0:
painter.save()
center_x = screen_x + screen_w / 2
center_y = screen_y + screen_h / 2
painter.translate(center_x, center_y)
painter.translate(w / 2, h / 2)
painter.rotate(element.rotation)
painter.translate(-screen_w / 2, -screen_h / 2)
rect = QRectF(0, 0, screen_w, screen_h)
else:
rect = QRectF(screen_x, screen_y, screen_w, screen_h)
painter.translate(-w / 2, -h / 2)
rect = QRectF(0, 0, w, h)
alignment = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop
if element.alignment == 'center':
if element.alignment == "center":
alignment = Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop
elif element.alignment == 'right':
elif element.alignment == "right":
alignment = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop
elif element.alignment == "justify":
alignment = Qt.AlignmentFlag.AlignJustify | Qt.AlignmentFlag.AlignTop
text_flags = Qt.TextFlag.TextWordWrap
painter.drawText(rect, int(alignment | text_flags), element.text_content)
if element.rotation != 0:
painter.restore()
painter.restore()
finally:
painter.end()
@@ -289,15 +313,98 @@ class RenderingMixin:
px, py, pw, ph = ghost_data.get_page_rect()
screen_x, screen_y = renderer.page_to_screen(px, py)
screen_w = pw * renderer.zoom
screen_h = ph * renderer.zoom
font = QFont("Arial", int(16 * renderer.zoom), QFont.Weight.Bold)
# Use base font size without zoom - zoom is applied via painter transform
font = QFont("Arial", 16, QFont.Weight.Bold)
painter.setFont(font)
painter.setPen(QColor(120, 120, 120))
rect = QRectF(screen_x, screen_y, screen_w, screen_h)
painter.save()
painter.translate(screen_x, screen_y)
painter.scale(renderer.zoom, renderer.zoom)
rect = QRectF(0, 0, pw, ph)
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "Click to Add Page")
painter.restore()
finally:
painter.end()
def _draw_page_print_guides(self, renderer, project):
"""
Draw bleed/cut/safe-area guide lines around a page using OpenGL.
- Green dashed rectangle: bleed boundary (extend backgrounds to here)
- Magenta rectangle: cut/trim line (finished page edge, only shown when bleed > 0)
- Red rectangle: safe area (keep text and logos inside)
Lines are only drawn when the corresponding project settings are non-zero.
"""
if not getattr(project, "show_print_guides", False):
return
bleed_mm = getattr(project, "page_bleed_mm", 0.0)
safe_mm = getattr(project, "page_safe_area_mm", 0.0)
if bleed_mm <= 0.0 and safe_mm <= 0.0:
return
dpi = project.working_dpi
zoom = renderer.zoom
sx = renderer.screen_x
sy = renderer.screen_y
sw = renderer.screen_width
sh = renderer.screen_height
# Convert mm to screen pixels
bleed_screen = bleed_mm * dpi / 25.4 * zoom
safe_screen = safe_mm * dpi / 25.4 * zoom
glLineWidth(1.0)
def _draw_rect_outline(x, y, w, h, r, g, b, dashed=False):
glColor3f(r, g, b)
if dashed:
glEnable(GL_LINE_STIPPLE)
glLineStipple(1, 0x00FF)
else:
glDisable(GL_LINE_STIPPLE)
glBegin(GL_LINE_LOOP)
glVertex2f(x, y)
glVertex2f(x + w, y)
glVertex2f(x + w, y + h)
glVertex2f(x, y + h)
glEnd()
glDisable(GL_LINE_STIPPLE)
# Bleed boundary green dashed rectangle outside the page
if bleed_screen > 0:
_draw_rect_outline(
sx - bleed_screen,
sy - bleed_screen,
sw + 2 * bleed_screen,
sh + 2 * bleed_screen,
0.0,
0.67,
0.0,
dashed=True,
)
# Cut/trim line magenta rectangle at the page edge (only meaningful when bleed > 0)
if bleed_screen > 0:
_draw_rect_outline(sx, sy, sw, sh, 0.8, 0.0, 0.8)
# Safe area red rectangle inside the page
if safe_screen > 0:
_draw_rect_outline(
sx + safe_screen,
sy + safe_screen,
sw - 2 * safe_screen,
sh - 2 * safe_screen,
0.8,
0.0,
0.0,
)
glColor3f(1.0, 1.0, 1.0) # Reset colour
+101 -123
View File
@@ -61,7 +61,7 @@ class ViewportMixin:
# Update scrollbars when viewport size changes
main_window = self.window()
if hasattr(main_window, 'update_scrollbars'):
if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars()
def _calculate_fit_to_screen_zoom(self):
@@ -72,7 +72,7 @@ class ViewportMixin:
float: Zoom level (1.0 = 100%, 0.5 = 50%, etc.)
"""
main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return 1.0
window_width = self.width()
@@ -106,7 +106,7 @@ class ViewportMixin:
list: [x_offset, y_offset] to center the page
"""
main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return [0, 0]
window_width = self.width()
@@ -141,8 +141,8 @@ class ViewportMixin:
dict: {'min_x', 'max_x', 'min_y', 'max_y', 'width', 'height'} in pixels
"""
main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
return {'min_x': 0, 'max_x': 800, 'min_y': 0, 'max_y': 600, 'width': 800, 'height': 600}
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return {"min_x": 0, "max_x": 800, "min_y": 0, "max_y": 600, "width": 800, "height": 600}
dpi = main_window.project.working_dpi
PAGE_MARGIN = 50
@@ -167,14 +167,92 @@ class ViewportMixin:
total_height += PAGE_MARGIN
return {
'min_x': 0,
'max_x': total_width,
'min_y': 0,
'max_y': total_height,
'width': total_width,
'height': total_height
"min_x": 0,
"max_x": total_width,
"min_y": 0,
"max_y": total_height,
"width": total_width,
"height": total_height,
}
def _clamp_vertical_pan(self, viewport_height: float) -> float:
"""Clamp vertical pan offset and return the original value before clamping."""
bounds = self.get_content_bounds()
content_height = bounds["height"]
# Save original for page selection (prevents clamping from changing which page we target)
original_pan_y: float = self.pan_offset[1]
if content_height > viewport_height:
max_pan_up = 0 # Can't pan beyond top edge
min_pan_up = -(content_height - viewport_height) # Can't pan beyond bottom edge
self.pan_offset[1] = max(min_pan_up, min(max_pan_up, self.pan_offset[1]))
return original_pan_y
def _build_page_centerlines(self, pages, dpi: float) -> list:
"""Build list of (center_y, center_x, width) tuples for each page."""
PAGE_MARGIN = 50
PAGE_SPACING = 50
centerlines = []
current_y = PAGE_MARGIN
for page in pages:
page_width_mm, page_height_mm = page.layout.size
screen_page_width = page_width_mm * dpi / 25.4 * self.zoom_level
screen_page_height = page_height_mm * dpi / 25.4 * self.zoom_level
page_center_y = current_y + screen_page_height / 2
page_center_x = PAGE_MARGIN + screen_page_width / 2
centerlines.append((page_center_y, page_center_x, screen_page_width))
current_y += screen_page_height + PAGE_SPACING
return centerlines
def _interpolate_target_centerline(self, centerlines: list, viewport_center_y: float) -> tuple:
"""Find target centerline by interpolating between pages based on viewport position."""
if not centerlines:
return 0, 0
# Find the page index we're at or past
page_idx = self._find_page_at_viewport_y(centerlines, viewport_center_y)
if page_idx == 0:
return centerlines[0][1], centerlines[0][2]
# Interpolate between previous and current page
prev_y, prev_x, prev_w = centerlines[page_idx - 1]
curr_y, curr_x, curr_w = centerlines[page_idx]
if curr_y == prev_y:
return curr_x, curr_w
t = max(0, min(1, (viewport_center_y - prev_y) / (curr_y - prev_y)))
return prev_x + t * (curr_x - prev_x), prev_w + t * (curr_w - prev_w)
def _find_page_at_viewport_y(self, centerlines: list, viewport_center_y: float) -> int:
"""Find index of page at or after viewport Y position."""
for i, (page_y, _, _) in enumerate(centerlines):
if viewport_center_y <= page_y:
return i
return len(centerlines) - 1 # Below all pages - use last
def _clamp_horizontal_pan(self, viewport_width: float, target_centerline_x: float, target_page_width: float):
"""Clamp horizontal pan to keep viewport centered on target page."""
ideal_pan_x = viewport_width / 2 - target_centerline_x
if target_page_width > viewport_width:
max_deviation = (target_page_width / 2) + (viewport_width / 4)
else:
max_deviation = 100 # Small margin to avoid jitter
min_pan_x = ideal_pan_x - max_deviation
max_pan_x = ideal_pan_x + max_deviation
self.pan_offset[0] = max(min_pan_x, min(max_pan_x, self.pan_offset[0]))
def clamp_pan_offset(self):
"""
Clamp pan offset to prevent scrolling beyond content bounds.
@@ -188,124 +266,24 @@ class ViewportMixin:
when zooming on pages of different widths.
"""
main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return
viewport_width = self.width()
viewport_height = self.height()
# Vertical clamping (returns original pan_y for page selection)
original_pan_y = self._clamp_vertical_pan(viewport_height)
# Build page centerline data
dpi = main_window.project.working_dpi
PAGE_MARGIN = 50
PAGE_SPACING = 50
# Vertical clamping
bounds = self.get_content_bounds()
content_height = bounds['height']
# Save original pan_offset[1] BEFORE clamping for page selection
# This prevents clamping from changing which page we think we're on
original_pan_y = self.pan_offset[1]
if content_height > viewport_height:
# Content is taller than viewport - restrict panning
max_pan_up = 0 # Can't pan beyond top edge
min_pan_up = -(content_height - viewport_height) # Can't pan beyond bottom edge
self.pan_offset[1] = max(min_pan_up, min(max_pan_up, self.pan_offset[1]))
# Don't force centering when content fits - preserve scroll position
# This prevents jumping when zooming in/out across the content_height == viewport_height boundary
# Horizontal clamping - centerline-based approach
# Calculate the centerline position for each page and interpolate
# Build list of page centerlines and their Y positions
page_centerlines = []
current_y = PAGE_MARGIN
for page in main_window.project.pages:
page_width_mm, page_height_mm = page.layout.size
page_width_px = page_width_mm * dpi / 25.4
page_height_px = page_height_mm * dpi / 25.4
screen_page_width = page_width_px * self.zoom_level
screen_page_height = page_height_px * self.zoom_level
# Calculate page center Y position (in world coordinates)
page_center_y = current_y + screen_page_height / 2
# Calculate the centerline X position (center of the page)
# Pages are left-aligned at PAGE_MARGIN, so center is at PAGE_MARGIN + width/2
page_center_x = PAGE_MARGIN + screen_page_width / 2
page_centerlines.append((page_center_y, page_center_x, screen_page_width))
current_y += screen_page_height + PAGE_SPACING
if not page_centerlines:
centerlines = self._build_page_centerlines(main_window.project.pages, dpi)
if not centerlines:
return
# Determine current viewport center Y in world coordinates using ORIGINAL pan_y
# This prevents vertical clamping from changing which page we're targeting
# viewport_center_y in screen coords = viewport_height / 2
# Convert to world coords: world_y = (screen_y - pan_offset[1]) / zoom_level
# But we want screen position, so we use pan_offset directly
viewport_center_y_world = -original_pan_y + viewport_height / 2
# Find target centerline by interpolating based on viewport position
viewport_center_y = -original_pan_y + viewport_height / 2
target_x, target_width = self._interpolate_target_centerline(centerlines, viewport_center_y)
# Find which pages we're between and interpolate
target_centerline_x = page_centerlines[0][1] # Default to first page
target_page_width = page_centerlines[0][2]
selected_page_index = 0
for i in range(len(page_centerlines)):
page_y, page_x, page_w = page_centerlines[i]
if viewport_center_y_world <= page_y:
# We're above or at this page's center
if i == 0:
# First page
target_centerline_x = page_x
target_page_width = page_w
selected_page_index = 0
else:
# Interpolate between previous and current page
prev_y, prev_x, prev_w = page_centerlines[i - 1]
# Linear interpolation factor
if page_y != prev_y:
t = (viewport_center_y_world - prev_y) / (page_y - prev_y)
t = max(0, min(1, t)) # Clamp to [0, 1]
target_centerline_x = prev_x + t * (page_x - prev_x)
target_page_width = prev_w + t * (page_w - prev_w)
selected_page_index = i - 1 if t < 0.5 else i
else:
target_centerline_x = page_x
target_page_width = page_w
selected_page_index = i
break
else:
# We're below all pages - use last page
target_centerline_x = page_centerlines[-1][1]
target_page_width = page_centerlines[-1][2]
selected_page_index = len(page_centerlines) - 1
# Horizontal clamping to keep viewport reasonably centered on the page
# The centerline should ideally be at viewport_width / 2
ideal_pan_x = viewport_width / 2 - target_centerline_x
# Calculate how far we need to allow panning to see the full width of the page
# If page is wider than viewport, allow panning to see left and right edges
# If page is narrower, keep it centered
if target_page_width > viewport_width:
# Page wider than viewport - allow panning to see edges plus some margin
# Allow user to pan to see any part of the page, with reasonable overshoot
max_deviation = (target_page_width / 2) + (viewport_width / 4)
else:
# Page narrower than viewport - keep centered with small margin for stability
max_deviation = 100 # Small margin to avoid jitter
# Calculate bounds
min_pan_x = ideal_pan_x - max_deviation
max_pan_x = ideal_pan_x + max_deviation
old_pan_x = self.pan_offset[0]
self.pan_offset[0] = max(min_pan_x, min(max_pan_x, self.pan_offset[0]))
# Horizontal clamping
self._clamp_horizontal_pan(viewport_width, target_x, target_width)
+408 -69
View File
@@ -13,18 +13,191 @@ from PIL import Image
from pyPhotoAlbum.image_utils import apply_pil_rotation, calculate_center_crop_coords
from pyPhotoAlbum.gl_imports import (
GL_AVAILABLE, glBegin, glEnd, glVertex2f, glColor3f, glColor4f,
GL_QUADS, GL_LINE_LOOP, glEnable, glDisable, GL_TEXTURE_2D,
glBindTexture, glTexCoord2f, glTexParameteri, GL_TEXTURE_MIN_FILTER,
GL_TEXTURE_MAG_FILTER, GL_LINEAR, glGenTextures, glTexImage2D,
GL_RGBA, GL_UNSIGNED_BYTE, glDeleteTextures, glGetString, GL_VERSION,
glLineStipple, GL_LINE_STIPPLE, glPushMatrix, glPopMatrix,
glTranslatef, glRotatef, GL_BLEND, glBlendFunc, GL_SRC_ALPHA,
GL_AVAILABLE,
glBegin,
glEnd,
glVertex2f,
glColor3f,
glColor4f,
GL_QUADS,
GL_LINE_LOOP,
glEnable,
glDisable,
GL_TEXTURE_2D,
glBindTexture,
glTexCoord2f,
glTexParameteri,
GL_TEXTURE_MIN_FILTER,
GL_TEXTURE_MAG_FILTER,
GL_LINEAR,
glGenTextures,
glTexImage2D,
GL_RGBA,
GL_UNSIGNED_BYTE,
glDeleteTextures,
glGetString,
GL_VERSION,
glLineStipple,
GL_LINE_STIPPLE,
glPushMatrix,
glPopMatrix,
glTranslatef,
glRotatef,
GL_BLEND,
glBlendFunc,
GL_SRC_ALPHA,
GL_ONE_MINUS_SRC_ALPHA,
)
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[int, int, int] = border_color
self.shadow_enabled = shadow_enabled
self.shadow_offset: Tuple[float, float] = shadow_offset
self.shadow_blur = shadow_blur
self.shadow_color: Tuple[int, int, int, int] = shadow_color
self.frame_style = frame_style
self.frame_color: Tuple[int, int, int] = frame_color
# frame_corners: (top_left, top_right, bottom_right, bottom_left)
self.frame_corners: Tuple[bool, bool, bool, bool] = 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: Optional[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
@@ -48,10 +221,13 @@ def get_asset_search_paths() -> Tuple[Optional[str], List[str]]:
"""Get the current asset resolution context."""
return _primary_project_folder, _asset_search_paths
class BaseLayoutElement(ABC):
"""Abstract base class for all layout elements"""
def __init__(self, x: float = 0, y: float = 0, width: float = 100, height: float = 100, rotation: float = 0, z_index: int = 0):
def __init__(
self, x: float = 0, y: float = 0, width: float = 100, height: float = 100, rotation: float = 0, z_index: int = 0
):
self.position = (x, y)
self.size = (width, height)
self.rotation = rotation
@@ -118,11 +294,18 @@ class BaseLayoutElement(ABC):
"""Deserialize from a dictionary"""
pass
class ImageData(BaseLayoutElement):
"""Class to store image data and properties"""
def __init__(self, image_path: str = "", crop_info: Optional[Tuple] = None,
image_dimensions: Optional[Tuple[int, int]] = None, **kwargs):
def __init__(
self,
image_path: str = "",
crop_info: Optional[Tuple] = None,
image_dimensions: Optional[Tuple[int, int]] = None,
style: Optional["ImageStyle"] = None,
**kwargs,
):
super().__init__(**kwargs)
self.image_path = image_path
self.crop_info = crop_info or (0, 0, 1, 1) # Default: no crop
@@ -135,6 +318,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()
@@ -190,26 +376,54 @@ class ImageData(BaseLayoutElement):
# Create texture from pending image if one exists (deferred from async load)
# Texture creation must happen during render when GL context is active
if hasattr(self, '_pending_pil_image') and self._pending_pil_image is not None:
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:
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)
@@ -217,13 +431,18 @@ class ImageData(BaseLayoutElement):
glColor4f(1.0, 1.0, 1.0, 1.0) # White color to show texture as-is
glBegin(GL_QUADS)
glTexCoord2f(tx_min, ty_min); glVertex2f(x, y)
glTexCoord2f(tx_max, ty_min); glVertex2f(x + w, y)
glTexCoord2f(tx_max, ty_max); glVertex2f(x + w, y + h)
glTexCoord2f(tx_min, ty_max); glVertex2f(x, y + h)
glTexCoord2f(tx_min, ty_min)
glVertex2f(x, y)
glTexCoord2f(tx_max, ty_min)
glVertex2f(x + w, y)
glTexCoord2f(tx_max, ty_max)
glVertex2f(x + w, y + h)
glTexCoord2f(tx_min, ty_max)
glVertex2f(x, y + h)
glEnd()
glDisable(GL_TEXTURE_2D)
glDisable(GL_BLEND)
# If no image or loading failed, draw placeholder
if not texture_id:
@@ -235,14 +454,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, # type: ignore[arg-type]
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"""
@@ -254,12 +545,16 @@ class ImageData(BaseLayoutElement):
"z_index": self.z_index,
"image_path": self.image_path,
"crop_info": self.crop_info,
"pil_rotation_90": getattr(self, 'pil_rotation_90', 0)
"pil_rotation_90": getattr(self, "pil_rotation_90", 0),
}
# Include image dimensions metadata if available
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())
@@ -298,6 +593,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.
@@ -313,10 +611,37 @@ class ImageData(BaseLayoutElement):
logger.debug(f"ImageData: Async load completed for {self.image_path}, size: {pil_image.size}")
# Apply PIL-level rotation if needed
if hasattr(self, 'pil_rotation_90') and self.pil_rotation_90 > 0:
if hasattr(self, "pil_rotation_90") and self.pil_rotation_90 > 0:
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
@@ -324,7 +649,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}")
@@ -334,12 +662,20 @@ 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.
Called during render() when GL context is active.
"""
if not hasattr(self, '_pending_pil_image') or self._pending_pil_image is None:
if not hasattr(self, "_pending_pil_image") or self._pending_pil_image is None:
return False
try:
@@ -355,11 +691,11 @@ class ImageData(BaseLayoutElement):
pil_image = self._pending_pil_image
# Ensure RGBA format for GL_RGBA texture (defensive check)
if pil_image.mode != 'RGBA':
pil_image = pil_image.convert('RGBA')
if pil_image.mode != "RGBA":
pil_image = pil_image.convert("RGBA")
# Delete old texture if it exists
if hasattr(self, '_texture_id') and self._texture_id:
if hasattr(self, "_texture_id") and self._texture_id:
glDeleteTextures([self._texture_id])
# Create GPU texture from pre-processed PIL image
@@ -369,8 +705,9 @@ class ImageData(BaseLayoutElement):
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, pil_image.width, pil_image.height,
0, GL_RGBA, GL_UNSIGNED_BYTE, img_data)
glTexImage2D(
GL_TEXTURE_2D, 0, GL_RGBA, pil_image.width, pil_image.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, img_data
)
# Cache texture
self._texture_id = texture_id
@@ -380,8 +717,8 @@ class ImageData(BaseLayoutElement):
self._pending_pil_image = None
# Clear the warning flag if we successfully created the texture
if hasattr(self, '_gl_context_warned'):
delattr(self, '_gl_context_warned')
if hasattr(self, "_gl_context_warned"):
delattr(self, "_gl_context_warned")
logger.info(f"ImageData: Successfully created texture for {self.image_path}")
return True
@@ -390,11 +727,13 @@ class ImageData(BaseLayoutElement):
error_str = str(e)
# Check if this is a GL context error (err 1282 = GL_INVALID_OPERATION)
# These are typically caused by no GL context being current
if 'GLError' in error_str and '1282' in error_str:
if "GLError" in error_str and "1282" in error_str:
# GL context not ready - keep pending image and try again next render
# Don't spam the console with repeated messages
if not hasattr(self, '_gl_context_warned'):
logger.warning(f"ImageData: GL context error (1282) for {self.image_path}, will retry on next render")
if not hasattr(self, "_gl_context_warned"):
logger.warning(
f"ImageData: GL context error (1282) for {self.image_path}, will retry on next render"
)
self._gl_context_warned = True
return False
else:
@@ -415,13 +754,22 @@ class ImageData(BaseLayoutElement):
self._async_loading = False
self._async_load_requested = False
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"""
@@ -475,8 +823,11 @@ class PlaceholderData(BaseLayoutElement):
"rotation": self.rotation,
"z_index": self.z_index,
"placeholder_type": self.placeholder_type,
"default_content": self.default_content
"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
@@ -492,6 +843,9 @@ 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):
"""Class to store text box data"""
@@ -519,29 +873,18 @@ class TextBoxData(BaseLayoutElement):
# Now render at origin (rotation pivot is at element center)
x, y = 0, 0
# Enable alpha blending for transparency
glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
# Draw a semi-transparent yellow rectangle as text box background
glColor4f(1.0, 1.0, 0.7, 0.3) # Light yellow with 30% opacity
glBegin(GL_QUADS)
glVertex2f(x, y)
glVertex2f(x + w, y)
glVertex2f(x + w, y + h)
glVertex2f(x, y + h)
glEnd()
glDisable(GL_BLEND)
# Draw border
glColor3f(0.0, 0.0, 0.0) # Black border
# No background fill - text boxes are transparent in final output
# Just draw a light dashed border for editing visibility
glEnable(GL_LINE_STIPPLE)
glLineStipple(2, 0xAAAA) # Dashed line pattern
glColor3f(0.7, 0.7, 0.7) # Light gray border
glBegin(GL_LINE_LOOP)
glVertex2f(x, y)
glVertex2f(x + w, y)
glVertex2f(x + w, y + h)
glVertex2f(x, y + h)
glEnd()
glDisable(GL_LINE_STIPPLE)
# Pop matrix if we pushed for rotation
if self.rotation != 0:
@@ -559,7 +902,7 @@ class TextBoxData(BaseLayoutElement):
"z_index": self.z_index,
"text_content": self.text_content,
"font_settings": self.font_settings,
"alignment": self.alignment
"alignment": self.alignment,
}
# Add base fields (v3.0+)
data.update(self._serialize_base_fields())
@@ -578,6 +921,7 @@ class TextBoxData(BaseLayoutElement):
self.font_settings = data.get("font_settings", {"family": "Arial", "size": 12, "color": (0, 0, 0)})
self.alignment = data.get("alignment", "left")
class GhostPageData(BaseLayoutElement):
"""Class to represent a ghost page placeholder for alignment in double-page spreads"""
@@ -640,12 +984,7 @@ class GhostPageData(BaseLayoutElement):
def serialize(self) -> Dict[str, Any]:
"""Serialize ghost page data to dictionary"""
data = {
"type": "ghostpage",
"position": self.position,
"size": self.size,
"page_size": self.page_size
}
data = {"type": "ghostpage", "position": self.position, "size": self.size, "page_size": self.page_size}
# Add base fields (v3.0+)
data.update(self._serialize_base_fields())
return data
+52 -37
View File
@@ -2,16 +2,33 @@
Page layout and template system for pyPhotoAlbum
"""
from typing import List, Dict, Any, Optional, Tuple
from typing import List, Dict, Any, Optional, Tuple, TYPE_CHECKING
if TYPE_CHECKING:
from PyQt6.QtWidgets import QWidget
from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData
from pyPhotoAlbum.snapping import SnappingSystem
from pyPhotoAlbum.gl_imports import (
glBegin, glEnd, glVertex2f, glColor3f, glColor4f,
GL_QUADS, GL_LINE_LOOP, GL_LINES, glLineWidth,
glEnable, glDisable, GL_DEPTH_TEST, GL_BLEND,
glBlendFunc, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA,
glBegin,
glEnd,
glVertex2f,
glColor3f,
glColor4f,
GL_QUADS,
GL_LINE_LOOP,
GL_LINES,
glLineWidth,
glEnable,
glDisable,
GL_DEPTH_TEST,
GL_BLEND,
glBlendFunc,
GL_SRC_ALPHA,
GL_ONE_MINUS_SRC_ALPHA,
)
class PageLayout:
"""Class to manage page layout and templates"""
@@ -32,16 +49,18 @@ class PageLayout:
self.background_color = (1.0, 1.0, 1.0) # White background
self.snapping_system = SnappingSystem()
self.show_snap_lines = True # Show snap lines while dragging
self._parent_widget: Optional["QWidget"] = None # Set by renderer
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)
def set_grid_layout(self, grid: 'GridLayout'):
def set_grid_layout(self, grid: "GridLayout"):
"""Set a grid layout for the page"""
self.grid_layout = grid
@@ -97,21 +116,22 @@ class PageLayout:
# For ImageData elements, request async loading if available
for element in self.elements:
# Check if this is an ImageData element that needs async loading
if isinstance(element, ImageData) and not hasattr(element, '_texture_id'):
if isinstance(element, ImageData) and not hasattr(element, "_texture_id"):
# Try to get async loader from a parent widget
if hasattr(self, '_async_loader'):
if hasattr(self, "_async_loader"):
loader = self._async_loader
elif hasattr(self, '_parent_widget') and hasattr(self._parent_widget, 'async_image_loader'):
loader = self._parent_widget.async_image_loader
elif hasattr(self, "_parent_widget") and hasattr(self._parent_widget, "async_image_loader"):
loader = self._parent_widget.async_image_loader # type: ignore[union-attr]
else:
loader = None
# Request async load if loader is available and not already requested
if loader and not element._async_load_requested:
from pyPhotoAlbum.async_backend import LoadPriority
# Determine priority based on visibility (HIGH for now, can be refined)
if hasattr(self._parent_widget, 'request_image_load'):
self._parent_widget.request_image_load(element, priority=LoadPriority.HIGH)
if hasattr(self._parent_widget, "request_image_load"):
self._parent_widget.request_image_load(element, priority=LoadPriority.HIGH) # type: ignore[union-attr]
element._async_load_requested = True
element._async_loading = True
@@ -169,6 +189,7 @@ class PageLayout:
# Create a temporary snapping system with project settings to get snap lines
from pyPhotoAlbum.snapping import SnappingSystem
temp_snap_sys = SnappingSystem(snap_threshold_mm=snap_threshold_mm)
temp_snap_sys.grid_size_mm = grid_size_mm
temp_snap_sys.snap_to_grid = snap_to_grid
@@ -178,17 +199,13 @@ class PageLayout:
snap_lines = temp_snap_sys.get_snap_lines(self.size, dpi)
# Enable alpha blending for transparency
glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
# Draw grid lines (darker gray with transparency) - visible when show_grid is enabled
if show_grid and snap_lines['grid']:
glColor4f(0.6, 0.6, 0.6, 0.4) # Gray with 40% opacity
# Draw grid lines (light gray, fully opaque) - visible when show_grid is enabled
if show_grid and snap_lines["grid"]:
glColor3f(0.8, 0.8, 0.8) # Light gray, fully opaque
glLineWidth(1.0)
for orientation, position in snap_lines['grid']:
for orientation, position in snap_lines["grid"]:
glBegin(GL_LINES)
if orientation == 'vertical':
if orientation == "vertical":
glVertex2f(page_x + position, page_y)
glVertex2f(page_x + position, page_y + self.size[1] * dpi / 25.4)
else: # horizontal
@@ -196,13 +213,13 @@ class PageLayout:
glVertex2f(page_x + self.size[0] * dpi / 25.4, page_y + position)
glEnd()
# Draw guides (cyan, more visible with transparency) - only show when show_snap_lines is on
if show_snap_lines and snap_lines['guides']:
glColor4f(0.0, 0.7, 0.9, 0.8) # Cyan with 80% opacity
# Draw guides (cyan, fully opaque) - only show when show_snap_lines is on
if show_snap_lines and snap_lines["guides"]:
glColor3f(0.0, 0.7, 0.9) # Cyan, fully opaque
glLineWidth(1.5)
for orientation, position in snap_lines['guides']:
for orientation, position in snap_lines["guides"]:
glBegin(GL_LINES)
if orientation == 'vertical':
if orientation == "vertical":
glVertex2f(page_x + position, page_y)
glVertex2f(page_x + position, page_y + self.size[1] * dpi / 25.4)
else: # horizontal
@@ -211,7 +228,6 @@ class PageLayout:
glEnd()
glLineWidth(1.0)
glDisable(GL_BLEND)
def serialize(self) -> Dict[str, Any]:
"""Serialize page layout to dictionary"""
@@ -223,7 +239,7 @@ class PageLayout:
"elements": [elem.serialize() for elem in self.elements],
"grid_layout": self.grid_layout.serialize() if self.grid_layout else None,
"snapping_system": self.snapping_system.serialize(),
"show_snap_lines": self.show_snap_lines
"show_snap_lines": self.show_snap_lines,
}
def deserialize(self, data: Dict[str, Any]):
@@ -236,9 +252,10 @@ class PageLayout:
# Deserialize elements and sort by z_index to establish list order
# This ensures backward compatibility with projects that used z_index
elem_list = []
elem_list: List[BaseLayoutElement] = []
for elem_data in data.get("elements", []):
elem_type = elem_data.get("type")
elem: BaseLayoutElement
if elem_type == "image":
elem = ImageData()
elif elem_type == "placeholder":
@@ -268,6 +285,7 @@ class PageLayout:
self.show_snap_lines = data.get("show_snap_lines", True)
class GridLayout:
"""Class to manage grid layouts"""
@@ -281,7 +299,9 @@ class GridLayout:
"""Merge cells in the grid"""
self.merged_cells.append((row, col))
def get_cell_position(self, row: int, col: int, page_width: float = 800, page_height: float = 600) -> Tuple[float, float]:
def get_cell_position(
self, row: int, col: int, page_width: float = 800, page_height: float = 600
) -> Tuple[float, float]:
"""Get the position of a grid cell"""
cell_width = (page_width - (self.spacing * (self.columns + 1))) / self.columns
cell_height = (page_height - (self.spacing * (self.rows + 1))) / self.rows
@@ -300,12 +320,7 @@ class GridLayout:
def serialize(self) -> Dict[str, Any]:
"""Serialize grid layout to dictionary"""
return {
"rows": self.rows,
"columns": self.columns,
"spacing": self.spacing,
"merged_cells": self.merged_cells
}
return {"rows": self.rows, "columns": self.columns, "spacing": self.spacing, "merged_cells": self.merged_cells}
def deserialize(self, data: Dict[str, Any]):
"""Deserialize from dictionary"""
+9 -11
View File
@@ -22,13 +22,9 @@ class PageRenderer:
a page and its elements consistently.
"""
def __init__(self,
page_width_mm: float,
page_height_mm: float,
screen_x: float,
screen_y: float,
dpi: int,
zoom: float):
def __init__(
self, page_width_mm: float, page_height_mm: float, screen_x: float, screen_y: float, dpi: int, zoom: float
):
"""
Initialize a page renderer.
@@ -96,8 +92,10 @@ class PageRenderer:
Returns:
True if the point is within the page bounds
"""
return (self.screen_x <= screen_x <= self.screen_x + self.screen_width and
self.screen_y <= screen_y <= self.screen_y + self.screen_height)
return (
self.screen_x <= screen_x <= self.screen_x + self.screen_width
and self.screen_y <= screen_y <= self.screen_y + self.screen_height
)
def get_sub_page_at(self, screen_x: float, is_facing_page: bool) -> Optional[str]:
"""
@@ -117,9 +115,9 @@ class PageRenderer:
center_x = self.screen_x + self.screen_width / 2
if screen_x < center_x:
return 'left'
return "left"
else:
return 'right'
return "right"
def begin_render(self):
"""
File diff suppressed because it is too large Load Diff
+27 -15
View File
@@ -6,11 +6,13 @@ import os
import math
import uuid
from datetime import datetime, timezone
from typing import List, Dict, Any, Optional, Tuple
from tempfile import TemporaryDirectory
from typing import List, Dict, Any, Optional, Tuple, Union
from pyPhotoAlbum.page_layout import PageLayout
from pyPhotoAlbum.commands import CommandHistory
from pyPhotoAlbum.asset_manager import AssetManager
class Page:
"""Class representing a single page in the photo album"""
@@ -51,7 +53,7 @@ class Page:
self.layout.is_facing_page = is_double_spread
height = self.layout.size[1]
# Use the base_width if available, otherwise derive it
if hasattr(self.layout, 'base_width'):
if hasattr(self.layout, "base_width"):
base_width = self.layout.base_width
else:
# If base_width not set, assume current width is correct
@@ -133,6 +135,7 @@ class Page:
self.layout = PageLayout()
self.layout.deserialize(layout_data)
class Project:
"""Class representing the entire photo album project"""
@@ -163,12 +166,17 @@ class Project:
self.cover_bleed_mm = 0.0 # Bleed margin for cover (default 0mm)
self.binding_type = "saddle_stitch" # Binding type for spine calculation
# Print guide configuration
self.page_bleed_mm = 0.0 # Bleed margin for interior pages (default 0mm, e.g. 3mm for printing)
self.page_safe_area_mm = 5.0 # Safe area margin inside cut line (default 5mm)
self.show_print_guides = False # Show bleed/cut/safe-area guides in the editor
# Embedded templates - templates that travel with the project
self.embedded_templates: Dict[str, Dict[str, Any]] = {}
# Temporary directory management (if loaded from .ppz)
# Using TemporaryDirectory instance that auto-cleans on deletion
self._temp_dir = None
self._temp_dir: Optional[TemporaryDirectory[str]] = None
# Global snapping settings (apply to all pages)
self.snap_to_grid = False
@@ -246,11 +254,7 @@ class Project:
return 0.0
# Count content pages (excluding cover)
content_page_count = sum(
page.get_page_count()
for page in self.pages
if not page.is_cover
)
content_page_count = sum(page.get_page_count() for page in self.pages if not page.is_cover)
if self.binding_type == "saddle_stitch":
# Calculate number of sheets (each sheet = 4 pages)
@@ -295,9 +299,11 @@ class Project:
cover_page.layout.base_width = page_width_mm # Store base width for reference
cover_page.manually_sized = True # Mark as manually sized
print(f"Cover dimensions updated: {cover_width:.1f} × {cover_height:.1f} mm "
f"(Front: {page_width_mm}, Spine: {spine_width:.2f}, Back: {page_width_mm}, "
f"Bleed: {self.cover_bleed_mm})")
print(
f"Cover dimensions updated: {cover_width:.1f} × {cover_height:.1f} mm "
f"(Front: {page_width_mm}, Spine: {spine_width:.2f}, Back: {page_width_mm}, "
f"Bleed: {self.cover_bleed_mm})"
)
def get_page_display_name(self, page: Page) -> str:
"""
@@ -323,7 +329,7 @@ class Project:
else:
return f"Page {adjusted_num}"
def calculate_page_layout_with_ghosts(self) -> List[Tuple[str, Any, int]]:
def calculate_page_layout_with_ghosts(self) -> List[Tuple[str, Optional["Page"], int]]:
"""
Calculate page layout including ghost pages for alignment.
Excludes cover from spread calculations.
@@ -336,7 +342,7 @@ class Project:
"""
from pyPhotoAlbum.models import GhostPageData
layout = []
layout: list[tuple[str, Optional["Page"], int]] = []
current_position = 1 # Start at position 1 (right page)
for page in self.pages:
@@ -368,11 +374,11 @@ class Project:
# Check if this is a double spread starting at an odd position
if page.is_double_spread and current_position % 2 == 1:
# Need to insert a ghost page to push the double spread to next position
layout.append(('ghost', None, current_position))
layout.append(("ghost", None, current_position))
current_position += 1
# Add the actual page
layout.append(('page', page, current_position))
layout.append(("page", page, current_position))
# Update position based on page type
if page.is_double_spread:
@@ -403,6 +409,9 @@ class Project:
"paper_thickness_mm": self.paper_thickness_mm,
"cover_bleed_mm": self.cover_bleed_mm,
"binding_type": self.binding_type,
"page_bleed_mm": self.page_bleed_mm,
"page_safe_area_mm": self.page_safe_area_mm,
"show_print_guides": self.show_print_guides,
"embedded_templates": self.embedded_templates,
"snap_to_grid": self.snap_to_grid,
"snap_to_edges": self.snap_to_edges,
@@ -435,6 +444,9 @@ class Project:
self.paper_thickness_mm = data.get("paper_thickness_mm", 0.2)
self.cover_bleed_mm = data.get("cover_bleed_mm", 0.0)
self.binding_type = data.get("binding_type", "saddle_stitch")
self.page_bleed_mm = data.get("page_bleed_mm", 0.0)
self.page_safe_area_mm = data.get("page_safe_area_mm", 5.0)
self.show_print_guides = data.get("show_print_guides", False)
# Deserialize embedded templates
self.embedded_templates = data.get("embedded_templates", {})
+148 -26
View File
@@ -7,17 +7,17 @@ import json
import zipfile
import shutil
import tempfile
from typing import Optional, Tuple
import threading
from typing import Optional, Tuple, Callable
from pathlib import Path
from pyPhotoAlbum.project import Project
from pyPhotoAlbum.version_manager import (
CURRENT_DATA_VERSION,
check_version_compatibility,
VersionCompatibility,
DataMigration
DataMigration,
)
# Legacy constant for backward compatibility
SERIALIZATION_VERSION = CURRENT_DATA_VERSION
@@ -44,7 +44,7 @@ def _import_external_images(project: Project):
# Absolute path - definitely external
is_external = True
external_path = element.image_path
elif not element.image_path.startswith('assets/'):
elif not element.image_path.startswith("assets/"):
# Relative path but not in assets folder
# Check if it exists relative to project folder
full_path = os.path.join(project.folder_path, element.image_path)
@@ -91,15 +91,15 @@ def _normalize_asset_paths(project: Project, project_folder: str):
original_path = element.image_path
# Skip if already a simple relative path (assets/...)
if not os.path.isabs(original_path) and not original_path.startswith('./projects/'):
if not os.path.isabs(original_path) and not original_path.startswith("./projects/"):
continue
# Try to extract just the filename or relative path from assets folder
# Pattern 1: "./projects/XXX/assets/filename.jpg" -> "assets/filename.jpg"
if '/assets/' in original_path:
parts = original_path.split('/assets/')
if "/assets/" in original_path:
parts = original_path.split("/assets/")
if len(parts) == 2:
new_path = os.path.join('assets', parts[1])
new_path = os.path.join("assets", parts[1])
element.image_path = new_path
normalized_count += 1
print(f"Normalized path: {original_path} -> {new_path}")
@@ -133,8 +133,8 @@ def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]:
"""
try:
# Ensure .ppz extension
if not zip_path.lower().endswith('.ppz'):
zip_path += '.ppz'
if not zip_path.lower().endswith(".ppz"):
zip_path += ".ppz"
# Check for and import any external images before saving
_import_external_images(project)
@@ -143,14 +143,14 @@ def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]:
project_data = project.serialize()
# Add version information
project_data['serialization_version'] = SERIALIZATION_VERSION # Legacy field
project_data['data_version'] = CURRENT_DATA_VERSION # New versioning system
project_data["serialization_version"] = SERIALIZATION_VERSION # Legacy field
project_data["data_version"] = CURRENT_DATA_VERSION # New versioning system
# Create ZIP file
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
# Write project.json with stable sorting for git-friendly diffs
project_json = json.dumps(project_data, indent=2, sort_keys=True)
zipf.writestr('project.json', project_json)
zipf.writestr("project.json", project_json)
# Add all files from the assets folder
assets_folder = project.asset_manager.assets_folder
@@ -171,6 +171,127 @@ def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]:
return False, error_msg
def save_to_zip_async(
project: Project,
zip_path: str,
on_complete: Optional[Callable[[bool, Optional[str]], None]] = None,
on_progress: Optional[Callable[[int, str], None]] = None,
) -> threading.Thread:
"""
Save a project to a ZIP file asynchronously in a background thread.
This provides instant UI responsiveness by:
1. Immediately serializing project.json to a temp folder (fast)
2. Creating the ZIP file in a background thread (slow)
3. Calling on_complete when done
Args:
project: The Project instance to save
zip_path: Path where the ZIP file should be created
on_complete: Optional callback(success: bool, error_msg: Optional[str])
called when save completes
on_progress: Optional callback(progress: int, message: str) where
progress is 0-100 and message describes current step
Returns:
The background thread (already started)
"""
# Ensure .ppz extension
final_zip_path = zip_path
if not final_zip_path.lower().endswith(".ppz"):
final_zip_path += ".ppz"
# ---- Work done on the CALLING (main) thread ----
# Serialization is pure Python and holds the GIL, but it's fast.
# Doing it here keeps the background thread to file-I/O only, which
# releases the GIL and keeps the UI responsive.
if on_progress:
on_progress(0, "Preparing to save...")
_import_external_images(project)
if on_progress:
on_progress(10, "Serializing project data...")
project_data = project.serialize()
project_data["serialization_version"] = SERIALIZATION_VERSION
project_data["data_version"] = CURRENT_DATA_VERSION
project_json_str = json.dumps(project_data, indent=2, sort_keys=True)
# Collect the asset file list now so the background thread doesn't
# need to touch the (potentially temporary) project folder.
assets_folder = project.asset_manager.assets_folder
folder_path = project.folder_path
asset_files: list[tuple[str, str]] = []
if os.path.exists(assets_folder):
for root, _dirs, files in os.walk(assets_folder):
for file in files:
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, folder_path)
asset_files.append((file_path, arcname))
total_files = 1 + len(asset_files) # project.json + assets
if on_progress:
on_progress(20, f"Starting background write ({total_files} files)...")
# ---- Work done on the BACKGROUND thread ----
# Only file I/O here — zipfile/zlib/shutil all release the GIL.
def _background_save():
"""Background thread: write ZIP file from pre-serialized data."""
temp_dir = None
try:
temp_dir = tempfile.mkdtemp(prefix="pyPhotoAlbum_save_")
temp_zip_path = os.path.join(temp_dir, "project.ppz")
if on_progress:
on_progress(25, f"Creating ZIP archive ({total_files} files)...")
with zipfile.ZipFile(temp_zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
zipf.writestr("project.json", project_json_str)
if asset_files:
progress_range = 90 - 25
for idx, (file_path, arcname) in enumerate(asset_files):
zipf.write(file_path, arcname)
if idx % 10 == 0 or idx == len(asset_files) - 1:
progress = 25 + int((idx + 1) / len(asset_files) * progress_range)
if on_progress:
on_progress(progress, f"Adding assets... ({idx + 1}/{len(asset_files)})")
if on_progress:
on_progress(95, "Finalizing save...")
os.makedirs(os.path.dirname(os.path.abspath(final_zip_path)), exist_ok=True)
if os.path.exists(final_zip_path):
os.remove(final_zip_path)
shutil.move(temp_zip_path, final_zip_path)
if on_progress:
on_progress(100, "Save complete!")
print(f"Project saved to {final_zip_path}")
if on_complete:
on_complete(True, None)
except Exception as e:
error_msg = f"Error saving project: {str(e)}"
print(error_msg)
if on_complete:
on_complete(False, error_msg)
finally:
if temp_dir and os.path.exists(temp_dir):
try:
shutil.rmtree(temp_dir)
except Exception:
pass
save_thread = threading.Thread(target=_background_save, daemon=True)
save_thread.start()
return save_thread
def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Project:
"""
Load a project from a ZIP file.
@@ -201,20 +322,20 @@ def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Project:
os.makedirs(extract_to, exist_ok=True)
# Extract ZIP contents
with zipfile.ZipFile(zip_path, 'r') as zipf:
with zipfile.ZipFile(zip_path, "r") as zipf:
zipf.extractall(extract_to)
# Load project.json
project_json_path = os.path.join(extract_to, 'project.json')
project_json_path = os.path.join(extract_to, "project.json")
if not os.path.exists(project_json_path):
raise ValueError("Invalid project file: project.json not found")
with open(project_json_path, 'r') as f:
with open(project_json_path, "r") as f:
project_data = json.load(f)
# Check version compatibility
# Try new version field first, fall back to legacy field
file_version = project_data.get('data_version', project_data.get('serialization_version', '1.0'))
file_version = project_data.get("data_version", project_data.get("serialization_version", "1.0"))
# Check if version is compatible
is_compatible, error_msg = check_version_compatibility(file_version, zip_path)
@@ -230,7 +351,7 @@ def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Project:
print(f"Note: Loading project with version {file_version}, current version is {CURRENT_DATA_VERSION}")
# Create new project
project_name = project_data.get('name', 'Untitled Project')
project_name = project_data.get("name", "Untitled Project")
project = Project(name=project_name, folder_path=extract_to)
# Deserialize project data
@@ -254,6 +375,7 @@ def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Project:
# Set asset resolution context for ImageData rendering
# Only set project folder - search paths are reserved for healing functionality
from pyPhotoAlbum.models import set_asset_resolution_context
set_asset_resolution_context(extract_to)
print(f"Project loaded from {zip_path} to {extract_to}")
@@ -271,17 +393,17 @@ def get_project_info(zip_path: str) -> Optional[dict]:
Dictionary with project info, or None if error
"""
try:
with zipfile.ZipFile(zip_path, 'r') as zipf:
with zipfile.ZipFile(zip_path, "r") as zipf:
# Read project.json
project_json = zipf.read('project.json').decode('utf-8')
project_json = zipf.read("project.json").decode("utf-8")
project_data = json.loads(project_json)
return {
'name': project_data.get('name', 'Unknown'),
'version': project_data.get('serialization_version', 'Unknown'),
'page_count': len(project_data.get('pages', [])),
'page_size_mm': project_data.get('page_size_mm', (0, 0)),
'working_dpi': project_data.get('working_dpi', 300),
"name": project_data.get("name", "Unknown"),
"version": project_data.get("serialization_version", "Unknown"),
"page_count": len(project_data.get("pages", [])),
"page_size_mm": project_data.get("page_size_mm", (0, 0)),
"working_dpi": project_data.get("working_dpi", 300),
}
except Exception as e:
print(f"Error reading project info: {e}")
+37 -46
View File
@@ -41,7 +41,7 @@ def build_ribbon_config(window_class: Type) -> Dict[str, Any]:
}
"""
# Structure to collect actions by tab and group
tabs = defaultdict(lambda: defaultdict(list))
tabs: Dict[str, Dict[str, List[Dict[str, Any]]]] = defaultdict(lambda: defaultdict(list))
# Scan all methods in the class and its bases (mixins)
for attr_name in dir(window_class):
@@ -49,21 +49,23 @@ def build_ribbon_config(window_class: Type) -> Dict[str, Any]:
attr = getattr(window_class, attr_name)
# Check if this attribute has ribbon action metadata
if hasattr(attr, '_ribbon_action'):
if hasattr(attr, "_ribbon_action"):
action_data = attr._ribbon_action
# Extract tab and group information
tab_name = action_data['tab']
group_name = action_data['group']
tab_name = action_data["tab"]
group_name = action_data["group"]
# Add action to the appropriate tab and group
tabs[tab_name][group_name].append({
'label': action_data['label'],
'action': action_data['action'],
'tooltip': action_data['tooltip'],
'icon': action_data.get('icon'),
'shortcut': action_data.get('shortcut'),
})
tabs[tab_name][group_name].append(
{
"label": action_data["label"],
"action": action_data["action"],
"tooltip": action_data["tooltip"],
"icon": action_data.get("icon"),
"shortcut": action_data.get("shortcut"),
}
)
except (AttributeError, TypeError):
# Skip attributes that can't be inspected
continue
@@ -72,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', 'Export']
tab_order = ["Home", "Insert", "Layout", "Arrange", "Style", "View"]
# Add tabs in the defined order, then add any remaining tabs
all_tabs = list(tabs.keys())
@@ -87,12 +89,12 @@ def build_ribbon_config(window_class: Type) -> Dict[str, Any]:
# Define group order per tab (if needed)
group_orders = {
'Home': ['File', 'Edit'],
'Insert': ['Media'],
'Layout': ['Navigation', 'Page', 'Templates'],
'Arrange': ['Align', 'Size', 'Distribute'],
'View': ['Zoom'],
'Export': ['Export'],
"Home": ["File", "Edit"],
"Insert": ["Media", "Snapping"],
"Layout": ["Page", "Templates"],
"Arrange": ["Align", "Distribute", "Size", "Order", "Transform"],
"Style": ["Corners", "Border", "Effects", "Frame", "Presets"],
"View": ["Zoom", "Guides"],
}
# Get the group order for this tab, or use alphabetical
@@ -107,14 +109,9 @@ def build_ribbon_config(window_class: Type) -> Dict[str, Any]:
for group_name in group_order:
if group_name in groups_dict:
actions = groups_dict[group_name]
groups_list.append({
'name': group_name,
'actions': actions
})
groups_list.append({"name": group_name, "actions": actions})
ribbon_config[tab_name] = {
'groups': groups_list
}
ribbon_config[tab_name] = {"groups": groups_list}
return ribbon_config
@@ -136,12 +133,12 @@ def get_keyboard_shortcuts(window_class: Type) -> Dict[str, str]:
try:
attr = getattr(window_class, attr_name)
if hasattr(attr, '_ribbon_action'):
if hasattr(attr, "_ribbon_action"):
action_data = attr._ribbon_action
shortcut = action_data.get('shortcut')
shortcut = action_data.get("shortcut")
if shortcut:
shortcuts[shortcut] = action_data['action']
shortcuts[shortcut] = action_data["action"]
except (AttributeError, TypeError):
continue
@@ -169,11 +166,11 @@ def validate_ribbon_config(config: Dict[str, Any]) -> List[str]:
errors.append(f"Tab '{tab_name}' data must be a dictionary")
continue
if 'groups' not in tab_data:
if "groups" not in tab_data:
errors.append(f"Tab '{tab_name}' missing 'groups' key")
continue
groups = tab_data['groups']
groups = tab_data["groups"]
if not isinstance(groups, list):
errors.append(f"Tab '{tab_name}' groups must be a list")
continue
@@ -183,14 +180,14 @@ def validate_ribbon_config(config: Dict[str, Any]) -> List[str]:
errors.append(f"Tab '{tab_name}' group {i} must be a dictionary")
continue
if 'name' not in group:
if "name" not in group:
errors.append(f"Tab '{tab_name}' group {i} missing 'name'")
if 'actions' not in group:
if "actions" not in group:
errors.append(f"Tab '{tab_name}' group {i} missing 'actions'")
continue
actions = group['actions']
actions = group["actions"]
if not isinstance(actions, list):
errors.append(f"Tab '{tab_name}' group {i} actions must be a list")
continue
@@ -200,12 +197,10 @@ def validate_ribbon_config(config: Dict[str, Any]) -> List[str]:
errors.append(f"Tab '{tab_name}' group {i} action {j} must be a dictionary")
continue
required_keys = ['label', 'action', 'tooltip']
required_keys = ["label", "action", "tooltip"]
for key in required_keys:
if key not in action:
errors.append(
f"Tab '{tab_name}' group {i} action {j} missing '{key}'"
)
errors.append(f"Tab '{tab_name}' group {i} action {j} missing '{key}'")
return errors
@@ -220,12 +215,8 @@ def print_ribbon_summary(config: Dict[str, Any]):
print("\n=== Ribbon Configuration Summary ===\n")
total_tabs = len(config)
total_groups = sum(len(tab_data['groups']) for tab_data in config.values())
total_actions = sum(
len(group['actions'])
for tab_data in config.values()
for group in tab_data['groups']
)
total_groups = sum(len(tab_data["groups"]) for tab_data in config.values())
total_actions = sum(len(group["actions"]) for tab_data in config.values() for group in tab_data["groups"])
print(f"Total Tabs: {total_tabs}")
print(f"Total Groups: {total_groups}")
@@ -233,9 +224,9 @@ def print_ribbon_summary(config: Dict[str, Any]):
for tab_name, tab_data in config.items():
print(f"📑 {tab_name}")
for group in tab_data['groups']:
for group in tab_data["groups"]:
print(f" 📦 {group['name']} ({len(group['actions'])} actions)")
for action in group['actions']:
shortcut = f" ({action['shortcut']})" if action.get('shortcut') else ""
for action in group["actions"]:
shortcut = f" ({action['shortcut']})" if action.get("shortcut") else ""
print(f"{action['label']}{shortcut}")
print()
+3 -1
View File
@@ -17,6 +17,7 @@ class RibbonWidget(QWidget):
# Use provided config or fall back to importing the old one
if ribbon_config is None:
from ribbon_config import RIBBON_CONFIG
self.ribbon_config = RIBBON_CONFIG
else:
self.ribbon_config = ribbon_config
@@ -106,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
+66 -141
View File
@@ -4,35 +4,31 @@ Provides grid snapping, edge snapping, and custom guide snapping
"""
import math
from typing import List, Tuple, Optional
from typing import Any, Dict, List, Tuple, Optional
from dataclasses import dataclass
@dataclass
class Guide:
"""Represents a snapping guide (vertical or horizontal line)"""
position: float # Position in mm
orientation: str # 'vertical' or 'horizontal'
def serialize(self) -> dict:
"""Serialize guide to dictionary"""
return {
"position": self.position,
"orientation": self.orientation
}
return {"position": self.position, "orientation": self.orientation}
@staticmethod
def deserialize(data: dict) -> 'Guide':
def deserialize(data: dict) -> "Guide":
"""Deserialize guide from dictionary"""
return Guide(
position=data.get("position", 0),
orientation=data.get("orientation", "vertical")
)
return Guide(position=data.get("position", 0), orientation=data.get("orientation", "vertical"))
@dataclass
class SnapResizeParams:
"""Parameters for snap resize operations"""
position: Tuple[float, float]
size: Tuple[float, float]
dx: float
@@ -40,7 +36,7 @@ class SnapResizeParams:
resize_handle: str
page_size: Tuple[float, float]
dpi: int = 300
project: Optional[any] = None
project: Optional[Any] = None
class SnappingSystem:
@@ -75,12 +71,14 @@ class SnappingSystem:
"""Remove all guides"""
self.guides.clear()
def snap_position(self,
position: Tuple[float, float],
size: Tuple[float, float],
page_size: Tuple[float, float],
dpi: int = 300,
project=None) -> Tuple[float, float]:
def snap_position(
self,
position: Tuple[float, float],
size: Tuple[float, float],
page_size: Tuple[float, float],
dpi: int = 300,
project=None,
) -> Tuple[float, float]:
"""
Apply snapping to a position using combined distance threshold
@@ -124,20 +122,24 @@ class SnappingSystem:
page_height_px = page_height_mm * dpi / 25.4
# Corners where element's top-left can snap
snap_points.extend([
(0, 0), # Top-left corner
(page_width_px - width, 0), # Top-right corner
(0, page_height_px - height), # Bottom-left corner
(page_width_px - width, page_height_px - height), # Bottom-right corner
])
snap_points.extend(
[
(0, 0), # Top-left corner
(page_width_px - width, 0), # Top-right corner
(0, page_height_px - height), # Bottom-left corner
(page_width_px - width, page_height_px - height), # Bottom-right corner
]
)
# Edge positions (element aligned to edge on one axis)
snap_points.extend([
(0, y), # Left edge
(page_width_px - width, y), # Right edge
(x, 0), # Top edge
(x, page_height_px - height), # Bottom edge
])
snap_points.extend(
[
(0, y), # Left edge
(page_width_px - width, y), # Right edge
(x, 0), # Top edge
(x, page_height_px - height), # Bottom edge
]
)
# 2. Grid snap points
if snap_to_grid:
@@ -166,8 +168,8 @@ class SnappingSystem:
# 3. Guide snap points
if snap_to_guides:
vertical_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == 'vertical']
horizontal_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == 'horizontal']
vertical_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == "vertical"]
horizontal_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == "horizontal"]
# Guide intersections (when both vertical and horizontal guides exist)
for vg in vertical_guides:
@@ -222,21 +224,21 @@ class SnappingSystem:
new_width, new_height = width, height
# Apply resize based on handle
if params.resize_handle in ['nw', 'n', 'ne']:
if params.resize_handle in ["nw", "n", "ne"]:
# Top edge moving
new_y = y + params.dy
new_height = height - params.dy
if params.resize_handle in ['sw', 's', 'se']:
if params.resize_handle in ["sw", "s", "se"]:
# Bottom edge moving
new_height = height + params.dy
if params.resize_handle in ['nw', 'w', 'sw']:
if params.resize_handle in ["nw", "w", "sw"]:
# Left edge moving
new_x = x + params.dx
new_width = width - params.dx
if params.resize_handle in ['ne', 'e', 'se']:
if params.resize_handle in ["ne", "e", "se"]:
# Right edge moving
new_width = width + params.dx
@@ -244,10 +246,10 @@ class SnappingSystem:
# Use _snap_edge_to_targets consistently for all edges
# Snap left edge (for nw, w, sw handles)
if params.resize_handle in ['nw', 'w', 'sw']:
if params.resize_handle in ["nw", "w", "sw"]:
# Try to snap the left edge
snapped_left = self._snap_edge_to_targets(
new_x, page_width_mm, params.dpi, snap_threshold_px, 'vertical', params.project
new_x, page_width_mm, params.dpi, snap_threshold_px, "vertical", params.project
)
if snapped_left is not None:
# Adjust width to compensate for position change
@@ -256,21 +258,21 @@ class SnappingSystem:
new_width += width_adjustment
# Snap right edge (for ne, e, se handles)
if params.resize_handle in ['ne', 'e', 'se']:
if params.resize_handle in ["ne", "e", "se"]:
# Calculate right edge position
right_edge = new_x + new_width
# Try to snap the right edge
snapped_right = self._snap_edge_to_targets(
right_edge, page_width_mm, params.dpi, snap_threshold_px, 'vertical', params.project
right_edge, page_width_mm, params.dpi, snap_threshold_px, "vertical", params.project
)
if snapped_right is not None:
new_width = snapped_right - new_x
# Snap top edge (for nw, n, ne handles)
if params.resize_handle in ['nw', 'n', 'ne']:
if params.resize_handle in ["nw", "n", "ne"]:
# Try to snap the top edge
snapped_top = self._snap_edge_to_targets(
new_y, page_height_mm, params.dpi, snap_threshold_px, 'horizontal', params.project
new_y, page_height_mm, params.dpi, snap_threshold_px, "horizontal", params.project
)
if snapped_top is not None:
# Adjust height to compensate for position change
@@ -279,12 +281,12 @@ class SnappingSystem:
new_height += height_adjustment
# Snap bottom edge (for sw, s, se handles)
if params.resize_handle in ['sw', 's', 'se']:
if params.resize_handle in ["sw", "s", "se"]:
# Calculate bottom edge position
bottom_edge = new_y + new_height
# Try to snap the bottom edge
snapped_bottom = self._snap_edge_to_targets(
bottom_edge, page_height_mm, params.dpi, snap_threshold_px, 'horizontal', params.project
bottom_edge, page_height_mm, params.dpi, snap_threshold_px, "horizontal", params.project
)
if snapped_bottom is not None:
new_height = snapped_bottom - new_y
@@ -296,13 +298,15 @@ class SnappingSystem:
return ((new_x, new_y), (new_width, new_height))
def _snap_edge_to_targets(self,
edge_position: float,
page_size_mm: float,
dpi: int,
snap_threshold_px: float,
orientation: str,
project=None) -> Optional[float]:
def _snap_edge_to_targets(
self,
edge_position: float,
page_size_mm: float,
dpi: int,
snap_threshold_px: float,
orientation: str,
project=None,
) -> Optional[float]:
"""
Snap an edge position to available targets (grid, edges, guides)
@@ -329,12 +333,12 @@ class SnappingSystem:
snap_to_guides = self.snap_to_guides
grid_size_mm = self.grid_size_mm
snap_candidates = []
snap_candidates: List[Tuple[float, float]] = []
# 1. Page edge snapping
if snap_to_edges:
# Snap to start edge (0)
snap_candidates.append((0, abs(edge_position - 0)))
snap_candidates.append((0.0, abs(edge_position - 0)))
# Snap to end edge
page_size_px = page_size_mm * dpi / 25.4
@@ -366,78 +370,6 @@ class SnappingSystem:
return best_snap
def _snap_axis(self,
position: float,
size: float,
page_size_mm: float,
dpi: int,
snap_threshold_px: float,
orientation: str) -> float:
"""
Snap along a single axis
Args:
position: Current position along axis in pixels
size: Element size along axis in pixels
page_size_mm: Page size along axis in mm
dpi: DPI for conversion
snap_threshold_px: Snap threshold in pixels
orientation: 'vertical' for x-axis, 'horizontal' for y-axis
Returns:
Snapped position in pixels
"""
snap_candidates = []
# 1. Page edge snapping
if self.snap_to_edges:
# Snap to start edge (0)
snap_candidates.append((0, abs(position - 0)))
# Snap to end edge
page_size_px = page_size_mm * dpi / 25.4
snap_candidates.append((page_size_px - size, abs(position - (page_size_px - size))))
# Also snap element's far edge to page edge
snap_candidates.append((page_size_px - size, abs((position + size) - page_size_px)))
# 2. Grid snapping
if self.snap_to_grid:
grid_size_px = self.grid_size_mm * dpi / 25.4
# Snap to nearest grid line
nearest_grid = round(position / grid_size_px) * grid_size_px
snap_candidates.append((nearest_grid, abs(position - nearest_grid)))
# Also try snapping element's far edge to grid
element_end = position + size
nearest_grid_end = round(element_end / grid_size_px) * grid_size_px
snap_candidates.append((nearest_grid_end - size, abs(element_end - nearest_grid_end)))
# 3. Guide snapping
if self.snap_to_guides:
for guide in self.guides:
if guide.orientation == orientation:
guide_pos_px = guide.position * dpi / 25.4
# Snap start edge to guide
snap_candidates.append((guide_pos_px, abs(position - guide_pos_px)))
# Snap end edge to guide
element_end = position + size
snap_candidates.append((guide_pos_px - size, abs(element_end - guide_pos_px)))
# Find the best snap candidate within threshold
best_snap = None
best_distance = snap_threshold_px
for snap_pos, distance in snap_candidates:
if distance < best_distance:
best_snap = snap_pos
best_distance = distance
return best_snap if best_snap is not None else position
def get_snap_lines(self, page_size: Tuple[float, float], dpi: int = 300) -> dict:
"""
Get all snap lines for visualization
@@ -453,42 +385,35 @@ class SnappingSystem:
page_width_px = page_width_mm * dpi / 25.4
page_height_px = page_height_mm * dpi / 25.4
result = {
'grid': [],
'edges': [],
'guides': []
}
result: Dict[str, List[Tuple[str, float]]] = {"grid": [], "edges": [], "guides": []}
# Grid lines
if self.snap_to_grid:
grid_size_px = self.grid_size_mm * dpi / 25.4
# Vertical grid lines
x = 0
x: float = 0
while x <= page_width_px:
result['grid'].append(('vertical', x))
result["grid"].append(("vertical", x))
x += grid_size_px
# Horizontal grid lines
y = 0
y: float = 0
while y <= page_height_px:
result['grid'].append(('horizontal', y))
result["grid"].append(("horizontal", y))
y += grid_size_px
# Edge lines
if self.snap_to_edges:
result['edges'].extend([
('vertical', 0),
('vertical', page_width_px),
('horizontal', 0),
('horizontal', page_height_px)
])
result["edges"].extend(
[("vertical", 0), ("vertical", page_width_px), ("horizontal", 0), ("horizontal", page_height_px)]
)
# Guide lines
if self.snap_to_guides:
for guide in self.guides:
guide_pos_px = guide.position * dpi / 25.4
result['guides'].append((guide.orientation, guide_pos_px))
result["guides"].append((guide.orientation, guide_pos_px))
return result
@@ -500,7 +425,7 @@ class SnappingSystem:
"snap_to_grid": self.snap_to_grid,
"snap_to_edges": self.snap_to_edges,
"snap_to_guides": self.snap_to_guides,
"guides": [guide.serialize() for guide in self.guides]
"guides": [guide.serialize() for guide in self.guides],
}
def deserialize(self, data: dict):
+29 -54
View File
@@ -14,7 +14,9 @@ from pyPhotoAlbum.project import Page
class Template:
"""Class representing a page layout template"""
def __init__(self, name: str = "Untitled Template", description: str = "", page_size_mm: Tuple[float, float] = (210, 297)):
def __init__(
self, name: str = "Untitled Template", description: str = "", page_size_mm: Tuple[float, float] = (210, 297)
):
self.name = name
self.description = description
self.page_size_mm = page_size_mm
@@ -30,21 +32,22 @@ class Template:
"name": self.name,
"description": self.description,
"page_size_mm": self.page_size_mm,
"elements": [elem.serialize() for elem in self.elements]
"elements": [elem.serialize() for elem in self.elements],
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Template':
def from_dict(cls, data: Dict[str, Any]) -> "Template":
"""Deserialize template from dictionary"""
template = cls(
name=data.get("name", "Untitled Template"),
description=data.get("description", ""),
page_size_mm=tuple(data.get("page_size_mm", (210, 297)))
page_size_mm=tuple(data.get("page_size_mm", (210, 297))),
)
# Deserialize elements
for elem_data in data.get("elements", []):
elem_type = elem_data.get("type")
elem: BaseLayoutElement
if elem_type == "placeholder":
elem = PlaceholderData()
elif elem_type == "textbox":
@@ -59,13 +62,13 @@ class Template:
def save_to_file(self, file_path: str):
"""Save template to JSON file"""
with open(file_path, 'w') as f:
with open(file_path, "w") as f:
json.dump(self.to_dict(), f, indent=2)
@classmethod
def load_from_file(cls, file_path: str) -> 'Template':
def load_from_file(cls, file_path: str) -> "Template":
"""Load template from JSON file"""
with open(file_path, 'r') as f:
with open(file_path, "r") as f:
data = json.load(f)
return cls.from_dict(data)
@@ -212,11 +215,7 @@ class TemplateManager:
Create a template from an existing page.
Converts all ImageData elements to PlaceholderData.
"""
template = Template(
name=name,
description=description,
page_size_mm=page.layout.size
)
template = Template(name=name, description=description, page_size_mm=page.layout.size)
# Convert elements
for element in page.layout.elements:
@@ -229,7 +228,7 @@ class TemplateManager:
width=element.size[0],
height=element.size[1],
rotation=element.rotation,
z_index=element.z_index
z_index=element.z_index,
)
template.add_element(placeholder)
elif isinstance(element, TextBoxData):
@@ -243,7 +242,7 @@ class TemplateManager:
width=element.size[0],
height=element.size[1],
rotation=element.rotation,
z_index=element.z_index
z_index=element.z_index,
)
template.add_element(text_box)
elif isinstance(element, PlaceholderData):
@@ -256,7 +255,7 @@ class TemplateManager:
width=element.size[0],
height=element.size[1],
rotation=element.rotation,
z_index=element.z_index
z_index=element.z_index,
)
template.add_element(placeholder)
@@ -268,7 +267,7 @@ class TemplateManager:
from_size: Tuple[float, float],
to_size: Tuple[float, float],
scale_mode: str = "proportional",
margin_percent: float = 0.0
margin_percent: float = 0.0,
) -> List[BaseLayoutElement]:
"""
Scale template elements to fit target page size with adjustable margins.
@@ -318,19 +317,19 @@ class TemplateManager:
offset_x = (to_width - from_width) / 2
offset_y = (to_height - from_height) / 2
scaled_elements = []
scaled_elements: List[BaseLayoutElement] = []
for element in elements:
# Create a new element of the same type
new_elem: BaseLayoutElement
if isinstance(element, PlaceholderData):
new_elem = PlaceholderData(
placeholder_type=element.placeholder_type,
default_content=element.default_content
placeholder_type=element.placeholder_type, default_content=element.default_content
)
elif isinstance(element, TextBoxData):
new_elem = TextBoxData(
text_content=element.text_content,
font_settings=element.font_settings.copy() if element.font_settings else None,
alignment=element.alignment
alignment=element.alignment,
)
else:
continue # Skip other types
@@ -339,14 +338,8 @@ class TemplateManager:
old_x, old_y = element.position
old_w, old_h = element.size
new_elem.position = (
old_x * scale_x + offset_x,
old_y * scale_y + offset_y
)
new_elem.size = (
old_w * scale_x,
old_h * scale_y
)
new_elem.position = (old_x * scale_x + offset_x, old_y * scale_y + offset_y)
new_elem.size = (old_w * scale_x, old_h * scale_y)
new_elem.rotation = element.rotation
new_elem.z_index = element.z_index
@@ -362,15 +355,9 @@ class TemplateManager:
for elem in scaled_elements:
# Convert position from mm to pixels
elem.position = (
elem.position[0] * mm_to_px,
elem.position[1] * mm_to_px
)
elem.position = (elem.position[0] * mm_to_px, elem.position[1] * mm_to_px)
# Convert size from mm to pixels
elem.size = (
elem.size[0] * mm_to_px,
elem.size[1] * mm_to_px
)
elem.size = (elem.size[0] * mm_to_px, elem.size[1] * mm_to_px)
return scaled_elements
@@ -381,7 +368,7 @@ class TemplateManager:
mode: str = "replace",
scale_mode: str = "proportional",
margin_percent: float = 2.5,
auto_embed: bool = True
auto_embed: bool = True,
):
"""
Apply template to an existing page with adjustable margins.
@@ -406,11 +393,7 @@ class TemplateManager:
# Scale template elements to fit page
scaled_elements = self.scale_template_elements(
template.elements,
template.page_size_mm,
page.layout.size,
scale_mode,
margin_percent
template.elements, template.page_size_mm, page.layout.size, scale_mode, margin_percent
)
# Add scaled elements to page
@@ -424,11 +407,7 @@ class TemplateManager:
# Get template placeholders (scaled)
scaled_elements = self.scale_template_elements(
template.elements,
template.page_size_mm,
page.layout.size,
scale_mode,
margin_percent
template.elements, template.page_size_mm, page.layout.size, scale_mode, margin_percent
)
template_placeholders = [e for e in scaled_elements if isinstance(e, PlaceholderData)]
@@ -451,7 +430,7 @@ class TemplateManager:
page.layout.add_element(placeholder)
# Add remaining images (if any) at their original positions
for img in existing_images[len(template_placeholders):]:
for img in existing_images[len(template_placeholders) :]:
page.layout.add_element(img)
# Add template text boxes
@@ -465,7 +444,7 @@ class TemplateManager:
target_size_mm: Optional[Tuple[float, float]] = None,
scale_mode: str = "proportional",
margin_percent: float = 2.5,
auto_embed: bool = True
auto_embed: bool = True,
) -> Page:
"""
Create a new page from a template.
@@ -494,11 +473,7 @@ class TemplateManager:
page_size = target_size_mm
# Scale template elements with margins
elements = self.scale_template_elements(
template.elements,
template.page_size_mm,
target_size_mm,
scale_mode,
margin_percent
template.elements, template.page_size_mm, target_size_mm, scale_mode, margin_percent
)
# Create new page layout
+23 -25
View File
@@ -3,8 +3,15 @@ Text editing dialog for pyPhotoAlbum
"""
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QPushButton,
QTextEdit, QLabel, QComboBox, QSpinBox, QColorDialog
QDialog,
QVBoxLayout,
QHBoxLayout,
QPushButton,
QTextEdit,
QLabel,
QComboBox,
QSpinBox,
QColorDialog,
)
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QFont, QColor
@@ -42,10 +49,9 @@ class TextEditDialog(QDialog):
# Font family
font_layout.addWidget(QLabel("Font:"))
self.font_combo = QComboBox()
self.font_combo.addItems([
"Arial", "Times New Roman", "Courier New",
"Helvetica", "Verdana", "Georgia", "Comic Sans MS"
])
self.font_combo.addItems(
["Arial", "Times New Roman", "Courier New", "Helvetica", "Verdana", "Georgia", "Comic Sans MS"]
)
font_layout.addWidget(self.font_combo)
# Font size
@@ -68,7 +74,7 @@ class TextEditDialog(QDialog):
alignment_layout = QHBoxLayout()
alignment_layout.addWidget(QLabel("Alignment:"))
self.alignment_combo = QComboBox()
self.alignment_combo.addItems(["left", "center", "right"])
self.alignment_combo.addItems(["left", "center", "right", "justify"])
alignment_layout.addWidget(self.alignment_combo)
alignment_layout.addStretch()
layout.addLayout(alignment_layout)
@@ -96,26 +102,22 @@ class TextEditDialog(QDialog):
self.text_edit.setPlainText(self.text_element.text_content)
# Load font settings
font_family = self.text_element.font_settings.get('family', 'Arial')
font_family = self.text_element.font_settings.get("family", "Arial")
index = self.font_combo.findText(font_family)
if index >= 0:
self.font_combo.setCurrentIndex(index)
font_size = self.text_element.font_settings.get('size', 12)
font_size = self.text_element.font_settings.get("size", 12)
self.font_size_spin.setValue(int(font_size))
# Load color
color = self.text_element.font_settings.get('color', (0, 0, 0))
color = self.text_element.font_settings.get("color", (0, 0, 0))
if all(isinstance(c, int) and c > 1 for c in color):
# Color in 0-255 range
self.current_color = QColor(*color)
else:
# Color in 0-1 range
self.current_color = QColor(
int(color[0] * 255),
int(color[1] * 255),
int(color[2] * 255)
)
self.current_color = QColor(int(color[0] * 255), int(color[1] * 255), int(color[2] * 255))
self._update_color_button()
# Load alignment
@@ -141,15 +143,11 @@ class TextEditDialog(QDialog):
def get_values(self):
"""Get the edited values"""
return {
'text_content': self.text_edit.toPlainText(),
'font_settings': {
'family': self.font_combo.currentText(),
'size': self.font_size_spin.value(),
'color': (
self.current_color.red(),
self.current_color.green(),
self.current_color.blue()
)
"text_content": self.text_edit.toPlainText(),
"font_settings": {
"family": self.font_combo.currentText(),
"size": self.font_size_spin.value(),
"color": (self.current_color.red(), self.current_color.green(), self.current_color.blue()),
},
'alignment': self.alignment_combo.currentText()
"alignment": self.alignment_combo.currentText(),
}
+899
View File
@@ -0,0 +1,899 @@
"""
Thumbnail Browser Widget - displays thumbnails from a folder for drag-and-drop into album.
"""
import os
from pathlib import Path
from typing import Optional, List, Tuple
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QFileDialog, QDockWidget, QScrollBar
from PyQt6.QtCore import Qt, QSize, QMimeData, QUrl, QPoint
from PyQt6.QtGui import QDrag, QCursor, QPainter, QFont, QColor
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
from pyPhotoAlbum.gl_imports import *
from pyPhotoAlbum.mixins.viewport import ViewportMixin
IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp"]
class DateHeader:
"""Represents a date separator header in the thumbnail list."""
def __init__(self, date_text: str, y_position: float):
self.date_text = date_text
self.y = y_position
self.height = 30.0 # Height of the header bar
class ThumbnailItem:
"""Represents a thumbnail with position and path information."""
def __init__(self, image_path: str, grid_pos: Tuple[int, int], thumbnail_size: float = 100.0):
self.image_path = image_path
self.grid_row, self.grid_col = grid_pos
self.thumbnail_size = thumbnail_size
self.is_used_in_project = False # Will be updated when checking against project
# Position in mm (will be calculated based on grid)
spacing = 10.0 # mm spacing between thumbnails
self.x = self.grid_col * (self.thumbnail_size + spacing) + spacing
self.y = self.grid_row * (self.thumbnail_size + spacing) + spacing
# Texture info (loaded async)
self._texture_id = None
self._pending_pil_image = None
self._async_loading = False
self._img_width = None
self._img_height = None
def get_bounds(self) -> Tuple[float, float, float, float]:
"""Return (x, y, width, height) bounds."""
return (self.x, self.y, self.thumbnail_size, self.thumbnail_size)
def contains_point(self, x: float, y: float) -> bool:
"""Check if point is inside this thumbnail."""
return self.x <= x <= self.x + self.thumbnail_size and self.y <= y <= self.y + self.thumbnail_size
class ThumbnailGLWidget(QOpenGLWidget):
"""
OpenGL widget that displays thumbnails in a grid.
Uses the same async loading and texture system as the main canvas.
"""
def __init__(self, main_window=None):
super().__init__()
self.thumbnails: List[ThumbnailItem] = []
self.date_headers: List[DateHeader] = []
self.current_folder: Optional[Path] = None
# Store reference to main window
self._main_window = main_window
# Viewport state
self.zoom_level = 1.0
self.pan_offset = (0, 0)
# Dragging state
self.drag_start_pos = None
self.dragging_thumbnail = None
# Scrollbar (created but managed by parent)
self.scrollbar = None
self._updating_scrollbar = False # Flag to prevent circular updates
# Sort mode (set by parent dock)
self.sort_mode = "name"
self._get_image_date_func = None # Function to get date from parent
# Enable OpenGL
self.setMinimumSize(QSize(250, 300))
def window(self):
"""Override window() to return stored main_window reference."""
return self._main_window if self._main_window else super().window()
def update(self):
"""Override update to batch repaints for better performance."""
# Just schedule the update - Qt will automatically batch multiple
# update() calls into a single paintGL() invocation
super().update()
def initializeGL(self):
"""Initialize OpenGL context."""
glClearColor(0.95, 0.95, 0.95, 1.0) # Light gray background
glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
glEnable(GL_TEXTURE_2D)
def resizeGL(self, w, h):
"""Handle resize events."""
glViewport(0, 0, w, h)
glMatrixMode(GL_PROJECTION)
glLoadIdentity()
glOrtho(0, w, h, 0, -1, 1) # 2D orthographic projection
glMatrixMode(GL_MODELVIEW)
# Rearrange thumbnails to fit new width
if hasattr(self, "image_files") and self.image_files:
self._arrange_thumbnails()
else:
# Still update scrollbar even if no thumbnails
self._update_scrollbar_range()
def paintGL(self):
"""Render thumbnails."""
glClear(GL_COLOR_BUFFER_BIT)
glLoadIdentity()
if not self.thumbnails:
return
# Apply zoom and pan
glTranslatef(self.pan_offset[0], self.pan_offset[1], 0)
glScalef(self.zoom_level, self.zoom_level, 1.0)
# Render date headers first (so they appear behind thumbnails)
for header in self.date_headers:
self._render_date_header(header)
# Render each thumbnail (placeholders or textures)
for thumb in self.thumbnails:
self._render_thumbnail(thumb)
def paintEvent(self, event):
"""Override paintEvent to add text labels after OpenGL rendering."""
# Call the default OpenGL paint
super().paintEvent(event)
# Draw text labels for date headers using QPainter
if self.date_headers and self.sort_mode == "date":
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# Set font for date labels
font = QFont("Arial", 11, QFont.Weight.Bold)
painter.setFont(font)
painter.setPen(QColor(255, 255, 255)) # White text
for header in self.date_headers:
# Transform header position to screen coordinates
screen_y = header.y * self.zoom_level + self.pan_offset[1]
screen_h = header.height * self.zoom_level
# Only draw if header is visible
if screen_y + screen_h >= 0 and screen_y <= self.height():
# Draw text centered vertically in the header bar
text_y = int(screen_y + screen_h / 2)
painter.drawText(10, text_y + 5, header.date_text)
painter.end()
def _render_thumbnail(self, thumb: ThumbnailItem):
"""Render a single thumbnail using placeholder pattern."""
x, y, w, h = thumb.get_bounds()
# If we have a pending image, convert it to texture (happens once per image)
if hasattr(thumb, "_pending_pil_image") and thumb._pending_pil_image is not None:
self._create_texture_for_thumbnail(thumb)
# Render based on state: texture, loading placeholder, or empty placeholder
if thumb._texture_id:
# Calculate aspect-ratio-corrected dimensions
if hasattr(thumb, "_img_width") and hasattr(thumb, "_img_height"):
img_aspect = thumb._img_width / thumb._img_height
thumb_aspect = w / h
if img_aspect > thumb_aspect:
# Image is wider - fit to width
render_w = w
render_h = w / img_aspect
render_x = x
render_y = y + (h - render_h) / 2
else:
# Image is taller - fit to height
render_h = h
render_w = h * img_aspect
render_x = x + (w - render_w) / 2
render_y = y
else:
# No aspect ratio info, use full bounds
render_x, render_y, render_w, render_h = x, y, w, h
# Render actual texture
glEnable(GL_TEXTURE_2D)
glBindTexture(GL_TEXTURE_2D, thumb._texture_id)
# If used in project, desaturate by tinting grey
if thumb.is_used_in_project:
glColor4f(0.5, 0.5, 0.5, 0.6) # Grey tint + partial transparency
else:
glColor4f(1.0, 1.0, 1.0, 1.0)
glBegin(GL_QUADS)
glTexCoord2f(0.0, 0.0)
glVertex2f(render_x, render_y)
glTexCoord2f(1.0, 0.0)
glVertex2f(render_x + render_w, render_y)
glTexCoord2f(1.0, 1.0)
glVertex2f(render_x + render_w, render_y + render_h)
glTexCoord2f(0.0, 1.0)
glVertex2f(render_x, render_y + render_h)
glEnd()
glDisable(GL_TEXTURE_2D)
else:
# Render placeholder (grey box while loading or if load failed)
glColor3f(0.8, 0.8, 0.8)
glBegin(GL_QUADS)
glVertex2f(x, y)
glVertex2f(x + w, y)
glVertex2f(x + w, y + h)
glVertex2f(x, y + h)
glEnd()
# Border
glColor3f(0.5, 0.5, 0.5)
glLineWidth(1.0)
glBegin(GL_LINE_LOOP)
glVertex2f(x, y)
glVertex2f(x + w, y)
glVertex2f(x + w, y + h)
glVertex2f(x, y + h)
glEnd()
def _render_date_header(self, header: DateHeader):
"""Render a date separator header."""
# Calculate full width bar
widget_width = self.width() / self.zoom_level
x = 0
y = header.y
w = widget_width
h = header.height
# Draw background bar (dark blue-gray)
glColor3f(0.3, 0.4, 0.5)
glBegin(GL_QUADS)
glVertex2f(x, y)
glVertex2f(x + w, y)
glVertex2f(x + w, y + h)
glVertex2f(x, y + h)
glEnd()
# Draw bottom border
glColor3f(0.2, 0.3, 0.4)
glLineWidth(2.0)
glBegin(GL_LINES)
glVertex2f(x, y + h)
glVertex2f(x + w, y + h)
glEnd()
# Note: Text rendering would require QPainter overlay
# For now, the colored bar serves as a visual separator
# Text will be added using QPainter in a future enhancement
def _create_texture_for_thumbnail(self, thumb: ThumbnailItem):
"""Create OpenGL texture from pending PIL image."""
if not thumb._pending_pil_image:
return False
try:
pil_image = thumb._pending_pil_image
# Ensure RGBA
if pil_image.mode != "RGBA":
pil_image = pil_image.convert("RGBA")
# Delete old texture
if thumb._texture_id:
glDeleteTextures([thumb._texture_id])
# Create texture
img_data = pil_image.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, pil_image.width, pil_image.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, img_data
)
thumb._texture_id = texture_id
thumb._img_width = pil_image.width
thumb._img_height = pil_image.height
thumb._pending_pil_image = None
return True
except Exception as e:
print(f"Error creating texture for thumbnail: {e}")
thumb._pending_pil_image = None
return False
def load_folder(self, folder_path: Path):
"""Load thumbnails from a folder."""
self.current_folder = folder_path
# Find all image files
self.image_files: list[Path] = []
for ext in IMAGE_EXTENSIONS:
self.image_files.extend(folder_path.glob(f"*{ext}"))
self.image_files.extend(folder_path.glob(f"*{ext.upper()}"))
self.image_files.sort()
# Arrange thumbnails based on current widget size and zoom
self._arrange_thumbnails()
# Update which images are already in use
self.update_used_images()
self.update()
def _arrange_thumbnails(self):
"""Arrange thumbnails in a grid based on widget width and zoom level."""
if not hasattr(self, "image_files") or not self.image_files:
self.thumbnails.clear()
return
# Calculate number of columns that fit
widget_width = self.width()
if widget_width <= 0:
widget_width = 250 # Default minimum width
# Thumbnail size in screen pixels (affected by zoom)
thumb_size_screen = 100.0 * self.zoom_level
spacing_screen = 10.0 * self.zoom_level
# Calculate columns
columns = max(1, int((widget_width - spacing_screen) / (thumb_size_screen + spacing_screen)))
# Calculate total grid width to center it
spacing = 10.0
grid_width = columns * (100.0 + spacing) - spacing # Total width in base units
# Horizontal offset to center the grid
h_offset = max(0, (widget_width / self.zoom_level - grid_width) / 2)
# Build a map of existing thumbnails by path to reuse them
existing_thumbs = {thumb.image_path: thumb for thumb in self.thumbnails}
# Clear lists but reuse thumbnail objects
self.thumbnails.clear()
self.date_headers.clear()
# For date mode: track current date and positioning
current_date_str = None
section_start_y = spacing
row_in_section = 0
col = 0
for idx, image_file in enumerate(self.image_files):
image_path = str(image_file)
# Check if we need a date header (only in date sort mode)
if self.sort_mode == "date" and self._get_image_date_func:
from datetime import datetime
timestamp = self._get_image_date_func(image_file)
date_obj = datetime.fromtimestamp(timestamp)
date_str = date_obj.strftime("%B %d, %Y") # e.g., "December 13, 2025"
if date_str != current_date_str:
# Starting a new date section
if current_date_str is not None:
# Not the first section - calculate where this section starts
# It should start after the last thumbnail of the previous section
if self.thumbnails:
last_thumb = self.thumbnails[-1]
# Start after the last row of previous section
last_row_y = last_thumb.y + last_thumb.thumbnail_size
section_start_y = last_row_y + spacing * 2 # Extra spacing between sections
# Add header at section start
header = DateHeader(date_str, section_start_y)
self.date_headers.append(header)
# Update section_start_y to after the header
section_start_y += header.height + spacing
current_date_str = date_str
row_in_section = 0
col = 0
# Calculate position
if self.sort_mode == "date":
# In date mode: position relative to section start
row = row_in_section
thumb_y = section_start_y + row * (100.0 + spacing)
else:
# In other modes: simple grid based on overall index
row = idx // columns
thumb_y = row * (100.0 + spacing) + spacing
# Calculate X position (always centered)
thumb_x = h_offset + col * (100.0 + spacing) + spacing
# Reuse existing thumbnail if available, otherwise create new
if image_path in existing_thumbs:
thumb = existing_thumbs[image_path]
thumb.grid_row = row
thumb.grid_col = col
thumb.x = thumb_x
thumb.y = thumb_y
else:
# Create new placeholder thumbnail
thumb = ThumbnailItem(image_path, (row, col))
thumb.x = thumb_x
thumb.y = thumb_y
# Request async load
self._request_thumbnail_load(thumb)
self.thumbnails.append(thumb)
# Update column and row counters
col += 1
if col >= columns:
col = 0
row_in_section += 1
# Update scrollbar range after arranging
self._update_scrollbar_range()
def _update_scrollbar_range(self):
"""Update scrollbar range based on content height."""
if not self.scrollbar or self._updating_scrollbar:
return
if not self.thumbnails:
self.scrollbar.setRange(0, 0)
self.scrollbar.setPageStep(self.height())
return
# Calculate total content height
if self.thumbnails:
# Find the maximum Y position
max_y = max(thumb.y + thumb.thumbnail_size for thumb in self.thumbnails)
content_height = max_y * self.zoom_level
else:
content_height = 0
# Visible height
visible_height = self.height()
# Scrollable range
scroll_range = max(0, int(content_height - visible_height))
self._updating_scrollbar = True
self.scrollbar.setRange(0, scroll_range)
self.scrollbar.setPageStep(visible_height)
self.scrollbar.setSingleStep(int(visible_height / 10)) # 10% of visible height per step
# Update scrollbar position based on current pan
scroll_pos = int(-self.pan_offset[1])
self.scrollbar.setValue(scroll_pos)
self._updating_scrollbar = False
def _on_scrollbar_changed(self, value):
"""Handle scrollbar value change."""
if self._updating_scrollbar:
return
# Update pan offset based on scrollbar value
self.pan_offset = (0, -value)
self.update()
def _update_scrollbar_position(self):
"""Update scrollbar position based on current pan offset."""
if not self.scrollbar or self._updating_scrollbar:
return
self._updating_scrollbar = True
scroll_pos = int(-self.pan_offset[1])
self.scrollbar.setValue(scroll_pos)
self._updating_scrollbar = False
def update_used_images(self):
"""Update which thumbnails are already used in the project."""
# Get reference to main window's project
main_window = self.window()
if not hasattr(main_window, "project") or not main_window.project:
return
project = main_window.project
# Collect all image paths used in the project
used_paths = set()
for page in project.pages:
from pyPhotoAlbum.models import ImageData
for element in page.layout.elements:
if isinstance(element, ImageData) and element.image_path:
# Resolve to absolute path for comparison
abs_path = element.resolve_image_path()
if abs_path:
used_paths.add(abs_path)
# Mark thumbnails as used
for thumb in self.thumbnails:
thumb.is_used_in_project = thumb.image_path in used_paths
def _request_thumbnail_load(self, thumb: ThumbnailItem):
"""Request async load for a thumbnail using main window's loader."""
# Skip if already loading or loaded
if thumb._async_loading or thumb._texture_id:
return
# Get main window's async loader
main_window = self.window()
if not main_window or not hasattr(main_window, "_gl_widget"):
return
gl_widget = main_window._gl_widget
if not hasattr(gl_widget, "async_image_loader"):
return
from pyPhotoAlbum.async_backend import LoadPriority
try:
# Mark as loading to prevent duplicate requests
thumb._async_loading = True
# Request load through main window's async loader
# Use LOW priority for thumbnails to not interfere with main canvas
gl_widget.async_image_loader.request_load(
Path(thumb.image_path),
priority=LoadPriority.LOW,
target_size=(200, 200), # Small thumbnails
user_data=thumb,
)
except RuntimeError:
thumb._async_loading = False # Reset on error
def _on_image_loaded(self, path: Path, image, user_data):
"""Handle async image loaded - sets pending image on the placeholder."""
if isinstance(user_data, ThumbnailItem):
# Store the loaded image in the placeholder
user_data._pending_pil_image = image
user_data._img_width = image.width
user_data._img_height = image.height
user_data._async_loading = False
# Schedule a repaint (will be batched if many images load quickly)
self.update()
def _on_image_load_failed(self, path: Path, error_msg: str, user_data):
"""Handle async image load failure."""
pass # Silently ignore load failures for thumbnails
def screen_to_viewport(self, screen_x: int, screen_y: int) -> Tuple[float, float]:
"""Convert screen coordinates to viewport coordinates (accounting for zoom/pan)."""
vp_x = (screen_x - self.pan_offset[0]) / self.zoom_level
vp_y = (screen_y - self.pan_offset[1]) / self.zoom_level
return vp_x, vp_y
def get_thumbnail_at(self, screen_x: int, screen_y: int) -> Optional[ThumbnailItem]:
"""Get thumbnail at screen position."""
vp_x, vp_y = self.screen_to_viewport(screen_x, screen_y)
for thumb in self.thumbnails:
if thumb.contains_point(vp_x, vp_y):
return thumb
return None
def mousePressEvent(self, event):
"""Handle mouse press for drag."""
if event.button() == Qt.MouseButton.LeftButton:
self.drag_start_pos = event.pos()
self.dragging_thumbnail = self.get_thumbnail_at(event.pos().x(), event.pos().y())
def mouseMoveEvent(self, event):
"""Handle mouse move for drag or pan."""
if not (event.buttons() & Qt.MouseButton.LeftButton):
return
if self.drag_start_pos is None:
return
# Check if we should start dragging a thumbnail
if self.dragging_thumbnail:
# Start drag operation
drag = QDrag(self)
mime_data = QMimeData()
# Set file URL for the drag
url = QUrl.fromLocalFile(self.dragging_thumbnail.image_path)
mime_data.setUrls([url])
drag.setMimeData(mime_data)
# Execute drag (this blocks until drop or cancel)
drag.exec(Qt.DropAction.CopyAction)
# Reset drag state
self.drag_start_pos = None
self.dragging_thumbnail = None
else:
# Pan the view (right-click or middle-click drag)
# Only allow vertical panning - grid is always horizontally centered
delta = event.pos() - self.drag_start_pos
self.pan_offset = (0, self.pan_offset[1] + delta.y()) # No horizontal pan - grid is centered
self.drag_start_pos = event.pos()
self._update_scrollbar_position()
self.update()
def mouseReleaseEvent(self, event):
"""Handle mouse release."""
self.drag_start_pos = None
self.dragging_thumbnail = None
def wheelEvent(self, event):
"""Handle mouse wheel for scrolling (or zooming with Ctrl)."""
delta = event.angleDelta().y()
# Check if Ctrl is pressed for zooming
if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
# Zoom mode
mouse_y = event.position().y()
zoom_factor = 1.1 if delta > 0 else 0.9
# Calculate vertical world position before zoom
world_y = (mouse_y - self.pan_offset[1]) / self.zoom_level
# Apply zoom
old_zoom = self.zoom_level
self.zoom_level *= zoom_factor
self.zoom_level = max(0.1, min(5.0, self.zoom_level)) # Clamp
# Rearrange thumbnails if zoom level changed significantly
# This recalculates horizontal centering
if abs(self.zoom_level - old_zoom) > 0.01:
self._arrange_thumbnails()
# Adjust vertical pan to keep mouse position fixed
# Keep horizontal pan at 0 (grid is always horizontally centered)
self.pan_offset = (
0, # No horizontal pan - grid is centered in _arrange_thumbnails
mouse_y - world_y * self.zoom_level,
)
else:
# Scroll mode - scroll vertically only
scroll_amount = delta * 0.5 # Adjust sensitivity
self.pan_offset = (0, self.pan_offset[1] + scroll_amount) # No horizontal pan
self._update_scrollbar_position()
self.update()
class ThumbnailBrowserDock(QDockWidget):
"""
Dockable widget containing the thumbnail browser.
"""
def __init__(self, parent=None):
super().__init__("Image Browser", parent)
# Create main widget
main_widget = QWidget()
layout = QVBoxLayout(main_widget)
layout.setContentsMargins(5, 5, 5, 5)
layout.setSpacing(5)
# Header with folder selection
header_layout = QHBoxLayout()
self.folder_label = QLabel("No folder selected")
self.folder_label.setStyleSheet("font-weight: bold; padding: 5px;")
header_layout.addWidget(self.folder_label)
self.select_folder_btn = QPushButton("Select Folder...")
self.select_folder_btn.clicked.connect(self._select_folder)
header_layout.addWidget(self.select_folder_btn)
layout.addLayout(header_layout)
# Sort toolbar
sort_layout = QHBoxLayout()
sort_layout.setContentsMargins(5, 0, 5, 5)
sort_label = QLabel("Sort by:")
sort_layout.addWidget(sort_label)
self.sort_name_btn = QPushButton("Name")
self.sort_name_btn.setCheckable(True)
self.sort_name_btn.setChecked(True) # Default sort
self.sort_name_btn.clicked.connect(lambda: self._sort_by("name"))
sort_layout.addWidget(self.sort_name_btn)
self.sort_date_btn = QPushButton("Date")
self.sort_date_btn.setCheckable(True)
self.sort_date_btn.clicked.connect(lambda: self._sort_by("date"))
sort_layout.addWidget(self.sort_date_btn)
self.sort_camera_btn = QPushButton("Camera")
self.sort_camera_btn.setCheckable(True)
self.sort_camera_btn.clicked.connect(lambda: self._sort_by("camera"))
sort_layout.addWidget(self.sort_camera_btn)
sort_layout.addStretch()
layout.addLayout(sort_layout)
# Track current sort mode
self.current_sort = "name"
# Create horizontal layout for GL widget and scrollbar
browser_layout = QHBoxLayout()
browser_layout.setContentsMargins(0, 0, 0, 0)
browser_layout.setSpacing(0)
# GL Widget for thumbnails
self.gl_widget = ThumbnailGLWidget(main_window=parent)
browser_layout.addWidget(self.gl_widget)
# Vertical scrollbar
self.scrollbar = QScrollBar(Qt.Orientation.Vertical)
self.scrollbar.valueChanged.connect(self.gl_widget._on_scrollbar_changed)
browser_layout.addWidget(self.scrollbar)
# Connect scrollbar to GL widget
self.gl_widget.scrollbar = self.scrollbar
layout.addLayout(browser_layout)
self.setWidget(main_widget)
# Dock settings
self.setAllowedAreas(Qt.DockWidgetArea.LeftDockWidgetArea | Qt.DockWidgetArea.RightDockWidgetArea)
self.setFeatures(
QDockWidget.DockWidgetFeature.DockWidgetClosable
| QDockWidget.DockWidgetFeature.DockWidgetMovable
| QDockWidget.DockWidgetFeature.DockWidgetFloatable
)
# Connect to main window's async loader when shown
self._connect_async_loader()
def _connect_async_loader(self):
"""Connect to main window's async image loader."""
main_window = self.window()
if not hasattr(main_window, "_gl_widget"):
return
gl_widget = main_window._gl_widget
if not hasattr(gl_widget, "async_image_loader"):
return
# Avoid duplicate connections
if hasattr(self, "_async_connected") and self._async_connected:
return
try:
# Connect signals
gl_widget.async_image_loader.image_loaded.connect(self.gl_widget._on_image_loaded)
gl_widget.async_image_loader.load_failed.connect(self.gl_widget._on_image_load_failed)
self._async_connected = True
except Exception:
pass # Silently handle connection errors
def showEvent(self, event):
"""Handle show event."""
super().showEvent(event)
# Ensure async loader is connected when shown
self._connect_async_loader()
def _select_folder(self):
"""Open dialog to select folder."""
folder_path = QFileDialog.getExistingDirectory(
self,
"Select Image Folder",
str(self.gl_widget.current_folder) if self.gl_widget.current_folder else str(Path.home()),
QFileDialog.Option.ShowDirsOnly,
)
if folder_path:
self.load_folder(Path(folder_path))
def load_folder(self, folder_path: Path):
"""Load thumbnails from folder."""
self.folder_label.setText(f"Folder: {folder_path.name}")
self.gl_widget.load_folder(folder_path)
# Apply current sort after loading
self._apply_sort()
self.gl_widget._arrange_thumbnails()
self.gl_widget.update_used_images()
self.gl_widget.update()
def _sort_by(self, sort_mode: str):
"""Sort thumbnails by the specified mode."""
# Update button states (only one can be checked)
self.sort_name_btn.setChecked(sort_mode == "name")
self.sort_date_btn.setChecked(sort_mode == "date")
self.sort_camera_btn.setChecked(sort_mode == "camera")
self.current_sort = sort_mode
# Re-sort the image files in the GL widget
if hasattr(self.gl_widget, "image_files") and self.gl_widget.image_files:
self._apply_sort()
# Re-arrange thumbnails with new order
self.gl_widget._arrange_thumbnails()
self.gl_widget.update_used_images()
self.gl_widget.update()
def _apply_sort(self):
"""Apply current sort mode to image files."""
if not hasattr(self.gl_widget, "image_files") or not self.gl_widget.image_files:
return
if self.current_sort == "name":
# Sort by filename only (not full path)
self.gl_widget.image_files.sort(key=lambda p: p.name.lower())
# Clear date headers for non-date sorts
self.gl_widget.date_headers.clear()
# Reset sort mode in GL widget
self.gl_widget.sort_mode = "name"
self.gl_widget._get_image_date_func = None
elif self.current_sort == "date":
# Sort by file modification time (or EXIF date if available)
self.gl_widget.image_files.sort(key=self._get_image_date)
# Date headers will be created during _arrange_thumbnails
self.gl_widget.sort_mode = "date"
self.gl_widget._get_image_date_func = self._get_image_date
elif self.current_sort == "camera":
# Sort by camera model from EXIF
self.gl_widget.image_files.sort(key=self._get_camera_model)
# Clear date headers for non-date sorts
self.gl_widget.date_headers.clear()
# Reset sort mode in GL widget
self.gl_widget.sort_mode = "camera"
self.gl_widget._get_image_date_func = None
def _get_image_date(self, image_path: Path) -> float:
"""Get image date from EXIF or file modification time."""
try:
from PIL import Image
from PIL.ExifTags import TAGS
with Image.open(image_path) as img:
exif = img.getexif()
if exif:
# Look for DateTimeOriginal (when photo was taken)
for tag_id, value in exif.items():
tag = TAGS.get(tag_id, tag_id)
if tag == "DateTimeOriginal":
# Convert EXIF date format "2023:12:13 14:30:00" to timestamp
from datetime import datetime
try:
dt = datetime.strptime(value, "%Y:%m:%d %H:%M:%S")
return dt.timestamp()
except:
pass
except Exception:
pass
# Fallback to file modification time
return image_path.stat().st_mtime
def _get_camera_model(self, image_path: Path) -> str:
"""Get camera model from EXIF metadata."""
try:
from PIL import Image
from PIL.ExifTags import TAGS
with Image.open(image_path) as img:
exif = img.getexif()
if exif:
# Look for camera model
for tag_id, value in exif.items():
tag = TAGS.get(tag_id, tag_id)
if tag == "Model":
return str(value).strip()
except Exception:
pass
# Fallback to filename if no EXIF data
return image_path.name.lower()
+9 -7
View File
@@ -7,7 +7,6 @@ import uuid
from datetime import datetime, timezone
from typing import Dict, Any, Optional, Callable, List
# Current data version - increment when making breaking changes to data format
CURRENT_DATA_VERSION = "3.0"
@@ -24,7 +23,7 @@ VERSION_HISTORY = {
"released": "2025-01-11",
"breaking_changes": [
"Asset paths changed from absolute/full-project-relative to project-relative",
"Added automatic path normalization for legacy projects"
"Added automatic path normalization for legacy projects",
],
"compatible_with": ["1.0", "2.0"], # 2.0 can read 1.0 with migration
},
@@ -38,7 +37,7 @@ VERSION_HISTORY = {
"Added deletion tracking (deleted flag and deleted_at timestamp)",
],
"compatible_with": ["1.0", "2.0", "3.0"], # 3.0 can read older versions with migration
}
},
}
@@ -120,9 +119,11 @@ class DataMigration:
@classmethod
def register_migration(cls, from_version: str, to_version: str):
"""Decorator to register a migration function"""
def decorator(func):
cls._migrations[(from_version, to_version)] = func
return func
return decorator
@classmethod
@@ -164,6 +165,7 @@ class DataMigration:
# Register migrations
@DataMigration.register_migration("1.0", "2.0")
def migrate_1_0_to_2_0(data: Dict[str, Any]) -> Dict[str, Any]:
"""
@@ -176,7 +178,7 @@ def migrate_1_0_to_2_0(data: Dict[str, Any]) -> Dict[str, Any]:
print("Migration 1.0 → 2.0: Asset paths will be normalized during load")
# Update version in data
data['data_version'] = "2.0"
data["data_version"] = "2.0"
# Note: Actual path normalization is handled in load_from_zip
# This migration mainly updates the version number
@@ -249,7 +251,7 @@ def migrate_2_0_to_3_0(data: Dict[str, Any]) -> Dict[str, Any]:
element_data["deleted_at"] = None
# Update version
data['data_version'] = "3.0"
data["data_version"] = "3.0"
print(f" Migrated {len(data.get('pages', []))} pages to v3.0")
@@ -287,7 +289,7 @@ def check_version_compatibility(file_version: str, file_path: str = "") -> tuple
error_msg += f"File version info:\n"
error_msg += f" Description: {file_info.get('description', 'Unknown')}\n"
error_msg += f" Released: {file_info.get('released', 'Unknown')}\n"
breaking_changes = file_info.get('breaking_changes', [])
breaking_changes = file_info.get("breaking_changes", [])
if breaking_changes:
error_msg += f" Breaking changes:\n"
for change in breaking_changes:
@@ -312,7 +314,7 @@ def format_version_info() -> str:
info.append(f" Description: {version_info.get('description', 'Unknown')}")
info.append(f" Released: {version_info.get('released', 'Unknown')}")
breaking_changes = version_info.get('breaking_changes', [])
breaking_changes = version_info.get("breaking_changes", [])
if breaking_changes:
info.append(f" Breaking changes:")
for change in breaking_changes:
+1
View File
@@ -31,6 +31,7 @@ dependencies = [
"Pillow>=8.0.0",
"reportlab>=3.5.0",
"lxml>=4.6.0",
"pypdf>=4.0.0",
]
[project.optional-dependencies]
+26 -87
View File
@@ -15,9 +15,9 @@ from pyPhotoAlbum.project import Project, Page
@pytest.fixture
def temp_image_file():
"""Create a temporary test image file"""
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f:
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
# Create a simple test image
img = Image.new('RGB', (100, 100), color='red')
img = Image.new("RGB", (100, 100), color="red")
img.save(f.name)
yield f.name
# Cleanup
@@ -37,37 +37,19 @@ def temp_dir():
@pytest.fixture
def sample_image_data(temp_image_file):
"""Create a sample ImageData instance"""
return ImageData(
image_path=temp_image_file,
x=10.0,
y=20.0,
width=100.0,
height=150.0
)
return ImageData(image_path=temp_image_file, x=10.0, y=20.0, width=100.0, height=150.0)
@pytest.fixture
def sample_placeholder_data():
"""Create a sample PlaceholderData instance"""
return PlaceholderData(
placeholder_type="image",
x=50.0,
y=60.0,
width=200.0,
height=150.0
)
return PlaceholderData(placeholder_type="image", x=50.0, y=60.0, width=200.0, height=150.0)
@pytest.fixture
def sample_textbox_data():
"""Create a sample TextBoxData instance"""
return TextBoxData(
text_content="Sample Text",
x=30.0,
y=40.0,
width=150.0,
height=50.0
)
return TextBoxData(text_content="Sample Text", x=30.0, y=40.0, width=150.0, height=50.0)
@pytest.fixture
@@ -119,10 +101,7 @@ def mock_main_window():
window.project = Project(name="Test Project")
# Add a test page
page = Page(
layout=PageLayout(width=210, height=297), # A4 size in mm
page_number=1
)
page = Page(layout=PageLayout(width=210, height=297), page_number=1) # A4 size in mm
window.project.pages.append(page)
window.project.working_dpi = 96
window.project.page_size_mm = (210, 297)
@@ -139,39 +118,19 @@ def mock_main_window():
@pytest.fixture
def sample_image_element():
"""Create a sample ImageData element for testing"""
return ImageData(
image_path="test.jpg",
x=100,
y=100,
width=200,
height=150,
z_index=1
)
return ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150, z_index=1)
@pytest.fixture
def sample_placeholder_element():
"""Create a sample PlaceholderData element for testing"""
return PlaceholderData(
x=50,
y=50,
width=100,
height=100,
z_index=0
)
return PlaceholderData(x=50, y=50, width=100, height=100, z_index=0)
@pytest.fixture
def sample_textbox_element():
"""Create a sample TextBoxData element for testing"""
return TextBoxData(
x=10,
y=10,
width=180,
height=50,
text_content="Test Text",
z_index=2
)
return TextBoxData(x=10, y=10, width=180, height=50, text_content="Test Text", z_index=2)
@pytest.fixture
@@ -189,19 +148,19 @@ def mock_page_renderer():
# Mock coordinate conversion methods
def page_to_screen(x, y):
return (renderer.screen_x + x * renderer.zoom,
renderer.screen_y + y * renderer.zoom)
return (renderer.screen_x + x * renderer.zoom, renderer.screen_y + y * renderer.zoom)
def screen_to_page(x, y):
return ((x - renderer.screen_x) / renderer.zoom,
(y - renderer.screen_y) / renderer.zoom)
return ((x - renderer.screen_x) / renderer.zoom, (y - renderer.screen_y) / renderer.zoom)
def is_point_in_page(x, y):
# Simple bounds check (assume 210mm x 297mm page at 96 DPI)
page_width_px = 210 * 96 / 25.4
page_height_px = 297 * 96 / 25.4
return (renderer.screen_x <= x <= renderer.screen_x + page_width_px * renderer.zoom and
renderer.screen_y <= y <= renderer.screen_y + page_height_px * renderer.zoom)
return (
renderer.screen_x <= x <= renderer.screen_x + page_width_px * renderer.zoom
and renderer.screen_y <= y <= renderer.screen_y + page_height_px * renderer.zoom
)
renderer.page_to_screen = page_to_screen
renderer.screen_to_page = screen_to_page
@@ -213,8 +172,8 @@ def mock_page_renderer():
@pytest.fixture
def create_mouse_event():
"""Factory fixture for creating QMouseEvent objects"""
def _create_event(event_type, x, y, button=Qt.MouseButton.LeftButton,
modifiers=Qt.KeyboardModifier.NoModifier):
def _create_event(event_type, x, y, button=Qt.MouseButton.LeftButton, modifiers=Qt.KeyboardModifier.NoModifier):
"""Create a QMouseEvent for testing
Args:
@@ -224,19 +183,15 @@ def create_mouse_event():
modifiers: Keyboard modifiers
"""
pos = QPointF(x, y)
return QMouseEvent(
event_type,
pos,
button,
button,
modifiers
)
return QMouseEvent(event_type, pos, button, button, modifiers)
return _create_event
@pytest.fixture
def create_wheel_event():
"""Factory fixture for creating QWheelEvent objects"""
def _create_event(x, y, delta_y=120, modifiers=Qt.KeyboardModifier.NoModifier):
"""Create a QWheelEvent for testing
@@ -257,38 +212,22 @@ def create_wheel_event():
Qt.MouseButton.NoButton,
modifiers,
Qt.ScrollPhase.NoScrollPhase,
False
False,
)
return _create_event
@pytest.fixture
def populated_page():
"""Create a page with multiple elements for testing"""
page = Page(
layout=PageLayout(width=210, height=297),
page_number=1
)
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
# Add various elements
page.layout.add_element(ImageData(
image_path="img1.jpg",
x=10, y=10,
width=100, height=75,
z_index=0
))
page.layout.add_element(ImageData(image_path="img1.jpg", x=10, y=10, width=100, height=75, z_index=0))
page.layout.add_element(PlaceholderData(
x=120, y=10,
width=80, height=60,
z_index=1
))
page.layout.add_element(PlaceholderData(x=120, y=10, width=80, height=60, z_index=1))
page.layout.add_element(TextBoxData(
x=10, y=100,
width=190, height=40,
text_content="Sample Text",
z_index=2
))
page.layout.add_element(TextBoxData(x=10, y=100, width=190, height=40, text_content="Sample Text", z_index=2))
return page
+24 -28
View File
@@ -72,8 +72,8 @@ class TestAlignmentManager:
def test_align_right_multiple_elements(self):
"""Test align_right with multiple elements"""
elem1 = ImageData(x=50, y=20, width=100, height=50) # right edge at 150
elem2 = ImageData(x=30, y=40, width=80, height=60) # right edge at 110
elem3 = ImageData(x=70, y=60, width=90, height=40) # right edge at 160
elem2 = ImageData(x=30, y=40, width=80, height=60) # right edge at 110
elem3 = ImageData(x=70, y=60, width=90, height=40) # right edge at 160
changes = AlignmentManager.align_right([elem1, elem2, elem3])
@@ -108,8 +108,8 @@ class TestAlignmentManager:
def test_align_bottom_multiple_elements(self):
"""Test align_bottom with multiple elements"""
elem1 = ImageData(x=50, y=30, width=100, height=50) # bottom at 80
elem2 = ImageData(x=30, y=20, width=80, height=60) # bottom at 80
elem3 = ImageData(x=70, y=40, width=90, height=50) # bottom at 90
elem2 = ImageData(x=30, y=20, width=80, height=60) # bottom at 80
elem3 = ImageData(x=70, y=40, width=90, height=50) # bottom at 90
changes = AlignmentManager.align_bottom([elem1, elem2, elem3])
@@ -125,17 +125,17 @@ class TestAlignmentManager:
def test_align_horizontal_center_multiple_elements(self):
"""Test align_horizontal_center with multiple elements"""
elem1 = ImageData(x=50, y=20, width=100, height=50) # center at 100
elem2 = ImageData(x=30, y=40, width=80, height=60) # center at 70
elem3 = ImageData(x=70, y=60, width=60, height=40) # center at 100
elem1 = ImageData(x=50, y=20, width=100, height=50) # center at 100
elem2 = ImageData(x=30, y=40, width=80, height=60) # center at 70
elem3 = ImageData(x=70, y=60, width=60, height=40) # center at 100
changes = AlignmentManager.align_horizontal_center([elem1, elem2, elem3])
# Average center = (100 + 70 + 100) / 3 = 90
# All elements should center at x=90
assert abs(elem1.position[0] + elem1.size[0]/2 - 90) < 0.01
assert abs(elem2.position[0] + elem2.size[0]/2 - 90) < 0.01
assert abs(elem3.position[0] + elem3.size[0]/2 - 90) < 0.01
assert abs(elem1.position[0] + elem1.size[0] / 2 - 90) < 0.01
assert abs(elem2.position[0] + elem2.size[0] / 2 - 90) < 0.01
assert abs(elem3.position[0] + elem3.size[0] / 2 - 90) < 0.01
# Y positions should not change
assert elem1.position[1] == 20
@@ -144,17 +144,17 @@ class TestAlignmentManager:
def test_align_vertical_center_multiple_elements(self):
"""Test align_vertical_center with multiple elements"""
elem1 = ImageData(x=50, y=20, width=100, height=50) # center at 45
elem2 = ImageData(x=30, y=40, width=80, height=60) # center at 70
elem3 = ImageData(x=70, y=30, width=60, height=40) # center at 50
elem1 = ImageData(x=50, y=20, width=100, height=50) # center at 45
elem2 = ImageData(x=30, y=40, width=80, height=60) # center at 70
elem3 = ImageData(x=70, y=30, width=60, height=40) # center at 50
changes = AlignmentManager.align_vertical_center([elem1, elem2, elem3])
# Average center = (45 + 70 + 50) / 3 = 55
# All elements should center at y=55
assert abs(elem1.position[1] + elem1.size[1]/2 - 55) < 0.01
assert abs(elem2.position[1] + elem2.size[1]/2 - 55) < 0.01
assert abs(elem3.position[1] + elem3.size[1]/2 - 55) < 0.01
assert abs(elem1.position[1] + elem1.size[1] / 2 - 55) < 0.01
assert abs(elem2.position[1] + elem2.size[1] / 2 - 55) < 0.01
assert abs(elem3.position[1] + elem3.size[1] / 2 - 55) < 0.01
# X positions should not change
assert elem1.position[0] == 50
@@ -452,7 +452,7 @@ class TestAlignmentManager:
assert len(changes) == 1
assert changes[0][0] == elem
assert changes[0][1] == (100, 80) # old position
assert changes[0][2] == (20, 15) # old size
assert changes[0][2] == (20, 15) # old size
def test_maximize_pattern_two_elements_horizontal(self):
"""Test maximize_pattern with two elements side by side"""
@@ -469,11 +469,11 @@ class TestAlignmentManager:
# Elements should not overlap (min_gap = 2.0)
gap_x = max(
elem2.position[0] - (elem1.position[0] + elem1.size[0]),
elem1.position[0] - (elem2.position[0] + elem2.size[0])
elem1.position[0] - (elem2.position[0] + elem2.size[0]),
)
gap_y = max(
elem2.position[1] - (elem1.position[1] + elem1.size[1]),
elem1.position[1] - (elem2.position[1] + elem2.size[1])
elem1.position[1] - (elem2.position[1] + elem2.size[1]),
)
# Either horizontal or vertical gap should be >= min_gap
@@ -510,11 +510,11 @@ class TestAlignmentManager:
# Calculate gaps between rectangles
gap_x = max(
elem_b.position[0] - (elem_a.position[0] + elem_a.size[0]),
elem_a.position[0] - (elem_b.position[0] + elem_b.size[0])
elem_a.position[0] - (elem_b.position[0] + elem_b.size[0]),
)
gap_y = max(
elem_b.position[1] - (elem_a.position[1] + elem_a.size[1]),
elem_a.position[1] - (elem_b.position[1] + elem_b.size[1])
elem_a.position[1] - (elem_b.position[1] + elem_b.size[1]),
)
# At least one gap should be >= min_gap
@@ -564,11 +564,7 @@ class TestAlignmentManager:
elem4 = ImageData(x=140, y=90, width=10, height=10)
page_size = (160, 110)
changes = AlignmentManager.maximize_pattern(
[elem1, elem2, elem3, elem4],
page_size,
min_gap=2.0
)
changes = AlignmentManager.maximize_pattern([elem1, elem2, elem3, elem4], page_size, min_gap=2.0)
# All elements should grow
for elem in [elem1, elem2, elem3, elem4]:
@@ -584,11 +580,11 @@ class TestAlignmentManager:
gap_x = max(
elem_b.position[0] - (elem_a.position[0] + elem_a.size[0]),
elem_a.position[0] - (elem_b.position[0] + elem_b.size[0])
elem_a.position[0] - (elem_b.position[0] + elem_b.size[0]),
)
gap_y = max(
elem_b.position[1] - (elem_a.position[1] + elem_a.size[1]),
elem_a.position[1] - (elem_b.position[1] + elem_b.size[1])
elem_a.position[1] - (elem_b.position[1] + elem_b.size[1]),
)
assert gap_x >= 2.0 or gap_y >= 2.0
+17 -41
View File
@@ -81,7 +81,7 @@ class TestGetSelectedElementsList:
class TestAlignLeft:
"""Test align_left method"""
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_align_left_success(self, mock_manager, qtbot):
"""Test aligning elements to the left"""
window = TestAlignmentWindow()
@@ -93,10 +93,7 @@ class TestAlignLeft:
window.gl_widget.selected_elements = {element1, element2}
# Mock AlignmentManager to return changes
mock_manager.align_left.return_value = [
(element1, (100, 0)),
(element2, (200, 100))
]
mock_manager.align_left.return_value = [(element1, (100, 0)), (element2, (200, 100))]
window.align_left()
@@ -106,7 +103,7 @@ class TestAlignLeft:
assert "aligned" in window._status_message.lower()
assert "left" in window._status_message.lower()
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_align_left_no_changes(self, mock_manager, qtbot):
"""Test align left when no changes needed"""
window = TestAlignmentWindow()
@@ -143,7 +140,7 @@ class TestAlignLeft:
class TestAlignRight:
"""Test align_right method"""
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_align_right_success(self, mock_manager, qtbot):
"""Test aligning elements to the right"""
window = TestAlignmentWindow()
@@ -154,10 +151,7 @@ class TestAlignRight:
window.gl_widget.selected_elements = {element1, element2}
mock_manager.align_right.return_value = [
(element1, (100, 0)),
(element2, (200, 100))
]
mock_manager.align_right.return_value = [(element1, (100, 0)), (element2, (200, 100))]
window.align_right()
@@ -169,7 +163,7 @@ class TestAlignRight:
class TestAlignTop:
"""Test align_top method"""
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_align_top_success(self, mock_manager, qtbot):
"""Test aligning elements to the top"""
window = TestAlignmentWindow()
@@ -180,10 +174,7 @@ class TestAlignTop:
window.gl_widget.selected_elements = {element1, element2}
mock_manager.align_top.return_value = [
(element1, (0, 50)),
(element2, (100, 100))
]
mock_manager.align_top.return_value = [(element1, (0, 50)), (element2, (100, 100))]
window.align_top()
@@ -195,7 +186,7 @@ class TestAlignTop:
class TestAlignBottom:
"""Test align_bottom method"""
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_align_bottom_success(self, mock_manager, qtbot):
"""Test aligning elements to the bottom"""
window = TestAlignmentWindow()
@@ -206,10 +197,7 @@ class TestAlignBottom:
window.gl_widget.selected_elements = {element1, element2}
mock_manager.align_bottom.return_value = [
(element1, (0, 50)),
(element2, (100, 100))
]
mock_manager.align_bottom.return_value = [(element1, (0, 50)), (element2, (100, 100))]
window.align_bottom()
@@ -221,7 +209,7 @@ class TestAlignBottom:
class TestAlignHorizontalCenter:
"""Test align_horizontal_center method"""
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_align_horizontal_center_success(self, mock_manager, qtbot):
"""Test aligning elements to horizontal center"""
window = TestAlignmentWindow()
@@ -232,10 +220,7 @@ class TestAlignHorizontalCenter:
window.gl_widget.selected_elements = {element1, element2}
mock_manager.align_horizontal_center.return_value = [
(element1, (0, 0)),
(element2, (200, 100))
]
mock_manager.align_horizontal_center.return_value = [(element1, (0, 0)), (element2, (200, 100))]
window.align_horizontal_center()
@@ -247,7 +232,7 @@ class TestAlignHorizontalCenter:
class TestAlignVerticalCenter:
"""Test align_vertical_center method"""
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_align_vertical_center_success(self, mock_manager, qtbot):
"""Test aligning elements to vertical center"""
window = TestAlignmentWindow()
@@ -258,10 +243,7 @@ class TestAlignVerticalCenter:
window.gl_widget.selected_elements = {element1, element2}
mock_manager.align_vertical_center.return_value = [
(element1, (0, 0)),
(element2, (100, 200))
]
mock_manager.align_vertical_center.return_value = [(element1, (0, 0)), (element2, (100, 200))]
window.align_vertical_center()
@@ -273,7 +255,7 @@ class TestAlignVerticalCenter:
class TestAlignmentCommandPattern:
"""Test alignment operations with command pattern for undo/redo"""
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_alignment_creates_command(self, mock_manager, qtbot):
"""Test that alignment creates a command for undo"""
window = TestAlignmentWindow()
@@ -284,10 +266,7 @@ class TestAlignmentCommandPattern:
window.gl_widget.selected_elements = {element1, element2}
mock_manager.align_left.return_value = [
(element1, (100, 0)),
(element2, (200, 100))
]
mock_manager.align_left.return_value = [(element1, (100, 0)), (element2, (200, 100))]
# Should have no commands initially
assert not window.project.history.can_undo()
@@ -297,7 +276,7 @@ class TestAlignmentCommandPattern:
# Should have created a command
assert window.project.history.can_undo()
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_alignment_undo_redo(self, mock_manager, qtbot):
"""Test that alignment can be undone and redone"""
window = TestAlignmentWindow()
@@ -309,10 +288,7 @@ class TestAlignmentCommandPattern:
window.gl_widget.selected_elements = {element1, element2}
# Mock alignment to return changes (command will handle actual moves)
mock_manager.align_top.return_value = [
(element1, (100, 0)),
(element2, (200, 100))
]
mock_manager.align_top.return_value = [(element1, (100, 0)), (element2, (200, 100))]
# Execute alignment - command created
window.align_top()
+25 -31
View File
@@ -28,8 +28,8 @@ class TestAssetDropWidget(AssetDropMixin, AssetPathMixin, PageNavigationMixin, V
def _get_project_folder(self):
"""Override to access project via window mock"""
main_window = self.window()
if hasattr(main_window, 'project') and main_window.project:
return getattr(main_window.project, 'folder_path', None)
if hasattr(main_window, "project") and main_window.project:
return getattr(main_window.project, "folder_path", None)
return None
@@ -43,7 +43,7 @@ class TestAssetDropInitialization:
# Should accept drops (set in GLWidget.__init__)
# This is a property of the widget, not the mixin
assert hasattr(widget, 'acceptDrops')
assert hasattr(widget, "acceptDrops")
class TestDragEnterEvent:
@@ -141,7 +141,7 @@ class TestDragMoveEvent:
class TestDropEvent:
"""Test dropEvent method"""
@patch('pyPhotoAlbum.mixins.asset_drop.AddElementCommand')
@patch("pyPhotoAlbum.mixins.asset_drop.AddElementCommand")
def test_drop_creates_image_element(self, mock_cmd_class, qtbot):
"""Test dropping image file creates ImageData element"""
widget = TestAssetDropWidget()
@@ -239,7 +239,7 @@ class TestDropEvent:
# Create a real test image file
test_image = tmp_path / "test_image.jpg"
test_image.write_bytes(b'\xFF\xD8\xFF\xE0' + b'\x00' * 100) # Minimal JPEG header
test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100) # Minimal JPEG header
# Setup project with page containing placeholder
mock_window = Mock()
@@ -248,6 +248,7 @@ class TestDropEvent:
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
from pyPhotoAlbum.models import PlaceholderData
placeholder = PlaceholderData(x=100, y=100, width=200, height=150)
page.layout.elements.append(placeholder)
@@ -280,7 +281,7 @@ class TestDropEvent:
# Image path should now be in assets folder (imported)
assert page.layout.elements[0].image_path.startswith("assets/")
@patch('pyPhotoAlbum.mixins.asset_drop.AddElementCommand')
@patch("pyPhotoAlbum.mixins.asset_drop.AddElementCommand")
def test_drop_multiple_files(self, mock_cmd_class, qtbot):
"""Test dropping first image from multiple files"""
widget = TestAssetDropWidget()
@@ -311,11 +312,13 @@ class TestDropEvent:
# Create drop event with multiple files (only first is used)
mime_data = QMimeData()
mime_data.setUrls([
QUrl.fromLocalFile("/path/to/image1.jpg"),
QUrl.fromLocalFile("/path/to/image2.png"),
QUrl.fromLocalFile("/path/to/image3.jpg")
])
mime_data.setUrls(
[
QUrl.fromLocalFile("/path/to/image1.jpg"),
QUrl.fromLocalFile("/path/to/image2.png"),
QUrl.fromLocalFile("/path/to/image3.jpg"),
]
)
event = Mock()
event.mimeData = Mock(return_value=mime_data)
@@ -364,7 +367,7 @@ class TestDropEvent:
# Create a real test image file
test_image = tmp_path / "new_image.jpg"
test_image.write_bytes(b'\xFF\xD8\xFF\xE0' + b'\x00' * 100)
test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
# Setup project with page containing existing ImageData
mock_window = Mock()
@@ -372,10 +375,7 @@ class TestDropEvent:
mock_window.project.working_dpi = 96
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
existing_image = ImageData(
image_path="assets/old_image.jpg",
x=100, y=100, width=200, height=150
)
existing_image = ImageData(image_path="assets/old_image.jpg", x=100, y=100, width=200, height=150)
page.layout.elements.append(existing_image)
mock_window.project.pages = [page]
@@ -407,24 +407,19 @@ class TestDropEvent:
widget.update = Mock()
test_image = tmp_path / "test.jpg"
test_image.write_bytes(b'\xFF\xD8\xFF\xE0' + b'\x00' * 100)
test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
mock_window = Mock()
mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
existing_image = ImageData(
image_path="assets/old.jpg",
x=100, y=100, width=200, height=150
)
existing_image = ImageData(image_path="assets/old.jpg", x=100, y=100, width=200, height=150)
page.layout.elements.append(existing_image)
mock_window.project.pages = [page]
# Mock asset manager to raise exception
mock_window.project.asset_manager = Mock()
mock_window.project.asset_manager.import_asset = Mock(
side_effect=Exception("Import failed")
)
mock_window.project.asset_manager.import_asset = Mock(side_effect=Exception("Import failed"))
widget.window = Mock(return_value=mock_window)
widget._get_element_at = Mock(return_value=existing_image)
@@ -454,7 +449,7 @@ class TestDropEvent:
# Create a corrupted/invalid image file
corrupted_image = tmp_path / "corrupted.jpg"
corrupted_image.write_bytes(b'not a valid image')
corrupted_image.write_bytes(b"not a valid image")
mock_window = Mock()
mock_window.project = Project(name="Test")
@@ -488,7 +483,8 @@ class TestDropEvent:
# Should use default dimensions (200, 150) from _calculate_image_dimensions
# Check that AddElementCommand was called with an ImageData
from pyPhotoAlbum.commands import AddElementCommand
with patch('pyPhotoAlbum.mixins.asset_drop.AddElementCommand') as mock_cmd:
with patch("pyPhotoAlbum.mixins.asset_drop.AddElementCommand") as mock_cmd:
# Re-run to check the call
widget.dropEvent(event)
assert mock_cmd.called
@@ -527,10 +523,7 @@ class TestExtractImagePathEdgeCases:
widget.update = Mock()
mime_data = QMimeData()
mime_data.setUrls([
QUrl.fromLocalFile("/path/to/document.pdf"),
QUrl.fromLocalFile("/path/to/file.txt")
])
mime_data.setUrls([QUrl.fromLocalFile("/path/to/document.pdf"), QUrl.fromLocalFile("/path/to/file.txt")])
event = Mock()
event.mimeData = Mock(return_value=mime_data)
@@ -576,7 +569,7 @@ class TestPlaceholderReplacementEdgeCases:
widget.update = Mock()
test_image = tmp_path / "test.jpg"
test_image.write_bytes(b'\xFF\xD8\xFF\xE0' + b'\x00' * 100)
test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
# Setup project WITHOUT pages
mock_window = Mock()
@@ -585,6 +578,7 @@ class TestPlaceholderReplacementEdgeCases:
mock_window.project.pages = [] # Empty pages list
from pyPhotoAlbum.models import PlaceholderData
placeholder = PlaceholderData(x=100, y=100, width=200, height=150)
mock_window.project.asset_manager = Mock()
+596
View File
@@ -0,0 +1,596 @@
"""
Tests for asset_heal_dialog module
"""
import pytest
import os
import shutil
from pathlib import Path
from unittest.mock import Mock, MagicMock, patch, call
from PyQt6.QtWidgets import QMessageBox, QFileDialog
from PyQt6.QtCore import Qt
class TestAssetHealDialog:
"""Tests for AssetHealDialog class"""
@pytest.fixture
def mock_project(self, tmp_path):
"""Create a mock project with folder_path"""
project = Mock()
project.folder_path = str(tmp_path / "project")
os.makedirs(project.folder_path, exist_ok=True)
# Create assets folder
assets_path = os.path.join(project.folder_path, "assets")
os.makedirs(assets_path, exist_ok=True)
project.asset_manager = Mock()
project.pages = []
return project
@pytest.fixture
def mock_page_with_image(self):
"""Create a mock page with an image element"""
from pyPhotoAlbum.models import ImageData
page = Mock()
element = Mock(spec=ImageData)
element.image_path = "assets/image.jpg"
page.layout = Mock()
page.layout.elements = [element]
return page
def test_init(self, qtbot, mock_project):
"""Test AssetHealDialog initialization"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
with patch.object(AssetHealDialog, '_scan_missing_assets'):
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
assert dialog.project is mock_project
assert dialog.search_paths == []
assert dialog.missing_assets == set()
assert dialog.windowTitle() == "Heal Missing Assets"
def test_init_ui(self, qtbot, mock_project):
"""Test UI initialization"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
with patch.object(AssetHealDialog, '_scan_missing_assets'):
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
# Check that UI elements exist
assert dialog.missing_list is not None
assert dialog.search_list is not None
def test_scan_missing_assets_no_missing(self, qtbot, mock_project, tmp_path):
"""Test scanning when no assets are missing"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
from pyPhotoAlbum.models import ImageData
# Create a valid image in assets folder
img_path = os.path.join(mock_project.folder_path, "assets", "image.jpg")
Path(img_path).touch()
element = Mock(spec=ImageData)
element.image_path = "assets/image.jpg"
page = Mock()
page.layout = Mock()
page.layout.elements = [element]
mock_project.pages = [page]
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
assert len(dialog.missing_assets) == 0
assert dialog.missing_list.count() == 1 # "No missing assets found!" message
item = dialog.missing_list.item(0)
assert "No missing assets" in item.text()
def test_scan_missing_assets_absolute_path(self, qtbot, mock_project):
"""Test scanning detects absolute paths as needing healing"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
from pyPhotoAlbum.models import ImageData
element = Mock(spec=ImageData)
element.image_path = "/absolute/path/to/image.jpg"
page = Mock()
page.layout = Mock()
page.layout.elements = [element]
mock_project.pages = [page]
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
assert "/absolute/path/to/image.jpg" in dialog.missing_assets
def test_scan_missing_assets_not_in_assets_folder(self, qtbot, mock_project):
"""Test scanning detects paths not in assets/ folder"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
from pyPhotoAlbum.models import ImageData
element = Mock(spec=ImageData)
element.image_path = "images/photo.jpg" # Not in assets/
page = Mock()
page.layout = Mock()
page.layout.elements = [element]
mock_project.pages = [page]
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
assert "images/photo.jpg" in dialog.missing_assets
def test_scan_missing_assets_file_missing(self, qtbot, mock_project):
"""Test scanning detects missing files in assets/ folder"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
from pyPhotoAlbum.models import ImageData
element = Mock(spec=ImageData)
element.image_path = "assets/missing.jpg" # File doesn't exist
page = Mock()
page.layout = Mock()
page.layout.elements = [element]
mock_project.pages = [page]
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
assert "assets/missing.jpg" in dialog.missing_assets
def test_scan_missing_assets_non_image_elements_ignored(self, qtbot, mock_project):
"""Test that non-ImageData elements are ignored"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
# TextBox element (not ImageData)
element = Mock()
element.image_path = None
page = Mock()
page.layout = Mock()
page.layout.elements = [element]
mock_project.pages = [page]
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
assert len(dialog.missing_assets) == 0
def test_scan_missing_assets_empty_image_path(self, qtbot, mock_project):
"""Test that elements with empty image_path are ignored"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
from pyPhotoAlbum.models import ImageData
element = Mock(spec=ImageData)
element.image_path = ""
page = Mock()
page.layout = Mock()
page.layout.elements = [element]
mock_project.pages = [page]
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
assert len(dialog.missing_assets) == 0
def test_add_search_path(self, qtbot, mock_project, tmp_path):
"""Test adding a search path"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
with patch.object(AssetHealDialog, '_scan_missing_assets'):
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
search_path = str(tmp_path / "search")
os.makedirs(search_path, exist_ok=True)
with patch.object(QFileDialog, 'getExistingDirectory', return_value=search_path):
dialog._add_search_path()
assert search_path in dialog.search_paths
assert dialog.search_list.count() == 1
def test_add_search_path_duplicate(self, qtbot, mock_project, tmp_path):
"""Test adding duplicate search path is ignored"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
with patch.object(AssetHealDialog, '_scan_missing_assets'):
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
search_path = str(tmp_path / "search")
os.makedirs(search_path, exist_ok=True)
with patch.object(QFileDialog, 'getExistingDirectory', return_value=search_path):
dialog._add_search_path()
dialog._add_search_path()
assert dialog.search_paths.count(search_path) == 1
assert dialog.search_list.count() == 1
def test_add_search_path_cancelled(self, qtbot, mock_project):
"""Test cancelling search path dialog"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
with patch.object(AssetHealDialog, '_scan_missing_assets'):
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
with patch.object(QFileDialog, 'getExistingDirectory', return_value=""):
dialog._add_search_path()
assert len(dialog.search_paths) == 0
def test_remove_search_path(self, qtbot, mock_project, tmp_path):
"""Test removing a search path"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
with patch.object(AssetHealDialog, '_scan_missing_assets'):
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
search_path = str(tmp_path / "search")
dialog.search_paths.append(search_path)
dialog.search_list.addItem(search_path)
dialog.search_list.setCurrentRow(0)
dialog._remove_search_path()
assert len(dialog.search_paths) == 0
assert dialog.search_list.count() == 0
def test_remove_search_path_none_selected(self, qtbot, mock_project):
"""Test removing when no path is selected"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
with patch.object(AssetHealDialog, '_scan_missing_assets'):
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
dialog.search_paths.append("/some/path")
dialog.search_list.addItem("/some/path")
dialog.search_list.setCurrentRow(-1) # No selection
dialog._remove_search_path()
# Should not remove anything
assert len(dialog.search_paths) == 1
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
def test_attempt_healing_no_missing_assets(self, mock_set_context, qtbot, mock_project):
"""Test healing when there are no missing assets"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
mock_project.pages = []
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
with patch.object(QMessageBox, 'information') as mock_info:
dialog._attempt_healing()
mock_info.assert_called_once()
args = mock_info.call_args[0]
assert "Assets found: 0" in args[2]
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
def test_attempt_healing_resolve_relative_path(self, mock_set_context, qtbot, mock_project, tmp_path):
"""Test healing by resolving relative path"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
from pyPhotoAlbum.models import ImageData
# Create the actual image file outside project
external_img = tmp_path / "external" / "image.jpg"
external_img.parent.mkdir(exist_ok=True)
external_img.touch()
# Element with relative path that resolves to external image
element = Mock(spec=ImageData)
rel_path = os.path.relpath(str(external_img), mock_project.folder_path)
element.image_path = rel_path
page = Mock()
page.layout = Mock()
page.layout.elements = [element]
mock_project.pages = [page]
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
# Mock the import_asset method
mock_project.asset_manager.import_asset.return_value = "assets/image.jpg"
with patch.object(QMessageBox, 'information') as mock_info:
dialog._attempt_healing()
# Should have imported the asset
mock_project.asset_manager.import_asset.assert_called()
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
def test_attempt_healing_absolute_path_exists(self, mock_set_context, qtbot, mock_project, tmp_path):
"""Test healing absolute path that exists"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
from pyPhotoAlbum.models import ImageData
# Create image at absolute path
abs_img = tmp_path / "image.jpg"
abs_img.touch()
element = Mock(spec=ImageData)
element.image_path = str(abs_img)
page = Mock()
page.layout = Mock()
page.layout.elements = [element]
mock_project.pages = [page]
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
mock_project.asset_manager.import_asset.return_value = "assets/image.jpg"
with patch.object(QMessageBox, 'information') as mock_info:
dialog._attempt_healing()
# Should import the asset
mock_project.asset_manager.import_asset.assert_called_with(str(abs_img))
# Should update element path
assert element.image_path == "assets/image.jpg"
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
def test_attempt_healing_search_path_by_filename(self, mock_set_context, qtbot, mock_project, tmp_path):
"""Test healing by finding file in search path by filename"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
from pyPhotoAlbum.models import ImageData
# Create search path with image
search_dir = tmp_path / "search"
search_dir.mkdir()
found_img = search_dir / "photo.jpg"
found_img.touch()
element = Mock(spec=ImageData)
element.image_path = "/missing/path/photo.jpg"
page = Mock()
page.layout = Mock()
page.layout.elements = [element]
mock_project.pages = [page]
with patch.object(AssetHealDialog, '_scan_missing_assets'):
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
dialog.missing_assets.add("/missing/path/photo.jpg")
dialog.search_paths.append(str(search_dir))
mock_project.asset_manager.import_asset.return_value = "assets/photo.jpg"
with patch.object(QMessageBox, 'information'):
dialog._attempt_healing()
mock_project.asset_manager.import_asset.assert_called_with(str(found_img))
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
def test_attempt_healing_search_path_by_relative_structure(self, mock_set_context, qtbot, mock_project, tmp_path):
"""Test healing by finding file in search path with same relative structure"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
from pyPhotoAlbum.models import ImageData
# Create search path with subdirectory structure
search_dir = tmp_path / "search"
subdir = search_dir / "photos"
subdir.mkdir(parents=True)
found_img = subdir / "image.jpg"
found_img.touch()
element = Mock(spec=ImageData)
element.image_path = "photos/image.jpg"
page = Mock()
page.layout = Mock()
page.layout.elements = [element]
mock_project.pages = [page]
with patch.object(AssetHealDialog, '_scan_missing_assets'):
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
dialog.missing_assets.add("photos/image.jpg")
dialog.search_paths.append(str(search_dir))
mock_project.asset_manager.import_asset.return_value = "assets/image.jpg"
with patch.object(QMessageBox, 'information'):
dialog._attempt_healing()
mock_project.asset_manager.import_asset.assert_called_with(str(found_img))
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
def test_attempt_healing_restore_to_assets_folder(self, mock_set_context, qtbot, mock_project, tmp_path):
"""Test healing by restoring file to assets folder"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
from pyPhotoAlbum.models import ImageData
# Create source image
source_img = tmp_path / "source" / "image.jpg"
source_img.parent.mkdir()
source_img.touch()
element = Mock(spec=ImageData)
element.image_path = "assets/image.jpg" # Already correct path, just missing file
page = Mock()
page.layout = Mock()
page.layout.elements = [element]
mock_project.pages = [page]
with patch.object(AssetHealDialog, '_scan_missing_assets'):
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
dialog.missing_assets.add("assets/image.jpg")
dialog.search_paths.append(str(source_img.parent))
with patch('shutil.copy2') as mock_copy:
with patch.object(QMessageBox, 'information'):
dialog._attempt_healing()
# Should copy file, not import
mock_copy.assert_called_once()
assert not mock_project.asset_manager.import_asset.called
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
def test_attempt_healing_not_found(self, mock_set_context, qtbot, mock_project):
"""Test healing when asset cannot be found"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
from pyPhotoAlbum.models import ImageData
element = Mock(spec=ImageData)
element.image_path = "/missing/image.jpg"
page = Mock()
page.layout = Mock()
page.layout.elements = [element]
mock_project.pages = [page]
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
with patch.object(QMessageBox, 'information') as mock_info:
dialog._attempt_healing()
args = mock_info.call_args[0]
assert "Still missing: 1" in args[2]
assert "/missing/image.jpg" in args[2]
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
def test_attempt_healing_import_error(self, mock_set_context, qtbot, mock_project, tmp_path):
"""Test healing when import_asset raises an error"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
from pyPhotoAlbum.models import ImageData
# Create image
img = tmp_path / "image.jpg"
img.touch()
element = Mock(spec=ImageData)
element.image_path = str(img)
page = Mock()
page.layout = Mock()
page.layout.elements = [element]
mock_project.pages = [page]
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
# Mock import to raise error
mock_project.asset_manager.import_asset.side_effect = Exception("Import failed")
with patch.object(QMessageBox, 'information') as mock_info:
dialog._attempt_healing()
# Should be in still_missing list
args = mock_info.call_args[0]
assert "Still missing: 1" in args[2]
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
def test_attempt_healing_many_missing(self, mock_set_context, qtbot, mock_project):
"""Test healing with more than 10 missing assets (tests truncation)"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
from pyPhotoAlbum.models import ImageData
pages = []
for i in range(15):
element = Mock(spec=ImageData)
element.image_path = f"/missing/image{i}.jpg"
page = Mock()
page.layout = Mock()
page.layout.elements = [element]
pages.append(page)
mock_project.pages = pages
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
with patch.object(QMessageBox, 'information') as mock_info:
dialog._attempt_healing()
args = mock_info.call_args[0]
# Should show "... and X more"
assert "and 5 more" in args[2]
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
def test_attempt_healing_multiple_elements_same_path(self, mock_set_context, qtbot, mock_project, tmp_path):
"""Test healing when multiple elements reference the same missing path"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
from pyPhotoAlbum.models import ImageData
# Create image
img = tmp_path / "image.jpg"
img.touch()
# Two elements with same path
element1 = Mock(spec=ImageData)
element1.image_path = str(img)
element2 = Mock(spec=ImageData)
element2.image_path = str(img)
page = Mock()
page.layout = Mock()
page.layout.elements = [element1, element2]
mock_project.pages = [page]
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
mock_project.asset_manager.import_asset.return_value = "assets/image.jpg"
with patch.object(QMessageBox, 'information'):
dialog._attempt_healing()
# Both elements should be updated
assert element1.image_path == "assets/image.jpg"
assert element2.image_path == "assets/image.jpg"
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
def test_attempt_healing_rescans_after(self, mock_set_context, qtbot, mock_project):
"""Test that _scan_missing_assets is called after healing"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
mock_project.pages = []
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
with patch.object(dialog, '_scan_missing_assets') as mock_scan:
with patch.object(QMessageBox, 'information'):
dialog._attempt_healing()
# Should rescan after healing
mock_scan.assert_called_once()
+469
View File
@@ -0,0 +1,469 @@
"""
Tests for AssetManager functionality including deduplication and unused asset detection
"""
import os
import pytest
import tempfile
import shutil
from PIL import Image
from pyPhotoAlbum.asset_manager import AssetManager, compute_file_md5
class TestComputeFileMd5:
"""Tests for the compute_file_md5 function"""
def test_compute_md5_existing_file(self, tmp_path):
"""Test MD5 computation for an existing file"""
# Create a test file
test_file = tmp_path / "test.txt"
test_file.write_text("Hello, World!")
md5_hash = compute_file_md5(str(test_file))
assert md5_hash is not None
# Known MD5 for "Hello, World!"
assert md5_hash == "65a8e27d8879283831b664bd8b7f0ad4"
def test_compute_md5_nonexistent_file(self):
"""Test MD5 computation returns None for non-existent file"""
md5_hash = compute_file_md5("/nonexistent/path/file.txt")
assert md5_hash is None
def test_compute_md5_same_content_same_hash(self, tmp_path):
"""Test that identical content produces identical hashes"""
content = b"Test content for hashing"
file1 = tmp_path / "file1.bin"
file2 = tmp_path / "file2.bin"
file1.write_bytes(content)
file2.write_bytes(content)
hash1 = compute_file_md5(str(file1))
hash2 = compute_file_md5(str(file2))
assert hash1 == hash2
def test_compute_md5_different_content_different_hash(self, tmp_path):
"""Test that different content produces different hashes"""
file1 = tmp_path / "file1.txt"
file2 = tmp_path / "file2.txt"
file1.write_text("Content A")
file2.write_text("Content B")
hash1 = compute_file_md5(str(file1))
hash2 = compute_file_md5(str(file2))
assert hash1 != hash2
class TestAssetManagerDeduplication:
"""Tests for AssetManager deduplication methods"""
@pytest.fixture
def asset_manager(self, tmp_path):
"""Create an AssetManager with a temporary project folder"""
project_folder = str(tmp_path / "test_project")
os.makedirs(project_folder)
return AssetManager(project_folder)
@pytest.fixture
def create_test_image(self):
"""Factory fixture for creating test images"""
def _create(path, color="red", size=(100, 100)):
img = Image.new("RGB", size, color=color)
img.save(path)
return path
return _create
def test_compute_all_hashes_empty_folder(self, asset_manager):
"""Test hash computation on empty assets folder"""
hashes = asset_manager.compute_all_hashes()
assert len(hashes) == 0
def test_compute_all_hashes_with_files(self, asset_manager, create_test_image):
"""Test hash computation with files in assets folder"""
# Create some test images
img1 = os.path.join(asset_manager.assets_folder, "image1.png")
img2 = os.path.join(asset_manager.assets_folder, "image2.png")
create_test_image(img1, color="red")
create_test_image(img2, color="blue")
hashes = asset_manager.compute_all_hashes()
assert len(hashes) == 2
assert "assets/image1.png" in hashes
assert "assets/image2.png" in hashes
def test_find_duplicates_no_duplicates(self, asset_manager, create_test_image):
"""Test finding duplicates when there are none"""
img1 = os.path.join(asset_manager.assets_folder, "image1.png")
img2 = os.path.join(asset_manager.assets_folder, "image2.png")
create_test_image(img1, color="red")
create_test_image(img2, color="blue")
duplicates = asset_manager.find_duplicates()
assert len(duplicates) == 0
def test_find_duplicates_with_duplicates(self, asset_manager, tmp_path):
"""Test finding actual duplicate files"""
# Create a source image
source_img = tmp_path / "source.png"
img = Image.new("RGB", (50, 50), color="green")
img.save(str(source_img))
# Copy the same image twice to assets folder
dup1 = os.path.join(asset_manager.assets_folder, "dup1.png")
dup2 = os.path.join(asset_manager.assets_folder, "dup2.png")
shutil.copy(str(source_img), dup1)
shutil.copy(str(source_img), dup2)
duplicates = asset_manager.find_duplicates()
assert len(duplicates) == 1 # One group of duplicates
# The group should contain both files
for paths in duplicates.values():
assert len(paths) == 2
assert "assets/dup1.png" in paths
assert "assets/dup2.png" in paths
def test_get_duplicate_stats_no_duplicates(self, asset_manager, create_test_image):
"""Test duplicate stats when there are no duplicates"""
img1 = os.path.join(asset_manager.assets_folder, "image1.png")
create_test_image(img1, color="red")
groups, files, bytes_to_save = asset_manager.get_duplicate_stats()
assert groups == 0
assert files == 0
assert bytes_to_save == 0
def test_get_duplicate_stats_with_duplicates(self, asset_manager, tmp_path):
"""Test duplicate stats with actual duplicates"""
# Create a source image
source_img = tmp_path / "source.png"
img = Image.new("RGB", (100, 100), color="purple")
img.save(str(source_img))
# Copy to assets folder 3 times (creates 2 duplicates)
for i in range(3):
dest = os.path.join(asset_manager.assets_folder, f"image{i}.png")
shutil.copy(str(source_img), dest)
groups, files, bytes_to_save = asset_manager.get_duplicate_stats()
assert groups == 1 # One group
assert files == 2 # Two extra copies to remove
assert bytes_to_save > 0
def test_deduplicate_assets_removes_files(self, asset_manager, tmp_path):
"""Test that deduplication actually removes duplicate files"""
# Create a source image
source_img = tmp_path / "source.png"
img = Image.new("RGB", (50, 50), color="yellow")
img.save(str(source_img))
# Copy to assets folder 3 times
for i in range(3):
dest = os.path.join(asset_manager.assets_folder, f"image{i}.png")
shutil.copy(str(source_img), dest)
asset_manager.reference_counts[f"assets/image{i}.png"] = 1
# Count files before
files_before = len(os.listdir(asset_manager.assets_folder))
assert files_before == 3
# Run deduplication
files_removed, bytes_saved = asset_manager.deduplicate_assets()
# Check results
assert files_removed == 2
assert bytes_saved > 0
# Count files after
files_after = len(os.listdir(asset_manager.assets_folder))
assert files_after == 1
def test_deduplicate_assets_updates_callback(self, asset_manager, tmp_path):
"""Test that deduplication calls the update callback correctly"""
# Create a source image
source_img = tmp_path / "source.png"
img = Image.new("RGB", (50, 50), color="cyan")
img.save(str(source_img))
# Copy to assets folder
dest1 = os.path.join(asset_manager.assets_folder, "a_first.png")
dest2 = os.path.join(asset_manager.assets_folder, "b_second.png")
shutil.copy(str(source_img), dest1)
shutil.copy(str(source_img), dest2)
# Track callback invocations
callback_calls = []
def track_callback(old_path, new_path):
callback_calls.append((old_path, new_path))
# Run deduplication
asset_manager.deduplicate_assets(update_references_callback=track_callback)
# Callback should have been called for the duplicate
assert len(callback_calls) == 1
# b_second.png should be remapped to a_first.png (alphabetical order)
assert callback_calls[0] == ("assets/b_second.png", "assets/a_first.png")
def test_deduplicate_assets_transfers_reference_counts(self, asset_manager, tmp_path):
"""Test that reference counts are properly transferred during deduplication"""
# Create a source image
source_img = tmp_path / "source.png"
img = Image.new("RGB", (50, 50), color="magenta")
img.save(str(source_img))
# Copy to assets folder
dest1 = os.path.join(asset_manager.assets_folder, "a_first.png")
dest2 = os.path.join(asset_manager.assets_folder, "b_second.png")
shutil.copy(str(source_img), dest1)
shutil.copy(str(source_img), dest2)
# Set reference counts
asset_manager.reference_counts["assets/a_first.png"] = 2
asset_manager.reference_counts["assets/b_second.png"] = 3
# Run deduplication
asset_manager.deduplicate_assets()
# Check reference counts were merged
assert asset_manager.reference_counts.get("assets/a_first.png") == 5
assert "assets/b_second.png" not in asset_manager.reference_counts
def test_serialize_includes_hashes(self, asset_manager, create_test_image):
"""Test that serialization includes asset hashes"""
img1 = os.path.join(asset_manager.assets_folder, "image1.png")
create_test_image(img1, color="red")
asset_manager.compute_all_hashes()
data = asset_manager.serialize()
assert "asset_hashes" in data
assert "assets/image1.png" in data["asset_hashes"]
def test_deserialize_restores_hashes(self, asset_manager):
"""Test that deserialization restores asset hashes"""
test_data = {
"reference_counts": {"assets/test.png": 1},
"asset_hashes": {"assets/test.png": "abc123hash"}
}
asset_manager.deserialize(test_data)
assert asset_manager.asset_hashes.get("assets/test.png") == "abc123hash"
def test_compute_asset_hash_single_file(self, asset_manager, create_test_image):
"""Test computing hash for a single asset"""
img_path = os.path.join(asset_manager.assets_folder, "single.png")
create_test_image(img_path, color="orange")
hash_result = asset_manager.compute_asset_hash("assets/single.png")
assert hash_result is not None
assert "assets/single.png" in asset_manager.asset_hashes
assert asset_manager.asset_hashes["assets/single.png"] == hash_result
class TestAssetManagerIntegration:
"""Integration tests for AssetManager with import and deduplication"""
@pytest.fixture
def asset_manager(self, tmp_path):
"""Create an AssetManager with a temporary project folder"""
project_folder = str(tmp_path / "test_project")
os.makedirs(project_folder)
return AssetManager(project_folder)
def test_import_then_deduplicate(self, asset_manager, tmp_path):
"""Test importing duplicate images and then deduplicating"""
# Create a source image
source_img = tmp_path / "source.png"
img = Image.new("RGB", (80, 80), color="navy")
img.save(str(source_img))
# Import the same image twice
path1 = asset_manager.import_asset(str(source_img))
path2 = asset_manager.import_asset(str(source_img))
assert path1 != path2 # Should have different names due to collision handling
# Check both files exist
assert os.path.exists(asset_manager.get_absolute_path(path1))
assert os.path.exists(asset_manager.get_absolute_path(path2))
# Find duplicates
duplicates = asset_manager.find_duplicates()
assert len(duplicates) == 1
# Deduplicate
files_removed, _ = asset_manager.deduplicate_assets()
assert files_removed == 1
# Only one file should remain
files_in_assets = os.listdir(asset_manager.assets_folder)
assert len(files_in_assets) == 1
class TestAssetManagerUnused:
"""Tests for AssetManager unused asset detection and removal"""
@pytest.fixture
def asset_manager(self, tmp_path):
"""Create an AssetManager with a temporary project folder"""
project_folder = str(tmp_path / "test_project")
os.makedirs(project_folder)
return AssetManager(project_folder)
@pytest.fixture
def create_test_image(self):
"""Factory fixture for creating test images"""
def _create(path, color="red", size=(100, 100)):
img = Image.new("RGB", size, color=color)
img.save(path)
return path
return _create
def test_find_unused_assets_empty_folder(self, asset_manager):
"""Test finding unused assets in empty folder"""
unused = asset_manager.find_unused_assets()
assert len(unused) == 0
def test_find_unused_assets_all_referenced(self, asset_manager, create_test_image):
"""Test finding unused assets when all are referenced"""
img1 = os.path.join(asset_manager.assets_folder, "image1.png")
img2 = os.path.join(asset_manager.assets_folder, "image2.png")
create_test_image(img1, color="red")
create_test_image(img2, color="blue")
# Add references for both
asset_manager.reference_counts["assets/image1.png"] = 1
asset_manager.reference_counts["assets/image2.png"] = 2
unused = asset_manager.find_unused_assets()
assert len(unused) == 0
def test_find_unused_assets_some_unreferenced(self, asset_manager, create_test_image):
"""Test finding unused assets when some have no references"""
img1 = os.path.join(asset_manager.assets_folder, "used.png")
img2 = os.path.join(asset_manager.assets_folder, "unused.png")
create_test_image(img1, color="red")
create_test_image(img2, color="blue")
# Only reference one
asset_manager.reference_counts["assets/used.png"] = 1
unused = asset_manager.find_unused_assets()
assert len(unused) == 1
assert "assets/unused.png" in unused
def test_find_unused_assets_zero_reference_count(self, asset_manager, create_test_image):
"""Test that zero reference count is considered unused"""
img = os.path.join(asset_manager.assets_folder, "orphan.png")
create_test_image(img, color="red")
# Set reference count to 0
asset_manager.reference_counts["assets/orphan.png"] = 0
unused = asset_manager.find_unused_assets()
assert len(unused) == 1
assert "assets/orphan.png" in unused
def test_get_unused_stats_no_unused(self, asset_manager, create_test_image):
"""Test unused stats when all assets are referenced"""
img = os.path.join(asset_manager.assets_folder, "image.png")
create_test_image(img, color="red")
asset_manager.reference_counts["assets/image.png"] = 1
count, total_bytes = asset_manager.get_unused_stats()
assert count == 0
assert total_bytes == 0
def test_get_unused_stats_with_unused(self, asset_manager, create_test_image):
"""Test unused stats with unreferenced files"""
img1 = os.path.join(asset_manager.assets_folder, "unused1.png")
img2 = os.path.join(asset_manager.assets_folder, "unused2.png")
create_test_image(img1, color="red")
create_test_image(img2, color="blue")
# No references for either file
count, total_bytes = asset_manager.get_unused_stats()
assert count == 2
assert total_bytes > 0
def test_remove_unused_assets_removes_files(self, asset_manager, create_test_image):
"""Test that unused assets are actually removed"""
used_path = os.path.join(asset_manager.assets_folder, "used.png")
unused_path = os.path.join(asset_manager.assets_folder, "unused.png")
create_test_image(used_path, color="red")
create_test_image(unused_path, color="blue")
# Only reference the used file
asset_manager.reference_counts["assets/used.png"] = 1
# Remove unused
files_removed, bytes_freed = asset_manager.remove_unused_assets()
assert files_removed == 1
assert bytes_freed > 0
# Check files on disk
assert os.path.exists(used_path)
assert not os.path.exists(unused_path)
def test_remove_unused_assets_no_unused(self, asset_manager, create_test_image):
"""Test removing unused when all assets are referenced"""
img = os.path.join(asset_manager.assets_folder, "used.png")
create_test_image(img, color="red")
asset_manager.reference_counts["assets/used.png"] = 1
files_removed, bytes_freed = asset_manager.remove_unused_assets()
assert files_removed == 0
assert bytes_freed == 0
assert os.path.exists(img)
def test_remove_unused_assets_cleans_tracking(self, asset_manager, create_test_image):
"""Test that removing unused assets cleans up internal tracking"""
img = os.path.join(asset_manager.assets_folder, "orphan.png")
create_test_image(img, color="red")
# Set up tracking with zero refs and a hash
asset_manager.reference_counts["assets/orphan.png"] = 0
asset_manager.asset_hashes["assets/orphan.png"] = "somehash"
asset_manager.remove_unused_assets()
# Tracking should be cleaned up
assert "assets/orphan.png" not in asset_manager.reference_counts
assert "assets/orphan.png" not in asset_manager.asset_hashes
def test_remove_unused_preserves_referenced(self, asset_manager, create_test_image):
"""Test that removing unused preserves all referenced assets"""
# Create several files
for i in range(5):
img = os.path.join(asset_manager.assets_folder, f"image{i}.png")
create_test_image(img, color="red")
# Reference only some of them
asset_manager.reference_counts["assets/image0.png"] = 1
asset_manager.reference_counts["assets/image2.png"] = 3
asset_manager.reference_counts["assets/image4.png"] = 1
files_removed, _ = asset_manager.remove_unused_assets()
assert files_removed == 2 # image1 and image3
# Check that referenced files still exist
assert os.path.exists(os.path.join(asset_manager.assets_folder, "image0.png"))
assert os.path.exists(os.path.join(asset_manager.assets_folder, "image2.png"))
assert os.path.exists(os.path.join(asset_manager.assets_folder, "image4.png"))
# Check that unreferenced files are gone
assert not os.path.exists(os.path.join(asset_manager.assets_folder, "image1.png"))
assert not os.path.exists(os.path.join(asset_manager.assets_folder, "image3.png"))
+183
View File
@@ -0,0 +1,183 @@
"""
Tests for asset_path mixin module
"""
import pytest
import os
from unittest.mock import Mock
class TestAssetPathMixin:
"""Tests for AssetPathMixin class"""
def test_resolve_asset_path_empty_path(self, tmp_path):
"""Test resolve_asset_path with empty path returns None"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock()
self.project.folder_path = str(tmp_path)
obj = TestClass()
assert obj.resolve_asset_path("") is None
assert obj.resolve_asset_path(None) is None
def test_resolve_asset_path_absolute_exists(self, tmp_path):
"""Test resolve_asset_path with existing absolute path"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
# Create a test file
test_file = tmp_path / "test_image.jpg"
test_file.write_text("test")
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock()
self.project.folder_path = str(tmp_path)
obj = TestClass()
result = obj.resolve_asset_path(str(test_file))
assert result == str(test_file)
def test_resolve_asset_path_absolute_not_exists(self, tmp_path):
"""Test resolve_asset_path with non-existing absolute path"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock()
self.project.folder_path = str(tmp_path)
obj = TestClass()
result = obj.resolve_asset_path("/nonexistent/path/image.jpg")
assert result is None
def test_resolve_asset_path_relative_exists(self, tmp_path):
"""Test resolve_asset_path with existing relative path"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
# Create assets folder and test file
assets_dir = tmp_path / "assets"
assets_dir.mkdir()
test_file = assets_dir / "photo.jpg"
test_file.write_text("test")
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock()
self.project.folder_path = str(tmp_path)
obj = TestClass()
result = obj.resolve_asset_path("assets/photo.jpg")
assert result == str(test_file)
def test_resolve_asset_path_relative_not_exists(self, tmp_path):
"""Test resolve_asset_path with non-existing relative path"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock()
self.project.folder_path = str(tmp_path)
obj = TestClass()
result = obj.resolve_asset_path("assets/nonexistent.jpg")
assert result is None
def test_resolve_asset_path_no_project_folder(self):
"""Test resolve_asset_path when project folder is not available"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = None
obj = TestClass()
result = obj.resolve_asset_path("assets/photo.jpg")
assert result is None
def test_get_asset_full_path_with_project(self, tmp_path):
"""Test get_asset_full_path returns correct path"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock()
self.project.folder_path = str(tmp_path)
obj = TestClass()
result = obj.get_asset_full_path("assets/photo.jpg")
expected = os.path.join(str(tmp_path), "assets/photo.jpg")
assert result == expected
def test_get_asset_full_path_no_project(self):
"""Test get_asset_full_path without project returns None"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = None
obj = TestClass()
result = obj.get_asset_full_path("assets/photo.jpg")
assert result is None
def test_get_asset_full_path_empty_path(self, tmp_path):
"""Test get_asset_full_path with empty path returns None"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock()
self.project.folder_path = str(tmp_path)
obj = TestClass()
assert obj.get_asset_full_path("") is None
assert obj.get_asset_full_path(None) is None
def test_get_project_folder_with_project(self, tmp_path):
"""Test _get_project_folder returns project folder"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock()
self.project.folder_path = str(tmp_path)
obj = TestClass()
result = obj._get_project_folder()
assert result == str(tmp_path)
def test_get_project_folder_no_project(self):
"""Test _get_project_folder without project returns None"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
pass
obj = TestClass()
result = obj._get_project_folder()
assert result is None
def test_get_project_folder_project_without_folder_path(self):
"""Test _get_project_folder with project missing folder_path"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock(spec=[]) # No folder_path attribute
obj = TestClass()
result = obj._get_project_folder()
assert result is None
+824
View File
@@ -0,0 +1,824 @@
"""
Tests for async_backend module
"""
import pytest
import asyncio
import threading
import time
from pathlib import Path
from unittest.mock import Mock, MagicMock, patch, call
from PIL import Image
from io import BytesIO
class TestLoadPriority:
"""Tests for LoadPriority enum"""
def test_load_priority_values(self):
"""Test that LoadPriority enum has correct values"""
from pyPhotoAlbum.async_backend import LoadPriority
assert LoadPriority.LOW.value == 0
assert LoadPriority.NORMAL.value == 1
assert LoadPriority.HIGH.value == 2
assert LoadPriority.URGENT.value == 3
def test_load_priority_ordering(self):
"""Test that LoadPriority values are ordered correctly"""
from pyPhotoAlbum.async_backend import LoadPriority
assert LoadPriority.LOW.value < LoadPriority.NORMAL.value
assert LoadPriority.NORMAL.value < LoadPriority.HIGH.value
assert LoadPriority.HIGH.value < LoadPriority.URGENT.value
class TestGetImageDimensions:
"""Tests for get_image_dimensions function"""
def test_get_image_dimensions_simple(self, tmp_path):
"""Test getting dimensions of a simple image"""
from pyPhotoAlbum.async_backend import get_image_dimensions
# Create a test image
img = Image.new("RGB", (800, 600), color="red")
img_path = tmp_path / "test.jpg"
img.save(img_path)
dims = get_image_dimensions(str(img_path))
assert dims == (800, 600)
def test_get_image_dimensions_with_max_size_width_larger(self, tmp_path):
"""Test dimensions scaled down when width is larger"""
from pyPhotoAlbum.async_backend import get_image_dimensions
# Create a wide image
img = Image.new("RGB", (1000, 500), color="blue")
img_path = tmp_path / "wide.jpg"
img.save(img_path)
dims = get_image_dimensions(str(img_path), max_size=300)
# Should be scaled down to fit within 300
assert dims == (300, 150)
def test_get_image_dimensions_with_max_size_height_larger(self, tmp_path):
"""Test dimensions scaled down when height is larger"""
from pyPhotoAlbum.async_backend import get_image_dimensions
# Create a tall image
img = Image.new("RGB", (500, 1000), color="green")
img_path = tmp_path / "tall.jpg"
img.save(img_path)
dims = get_image_dimensions(str(img_path), max_size=300)
# Should be scaled down to fit within 300
assert dims == (150, 300)
def test_get_image_dimensions_already_smaller_than_max(self, tmp_path):
"""Test dimensions not scaled when already smaller than max"""
from pyPhotoAlbum.async_backend import get_image_dimensions
img = Image.new("RGB", (200, 150), color="yellow")
img_path = tmp_path / "small.jpg"
img.save(img_path)
dims = get_image_dimensions(str(img_path), max_size=300)
# Should remain the same
assert dims == (200, 150)
def test_get_image_dimensions_invalid_file(self):
"""Test get_image_dimensions with invalid file returns None"""
from pyPhotoAlbum.async_backend import get_image_dimensions
dims = get_image_dimensions("/nonexistent/file.jpg")
assert dims is None
def test_get_image_dimensions_not_an_image(self, tmp_path):
"""Test get_image_dimensions with non-image file returns None"""
from pyPhotoAlbum.async_backend import get_image_dimensions
text_file = tmp_path / "not_image.txt"
text_file.write_text("This is not an image")
dims = get_image_dimensions(str(text_file))
assert dims is None
class TestLoadRequest:
"""Tests for LoadRequest dataclass"""
def test_load_request_creation(self):
"""Test creating a LoadRequest"""
from pyPhotoAlbum.async_backend import LoadRequest, LoadPriority
request = LoadRequest(
priority=LoadPriority.HIGH,
request_id=1,
path=Path("/test/image.jpg"),
target_size=(300, 300),
callback=None,
user_data={"test": "data"},
)
assert request.priority == LoadPriority.HIGH
assert request.request_id == 1
assert request.path == Path("/test/image.jpg")
assert request.target_size == (300, 300)
assert request.user_data == {"test": "data"}
def test_load_request_ordering_by_priority(self):
"""Test that LoadRequests are ordered by priority (fixed with IntEnum)"""
from pyPhotoAlbum.async_backend import LoadRequest, LoadPriority
req1 = LoadRequest(priority=LoadPriority.LOW, request_id=1, path=Path("/a.jpg"))
req2 = LoadRequest(priority=LoadPriority.HIGH, request_id=2, path=Path("/b.jpg"))
# LOW priority (value 0) should be < HIGH priority (value 2) in the priority queue
# This means LOW will be processed before HIGH (priority queue uses min-heap)
assert req1 < req2
def test_load_request_ordering_by_id_when_same_priority(self):
"""Test that LoadRequests with same priority are ordered by request_id"""
from pyPhotoAlbum.async_backend import LoadRequest, LoadPriority
req1 = LoadRequest(priority=LoadPriority.NORMAL, request_id=1, path=Path("/a.jpg"))
req2 = LoadRequest(priority=LoadPriority.NORMAL, request_id=2, path=Path("/b.jpg"))
assert req1 < req2
class TestImageCache:
"""Tests for ImageCache class"""
def test_image_cache_init(self):
"""Test ImageCache initialization"""
from pyPhotoAlbum.async_backend import ImageCache
cache = ImageCache(max_memory_mb=256)
assert cache.max_memory_bytes == 256 * 1024 * 1024
assert cache.current_memory_bytes == 0
def test_image_cache_estimate_image_size_rgba(self):
"""Test estimating RGBA image size"""
from pyPhotoAlbum.async_backend import ImageCache
cache = ImageCache()
img = Image.new("RGBA", (100, 100))
size = cache._estimate_image_size(img)
# 100 * 100 * 4 bytes (RGBA)
assert size == 40000
def test_image_cache_estimate_image_size_rgb(self):
"""Test estimating RGB image size"""
from pyPhotoAlbum.async_backend import ImageCache
cache = ImageCache()
img = Image.new("RGB", (100, 100))
size = cache._estimate_image_size(img)
# 100 * 100 * 3 bytes (RGB)
assert size == 30000
def test_image_cache_make_key_without_size(self):
"""Test making cache key without target size"""
from pyPhotoAlbum.async_backend import ImageCache
cache = ImageCache()
key = cache._make_key(Path("/test/image.jpg"))
assert key == "/test/image.jpg"
def test_image_cache_make_key_with_size(self):
"""Test making cache key with target size"""
from pyPhotoAlbum.async_backend import ImageCache
cache = ImageCache()
key = cache._make_key(Path("/test/image.jpg"), (300, 300))
assert key == "/test/image.jpg:300x300"
def test_image_cache_put_and_get(self):
"""Test putting and getting image from cache"""
from pyPhotoAlbum.async_backend import ImageCache
cache = ImageCache()
img = Image.new("RGB", (100, 100), color="red")
path = Path("/test/image.jpg")
cache.put(path, img)
cached_img = cache.get(path)
assert cached_img is not None
assert cached_img.size == img.size
assert cached_img.mode == img.mode
def test_image_cache_get_returns_copy(self):
"""Test that get returns a copy of the image"""
from pyPhotoAlbum.async_backend import ImageCache
cache = ImageCache()
img = Image.new("RGB", (100, 100))
path = Path("/test/image.jpg")
cache.put(path, img)
cached_img = cache.get(path)
# Modify the cached image
cached_img.putpixel((0, 0), (255, 0, 0))
# Get it again - should be unchanged
cached_img2 = cache.get(path)
assert cached_img2.getpixel((0, 0)) != (255, 0, 0)
def test_image_cache_miss(self):
"""Test cache miss returns None"""
from pyPhotoAlbum.async_backend import ImageCache
cache = ImageCache()
cached_img = cache.get(Path("/nonexistent.jpg"))
assert cached_img is None
def test_image_cache_different_sizes_different_keys(self):
"""Test that different target sizes use different cache keys"""
from pyPhotoAlbum.async_backend import ImageCache
cache = ImageCache()
img1 = Image.new("RGB", (100, 100), color="red")
img2 = Image.new("RGB", (50, 50), color="blue")
path = Path("/test/image.jpg")
cache.put(path, img1, target_size=None)
cache.put(path, img2, target_size=(50, 50))
cached_full = cache.get(path, target_size=None)
cached_small = cache.get(path, target_size=(50, 50))
assert cached_full.size == (100, 100)
assert cached_small.size == (50, 50)
def test_image_cache_lru_eviction(self):
"""Test that LRU items are evicted when cache is full"""
from pyPhotoAlbum.async_backend import ImageCache
# Small cache that can hold only 1 small image
cache = ImageCache(max_memory_mb=1)
# Create images that will fill the cache
img1 = Image.new("RGB", (500, 500)) # ~750KB
img2 = Image.new("RGB", (500, 500)) # ~750KB
# Add img1
cache.put(Path("/img1.jpg"), img1)
assert cache.get(Path("/img1.jpg")) is not None
# Add img2 - should evict img1 due to memory limit
cache.put(Path("/img2.jpg"), img2)
# img1 should be evicted to make room for img2
assert cache.get(Path("/img1.jpg")) is None
# img2 should be there
assert cache.get(Path("/img2.jpg")) is not None
def test_image_cache_update_existing(self):
"""Test updating an existing cache entry"""
from pyPhotoAlbum.async_backend import ImageCache
cache = ImageCache()
img1 = Image.new("RGB", (100, 100), color="red")
img2 = Image.new("RGB", (200, 200), color="blue")
path = Path("/test/image.jpg")
cache.put(path, img1)
cache.put(path, img2) # Update
cached = cache.get(path)
assert cached.size == (200, 200)
def test_image_cache_clear(self):
"""Test clearing the cache"""
from pyPhotoAlbum.async_backend import ImageCache
cache = ImageCache()
img = Image.new("RGB", (100, 100))
cache.put(Path("/img1.jpg"), img)
cache.put(Path("/img2.jpg"), img)
cache.clear()
assert cache.current_memory_bytes == 0
assert cache.get(Path("/img1.jpg")) is None
assert cache.get(Path("/img2.jpg")) is None
def test_image_cache_get_stats(self):
"""Test getting cache statistics"""
from pyPhotoAlbum.async_backend import ImageCache
cache = ImageCache(max_memory_mb=100)
img = Image.new("RGB", (100, 100))
cache.put(Path("/img1.jpg"), img)
cache.put(Path("/img2.jpg"), img)
stats = cache.get_stats()
assert stats["items"] == 2
assert stats["memory_mb"] > 0
assert stats["max_memory_mb"] == 100
assert 0 <= stats["utilization"] <= 100
def test_image_cache_thread_safety(self):
"""Test that cache operations are thread-safe"""
from pyPhotoAlbum.async_backend import ImageCache
cache = ImageCache()
img = Image.new("RGB", (50, 50))
def put_images(start):
for i in range(start, start + 10):
cache.put(Path(f"/img{i}.jpg"), img)
def get_images(start):
for i in range(start, start + 10):
cache.get(Path(f"/img{i}.jpg"))
threads = []
for i in range(5):
t1 = threading.Thread(target=put_images, args=(i * 10,))
t2 = threading.Thread(target=get_images, args=(i * 10,))
threads.extend([t1, t2])
for t in threads:
t.start()
for t in threads:
t.join()
# Should not crash
assert cache.current_memory_bytes >= 0
class TestAsyncImageLoader:
"""Tests for AsyncImageLoader class"""
def test_async_image_loader_init(self):
"""Test AsyncImageLoader initialization"""
from pyPhotoAlbum.async_backend import AsyncImageLoader, ImageCache
cache = ImageCache()
loader = AsyncImageLoader(cache=cache, max_workers=2)
assert loader.cache is cache
assert loader.max_workers == 2
assert loader._shutdown is False
def test_async_image_loader_init_creates_cache(self):
"""Test AsyncImageLoader creates cache if not provided"""
from pyPhotoAlbum.async_backend import AsyncImageLoader
loader = AsyncImageLoader()
assert loader.cache is not None
def test_async_image_loader_start(self):
"""Test starting AsyncImageLoader"""
from pyPhotoAlbum.async_backend import AsyncImageLoader
loader = AsyncImageLoader()
loader.start()
# Give it time to start
time.sleep(0.1)
assert loader._loop is not None
assert loader._loop_thread is not None
assert loader._loop_thread.is_alive()
loader.stop()
def test_async_image_loader_start_twice(self):
"""Test starting AsyncImageLoader twice doesn't create multiple threads"""
from pyPhotoAlbum.async_backend import AsyncImageLoader
loader = AsyncImageLoader()
loader.start()
time.sleep(0.1)
thread1 = loader._loop_thread
loader.start() # Should warn but not create new thread
time.sleep(0.1)
assert loader._loop_thread is thread1
loader.stop()
def test_async_image_loader_stop(self):
"""Test stopping AsyncImageLoader"""
from pyPhotoAlbum.async_backend import AsyncImageLoader
loader = AsyncImageLoader()
loader.start()
time.sleep(0.1)
loader.stop()
time.sleep(0.2)
assert loader._shutdown is True
def test_async_image_loader_load_and_process_image(self, tmp_path):
"""Test _load_and_process_image method"""
from pyPhotoAlbum.async_backend import AsyncImageLoader
# Create test image
img = Image.new("RGB", (800, 600), color="blue")
img_path = tmp_path / "test.jpg"
img.save(img_path)
loader = AsyncImageLoader()
result = loader._load_and_process_image(img_path, None)
assert result is not None
assert result.mode == "RGBA" # Should be converted to RGBA
def test_async_image_loader_load_and_process_image_with_resize(self, tmp_path):
"""Test _load_and_process_image with target size"""
from pyPhotoAlbum.async_backend import AsyncImageLoader
# Create large test image
img = Image.new("RGB", (2000, 1500), color="green")
img_path = tmp_path / "large.jpg"
img.save(img_path)
loader = AsyncImageLoader()
result = loader._load_and_process_image(img_path, (500, 500))
assert result is not None
# Should be resized to fit within 500x500
assert result.size[0] <= 500
assert result.size[1] <= 500
def test_async_image_loader_emit_loaded(self, qtbot):
"""Test _emit_loaded signal"""
from pyPhotoAlbum.async_backend import AsyncImageLoader
loader = AsyncImageLoader()
signal_received = []
def on_loaded(path, img, user_data):
signal_received.append((path, img, user_data))
loader.image_loaded.connect(on_loaded)
mock_img = Mock()
user_data = {"test": "data"}
loader._emit_loaded(Path("/test.jpg"), mock_img, user_data)
assert len(signal_received) == 1
assert signal_received[0][0] == Path("/test.jpg")
assert signal_received[0][2] == user_data
def test_async_image_loader_emit_failed(self, qtbot):
"""Test _emit_failed signal"""
from pyPhotoAlbum.async_backend import AsyncImageLoader
loader = AsyncImageLoader()
signal_received = []
def on_failed(path, error, user_data):
signal_received.append((path, error, user_data))
loader.load_failed.connect(on_failed)
user_data = {"test": "data"}
loader._emit_failed(Path("/test.jpg"), "Error message", user_data)
assert len(signal_received) == 1
assert signal_received[0][0] == Path("/test.jpg")
assert signal_received[0][1] == "Error message"
def test_async_image_loader_request_load_not_started(self):
"""Test request_load when loader not started"""
from pyPhotoAlbum.async_backend import AsyncImageLoader, LoadPriority
loader = AsyncImageLoader()
result = loader.request_load(Path("/test.jpg"), priority=LoadPriority.HIGH)
assert result is False
def test_async_image_loader_request_load_success(self, tmp_path):
"""Test successful request_load"""
from pyPhotoAlbum.async_backend import AsyncImageLoader, LoadPriority
# Create test image
img = Image.new("RGB", (100, 100), color="red")
img_path = tmp_path / "test.jpg"
img.save(img_path)
loader = AsyncImageLoader()
loader.start()
time.sleep(0.1)
result = loader.request_load(img_path, priority=LoadPriority.HIGH)
assert result is True
loader.stop()
def test_async_image_loader_request_load_duplicate(self, tmp_path):
"""Test requesting same image twice returns False"""
from pyPhotoAlbum.async_backend import AsyncImageLoader, LoadPriority
img = Image.new("RGB", (100, 100))
img_path = tmp_path / "test.jpg"
img.save(img_path)
loader = AsyncImageLoader()
loader.start()
time.sleep(0.1)
result1 = loader.request_load(img_path, priority=LoadPriority.HIGH)
result2 = loader.request_load(img_path, priority=LoadPriority.HIGH)
assert result1 is True
assert result2 is False # Already pending
loader.stop()
def test_async_image_loader_cancel_pending(self, tmp_path):
"""Test canceling a pending load request"""
from pyPhotoAlbum.async_backend import AsyncImageLoader, LoadPriority
img = Image.new("RGB", (100, 100))
img_path = tmp_path / "test.jpg"
img.save(img_path)
loader = AsyncImageLoader()
loader.start()
time.sleep(0.1)
loader.request_load(img_path, priority=LoadPriority.LOW)
result = loader.cancel_load(img_path)
assert result is True
loader.stop()
def test_async_image_loader_cancel_nonexistent(self):
"""Test canceling a non-existent load request"""
from pyPhotoAlbum.async_backend import AsyncImageLoader
loader = AsyncImageLoader()
result = loader.cancel_load(Path("/nonexistent.jpg"))
assert result is False
def test_async_image_loader_get_stats(self):
"""Test getting loader statistics"""
from pyPhotoAlbum.async_backend import AsyncImageLoader
loader = AsyncImageLoader()
stats = loader.get_stats()
assert "pending" in stats
assert "active" in stats
assert "cache" in stats
class TestAsyncPDFGenerator:
"""Tests for AsyncPDFGenerator class"""
def test_async_pdf_generator_init(self):
"""Test AsyncPDFGenerator initialization"""
from pyPhotoAlbum.async_backend import AsyncPDFGenerator, ImageCache
cache = ImageCache()
generator = AsyncPDFGenerator(image_cache=cache, max_workers=1)
assert generator.image_cache is cache
assert generator.max_workers == 1
assert generator._shutdown is False
def test_async_pdf_generator_init_creates_cache(self):
"""Test AsyncPDFGenerator creates cache if not provided"""
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
generator = AsyncPDFGenerator()
assert generator.image_cache is not None
def test_async_pdf_generator_start(self):
"""Test starting AsyncPDFGenerator"""
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
generator = AsyncPDFGenerator()
generator.start()
time.sleep(0.1)
assert generator._loop is not None
assert generator._loop_thread is not None
assert generator._loop_thread.is_alive()
generator.stop()
def test_async_pdf_generator_start_twice(self):
"""Test starting AsyncPDFGenerator twice doesn't create multiple threads"""
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
generator = AsyncPDFGenerator()
generator.start()
time.sleep(0.1)
thread1 = generator._loop_thread
generator.start() # Should warn
time.sleep(0.1)
assert generator._loop_thread is thread1
generator.stop()
def test_async_pdf_generator_stop(self):
"""Test stopping AsyncPDFGenerator"""
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
generator = AsyncPDFGenerator()
generator.start()
time.sleep(0.1)
generator.stop()
time.sleep(0.2)
assert generator._shutdown is True
def test_async_pdf_generator_export_not_started(self):
"""Test export_pdf when generator not started"""
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
generator = AsyncPDFGenerator()
mock_project = Mock()
result = generator.export_pdf(mock_project, "/output.pdf")
assert result is False
def test_async_pdf_generator_export_already_exporting(self):
"""Test export_pdf when already exporting"""
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
generator = AsyncPDFGenerator()
generator.start()
time.sleep(0.1)
mock_project = Mock()
# Start first export
generator._current_export = Mock()
generator._current_export.done.return_value = False
result = generator.export_pdf(mock_project, "/output.pdf")
assert result is False
generator.stop()
def test_async_pdf_generator_cancel_export(self):
"""Test cancel_export method"""
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
generator = AsyncPDFGenerator()
# Mock an active export
generator._current_export = Mock()
generator._current_export.done.return_value = False
generator.cancel_export()
assert generator._cancel_requested is True
generator._current_export.cancel.assert_called_once()
def test_async_pdf_generator_is_exporting_true(self):
"""Test is_exporting returns True when exporting"""
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
generator = AsyncPDFGenerator()
generator._current_export = Mock()
generator._current_export.done.return_value = False
assert generator.is_exporting() is True
def test_async_pdf_generator_is_exporting_false(self):
"""Test is_exporting returns False when not exporting"""
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
generator = AsyncPDFGenerator()
assert generator.is_exporting() is False
def test_async_pdf_generator_get_stats(self):
"""Test getting generator statistics"""
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
generator = AsyncPDFGenerator()
stats = generator.get_stats()
assert "exporting" in stats
assert "cache" in stats
def test_async_pdf_generator_export_with_cache_uses_cache(self):
"""Test _export_with_cache uses cached images"""
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
from PIL import Image
from unittest.mock import patch
generator = AsyncPDFGenerator()
# Mock exporter that tries to open an image
mock_exporter = Mock()
mock_exporter.export.return_value = (True, [])
def mock_progress(current, total, msg):
return True
# Run export (just verify the method exists and can be called)
with patch('PIL.Image.open') as mock_open:
mock_img = Image.new("RGBA", (50, 50), color="blue")
mock_open.return_value = mock_img
success, warnings = generator._export_with_cache(mock_exporter, "/fake/output.pdf", mock_progress)
assert success is True
mock_exporter.export.assert_called_once()
def test_async_pdf_generator_progress_signal(self, qtbot):
"""Test progress_updated signal"""
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
generator = AsyncPDFGenerator()
signal_received = []
def on_progress(current, total, message):
signal_received.append((current, total, message))
generator.progress_updated.connect(on_progress)
generator.progress_updated.emit(5, 10, "Processing page 5")
assert len(signal_received) == 1
assert signal_received[0] == (5, 10, "Processing page 5")
def test_async_pdf_generator_complete_signal(self, qtbot):
"""Test export_complete signal"""
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
generator = AsyncPDFGenerator()
signal_received = []
def on_complete(success, warnings):
signal_received.append((success, warnings))
generator.export_complete.connect(on_complete)
generator.export_complete.emit(True, ["warning1"])
assert len(signal_received) == 1
assert signal_received[0] == (True, ["warning1"])
def test_async_pdf_generator_failed_signal(self, qtbot):
"""Test export_failed signal"""
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
generator = AsyncPDFGenerator()
signal_received = []
def on_failed(error_msg):
signal_received.append(error_msg)
generator.export_failed.connect(on_failed)
generator.export_failed.emit("Export failed")
assert len(signal_received) == 1
assert signal_received[0] == "Export failed"
+635
View File
@@ -0,0 +1,635 @@
"""
Tests for async_loading mixin module
"""
import pytest
from pathlib import Path
from unittest.mock import Mock, MagicMock, patch, PropertyMock
class TestAsyncLoadingMixinInit:
"""Tests for AsyncLoadingMixin initialization"""
def test_init_async_loading_creates_cache(self):
"""Test that _init_async_loading creates image cache"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
with (
patch("pyPhotoAlbum.mixins.async_loading.ImageCache") as mock_cache,
patch("pyPhotoAlbum.mixins.async_loading.AsyncImageLoader") as mock_loader,
patch("pyPhotoAlbum.mixins.async_loading.AsyncPDFGenerator") as mock_pdf,
):
mock_loader_instance = Mock()
mock_loader.return_value = mock_loader_instance
mock_pdf_instance = Mock()
mock_pdf.return_value = mock_pdf_instance
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj._init_async_loading()
mock_cache.assert_called_once_with(max_memory_mb=512)
assert hasattr(obj, "image_cache")
def test_init_async_loading_creates_image_loader(self):
"""Test that _init_async_loading creates async image loader"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
with (
patch("pyPhotoAlbum.mixins.async_loading.ImageCache") as mock_cache,
patch("pyPhotoAlbum.mixins.async_loading.AsyncImageLoader") as mock_loader,
patch("pyPhotoAlbum.mixins.async_loading.AsyncPDFGenerator") as mock_pdf,
):
mock_loader_instance = Mock()
mock_loader.return_value = mock_loader_instance
mock_pdf_instance = Mock()
mock_pdf.return_value = mock_pdf_instance
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj._init_async_loading()
mock_loader.assert_called_once()
assert hasattr(obj, "async_image_loader")
mock_loader_instance.start.assert_called_once()
def test_init_async_loading_creates_pdf_generator(self):
"""Test that _init_async_loading creates async PDF generator"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
with (
patch("pyPhotoAlbum.mixins.async_loading.ImageCache") as mock_cache,
patch("pyPhotoAlbum.mixins.async_loading.AsyncImageLoader") as mock_loader,
patch("pyPhotoAlbum.mixins.async_loading.AsyncPDFGenerator") as mock_pdf,
):
mock_loader_instance = Mock()
mock_loader.return_value = mock_loader_instance
mock_pdf_instance = Mock()
mock_pdf.return_value = mock_pdf_instance
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj._init_async_loading()
mock_pdf.assert_called_once()
assert hasattr(obj, "async_pdf_generator")
mock_pdf_instance.start.assert_called_once()
class TestAsyncLoadingMixinCleanup:
"""Tests for AsyncLoadingMixin cleanup"""
def test_cleanup_stops_image_loader(self):
"""Test that _cleanup_async_loading stops image loader"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
obj.async_pdf_generator = Mock()
obj.image_cache = Mock()
obj._cleanup_async_loading()
obj.async_image_loader.stop.assert_called_once()
def test_cleanup_stops_pdf_generator(self):
"""Test that _cleanup_async_loading stops PDF generator"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
obj.async_pdf_generator = Mock()
obj.image_cache = Mock()
obj._cleanup_async_loading()
obj.async_pdf_generator.stop.assert_called_once()
def test_cleanup_clears_cache(self):
"""Test that _cleanup_async_loading clears image cache"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
obj.async_pdf_generator = Mock()
obj.image_cache = Mock()
obj._cleanup_async_loading()
obj.image_cache.clear.assert_called_once()
def test_cleanup_handles_missing_components(self):
"""Test that _cleanup_async_loading handles missing components gracefully"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
# Don't set any async components
# Should not raise
obj._cleanup_async_loading()
class TestOnImageLoaded:
"""Tests for _on_image_loaded callback"""
def test_on_image_loaded_calls_element_callback(self):
"""Test that _on_image_loaded calls element's callback"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
def update(self):
pass
obj = TestClass()
mock_image = Mock()
mock_user_data = Mock()
mock_user_data._on_async_image_loaded = Mock()
obj._on_image_loaded(Path("/test/image.jpg"), mock_image, mock_user_data)
mock_user_data._on_async_image_loaded.assert_called_once_with(mock_image)
def test_on_image_loaded_triggers_update(self):
"""Test that _on_image_loaded triggers widget update"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
def __init__(self):
self.update_called = False
def update(self):
self.update_called = True
obj = TestClass()
obj._on_image_loaded(Path("/test/image.jpg"), Mock(), None)
assert obj.update_called
def test_on_image_loaded_handles_none_user_data(self):
"""Test that _on_image_loaded handles None user_data"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
def update(self):
pass
obj = TestClass()
# Should not raise
obj._on_image_loaded(Path("/test/image.jpg"), Mock(), None)
class TestOnImageLoadFailed:
"""Tests for _on_image_load_failed callback"""
def test_on_image_load_failed_calls_element_callback(self):
"""Test that _on_image_load_failed calls element's callback"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
mock_user_data = Mock()
mock_user_data._on_async_image_load_failed = Mock()
obj._on_image_load_failed(Path("/test/image.jpg"), "Error message", mock_user_data)
mock_user_data._on_async_image_load_failed.assert_called_once_with("Error message")
def test_on_image_load_failed_handles_none_user_data(self):
"""Test that _on_image_load_failed handles None user_data"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
# Should not raise
obj._on_image_load_failed(Path("/test/image.jpg"), "Error", None)
class TestOnPdfProgress:
"""Tests for _on_pdf_progress callback"""
def test_on_pdf_progress_updates_dialog(self):
"""Test that _on_pdf_progress updates progress dialog"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj._pdf_progress_dialog = Mock()
obj._on_pdf_progress(5, 10, "Processing page 5")
obj._pdf_progress_dialog.setValue.assert_called_once_with(5)
obj._pdf_progress_dialog.setLabelText.assert_called_once_with("Processing page 5")
def test_on_pdf_progress_handles_no_dialog(self):
"""Test that _on_pdf_progress handles missing dialog"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
# No _pdf_progress_dialog attribute
# Should not raise
obj._on_pdf_progress(5, 10, "Processing")
class TestOnPdfComplete:
"""Tests for _on_pdf_complete callback"""
def test_on_pdf_complete_closes_dialog(self):
"""Test that _on_pdf_complete closes progress dialog"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
def window(self):
return Mock(spec=[])
obj = TestClass()
mock_dialog = Mock()
obj._pdf_progress_dialog = mock_dialog
obj._on_pdf_complete(True, [])
mock_dialog.close.assert_called_once()
assert obj._pdf_progress_dialog is None
def test_on_pdf_complete_shows_success_status(self):
"""Test that _on_pdf_complete shows success status"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
mock_main_window = Mock()
class TestClass(AsyncLoadingMixin):
def window(self):
return mock_main_window
obj = TestClass()
obj._on_pdf_complete(True, [])
mock_main_window.show_status.assert_called_once()
call_args = mock_main_window.show_status.call_args[0]
assert "successfully" in call_args[0]
def test_on_pdf_complete_shows_warnings(self):
"""Test that _on_pdf_complete shows warning count"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
mock_main_window = Mock()
class TestClass(AsyncLoadingMixin):
def window(self):
return mock_main_window
obj = TestClass()
obj._on_pdf_complete(True, ["warning1", "warning2"])
mock_main_window.show_status.assert_called_once()
call_args = mock_main_window.show_status.call_args[0]
assert "2 warnings" in call_args[0]
def test_on_pdf_complete_shows_failure_status(self):
"""Test that _on_pdf_complete shows failure status"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
mock_main_window = Mock()
class TestClass(AsyncLoadingMixin):
def window(self):
return mock_main_window
obj = TestClass()
obj._on_pdf_complete(False, [])
mock_main_window.show_status.assert_called_once()
call_args = mock_main_window.show_status.call_args[0]
assert "failed" in call_args[0]
class TestOnPdfFailed:
"""Tests for _on_pdf_failed callback"""
def test_on_pdf_failed_closes_dialog(self):
"""Test that _on_pdf_failed closes progress dialog"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
def window(self):
return Mock(spec=[])
obj = TestClass()
mock_dialog = Mock()
obj._pdf_progress_dialog = mock_dialog
obj._on_pdf_failed("Error occurred")
mock_dialog.close.assert_called_once()
assert obj._pdf_progress_dialog is None
def test_on_pdf_failed_shows_error_status(self):
"""Test that _on_pdf_failed shows error status"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
mock_main_window = Mock()
class TestClass(AsyncLoadingMixin):
def window(self):
return mock_main_window
obj = TestClass()
obj._on_pdf_failed("Something went wrong")
mock_main_window.show_status.assert_called_once()
call_args = mock_main_window.show_status.call_args[0]
assert "failed" in call_args[0]
assert "Something went wrong" in call_args[0]
class TestRequestImageLoad:
"""Tests for request_image_load method"""
def test_request_image_load_no_loader(self):
"""Test request_image_load when loader not initialized"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
mock_image_data = Mock()
# Should not raise
obj.request_image_load(mock_image_data)
def test_request_image_load_empty_path(self):
"""Test request_image_load with empty image path"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
mock_image_data = Mock()
mock_image_data.image_path = ""
obj.request_image_load(mock_image_data)
obj.async_image_loader.request_load.assert_not_called()
def test_request_image_load_non_assets_path_skipped(self):
"""Test request_image_load skips paths outside assets folder"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
mock_image_data = Mock()
mock_image_data.image_path = "/absolute/path/image.jpg"
obj.request_image_load(mock_image_data)
obj.async_image_loader.request_load.assert_not_called()
def test_request_image_load_path_not_resolved(self):
"""Test request_image_load when path resolution fails"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
mock_image_data = Mock()
mock_image_data.image_path = "assets/missing.jpg"
mock_image_data.resolve_image_path.return_value = None
obj.request_image_load(mock_image_data)
obj.async_image_loader.request_load.assert_not_called()
def test_request_image_load_success(self, tmp_path):
"""Test successful request_image_load"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin, LoadPriority
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
# Create actual file
asset_path = tmp_path / "assets" / "photo.jpg"
asset_path.parent.mkdir(parents=True)
asset_path.write_text("test")
mock_image_data = Mock()
mock_image_data.image_path = "assets/photo.jpg"
mock_image_data.resolve_image_path.return_value = str(asset_path)
obj.request_image_load(mock_image_data, priority=LoadPriority.HIGH)
obj.async_image_loader.request_load.assert_called_once()
call_kwargs = obj.async_image_loader.request_load.call_args[1]
assert call_kwargs["priority"] == LoadPriority.HIGH
assert call_kwargs["user_data"] == mock_image_data
class TestExportPdfAsync:
"""Tests for export_pdf_async method"""
def test_export_pdf_async_no_generator(self):
"""Test export_pdf_async when generator not initialized"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
mock_project = Mock()
result = obj.export_pdf_async(mock_project, "/output.pdf")
assert result is False
def test_export_pdf_async_creates_progress_dialog(self, qtbot):
"""Test export_pdf_async creates progress dialog"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
from PyQt6.QtWidgets import QWidget
class TestWidget(QWidget, AsyncLoadingMixin):
pass
widget = TestWidget()
qtbot.addWidget(widget)
widget.async_pdf_generator = Mock()
widget.async_pdf_generator.export_pdf.return_value = True
mock_project = Mock()
mock_project.pages = [Mock(is_cover=False, is_double_spread=False)]
widget.export_pdf_async(mock_project, "/output.pdf")
assert hasattr(widget, "_pdf_progress_dialog")
assert widget._pdf_progress_dialog is not None
def test_export_pdf_async_calls_generator(self, qtbot):
"""Test export_pdf_async calls the PDF generator"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
from PyQt6.QtWidgets import QWidget
class TestWidget(QWidget, AsyncLoadingMixin):
pass
widget = TestWidget()
qtbot.addWidget(widget)
widget.async_pdf_generator = Mock()
widget.async_pdf_generator.export_pdf.return_value = True
mock_project = Mock()
mock_project.pages = []
result = widget.export_pdf_async(mock_project, "/output.pdf", export_dpi=150)
widget.async_pdf_generator.export_pdf.assert_called_once_with(mock_project, "/output.pdf", 150)
assert result is True
class TestOnPdfCancel:
"""Tests for _on_pdf_cancel callback"""
def test_on_pdf_cancel_cancels_export(self):
"""Test that _on_pdf_cancel cancels the export"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_pdf_generator = Mock()
obj._on_pdf_cancel()
obj.async_pdf_generator.cancel_export.assert_called_once()
def test_on_pdf_cancel_handles_no_generator(self):
"""Test that _on_pdf_cancel handles missing generator"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
# No async_pdf_generator
# Should not raise
obj._on_pdf_cancel()
class TestGetAsyncStats:
"""Tests for get_async_stats method"""
def test_get_async_stats_empty(self):
"""Test get_async_stats with no components initialized"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
stats = obj.get_async_stats()
assert stats == {}
def test_get_async_stats_with_loader(self):
"""Test get_async_stats includes loader stats"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
obj.async_image_loader.get_stats.return_value = {"loaded": 10}
stats = obj.get_async_stats()
assert "image_loader" in stats
assert stats["image_loader"]["loaded"] == 10
def test_get_async_stats_with_pdf_generator(self):
"""Test get_async_stats includes PDF generator stats"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_pdf_generator = Mock()
obj.async_pdf_generator.get_stats.return_value = {"exports": 5}
stats = obj.get_async_stats()
assert "pdf_generator" in stats
assert stats["pdf_generator"]["exports"] == 5
def test_get_async_stats_with_all_components(self):
"""Test get_async_stats includes all component stats"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
obj.async_image_loader.get_stats.return_value = {"loaded": 10}
obj.async_pdf_generator = Mock()
obj.async_pdf_generator.get_stats.return_value = {"exports": 5}
stats = obj.get_async_stats()
assert "image_loader" in stats
assert "pdf_generator" in stats
+511
View File
@@ -0,0 +1,511 @@
"""
Tests for AutosaveManager
"""
import pytest
import json
import tempfile
import shutil
from pathlib import Path
from datetime import datetime, timedelta
from unittest.mock import Mock, patch, MagicMock
from pyPhotoAlbum.autosave_manager import AutosaveManager
class TestAutosaveManagerInit:
"""Tests for AutosaveManager initialization"""
def test_init_creates_checkpoint_directory(self, tmp_path, monkeypatch):
"""Test that init creates the checkpoint directory"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
assert checkpoint_dir.exists()
def test_init_with_existing_directory(self, tmp_path, monkeypatch):
"""Test init when checkpoint directory already exists"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
assert checkpoint_dir.exists()
class TestGetCheckpointPath:
"""Tests for _get_checkpoint_path method"""
def test_get_checkpoint_path_basic(self, tmp_path, monkeypatch):
"""Test basic checkpoint path generation"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
path = manager._get_checkpoint_path("MyProject")
assert path.parent == checkpoint_dir
assert path.suffix == ".ppz"
assert "checkpoint_MyProject_" in path.name
def test_get_checkpoint_path_with_timestamp(self, tmp_path, monkeypatch):
"""Test checkpoint path with specific timestamp"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
timestamp = datetime(2024, 1, 15, 10, 30, 45)
path = manager._get_checkpoint_path("TestProject", timestamp)
assert "20240115_103045" in path.name
def test_get_checkpoint_path_sanitizes_name(self, tmp_path, monkeypatch):
"""Test that special characters in project name are sanitized"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
path = manager._get_checkpoint_path("My Project!@#$%")
# Should not contain special characters except - and _
name_without_ext = path.stem
for char in name_without_ext:
assert char.isalnum() or char in "-_", f"Invalid char: {char}"
class TestCreateCheckpoint:
"""Tests for create_checkpoint method"""
def test_create_checkpoint_success(self, tmp_path, monkeypatch):
"""Test successful checkpoint creation"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
# Mock save_to_zip - note the return value format
with patch("pyPhotoAlbum.autosave_manager.save_to_zip") as mock_save:
mock_save.return_value = (True, "Success")
mock_project = Mock()
mock_project.name = "TestProject"
mock_project.file_path = "/path/to/project.ppz"
success, message = manager.create_checkpoint(mock_project)
assert success is True
assert "Checkpoint created" in message
mock_save.assert_called_once()
def test_create_checkpoint_failure(self, tmp_path, monkeypatch):
"""Test checkpoint creation failure"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
with patch("pyPhotoAlbum.autosave_manager.save_to_zip") as mock_save:
mock_save.return_value = (False, "Disk full")
mock_project = Mock()
mock_project.name = "TestProject"
success, message = manager.create_checkpoint(mock_project)
assert success is False
assert "Checkpoint failed" in message
def test_create_checkpoint_exception(self, tmp_path, monkeypatch):
"""Test checkpoint creation with exception"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
with patch("pyPhotoAlbum.autosave_manager.save_to_zip") as mock_save:
mock_save.side_effect = Exception("IO Error")
mock_project = Mock()
mock_project.name = "TestProject"
success, message = manager.create_checkpoint(mock_project)
assert success is False
assert "Checkpoint error" in message
class TestSaveCheckpointMetadata:
"""Tests for _save_checkpoint_metadata method"""
def test_save_metadata(self, tmp_path, monkeypatch):
"""Test saving checkpoint metadata"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
mock_project = Mock()
mock_project.name = "TestProject"
mock_project.file_path = "/path/to/original.ppz"
checkpoint_path = checkpoint_dir / "checkpoint_TestProject_20240115_103045.ppz"
checkpoint_path.touch()
manager._save_checkpoint_metadata(mock_project, checkpoint_path)
metadata_path = checkpoint_path.with_suffix(".json")
assert metadata_path.exists()
with open(metadata_path, "r") as f:
metadata = json.load(f)
assert metadata["project_name"] == "TestProject"
assert metadata["original_path"] == "/path/to/original.ppz"
assert "timestamp" in metadata
class TestListCheckpoints:
"""Tests for list_checkpoints method"""
def test_list_checkpoints_empty(self, tmp_path, monkeypatch):
"""Test listing checkpoints when none exist"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
checkpoints = manager.list_checkpoints()
assert checkpoints == []
def test_list_checkpoints_with_files(self, tmp_path, monkeypatch):
"""Test listing checkpoints with existing files"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create some checkpoint files
cp1 = checkpoint_dir / "checkpoint_Project1_20240115_100000.ppz"
cp2 = checkpoint_dir / "checkpoint_Project2_20240115_110000.ppz"
cp1.touch()
cp2.touch()
# Create metadata for first checkpoint
metadata1 = {"project_name": "Project1", "timestamp": "2024-01-15T10:00:00"}
with open(cp1.with_suffix(".json"), "w") as f:
json.dump(metadata1, f)
manager = AutosaveManager()
checkpoints = manager.list_checkpoints()
assert len(checkpoints) == 2
def test_list_checkpoints_filter_by_project(self, tmp_path, monkeypatch):
"""Test listing checkpoints filtered by project name"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create checkpoint files with metadata
cp1 = checkpoint_dir / "checkpoint_Project1_20240115_100000.ppz"
cp2 = checkpoint_dir / "checkpoint_Project2_20240115_110000.ppz"
cp1.touch()
cp2.touch()
metadata1 = {"project_name": "Project1", "timestamp": "2024-01-15T10:00:00"}
metadata2 = {"project_name": "Project2", "timestamp": "2024-01-15T11:00:00"}
with open(cp1.with_suffix(".json"), "w") as f:
json.dump(metadata1, f)
with open(cp2.with_suffix(".json"), "w") as f:
json.dump(metadata2, f)
manager = AutosaveManager()
checkpoints = manager.list_checkpoints("Project1")
assert len(checkpoints) == 1
assert checkpoints[0][1]["project_name"] == "Project1"
def test_list_checkpoints_sorted_by_timestamp(self, tmp_path, monkeypatch):
"""Test that checkpoints are sorted by timestamp (newest first)"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create checkpoints with different timestamps
cp1 = checkpoint_dir / "checkpoint_Project_20240115_080000.ppz"
cp2 = checkpoint_dir / "checkpoint_Project_20240115_120000.ppz"
cp3 = checkpoint_dir / "checkpoint_Project_20240115_100000.ppz"
cp1.touch()
cp2.touch()
cp3.touch()
for cp, hour in [(cp1, "08"), (cp2, "12"), (cp3, "10")]:
metadata = {"project_name": "Project", "timestamp": f"2024-01-15T{hour}:00:00"}
with open(cp.with_suffix(".json"), "w") as f:
json.dump(metadata, f)
manager = AutosaveManager()
checkpoints = manager.list_checkpoints()
# Should be sorted newest first: 12:00, 10:00, 08:00
assert "12:00:00" in checkpoints[0][1]["timestamp"]
assert "10:00:00" in checkpoints[1][1]["timestamp"]
assert "08:00:00" in checkpoints[2][1]["timestamp"]
class TestLoadCheckpoint:
"""Tests for load_checkpoint method"""
def test_load_checkpoint_success(self, tmp_path, monkeypatch):
"""Test successful checkpoint loading"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
with patch("pyPhotoAlbum.autosave_manager.load_from_zip") as mock_load:
mock_project = Mock()
mock_load.return_value = mock_project
checkpoint_path = checkpoint_dir / "checkpoint_Test.ppz"
success, result = manager.load_checkpoint(checkpoint_path)
assert success is True
assert result == mock_project
def test_load_checkpoint_failure(self, tmp_path, monkeypatch):
"""Test checkpoint loading failure"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
with patch("pyPhotoAlbum.autosave_manager.load_from_zip") as mock_load:
mock_load.side_effect = Exception("Corrupt file")
checkpoint_path = checkpoint_dir / "checkpoint_Test.ppz"
success, result = manager.load_checkpoint(checkpoint_path)
assert success is False
assert "Failed to load checkpoint" in result
class TestDeleteCheckpoint:
"""Tests for delete_checkpoint method"""
def test_delete_checkpoint_success(self, tmp_path, monkeypatch):
"""Test successful checkpoint deletion"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create checkpoint and metadata files
cp = checkpoint_dir / "checkpoint_Test.ppz"
cp.touch()
metadata = cp.with_suffix(".json")
metadata.touch()
manager = AutosaveManager()
result = manager.delete_checkpoint(cp)
assert result is True
assert not cp.exists()
assert not metadata.exists()
def test_delete_checkpoint_nonexistent(self, tmp_path, monkeypatch):
"""Test deleting nonexistent checkpoint"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
cp = checkpoint_dir / "nonexistent.ppz"
result = manager.delete_checkpoint(cp)
assert result is True # Should succeed even if file doesn't exist
class TestDeleteAllCheckpoints:
"""Tests for delete_all_checkpoints method"""
def test_delete_all_checkpoints(self, tmp_path, monkeypatch):
"""Test deleting all checkpoints"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create multiple checkpoints
for i in range(3):
cp = checkpoint_dir / f"checkpoint_Project_{i}.ppz"
cp.touch()
metadata = {"project_name": "Project", "timestamp": f"2024-01-15T{i}:00:00"}
with open(cp.with_suffix(".json"), "w") as f:
json.dump(metadata, f)
manager = AutosaveManager()
manager.delete_all_checkpoints()
remaining = list(checkpoint_dir.glob("checkpoint_*.ppz"))
assert len(remaining) == 0
def test_delete_all_checkpoints_filtered(self, tmp_path, monkeypatch):
"""Test deleting all checkpoints for specific project"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create checkpoints for different projects
for name in ["ProjectA", "ProjectB", "ProjectA"]:
cp = checkpoint_dir / f"checkpoint_{name}_{datetime.now().strftime('%Y%m%d_%H%M%S%f')}.ppz"
cp.touch()
metadata = {"project_name": name, "timestamp": datetime.now().isoformat()}
with open(cp.with_suffix(".json"), "w") as f:
json.dump(metadata, f)
manager = AutosaveManager()
manager.delete_all_checkpoints("ProjectA")
# Only ProjectB should remain
remaining = list(checkpoint_dir.glob("checkpoint_*.ppz"))
assert len(remaining) == 1
assert "ProjectB" in remaining[0].name
class TestCleanupOldCheckpoints:
"""Tests for cleanup_old_checkpoints method"""
def test_cleanup_old_checkpoints_by_age(self, tmp_path, monkeypatch):
"""Test cleanup of old checkpoints by age"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create old and new checkpoints
old_time = datetime.now() - timedelta(hours=48)
new_time = datetime.now() - timedelta(hours=1)
old_cp = checkpoint_dir / "checkpoint_Project_old.ppz"
new_cp = checkpoint_dir / "checkpoint_Project_new.ppz"
old_cp.touch()
new_cp.touch()
old_metadata = {"project_name": "Project", "timestamp": old_time.isoformat()}
new_metadata = {"project_name": "Project", "timestamp": new_time.isoformat()}
with open(old_cp.with_suffix(".json"), "w") as f:
json.dump(old_metadata, f)
with open(new_cp.with_suffix(".json"), "w") as f:
json.dump(new_metadata, f)
manager = AutosaveManager()
manager.cleanup_old_checkpoints(max_age_hours=24)
# Only new checkpoint should remain
remaining = list(checkpoint_dir.glob("checkpoint_*.ppz"))
assert len(remaining) == 1
assert "new" in remaining[0].name
def test_cleanup_old_checkpoints_by_count(self, tmp_path, monkeypatch):
"""Test cleanup of checkpoints by count"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create many recent checkpoints
for i in range(5):
timestamp = datetime.now() - timedelta(hours=i)
cp = checkpoint_dir / f"checkpoint_Project_{i:02d}.ppz"
cp.touch()
metadata = {"project_name": "Project", "timestamp": timestamp.isoformat()}
with open(cp.with_suffix(".json"), "w") as f:
json.dump(metadata, f)
manager = AutosaveManager()
manager.cleanup_old_checkpoints(max_age_hours=24 * 7, max_count=3)
# Should only keep 3 most recent
remaining = list(checkpoint_dir.glob("checkpoint_*.ppz"))
assert len(remaining) == 3
class TestHasCheckpoints:
"""Tests for has_checkpoints method"""
def test_has_checkpoints_true(self, tmp_path, monkeypatch):
"""Test has_checkpoints returns True when checkpoints exist"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
cp = checkpoint_dir / "checkpoint_Test.ppz"
cp.touch()
manager = AutosaveManager()
assert manager.has_checkpoints() is True
def test_has_checkpoints_false(self, tmp_path, monkeypatch):
"""Test has_checkpoints returns False when no checkpoints"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
assert manager.has_checkpoints() is False
class TestGetLatestCheckpoint:
"""Tests for get_latest_checkpoint method"""
def test_get_latest_checkpoint(self, tmp_path, monkeypatch):
"""Test getting the latest checkpoint"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create checkpoints with different timestamps
for hour in [8, 10, 12]:
cp = checkpoint_dir / f"checkpoint_Project_{hour:02d}.ppz"
cp.touch()
metadata = {"project_name": "Project", "timestamp": f"2024-01-15T{hour:02d}:00:00"}
with open(cp.with_suffix(".json"), "w") as f:
json.dump(metadata, f)
manager = AutosaveManager()
result = manager.get_latest_checkpoint()
assert result is not None
assert "12:00:00" in result[1]["timestamp"]
def test_get_latest_checkpoint_none(self, tmp_path, monkeypatch):
"""Test getting latest checkpoint when none exist"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
result = manager.get_latest_checkpoint()
assert result is None
def test_get_latest_checkpoint_filtered(self, tmp_path, monkeypatch):
"""Test getting latest checkpoint for specific project"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create checkpoints for different projects
for name, hour in [("ProjectA", 10), ("ProjectB", 12), ("ProjectA", 8)]:
cp = checkpoint_dir / f"checkpoint_{name}_{hour:02d}.ppz"
cp.touch()
metadata = {"project_name": name, "timestamp": f"2024-01-15T{hour:02d}:00:00"}
with open(cp.with_suffix(".json"), "w") as f:
json.dump(metadata, f)
manager = AutosaveManager()
result = manager.get_latest_checkpoint("ProjectA")
assert result is not None
assert result[1]["project_name"] == "ProjectA"
assert "10:00:00" in result[1]["timestamp"] # Latest for ProjectA
+6 -6
View File
@@ -249,7 +249,7 @@ class TestDialogMethods:
qtbot.addWidget(window)
mock_critical = Mock()
monkeypatch.setattr(QMessageBox, 'critical', mock_critical)
monkeypatch.setattr(QMessageBox, "critical", mock_critical)
window.show_error("Error Title", "Error message")
@@ -260,7 +260,7 @@ class TestDialogMethods:
qtbot.addWidget(window)
mock_warning = Mock()
monkeypatch.setattr(QMessageBox, 'warning', mock_warning)
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
window.show_warning("Warning Title", "Warning message")
@@ -271,7 +271,7 @@ class TestDialogMethods:
qtbot.addWidget(window)
mock_info = Mock()
monkeypatch.setattr(QMessageBox, 'information', mock_info)
monkeypatch.setattr(QMessageBox, "information", mock_info)
window.show_info("Info Title", "Info message")
@@ -309,7 +309,7 @@ class TestRequirePage:
window._gl_widget = Mock()
mock_warning = Mock()
monkeypatch.setattr(QMessageBox, 'warning', mock_warning)
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
result = window.require_page(show_warning=True)
@@ -366,7 +366,7 @@ class TestRequireSelection:
window._gl_widget = gl_widget
mock_info = Mock()
monkeypatch.setattr(QMessageBox, 'information', mock_info)
monkeypatch.setattr(QMessageBox, "information", mock_info)
result = window.require_selection(min_count=1, show_warning=True)
@@ -386,7 +386,7 @@ class TestRequireSelection:
window._gl_widget = gl_widget
mock_info = Mock()
monkeypatch.setattr(QMessageBox, 'information', mock_info)
monkeypatch.setattr(QMessageBox, "information", mock_info)
result = window.require_selection(min_count=3, show_warning=True)
+83 -115
View File
@@ -16,7 +16,7 @@ from pyPhotoAlbum.commands import (
ChangeZOrderCommand,
StateChangeCommand,
CommandHistory,
_normalize_asset_path
_normalize_asset_path,
)
from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
from pyPhotoAlbum.page_layout import PageLayout
@@ -107,9 +107,9 @@ class TestAddElementCommand:
data = cmd.serialize()
assert data['type'] == 'add_element'
assert 'element' in data
assert data['executed'] is True
assert data["type"] == "add_element"
assert "element" in data
assert data["executed"] is True
def test_add_element_with_asset_manager(self):
"""Test add element with asset manager reference"""
@@ -164,8 +164,8 @@ class TestDeleteElementCommand:
cmd = DeleteElementCommand(layout, element)
data = cmd.serialize()
assert data['type'] == 'delete_element'
assert 'element' in data
assert data["type"] == "delete_element"
assert "element" in data
class TestMoveElementCommand:
@@ -198,9 +198,9 @@ class TestMoveElementCommand:
cmd = MoveElementCommand(element, old_position=(100, 100), new_position=(200, 200))
data = cmd.serialize()
assert data['type'] == 'move_element'
assert data['old_position'] == (100, 100)
assert data['new_position'] == (200, 200)
assert data["type"] == "move_element"
assert data["old_position"] == (100, 100)
assert data["new_position"] == (200, 200)
class TestResizeElementCommand:
@@ -211,11 +211,7 @@ class TestResizeElementCommand:
element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150)
cmd = ResizeElementCommand(
element,
old_position=(100, 100),
old_size=(200, 150),
new_position=(100, 100),
new_size=(300, 225)
element, old_position=(100, 100), old_size=(200, 150), new_position=(100, 100), new_size=(300, 225)
)
cmd.execute()
@@ -226,11 +222,7 @@ class TestResizeElementCommand:
element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150)
cmd = ResizeElementCommand(
element,
old_position=(100, 100),
old_size=(200, 150),
new_position=(100, 100),
new_size=(300, 225)
element, old_position=(100, 100), old_size=(200, 150), new_position=(100, 100), new_size=(300, 225)
)
cmd.execute()
@@ -243,11 +235,7 @@ class TestResizeElementCommand:
element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150)
cmd = ResizeElementCommand(
element,
old_position=(100, 100),
old_size=(200, 150),
new_position=(90, 90),
new_size=(220, 165)
element, old_position=(100, 100), old_size=(200, 150), new_position=(90, 90), new_size=(220, 165)
)
cmd.execute()
@@ -298,9 +286,9 @@ class TestRotateElementCommand:
cmd = RotateElementCommand(element, old_rotation=0, new_rotation=45)
data = cmd.serialize()
assert data['type'] == 'rotate_element'
assert data['old_rotation'] == 0
assert data['new_rotation'] == 45
assert data["type"] == "rotate_element"
assert data["old_rotation"] == 0
assert data["new_rotation"] == 45
class TestAdjustImageCropCommand:
@@ -310,32 +298,25 @@ class TestAdjustImageCropCommand:
"""Test adjusting image crop"""
element = ImageData(
image_path="/test.jpg",
x=100, y=100,
width=200, height=150,
crop_info={'x': 0.0, 'y': 0.0, 'width': 1.0, 'height': 1.0}
x=100,
y=100,
width=200,
height=150,
crop_info={"x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0},
)
new_crop = {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8}
cmd = AdjustImageCropCommand(
element,
old_crop_info=element.crop_info.copy(),
new_crop_info=new_crop
)
new_crop = {"x": 0.1, "y": 0.1, "width": 0.8, "height": 0.8}
cmd = AdjustImageCropCommand(element, old_crop_info=element.crop_info.copy(), new_crop_info=new_crop)
cmd.execute()
assert element.crop_info == new_crop
def test_adjust_crop_undo(self):
"""Test undoing crop adjustment"""
old_crop = {'x': 0.0, 'y': 0.0, 'width': 1.0, 'height': 1.0}
element = ImageData(
image_path="/test.jpg",
x=100, y=100,
width=200, height=150,
crop_info=old_crop.copy()
)
old_crop = {"x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0}
element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150, crop_info=old_crop.copy())
new_crop = {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8}
new_crop = {"x": 0.1, "y": 0.1, "width": 0.8, "height": 0.8}
cmd = AdjustImageCropCommand(element, old_crop_info=old_crop, new_crop_info=new_crop)
cmd.execute()
@@ -399,10 +380,7 @@ class TestResizeElementsCommand:
element2.size = (300, 300)
# Command expects list of (element, old_position, old_size) tuples
changes = [
(element1, (100, 100), (100, 100)),
(element2, (200, 200), (150, 150))
]
changes = [(element1, (100, 100), (100, 100)), (element2, (200, 200), (150, 150))]
cmd = ResizeElementsCommand(changes)
cmd.execute()
@@ -421,10 +399,7 @@ class TestResizeElementsCommand:
element2.size = (300, 300)
# Command expects list of (element, old_position, old_size) tuples
changes = [
(element1, (100, 100), (100, 100)),
(element2, (200, 200), (150, 150))
]
changes = [(element1, (100, 100), (100, 100)), (element2, (200, 200), (150, 150))]
cmd = ResizeElementsCommand(changes)
cmd.execute()
@@ -476,81 +451,68 @@ class TestStateChangeCommand:
def test_state_change_undo(self):
"""Test undoing state change"""
element = TextBoxData(
text_content="Old Text",
x=100, y=100,
width=200, height=100
)
element = TextBoxData(text_content="Old Text", x=100, y=100, width=200, height=100)
# Define restore function
def restore_state(state):
element.text_content = state['text_content']
element.text_content = state["text_content"]
old_state = {'text_content': 'Old Text'}
new_state = {'text_content': 'New Text'}
old_state = {"text_content": "Old Text"}
new_state = {"text_content": "New Text"}
# Apply new state first
element.text_content = 'New Text'
element.text_content = "New Text"
cmd = StateChangeCommand(
description="Change text",
restore_func=restore_state,
before_state=old_state,
after_state=new_state
description="Change text", restore_func=restore_state, before_state=old_state, after_state=new_state
)
# Undo should restore old state
cmd.undo()
assert element.text_content == 'Old Text'
assert element.text_content == "Old Text"
def test_state_change_redo(self):
"""Test redoing state change"""
element = TextBoxData(
text_content="Old Text",
x=100, y=100,
width=200, height=100
)
element = TextBoxData(text_content="Old Text", x=100, y=100, width=200, height=100)
# Define restore function
def restore_state(state):
element.text_content = state['text_content']
element.text_content = state["text_content"]
old_state = {'text_content': 'Old Text'}
new_state = {'text_content': 'New Text'}
old_state = {"text_content": "Old Text"}
new_state = {"text_content": "New Text"}
# Apply new state first
element.text_content = 'New Text'
element.text_content = "New Text"
cmd = StateChangeCommand(
description="Change text",
restore_func=restore_state,
before_state=old_state,
after_state=new_state
description="Change text", restore_func=restore_state, before_state=old_state, after_state=new_state
)
# Undo then redo
cmd.undo()
assert element.text_content == 'Old Text'
assert element.text_content == "Old Text"
cmd.redo()
assert element.text_content == 'New Text'
assert element.text_content == "New Text"
def test_state_change_serialization(self):
"""Test serializing state change command"""
def restore_func(state):
pass
cmd = StateChangeCommand(
description="Test operation",
restore_func=restore_func,
before_state={'test': 'before'},
after_state={'test': 'after'}
before_state={"test": "before"},
after_state={"test": "after"},
)
data = cmd.serialize()
assert data['type'] == 'state_change'
assert data['description'] == 'Test operation'
assert data["type"] == "state_change"
assert data["description"] == "Test operation"
class TestCommandHistory:
@@ -656,7 +618,7 @@ class TestCommandHistory:
layout = PageLayout(width=210, height=297)
for i in range(5):
element = ImageData(image_path=f"/test{i}.jpg", x=i*10, y=i*10, width=100, height=100)
element = ImageData(image_path=f"/test{i}.jpg", x=i * 10, y=i * 10, width=100, height=100)
history.execute(AddElementCommand(layout, element))
# Should only have 3 commands in history (max_history)
@@ -678,8 +640,8 @@ class TestCommandHistory:
# Serialize
data = history.serialize()
assert len(data['undo_stack']) == 1
assert data['undo_stack'][0]['type'] == 'add_element'
assert len(data["undo_stack"]) == 1
assert data["undo_stack"][0]["type"] == "add_element"
# Create mock project for deserialization
mock_project = Mock()
@@ -734,7 +696,7 @@ class TestCommandHistory:
# Manually build serialized history data
data = {
'undo_stack': [
"undo_stack": [
cmd1.serialize(),
cmd2.serialize(),
cmd3.serialize(),
@@ -745,8 +707,8 @@ class TestCommandHistory:
cmd8.serialize(),
cmd9.serialize(),
],
'redo_stack': [],
'max_history': 100
"redo_stack": [],
"max_history": 100,
}
# Create mock project
@@ -758,28 +720,29 @@ class TestCommandHistory:
new_history.deserialize(data, mock_project)
assert len(new_history.undo_stack) == 9
assert new_history.undo_stack[0].__class__.__name__ == 'AddElementCommand'
assert new_history.undo_stack[1].__class__.__name__ == 'DeleteElementCommand'
assert new_history.undo_stack[2].__class__.__name__ == 'MoveElementCommand'
assert new_history.undo_stack[3].__class__.__name__ == 'ResizeElementCommand'
assert new_history.undo_stack[4].__class__.__name__ == 'RotateElementCommand'
assert new_history.undo_stack[5].__class__.__name__ == 'AdjustImageCropCommand'
assert new_history.undo_stack[6].__class__.__name__ == 'AlignElementsCommand'
assert new_history.undo_stack[7].__class__.__name__ == 'ResizeElementsCommand'
assert new_history.undo_stack[8].__class__.__name__ == 'ChangeZOrderCommand'
assert new_history.undo_stack[0].__class__.__name__ == "AddElementCommand"
assert new_history.undo_stack[1].__class__.__name__ == "DeleteElementCommand"
assert new_history.undo_stack[2].__class__.__name__ == "MoveElementCommand"
assert new_history.undo_stack[3].__class__.__name__ == "ResizeElementCommand"
assert new_history.undo_stack[4].__class__.__name__ == "RotateElementCommand"
assert new_history.undo_stack[5].__class__.__name__ == "AdjustImageCropCommand"
assert new_history.undo_stack[6].__class__.__name__ == "AlignElementsCommand"
assert new_history.undo_stack[7].__class__.__name__ == "ResizeElementsCommand"
assert new_history.undo_stack[8].__class__.__name__ == "ChangeZOrderCommand"
def test_history_deserialize_unknown_command_type(self):
"""Test deserializing unknown command type returns None and continues"""
history = CommandHistory()
mock_project = Mock()
mock_project.pages = []
data = {
'undo_stack': [
{'type': 'unknown_command', 'data': 'test'},
{'type': 'add_element', 'element': ImageData().serialize(), 'executed': True}
"undo_stack": [
{"type": "unknown_command", "data": "test"},
{"type": "add_element", "element": ImageData().serialize(), "executed": True},
],
'redo_stack': [],
'max_history': 100
"redo_stack": [],
"max_history": 100,
}
# Should not raise exception, just skip unknown command
@@ -787,7 +750,7 @@ class TestCommandHistory:
# Should only have the valid command
assert len(history.undo_stack) == 1
assert history.undo_stack[0].__class__.__name__ == 'AddElementCommand'
assert history.undo_stack[0].__class__.__name__ == "AddElementCommand"
def test_history_deserialize_malformed_command(self):
"""Test deserializing malformed command handles exception gracefully"""
@@ -795,13 +758,17 @@ class TestCommandHistory:
mock_project = Mock()
data = {
'undo_stack': [
{'type': 'add_element'}, # Missing required 'element' field
{'type': 'move_element', 'element': ImageData().serialize(),
'old_position': (0, 0), 'new_position': (10, 10)}
"undo_stack": [
{"type": "add_element"}, # Missing required 'element' field
{
"type": "move_element",
"element": ImageData().serialize(),
"old_position": (0, 0),
"new_position": (10, 10),
},
],
'redo_stack': [],
'max_history': 100
"redo_stack": [],
"max_history": 100,
}
# Should not raise exception, just skip malformed command
@@ -809,7 +776,7 @@ class TestCommandHistory:
# Should only have the valid command
assert len(history.undo_stack) == 1
assert history.undo_stack[0].__class__.__name__ == 'MoveElementCommand'
assert history.undo_stack[0].__class__.__name__ == "MoveElementCommand"
def test_history_serialize_deserialize_with_redo_stack(self):
"""Test serializing and deserializing with items in redo stack"""
@@ -826,11 +793,12 @@ class TestCommandHistory:
# Serialize
data = history.serialize()
assert len(data['undo_stack']) == 1
assert len(data['redo_stack']) == 1
assert len(data["undo_stack"]) == 1
assert len(data["redo_stack"]) == 1
# Deserialize
mock_project = Mock()
mock_project.pages = []
new_history = CommandHistory()
new_history.deserialize(data, mock_project)
+10 -18
View File
@@ -56,7 +56,7 @@ class TestGetSelectedElementsList:
class TestDistributeHorizontally:
"""Test distribute_horizontally method"""
@patch('pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager")
def test_distribute_horizontally_success(self, mock_manager, qtbot):
window = TestDistributionWindow()
qtbot.addWidget(window)
@@ -70,7 +70,7 @@ class TestDistributeHorizontally:
mock_manager.distribute_horizontally.return_value = [
(element1, (0, 0)),
(element2, (150, 0)),
(element3, (500, 0))
(element3, (500, 0)),
]
window.distribute_horizontally()
@@ -98,7 +98,7 @@ class TestDistributeHorizontally:
class TestDistributeVertically:
"""Test distribute_vertically method"""
@patch('pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager")
def test_distribute_vertically_success(self, mock_manager, qtbot):
window = TestDistributionWindow()
qtbot.addWidget(window)
@@ -112,7 +112,7 @@ class TestDistributeVertically:
mock_manager.distribute_vertically.return_value = [
(element1, (0, 0)),
(element2, (0, 150)),
(element3, (0, 500))
(element3, (0, 500)),
]
window.distribute_vertically()
@@ -125,7 +125,7 @@ class TestDistributeVertically:
class TestSpaceHorizontally:
"""Test space_horizontally method"""
@patch('pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager")
def test_space_horizontally_success(self, mock_manager, qtbot):
window = TestDistributionWindow()
qtbot.addWidget(window)
@@ -136,11 +136,7 @@ class TestSpaceHorizontally:
window.gl_widget.selected_elements = {element1, element2, element3}
mock_manager.space_horizontally.return_value = [
(element1, (0, 0)),
(element2, (100, 0)),
(element3, (200, 0))
]
mock_manager.space_horizontally.return_value = [(element1, (0, 0)), (element2, (100, 0)), (element3, (200, 0))]
window.space_horizontally()
@@ -152,7 +148,7 @@ class TestSpaceHorizontally:
class TestSpaceVertically:
"""Test space_vertically method"""
@patch('pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager")
def test_space_vertically_success(self, mock_manager, qtbot):
window = TestDistributionWindow()
qtbot.addWidget(window)
@@ -163,11 +159,7 @@ class TestSpaceVertically:
window.gl_widget.selected_elements = {element1, element2, element3}
mock_manager.space_vertically.return_value = [
(element1, (0, 0)),
(element2, (0, 100)),
(element3, (0, 200))
]
mock_manager.space_vertically.return_value = [(element1, (0, 0)), (element2, (0, 100)), (element3, (0, 200))]
window.space_vertically()
@@ -178,7 +170,7 @@ class TestSpaceVertically:
class TestDistributionCommandPattern:
"""Test distribution operations with command pattern"""
@patch('pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager")
def test_distribution_creates_command(self, mock_manager, qtbot):
window = TestDistributionWindow()
qtbot.addWidget(window)
@@ -192,7 +184,7 @@ class TestDistributionCommandPattern:
mock_manager.distribute_horizontally.return_value = [
(element1, (0, 0)),
(element2, (100, 0)),
(element3, (200, 0))
(element3, (200, 0)),
]
assert not window.project.history.can_undo()
+1 -1
View File
@@ -31,7 +31,7 @@ class TestEditWindow(EditOperationsMixin, QMainWindow):
return len(self.gl_widget.selected_elements) >= min_count
def get_current_page(self):
if hasattr(self, '_current_page'):
if hasattr(self, "_current_page"):
return self._current_page
return None
+17 -20
View File
@@ -15,6 +15,7 @@ from pyPhotoAlbum.page_layout import PageLayout
# Create test widget combining necessary mixins
class TestManipulationWidget(ElementManipulationMixin, ElementSelectionMixin, QOpenGLWidget):
"""Test widget combining manipulation and selection mixins"""
def __init__(self):
super().__init__()
self._page_renderers = []
@@ -36,11 +37,7 @@ class TestElementManipulationInitialization:
assert widget.rotation_mode is False
assert widget.rotation_start_angle is None
assert widget.rotation_snap_angle == 15
assert widget.snap_state == {
'is_snapped': False,
'last_position': None,
'last_size': None
}
assert widget.snap_state == {"is_snapped": False, "last_position": None, "last_size": None}
def test_rotation_mode_is_mutable(self, qtbot):
"""Test that rotation mode can be toggled"""
@@ -72,7 +69,7 @@ class TestResizeElementNoSnap:
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
widget.selected_element = elem
widget.resize_handle = 'se'
widget.resize_handle = "se"
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (200, 150)
@@ -89,7 +86,7 @@ class TestResizeElementNoSnap:
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
widget.selected_element = elem
widget.resize_handle = 'nw'
widget.resize_handle = "nw"
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (200, 150)
@@ -106,7 +103,7 @@ class TestResizeElementNoSnap:
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
widget.selected_element = elem
widget.resize_handle = 'ne'
widget.resize_handle = "ne"
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (200, 150)
@@ -123,7 +120,7 @@ class TestResizeElementNoSnap:
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
widget.selected_element = elem
widget.resize_handle = 'sw'
widget.resize_handle = "sw"
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (200, 150)
@@ -140,7 +137,7 @@ class TestResizeElementNoSnap:
elem = ImageData(image_path="test.jpg", x=100, y=100, width=50, height=50)
widget.selected_element = elem
widget.resize_handle = 'se'
widget.resize_handle = "se"
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (50, 50)
@@ -157,7 +154,7 @@ class TestResizeElementNoSnap:
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
widget.selected_element = elem
widget.resize_handle = 'se'
widget.resize_handle = "se"
# Don't set resize_start_pos or resize_start_size
original_pos = elem.position
@@ -185,7 +182,7 @@ class TestResizeElementWithSnap:
elem._parent_page = page
widget.selected_element = elem
widget.resize_handle = 'se'
widget.resize_handle = "se"
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (200, 150)
@@ -208,7 +205,7 @@ class TestResizeElementWithSnap:
params = call_args[0][0]
assert params.dx == 50
assert params.dy == 30
assert params.resize_handle == 'se'
assert params.resize_handle == "se"
# Verify element was updated
assert elem.size == (250, 180)
@@ -220,7 +217,7 @@ class TestResizeElementWithSnap:
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
widget.selected_element = elem
widget.resize_handle = 'se'
widget.resize_handle = "se"
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (200, 150)
@@ -241,7 +238,7 @@ class TestResizeElementWithSnap:
elem._parent_page = page
widget.selected_element = elem
widget.resize_handle = 'se'
widget.resize_handle = "se"
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (50, 50)
@@ -344,20 +341,20 @@ class TestManipulationStateManagement:
widget = TestManipulationWidget()
qtbot.addWidget(widget)
assert 'is_snapped' in widget.snap_state
assert 'last_position' in widget.snap_state
assert 'last_size' in widget.snap_state
assert "is_snapped" in widget.snap_state
assert "last_position" in widget.snap_state
assert "last_size" in widget.snap_state
def test_resize_state_can_be_set(self, qtbot):
"""Test resize state variables can be set"""
widget = TestManipulationWidget()
qtbot.addWidget(widget)
widget.resize_handle = 'nw'
widget.resize_handle = "nw"
widget.resize_start_pos = (10, 20)
widget.resize_start_size = (100, 200)
assert widget.resize_handle == 'nw'
assert widget.resize_handle == "nw"
assert widget.resize_start_pos == (10, 20)
assert widget.resize_start_size == (100, 200)
+1
View File
@@ -258,6 +258,7 @@ class TestElementMaximizer:
def test_maximize_empty_elements(self):
"""Test maximize with empty element list."""
from pyPhotoAlbum.alignment import AlignmentManager
result = AlignmentManager.maximize_pattern([], (200.0, 200.0))
assert result == []
+13 -13
View File
@@ -43,11 +43,11 @@ class TestElementWindow(ElementOperationsMixin, AssetPathMixin, QMainWindow):
def require_page(self):
"""Track require_page calls"""
self._require_page_called = True
return self._current_page is not None if hasattr(self, '_current_page') else False
return self._current_page is not None if hasattr(self, "_current_page") else False
def get_current_page(self):
"""Return mock current page"""
if hasattr(self, '_current_page'):
if hasattr(self, "_current_page"):
return self._current_page
return None
@@ -71,8 +71,8 @@ class TestElementWindow(ElementOperationsMixin, AssetPathMixin, QMainWindow):
class TestAddImage:
"""Test add_image method"""
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
@patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions')
@patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName")
@patch("pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions")
def test_add_image_success(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test successfully adding an image"""
window = TestElementWindow()
@@ -106,7 +106,7 @@ class TestAddImage:
assert window._update_view_called
assert "added image" in window._status_message.lower()
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
@patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName")
def test_add_image_cancelled(self, mock_file_dialog, qtbot):
"""Test cancelling image selection"""
window = TestElementWindow()
@@ -139,8 +139,8 @@ class TestAddImage:
assert window._require_page_called
assert not window._update_view_called
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
@patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions')
@patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName")
@patch("pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions")
def test_add_image_scales_large_image(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test that large images are scaled down"""
window = TestElementWindow()
@@ -164,8 +164,8 @@ class TestAddImage:
# Image should be added (scaled down by get_image_dimensions)
assert window._update_view_called
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
@patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions')
@patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName")
@patch("pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions")
def test_add_image_fallback_dimensions(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test fallback dimensions when get_image_dimensions returns None"""
window = TestElementWindow()
@@ -294,8 +294,8 @@ class TestAddPlaceholder:
class TestElementOperationsIntegration:
"""Test integration between element operations"""
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
@patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions')
@patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName")
@patch("pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions")
def test_add_multiple_elements(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test adding multiple different element types"""
window = TestElementWindow()
@@ -326,8 +326,8 @@ class TestElementOperationsIntegration:
# Should have added all three elements
assert window._update_view_called
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
@patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions')
@patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName")
@patch("pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions")
def test_add_image_with_undo(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test that adding image can be undone"""
window = TestElementWindow()

Some files were not shown because too many files have changed in this diff Show More