Update coverage badges [skip ci]
35
.dockerignore
Normal 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
|
||||
182
.gitea/workflows/ci.yml
Normal file
@ -0,0 +1,182 @@
|
||||
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"
|
||||
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
|
||||
38
.gitea/workflows/lint.yml
Normal file
@ -0,0 +1,38 @@
|
||||
name: Lint
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-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
|
||||
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: Check formatting with black
|
||||
run: |
|
||||
black --check pyPhotoAlbum
|
||||
continue-on-error: true
|
||||
|
||||
- name: Type check with mypy
|
||||
run: |
|
||||
mypy pyPhotoAlbum --ignore-missing-imports
|
||||
continue-on-error: true
|
||||
123
.gitea/workflows/tests.yml
Normal file
@ -0,0 +1,123 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, develop]
|
||||
paths-ignore:
|
||||
- 'coverage*.svg'
|
||||
- 'README.md'
|
||||
pull_request:
|
||||
branches: [main, master, develop]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.11', '3.12', '3.13', '3.14']
|
||||
|
||||
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 Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[dev]"
|
||||
pip install coverage-badge interrogate
|
||||
|
||||
- name: Download initial failed badges
|
||||
run: |
|
||||
mkdir -p cov_info
|
||||
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"
|
||||
|
||||
- name: Run tests with coverage
|
||||
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
|
||||
env:
|
||||
QT_QPA_PLATFORM: offscreen
|
||||
|
||||
- name: Check documentation coverage
|
||||
id: docs
|
||||
continue-on-error: true
|
||||
run: |
|
||||
interrogate -v --ignore-init-method --ignore-init-module --ignore-magic --ignore-private --ignore-property-decorators --ignore-semiprivate --fail-under=80 pyPhotoAlbum/
|
||||
|
||||
- name: Update test coverage badge on success
|
||||
if: steps.pytest.outcome == 'success' && always()
|
||||
run: |
|
||||
if [ -f coverage.json ]; then
|
||||
coverage-badge -o cov_info/coverage.svg -f
|
||||
echo "✅ Test coverage badge updated"
|
||||
fi
|
||||
|
||||
- name: Update docs coverage badge on success
|
||||
if: steps.docs.outcome == 'success' && always()
|
||||
run: |
|
||||
rm -f cov_info/coverage-docs.svg
|
||||
interrogate --generate-badge cov_info/coverage-docs.svg pyPhotoAlbum/
|
||||
echo "✅ Docs coverage badge updated"
|
||||
|
||||
- name: Generate coverage reports
|
||||
if: steps.pytest.outcome == 'success'
|
||||
run: |
|
||||
python -c "
|
||||
import json
|
||||
import os
|
||||
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)
|
||||
with open('cov_info/coverage-summary.txt', 'w') as f:
|
||||
f.write(f'{total_coverage}%')
|
||||
print(f'Test Coverage: {total_coverage}%')
|
||||
"
|
||||
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 }}"
|
||||
ls -la cov_info/ 2>/dev/null || echo "No cov_info directory"
|
||||
|
||||
- name: Upload coverage artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
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'
|
||||
run: |
|
||||
git config --local user.email "action@gitea.local"
|
||||
git config --local user.name "Gitea Action"
|
||||
|
||||
git remote set-url origin https://${{ secrets.PUSH_TOKEN }}@gitea.tourolle.paris/dtourolle/pyPhotoAlbum.git
|
||||
|
||||
git checkout --orphan badges
|
||||
|
||||
find . -maxdepth 1 -not -name '.git' -not -name 'cov_info' -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
git add -f cov_info/
|
||||
|
||||
git commit -m "Update coverage badges [skip ci]"
|
||||
git push -f origin badges
|
||||
140
.gitignore
vendored
Normal file
@ -0,0 +1,140 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
coverage.json
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cov_info/
|
||||
coverage*.svg
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# pipenv
|
||||
Pipfile.lock
|
||||
|
||||
# PEP 582
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Project specific
|
||||
projects/
|
||||
*.ppz # pyPhotoAlbum project ZIP files (user projects)
|
||||
*.pyc
|
||||
.vscode/settings.json
|
||||
95
GET_STARTED.md
Normal file
@ -0,0 +1,95 @@
|
||||
# 🚀 Get Started with pyPhotoAlbum
|
||||
|
||||
## Quick Installation (Recommended)
|
||||
|
||||
```bash
|
||||
# Generate icons and install everything
|
||||
./generate_icons.sh && ./install.sh
|
||||
|
||||
# Launch the app
|
||||
pyphotoalbum
|
||||
```
|
||||
|
||||
After installation, log out and back in to refresh GNOME. You can then launch from the terminal or by searching "photo" in Activities.
|
||||
|
||||
---
|
||||
|
||||
## Alternative Installation Methods
|
||||
|
||||
### 📦 Fedora RPM Package
|
||||
|
||||
```bash
|
||||
# Create source tarball
|
||||
cd ..
|
||||
tar czf pyphotoalbum-0.1.0.tar.gz pyPhotoAlbum/ --exclude=.git --exclude=venv --exclude=__pycache__
|
||||
mv pyphotoalbum-0.1.0.tar.gz ~/rpmbuild/SOURCES/
|
||||
cd pyPhotoAlbum
|
||||
|
||||
# Build and install
|
||||
rpmbuild -ba pyphotoalbum.spec
|
||||
sudo dnf install ~/rpmbuild/RPMS/noarch/pyphotoalbum-0.1.0-1.*.noarch.rpm
|
||||
```
|
||||
|
||||
### 📦 Arch/CachyOS Package
|
||||
|
||||
```bash
|
||||
# Create source tarball
|
||||
cd ..
|
||||
tar czf pyphotoalbum-0.1.0.tar.gz pyPhotoAlbum/ --exclude=.git --exclude=venv --exclude=__pycache__
|
||||
mv pyphotoalbum-0.1.0.tar.gz pyPhotoAlbum/
|
||||
cd pyPhotoAlbum
|
||||
|
||||
# Build and install
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
### 💻 Development Installation
|
||||
|
||||
```bash
|
||||
# Create virtual environment
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# Install in editable mode with dev tools
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Run the app
|
||||
python pyPhotoAlbum/main.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Features After Installation
|
||||
|
||||
✅ **Command:** `pyphotoalbum` available in terminal
|
||||
✅ **Icon:** Beautiful camera icon in GNOME Activities
|
||||
✅ **Taskbar:** Proper icon when app is running
|
||||
✅ **Menu:** Right-click for "New Project" action
|
||||
✅ **Search:** Find by typing "photo" in Activities
|
||||
✅ **Grouping:** Multiple windows group under one icon
|
||||
|
||||
---
|
||||
|
||||
## 🆘 Troubleshooting
|
||||
|
||||
### Command not found?
|
||||
```bash
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
|
||||
### Icon not showing?
|
||||
```bash
|
||||
./generate_icons.sh
|
||||
./install.sh
|
||||
rm -f ~/.cache/icon-cache.kcache
|
||||
```
|
||||
Then log out and back in.
|
||||
|
||||
### Need detailed help?
|
||||
See [INSTALLATION.md](INSTALLATION.md) for comprehensive installation instructions and troubleshooting.
|
||||
|
||||
---
|
||||
|
||||
**Estimated time:** 5-10 minutes
|
||||
**Questions?** Open an issue or check [INSTALLATION.md](INSTALLATION.md)
|
||||
493
INSTALLATION.md
Normal file
@ -0,0 +1,493 @@
|
||||
# pyPhotoAlbum Installation Guide
|
||||
|
||||
This guide provides multiple installation methods for pyPhotoAlbum on Linux systems, with specific instructions for Fedora and Arch/CachyOS.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Quick Install (Recommended)](#quick-install-recommended)
|
||||
- [Manual Installation](#manual-installation)
|
||||
- [Distribution-Specific Packages](#distribution-specific-packages)
|
||||
- [Fedora (RPM)](#fedora-rpm)
|
||||
- [Arch/CachyOS (PKGBUILD)](#archcachyos-pkgbuild)
|
||||
- [Development Installation](#development-installation)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Quick Install (Recommended)
|
||||
|
||||
The easiest way to install pyPhotoAlbum is using the provided installation script:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://gitea.tourolle.paris/dtourolle/pyPhotoAlbum.git
|
||||
cd pyPhotoAlbum
|
||||
|
||||
# Run the installation script
|
||||
./install.sh
|
||||
```
|
||||
|
||||
The script will:
|
||||
1. Detect your distribution (Fedora, Arch, Ubuntu, etc.)
|
||||
2. Offer to install system dependencies
|
||||
3. Install pyPhotoAlbum
|
||||
4. Set up desktop integration (icon and menu entry)
|
||||
|
||||
### Installation Modes
|
||||
|
||||
**User installation (default):**
|
||||
```bash
|
||||
./install.sh
|
||||
```
|
||||
- Installs to `~/.local/`
|
||||
- No root privileges required
|
||||
- Only affects current user
|
||||
|
||||
**System-wide installation:**
|
||||
```bash
|
||||
sudo ./install.sh --system
|
||||
```
|
||||
- Installs to `/usr/`
|
||||
- Requires root privileges
|
||||
- Available to all users
|
||||
|
||||
---
|
||||
|
||||
## Manual Installation
|
||||
|
||||
### Step 1: Install Dependencies
|
||||
|
||||
**Fedora:**
|
||||
```bash
|
||||
sudo dnf install python3 python3-pip python3-qt6 python3-pyopengl \
|
||||
python3-numpy python3-pillow python3-reportlab python3-lxml
|
||||
```
|
||||
|
||||
**Arch/CachyOS:**
|
||||
```bash
|
||||
sudo pacman -S python python-pip python-pyqt6 python-pyopengl \
|
||||
python-numpy python-pillow python-reportlab python-lxml
|
||||
```
|
||||
|
||||
**Ubuntu/Debian:**
|
||||
```bash
|
||||
sudo apt install python3 python3-pip python3-pyqt6 python3-opengl \
|
||||
python3-numpy python3-pil python3-reportlab python3-lxml
|
||||
```
|
||||
|
||||
**Other distributions:**
|
||||
|
||||
If your distribution isn't listed, install these Python packages via pip:
|
||||
```bash
|
||||
pip install --user PyQt6 PyOpenGL numpy Pillow reportlab lxml
|
||||
```
|
||||
|
||||
### Step 2: Install pyPhotoAlbum
|
||||
|
||||
**For current user only:**
|
||||
```bash
|
||||
cd pyPhotoAlbum
|
||||
pip install --user .
|
||||
```
|
||||
|
||||
**System-wide:**
|
||||
```bash
|
||||
cd pyPhotoAlbum
|
||||
sudo pip install .
|
||||
```
|
||||
|
||||
### Step 3: Desktop Integration (Optional)
|
||||
|
||||
**User installation:**
|
||||
```bash
|
||||
# Create directories
|
||||
mkdir -p ~/.local/share/applications
|
||||
mkdir -p ~/.local/share/icons/hicolor/256x256/apps
|
||||
|
||||
# Install files
|
||||
cp pyphotoalbum.desktop ~/.local/share/applications/
|
||||
cp pyPhotoAlbum/icons/icon.png ~/.local/share/icons/hicolor/256x256/apps/pyphotoalbum.png
|
||||
|
||||
# Update caches
|
||||
update-desktop-database ~/.local/share/applications
|
||||
gtk-update-icon-cache ~/.local/share/icons/hicolor/
|
||||
```
|
||||
|
||||
**System-wide:**
|
||||
```bash
|
||||
sudo install -Dm644 pyphotoalbum.desktop /usr/share/applications/pyphotoalbum.desktop
|
||||
sudo install -Dm644 pyPhotoAlbum/icons/icon.png /usr/share/icons/hicolor/256x256/apps/pyphotoalbum.png
|
||||
sudo update-desktop-database /usr/share/applications
|
||||
sudo gtk-update-icon-cache /usr/share/icons/hicolor/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Distribution-Specific Packages
|
||||
|
||||
### Fedora (RPM)
|
||||
|
||||
Build and install an RPM package for Fedora:
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
```bash
|
||||
sudo dnf install rpm-build rpmdevtools
|
||||
```
|
||||
|
||||
#### Build Source Tarball
|
||||
|
||||
```bash
|
||||
# From the project root
|
||||
cd ..
|
||||
tar czf pyphotoalbum-0.1.0.tar.gz pyPhotoAlbum/
|
||||
mv pyphotoalbum-0.1.0.tar.gz ~/rpmbuild/SOURCES/
|
||||
```
|
||||
|
||||
#### Build RPM
|
||||
|
||||
```bash
|
||||
cd pyPhotoAlbum
|
||||
rpmbuild -ba pyphotoalbum.spec
|
||||
```
|
||||
|
||||
The RPM will be created in `~/rpmbuild/RPMS/noarch/`
|
||||
|
||||
#### Install RPM
|
||||
|
||||
```bash
|
||||
sudo dnf install ~/rpmbuild/RPMS/noarch/pyphotoalbum-0.1.0-1.*.noarch.rpm
|
||||
```
|
||||
|
||||
#### Create Local Repository (Optional)
|
||||
|
||||
To create a local yum repository:
|
||||
|
||||
```bash
|
||||
# Create repository directory
|
||||
sudo mkdir -p /var/local-repo
|
||||
|
||||
# Copy RPM
|
||||
sudo cp ~/rpmbuild/RPMS/noarch/pyphotoalbum-*.rpm /var/local-repo/
|
||||
|
||||
# Create repository metadata
|
||||
sudo createrepo /var/local-repo
|
||||
|
||||
# Add repository configuration
|
||||
sudo tee /etc/yum.repos.d/local.repo << EOF
|
||||
[local]
|
||||
name=Local Repository
|
||||
baseurl=file:///var/local-repo
|
||||
enabled=1
|
||||
gpgcheck=0
|
||||
EOF
|
||||
|
||||
# Install from local repository
|
||||
sudo dnf install pyphotoalbum
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Arch/CachyOS (PKGBUILD)
|
||||
|
||||
Build and install using the provided PKGBUILD:
|
||||
|
||||
#### Build Source Tarball
|
||||
|
||||
```bash
|
||||
# From the project root
|
||||
cd ..
|
||||
tar czf pyphotoalbum-0.1.0.tar.gz pyPhotoAlbum/
|
||||
mv pyphotoalbum-0.1.0.tar.gz pyPhotoAlbum/
|
||||
cd pyPhotoAlbum
|
||||
```
|
||||
|
||||
#### Build Package
|
||||
|
||||
```bash
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
This will:
|
||||
- Build the package
|
||||
- Install it automatically (`-i` flag)
|
||||
- Sync dependencies (`-s` flag)
|
||||
|
||||
#### Build Without Installing
|
||||
|
||||
```bash
|
||||
makepkg
|
||||
```
|
||||
|
||||
The package will be created as `pyphotoalbum-0.1.0-1-any.pkg.tar.zst`
|
||||
|
||||
#### Install Package
|
||||
|
||||
```bash
|
||||
sudo pacman -U pyphotoalbum-0.1.0-1-any.pkg.tar.zst
|
||||
```
|
||||
|
||||
#### Create Local Repository (Optional)
|
||||
|
||||
To create a local pacman repository:
|
||||
|
||||
```bash
|
||||
# Create repository directory
|
||||
mkdir -p ~/local-repo
|
||||
|
||||
# Copy package
|
||||
cp pyphotoalbum-*.pkg.tar.zst ~/local-repo/
|
||||
|
||||
# Create repository database
|
||||
cd ~/local-repo
|
||||
repo-add local.db.tar.gz pyphotoalbum-*.pkg.tar.zst
|
||||
|
||||
# Add repository to pacman.conf
|
||||
sudo tee -a /etc/pacman.conf << EOF
|
||||
|
||||
[local]
|
||||
SigLevel = Optional TrustAll
|
||||
Server = file:///home/$USER/local-repo
|
||||
EOF
|
||||
|
||||
# Update and install
|
||||
sudo pacman -Sy pyphotoalbum
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development Installation
|
||||
|
||||
For development work, install in editable mode:
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://gitea.tourolle.paris/dtourolle/pyPhotoAlbum.git
|
||||
cd pyPhotoAlbum
|
||||
|
||||
# Create virtual environment (recommended)
|
||||
python -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
|
||||
# Install in editable mode with development dependencies
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Run tests
|
||||
pytest
|
||||
|
||||
# Run application
|
||||
pyphotoalbum
|
||||
# or
|
||||
python pyPhotoAlbum/main.py
|
||||
```
|
||||
|
||||
### Development Tools
|
||||
|
||||
The development installation includes:
|
||||
- **pytest** - Testing framework
|
||||
- **pytest-qt** - Qt testing support
|
||||
- **pytest-cov** - Coverage reporting
|
||||
- **pytest-mock** - Mocking utilities
|
||||
- **flake8** - Linting
|
||||
- **black** - Code formatting
|
||||
- **mypy** - Type checking
|
||||
|
||||
### Running Development Tools
|
||||
|
||||
```bash
|
||||
# Format code
|
||||
black pyPhotoAlbum tests
|
||||
|
||||
# Run linter
|
||||
flake8 pyPhotoAlbum tests
|
||||
|
||||
# Type checking
|
||||
mypy pyPhotoAlbum
|
||||
|
||||
# Run tests with coverage
|
||||
pytest --cov=pyPhotoAlbum --cov-report=html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Command not found: pyphotoalbum
|
||||
|
||||
**Issue:** After user installation, the `pyphotoalbum` command is not found.
|
||||
|
||||
**Solution:** Add `~/.local/bin` to your PATH:
|
||||
|
||||
```bash
|
||||
# Add to ~/.bashrc or ~/.zshrc
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
|
||||
### Application doesn't appear in menu
|
||||
|
||||
**Issue:** Desktop entry not showing in application menu.
|
||||
|
||||
**Solution:** Update desktop database:
|
||||
|
||||
```bash
|
||||
# For user installation
|
||||
update-desktop-database ~/.local/share/applications
|
||||
|
||||
# For system installation
|
||||
sudo update-desktop-database /usr/share/applications
|
||||
```
|
||||
|
||||
You may need to log out and back in or restart your desktop environment.
|
||||
|
||||
### Icon not displaying
|
||||
|
||||
**Issue:** Application icon not showing in menu or taskbar.
|
||||
|
||||
**Solution:** Update icon cache:
|
||||
|
||||
```bash
|
||||
# For user installation
|
||||
gtk-update-icon-cache ~/.local/share/icons/hicolor/
|
||||
|
||||
# For system installation
|
||||
sudo gtk-update-icon-cache /usr/share/icons/hicolor/
|
||||
```
|
||||
|
||||
### PyQt6 import errors
|
||||
|
||||
**Issue:** `ImportError: cannot import name 'xxx' from 'PyQt6'`
|
||||
|
||||
**Solution:** Ensure PyQt6 is properly installed:
|
||||
|
||||
```bash
|
||||
# Uninstall and reinstall
|
||||
pip uninstall PyQt6 PyQt6-Qt6 PyQt6-sip
|
||||
pip install PyQt6
|
||||
```
|
||||
|
||||
### OpenGL errors
|
||||
|
||||
**Issue:** OpenGL-related errors when starting the application.
|
||||
|
||||
**Solution:** Install OpenGL libraries:
|
||||
|
||||
**Fedora:**
|
||||
```bash
|
||||
sudo dnf install mesa-libGL mesa-libGL-devel
|
||||
```
|
||||
|
||||
**Arch/CachyOS:**
|
||||
```bash
|
||||
sudo pacman -S mesa libglvnd
|
||||
```
|
||||
|
||||
### Permission denied errors
|
||||
|
||||
**Issue:** Permission errors during system-wide installation.
|
||||
|
||||
**Solution:** Use `sudo` or switch to user installation:
|
||||
|
||||
```bash
|
||||
# User installation (no sudo needed)
|
||||
pip install --user .
|
||||
./install.sh # Without --system flag
|
||||
```
|
||||
|
||||
### Building RPM fails
|
||||
|
||||
**Issue:** Missing build dependencies for RPM.
|
||||
|
||||
**Solution:** Install all build requirements:
|
||||
|
||||
```bash
|
||||
sudo dnf install rpm-build rpmdevtools python3-devel python3-setuptools \
|
||||
python3-pip desktop-file-utils
|
||||
```
|
||||
|
||||
### Building on Arch fails
|
||||
|
||||
**Issue:** Missing dependencies when running makepkg.
|
||||
|
||||
**Solution:** Install build dependencies:
|
||||
|
||||
```bash
|
||||
sudo pacman -S base-devel python-build python-installer python-wheel
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verifying Installation
|
||||
|
||||
After installation, verify it works:
|
||||
|
||||
```bash
|
||||
# Check if command is available
|
||||
which pyphotoalbum
|
||||
|
||||
# Check Python package
|
||||
python -c "import pyPhotoAlbum; print(pyPhotoAlbum.__file__)"
|
||||
|
||||
# Run application
|
||||
pyphotoalbum --version # If version flag is implemented
|
||||
pyphotoalbum
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Uninstallation
|
||||
|
||||
### User Installation
|
||||
|
||||
```bash
|
||||
pip uninstall pyphotoalbum
|
||||
rm ~/.local/share/applications/pyphotoalbum.desktop
|
||||
rm ~/.local/share/icons/hicolor/256x256/apps/pyphotoalbum.png
|
||||
update-desktop-database ~/.local/share/applications
|
||||
```
|
||||
|
||||
### System Installation
|
||||
|
||||
```bash
|
||||
sudo pip uninstall pyphotoalbum
|
||||
sudo rm /usr/share/applications/pyphotoalbum.desktop
|
||||
sudo rm /usr/share/icons/hicolor/256x256/apps/pyphotoalbum.png
|
||||
sudo update-desktop-database /usr/share/applications
|
||||
```
|
||||
|
||||
### RPM (Fedora)
|
||||
|
||||
```bash
|
||||
sudo dnf remove pyphotoalbum
|
||||
```
|
||||
|
||||
### Pacman (Arch/CachyOS)
|
||||
|
||||
```bash
|
||||
sudo pacman -R pyphotoalbum
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Getting Help
|
||||
|
||||
If you encounter issues not covered here:
|
||||
|
||||
1. Check the [README.md](README.md) for general information
|
||||
2. Search existing issues: https://gitea.tourolle.paris/dtourolle/pyPhotoAlbum/issues
|
||||
3. Create a new issue with:
|
||||
- Your distribution and version
|
||||
- Installation method used
|
||||
- Complete error messages
|
||||
- Output of `python --version` and `pip list | grep -i pyqt`
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
After installation, see:
|
||||
- [README.md](README.md) - General usage and features
|
||||
- [EMBEDDED_TEMPLATES.md](pyPhotoAlbum/EMBEDDED_TEMPLATES.md) - Template system
|
||||
- Examples in the `examples/` directory
|
||||
|
||||
Enjoy using pyPhotoAlbum!
|
||||
541
MERGE_FEATURE.md
Normal file
@ -0,0 +1,541 @@
|
||||
# Project Merge & Conflict Resolution Feature
|
||||
|
||||
## Overview
|
||||
|
||||
pyPhotoAlbum v3.0 introduces comprehensive merge conflict resolution support, enabling multiple users to edit the same album and merge their changes intelligently. The system uses UUIDs, timestamps, and a project ID to track changes and resolve conflicts.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Key Features](#key-features)
|
||||
- [How It Works](#how-it-works)
|
||||
- [File Format Changes (v3.0)](#file-format-changes-v30)
|
||||
- [User Guide](#user-guide)
|
||||
- [Developer Guide](#developer-guide)
|
||||
- [Testing](#testing)
|
||||
- [Migration from v2.0](#migration-from-v20)
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. **Project ID-Based Merge Detection**
|
||||
- Each project has a unique `project_id` (UUID)
|
||||
- **Same project_id** → Merge with conflict resolution
|
||||
- **Different project_id** → Concatenate (combine all pages)
|
||||
|
||||
### 2. **UUID-Based Element Tracking**
|
||||
- Every page and element has a stable UUID
|
||||
- Elements can be tracked even when page numbers or z-order changes
|
||||
- Enables reliable conflict detection across versions
|
||||
|
||||
### 3. **Timestamp-Based Conflict Resolution**
|
||||
- All changes tracked with `created` and `last_modified` timestamps (ISO 8601 UTC)
|
||||
- Automatic "Latest Wins" strategy available
|
||||
- Manual conflict resolution through visual dialog
|
||||
|
||||
### 4. **Soft Delete Support**
|
||||
- Deleted items marked with `deleted` flag and `deleted_at` timestamp
|
||||
- Prevents resurrection conflicts
|
||||
- Tombstone pattern ensures deleted items stay deleted
|
||||
|
||||
### 5. **Visual Merge Dialog**
|
||||
- Side-by-side comparison of conflicting changes
|
||||
- Page previews and element details
|
||||
- Multiple resolution strategies:
|
||||
- **Latest Wins**: Most recent change wins (automatic)
|
||||
- **Always Use Yours**: Keep all local changes
|
||||
- **Always Use Theirs**: Accept all remote changes
|
||||
- **Manual**: Choose per-conflict
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
### Merge Workflow
|
||||
|
||||
```
|
||||
1. User clicks "Merge Projects" in File ribbon tab
|
||||
↓
|
||||
2. Select .ppz file to merge
|
||||
↓
|
||||
3. System compares project_ids
|
||||
├─→ Same ID: Detect conflicts → Show merge dialog
|
||||
└─→ Different ID: Ask to concatenate
|
||||
↓
|
||||
4. User resolves conflicts (if any)
|
||||
↓
|
||||
5. Merged project becomes current project
|
||||
↓
|
||||
6. User saves merged project
|
||||
```
|
||||
|
||||
### Conflict Detection
|
||||
|
||||
The system detects three types of conflicts:
|
||||
|
||||
#### 1. **Page-Level Conflicts**
|
||||
- Page modified in both versions
|
||||
- Page deleted in one, modified in other
|
||||
- Page properties changed (size, type, etc.)
|
||||
|
||||
#### 2. **Element-Level Conflicts**
|
||||
- Element modified in both versions (position, size, rotation, content)
|
||||
- Element deleted in one, modified in other
|
||||
- Element properties changed differently
|
||||
|
||||
#### 3. **Project-Level Conflicts**
|
||||
- Settings changed in both (page size, DPI, cover settings, etc.)
|
||||
|
||||
### Automatic Conflict Resolution
|
||||
|
||||
**Non-conflicting changes** are automatically merged:
|
||||
- Page 1 modified in version A, Page 2 modified in version B → Keep both
|
||||
- New pages added at different positions → Merge both sets
|
||||
- Different elements modified → Keep all modifications
|
||||
|
||||
**Conflicting changes** require resolution:
|
||||
- Same element modified in both versions
|
||||
- Element/page deleted in one but modified in other
|
||||
|
||||
---
|
||||
|
||||
## File Format Changes (v3.0)
|
||||
|
||||
### What's New in v3.0
|
||||
|
||||
#### Project Level
|
||||
```json
|
||||
{
|
||||
"data_version": "3.0",
|
||||
"project_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"created": "2025-01-22T10:30:00.123456+00:00",
|
||||
"last_modified": "2025-01-22T14:45:12.789012+00:00",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
#### Page Level
|
||||
```json
|
||||
{
|
||||
"page_number": 1,
|
||||
"uuid": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
|
||||
"created": "2025-01-22T10:30:00.123456+00:00",
|
||||
"last_modified": "2025-01-22T11:15:30.456789+00:00",
|
||||
"deleted": false,
|
||||
"deleted_at": null,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
#### Element Level
|
||||
```json
|
||||
{
|
||||
"type": "image",
|
||||
"uuid": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
|
||||
"created": "2025-01-22T10:30:00.123456+00:00",
|
||||
"last_modified": "2025-01-22T13:20:45.123456+00:00",
|
||||
"deleted": false,
|
||||
"deleted_at": null,
|
||||
"position": [10, 10],
|
||||
"size": [100, 100],
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### Backwards Compatibility
|
||||
|
||||
- **v3.0 can read v2.0 and v1.0 files** with automatic migration
|
||||
- **v2.0/v1.0 cannot read v3.0 files** (breaking change)
|
||||
- Migration automatically generates UUIDs and timestamps for old files
|
||||
|
||||
---
|
||||
|
||||
## User Guide
|
||||
|
||||
### How to Merge Two Album Versions
|
||||
|
||||
1. **Open your current album** in pyPhotoAlbum
|
||||
|
||||
2. **Click "Merge Projects"** in the File tab of the ribbon
|
||||
|
||||
3. **Select the other album file** (.ppz) to merge
|
||||
|
||||
4. **System analyzes the projects:**
|
||||
- If they're the same album (same project_id):
|
||||
- Shows conflicts requiring resolution
|
||||
- Auto-merges non-conflicting changes
|
||||
- If they're different albums:
|
||||
- Asks if you want to combine all pages
|
||||
|
||||
5. **Resolve conflicts** (if merging same album):
|
||||
- View side-by-side comparison
|
||||
- Choose "Use Your Version" or "Use Other Version" for each conflict
|
||||
- Or click "Auto-Resolve All" with a strategy:
|
||||
- **Latest Wins**: Keeps most recently modified version
|
||||
- **Always Use Yours**: Keeps all your changes
|
||||
- **Always Use Theirs**: Accepts all their changes
|
||||
|
||||
6. **Click "Apply Merge"** to complete the merge
|
||||
|
||||
7. **Save the merged album** when ready
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Save before merging** - The system will prompt you, but it's good practice
|
||||
|
||||
2. **Use cloud sync carefully** - If using Dropbox/Google Drive:
|
||||
- Each person should have their own working copy
|
||||
- Merge explicitly rather than relying on cloud sync conflicts
|
||||
|
||||
3. **Communicate with collaborators** - Agree on who edits which pages to minimize conflicts
|
||||
|
||||
4. **Review the merge** - Check the merged result before saving
|
||||
|
||||
5. **Keep backups** - The autosave system creates checkpoints, but manual backups are recommended
|
||||
|
||||
### Common Scenarios
|
||||
|
||||
#### Scenario 1: You and a Friend Edit Different Pages
|
||||
- **Result**: Auto-merge ✅
|
||||
- No conflicts, both sets of changes preserved
|
||||
|
||||
#### Scenario 2: You Both Edit the Same Image Position
|
||||
- **Result**: Conflict resolution needed ⚠️
|
||||
- You choose which position to keep
|
||||
|
||||
#### Scenario 3: You Delete an Image, They Move It
|
||||
- **Result**: Conflict resolution needed ⚠️
|
||||
- You choose: keep it deleted or use their moved version
|
||||
|
||||
#### Scenario 4: Combining Two Different Albums
|
||||
- **Result**: Concatenation
|
||||
- All pages from both albums combined into one
|
||||
|
||||
---
|
||||
|
||||
## Developer Guide
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
pyPhotoAlbum/
|
||||
├── models.py # BaseLayoutElement with UUID/timestamp support
|
||||
├── project.py # Project and Page with UUID/timestamp support
|
||||
├── version_manager.py # v3.0 migration logic
|
||||
├── project_serializer.py # Save/load with v3.0 support
|
||||
├── merge_manager.py # Core merge conflict detection & resolution
|
||||
├── merge_dialog.py # Qt UI for visual conflict resolution
|
||||
└── mixins/operations/
|
||||
└── merge_ops.py # Ribbon integration & workflow
|
||||
```
|
||||
|
||||
### Key Classes
|
||||
|
||||
#### MergeManager
|
||||
```python
|
||||
from pyPhotoAlbum.merge_manager import MergeManager, MergeStrategy
|
||||
|
||||
manager = MergeManager()
|
||||
|
||||
# Check if projects should be merged or concatenated
|
||||
should_merge = manager.should_merge_projects(project_a_data, project_b_data)
|
||||
|
||||
# Detect conflicts
|
||||
conflicts = manager.detect_conflicts(our_data, their_data)
|
||||
|
||||
# Auto-resolve
|
||||
resolutions = manager.auto_resolve_conflicts(MergeStrategy.LATEST_WINS)
|
||||
|
||||
# Apply merge
|
||||
merged_data = manager.apply_resolutions(our_data, their_data, resolutions)
|
||||
```
|
||||
|
||||
#### Data Model Updates
|
||||
|
||||
```python
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
from pyPhotoAlbum.project import Page, Project
|
||||
|
||||
# All elements now have:
|
||||
element = ImageData(...)
|
||||
element.uuid # Auto-generated UUID
|
||||
element.created # ISO 8601 timestamp
|
||||
element.last_modified # ISO 8601 timestamp
|
||||
element.deleted # Boolean flag
|
||||
element.deleted_at # Timestamp when deleted
|
||||
|
||||
# Mark as modified
|
||||
element.mark_modified() # Updates last_modified
|
||||
|
||||
# Mark as deleted
|
||||
element.mark_deleted() # Sets deleted=True, deleted_at=now
|
||||
|
||||
# Same for pages and projects
|
||||
page.mark_modified()
|
||||
project.mark_modified()
|
||||
```
|
||||
|
||||
### Adding Merge Support to Custom Elements
|
||||
|
||||
If you create custom element types, ensure they:
|
||||
|
||||
1. **Inherit from BaseLayoutElement**
|
||||
```python
|
||||
class MyCustomElement(BaseLayoutElement):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs) # Initializes UUID and timestamps
|
||||
# Your custom fields here
|
||||
```
|
||||
|
||||
2. **Call `_deserialize_base_fields()` first in deserialize**
|
||||
```python
|
||||
def deserialize(self, data: Dict[str, Any]):
|
||||
self._deserialize_base_fields(data) # Load UUID/timestamps
|
||||
# Load your custom fields
|
||||
```
|
||||
|
||||
3. **Include base fields in serialize**
|
||||
```python
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
data = {
|
||||
"type": "mycustom",
|
||||
# Your custom fields
|
||||
}
|
||||
data.update(self._serialize_base_fields()) # Add UUID/timestamps
|
||||
return data
|
||||
```
|
||||
|
||||
4. **Call `mark_modified()` when changed**
|
||||
```python
|
||||
def set_my_property(self, value):
|
||||
self.my_property = value
|
||||
self.mark_modified() # Update timestamp
|
||||
```
|
||||
|
||||
### Migration System
|
||||
|
||||
To add a new migration (e.g., v3.0 to v4.0):
|
||||
|
||||
```python
|
||||
# In version_manager.py
|
||||
|
||||
@DataMigration.register_migration("3.0", "4.0")
|
||||
def migrate_3_0_to_4_0(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Migrate from version 3.0 to 4.0.
|
||||
|
||||
Main changes:
|
||||
- Add new fields
|
||||
- Update structures
|
||||
"""
|
||||
# Perform migration
|
||||
data['new_field'] = default_value
|
||||
|
||||
# Update version
|
||||
data['data_version'] = "4.0"
|
||||
|
||||
return data
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
Run the provided test scripts:
|
||||
|
||||
```bash
|
||||
# Test v2.0 → v3.0 migration
|
||||
python test_migration.py
|
||||
|
||||
# Test merge functionality
|
||||
python test_merge.py
|
||||
```
|
||||
|
||||
Expected output: All tests should pass with ✅
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Testing Checklist
|
||||
|
||||
#### Test 1: Basic Migration
|
||||
- [ ] Open a v2.0 project
|
||||
- [ ] Verify it loads without errors
|
||||
- [ ] Check console for "Migration 2.0 → 3.0" message
|
||||
- [ ] Save the project
|
||||
- [ ] Verify saved version is 3.0
|
||||
|
||||
#### Test 2: Same Project Merge
|
||||
- [ ] Create a project, save it
|
||||
- [ ] Open the file twice in different instances
|
||||
- [ ] Modify same element in both
|
||||
- [ ] Merge them
|
||||
- [ ] Verify conflict dialog appears
|
||||
- [ ] Resolve conflict
|
||||
- [ ] Verify merged result
|
||||
|
||||
#### Test 3: Different Project Concatenation
|
||||
- [ ] Create two different projects
|
||||
- [ ] Try to merge them
|
||||
- [ ] Verify concatenation option appears
|
||||
- [ ] Verify combined project has all pages
|
||||
|
||||
#### Test 4: Auto-Merge Non-Conflicting
|
||||
- [ ] Create project with 2 pages
|
||||
- [ ] Version A: Edit page 1
|
||||
- [ ] Version B: Edit page 2
|
||||
- [ ] Merge
|
||||
- [ ] Verify auto-merge without conflicts
|
||||
- [ ] Verify both edits preserved
|
||||
|
||||
### Automated Testing
|
||||
|
||||
Run the test scripts:
|
||||
|
||||
```bash
|
||||
cd /home/dtourolle/Development/pyPhotoAlbum
|
||||
|
||||
# Migration test
|
||||
./test_migration.py
|
||||
|
||||
# Merge test
|
||||
./test_merge.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration from v2.0
|
||||
|
||||
### Automatic Migration
|
||||
|
||||
When you open a v2.0 project in v3.0, it will automatically:
|
||||
|
||||
1. Generate a unique `project_id`
|
||||
2. Generate `uuid` for all pages and elements
|
||||
3. Set `created` and `last_modified` to current time
|
||||
4. Add `deleted` and `deleted_at` fields (all set to False/None)
|
||||
5. Update `data_version` to "3.0"
|
||||
|
||||
### Migration Output Example
|
||||
|
||||
```
|
||||
Migration 2.0 → 3.0: Adding UUIDs, timestamps, and project_id
|
||||
Generated project_id: 550e8400-e29b-41d4-a716-446655440000
|
||||
Migrated 5 pages to v3.0
|
||||
Migration completed successfully
|
||||
```
|
||||
|
||||
### After Migration
|
||||
|
||||
- **Save the project** to persist the migration
|
||||
- The migrated file can **only be opened in v3.0+**
|
||||
- Keep a backup of v2.0 file if you need v2.0 compatibility
|
||||
|
||||
### Rollback
|
||||
|
||||
If you need to rollback to v2.0:
|
||||
1. Don't save after opening in v3.0
|
||||
2. Close without saving
|
||||
3. Open original v2.0 file in v2.0
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Merge Dialog Won't Appear
|
||||
|
||||
**Problem**: Clicking "Merge Projects" does nothing
|
||||
|
||||
**Solutions**:
|
||||
- Check both projects are v3.0 (or were migrated)
|
||||
- Verify projects have the same `project_id`
|
||||
- Check console for error messages
|
||||
|
||||
### Can't Resolve Conflicts
|
||||
|
||||
**Problem**: "Apply Merge" button is grayed out
|
||||
|
||||
**Solutions**:
|
||||
- Make a resolution choice for each conflict
|
||||
- Or click "Auto-Resolve All" first
|
||||
|
||||
### Changes Not Preserved
|
||||
|
||||
**Problem**: After merge, some changes are missing
|
||||
|
||||
**Solutions**:
|
||||
- Check which resolution strategy you used
|
||||
- "Latest Wins" prefers most recent modifications
|
||||
- Review each conflict manually if needed
|
||||
|
||||
### Project Won't Load
|
||||
|
||||
**Problem**: "Incompatible file version" error
|
||||
|
||||
**Solutions**:
|
||||
- This is a v2.0 or v1.0 file
|
||||
- Migration should happen automatically
|
||||
- If not, check version_manager.py for errors
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
### Q: Can I merge more than two projects at once?
|
||||
**A:** Not directly. Merge two at a time, then merge the result with a third.
|
||||
|
||||
### Q: What happens to undo history after merge?
|
||||
**A:** Undo history is session-specific and not preserved during merge. Save before merging.
|
||||
|
||||
### Q: Can I see what changed before merging?
|
||||
**A:** The merge dialog shows changed elements with timestamps. Future versions may add detailed diff view.
|
||||
|
||||
### Q: Is merge atomic?
|
||||
**A:** No. If you cancel during conflict resolution, no changes are made. Once you click "Apply Merge", the changes are applied to the current project.
|
||||
|
||||
### Q: Can I merge projects from different versions?
|
||||
**A:** Yes! v2.0 and v1.0 projects are automatically migrated to v3.0 before merging.
|
||||
|
||||
### Q: What if two people add the same image?
|
||||
**A:** If the image has the same filename and is added to different pages, both instances are kept. If added to the same location on the same page, it becomes a conflict.
|
||||
|
||||
### Q: Can I programmatically merge projects?
|
||||
**A:** Yes! See the Developer Guide section for `MergeManager` API usage.
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements for future versions:
|
||||
|
||||
1. **Three-way merge** - Use base version for better conflict resolution
|
||||
2. **Merge history tracking** - Log all merges performed
|
||||
3. **Partial merge** - Merge only specific pages
|
||||
4. **Cloud collaboration** - Real-time collaborative editing
|
||||
5. **Merge preview** - Show full diff before applying
|
||||
6. **Asset conflict handling** - Better handling of duplicate assets
|
||||
7. **Conflict visualization** - Visual overlay showing changes
|
||||
|
||||
---
|
||||
|
||||
## Version History
|
||||
|
||||
### v3.0 (2025-01-22)
|
||||
- ✨ Initial merge conflict resolution feature
|
||||
- ✨ UUID and timestamp tracking
|
||||
- ✨ Project ID-based merge detection
|
||||
- ✨ Visual merge dialog
|
||||
- ✨ Automatic migration from v2.0
|
||||
- ✨ Soft delete support
|
||||
|
||||
---
|
||||
|
||||
## Credits
|
||||
|
||||
Merge system designed and implemented with the following principles:
|
||||
- **UUID stability** - Elements tracked across versions
|
||||
- **Timestamp precision** - ISO 8601 UTC for reliable ordering
|
||||
- **Backwards compatibility** - Seamless migration from v2.0
|
||||
- **User-friendly** - Visual conflict resolution
|
||||
- **Developer-friendly** - Clean API, well-documented
|
||||
|
||||
For questions or issues, please file a bug report in the project repository.
|
||||
55
PKGBUILD
Normal file
@ -0,0 +1,55 @@
|
||||
# Maintainer: Your Name <your.email@example.com>
|
||||
pkgname=pyphotoalbum
|
||||
pkgver=0.1.0
|
||||
pkgrel=1
|
||||
pkgdesc="A Python application for designing photo albums and exporting them to PDF"
|
||||
arch=('any')
|
||||
url="https://gitea.tourolle.paris/dtourolle/pyPhotoAlbum"
|
||||
license=('MIT')
|
||||
depends=(
|
||||
'python>=3.9'
|
||||
'python-pyqt6'
|
||||
'python-pyopengl'
|
||||
'python-numpy'
|
||||
'python-pillow'
|
||||
'python-reportlab'
|
||||
'python-lxml'
|
||||
)
|
||||
makedepends=(
|
||||
'python-build'
|
||||
'python-installer'
|
||||
'python-wheel'
|
||||
'python-setuptools'
|
||||
)
|
||||
optdepends=(
|
||||
'python-pytest: for running tests'
|
||||
'python-pytest-qt: for running tests'
|
||||
)
|
||||
source=("${pkgname}-${pkgver}.tar.gz")
|
||||
sha256sums=('SKIP') # Update with actual checksum after creating source tarball
|
||||
|
||||
build() {
|
||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
||||
python -m build --wheel --no-isolation
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
||||
|
||||
# Install Python package
|
||||
python -m installer --destdir="$pkgdir" dist/*.whl
|
||||
|
||||
# Install desktop file
|
||||
install -Dm644 "${pkgname}.desktop" \
|
||||
"${pkgdir}/usr/share/applications/${pkgname}.desktop"
|
||||
|
||||
# Install icon
|
||||
install -Dm644 pyPhotoAlbum/icons/icon.png \
|
||||
"${pkgdir}/usr/share/icons/hicolor/256x256/apps/${pkgname}.png"
|
||||
|
||||
# Install license
|
||||
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||
|
||||
# Install documentation
|
||||
install -Dm644 README.md "${pkgdir}/usr/share/doc/${pkgname}/README.md"
|
||||
}
|
||||
133
README.md
Normal file
@ -0,0 +1,133 @@
|
||||
# pyPhotoAlbum
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
A desktop application for designing and creating professional photo albums with an intuitive drag-and-drop interface and high-quality PDF export.
|
||||
|
||||
## Overview
|
||||
|
||||
pyPhotoAlbum is a photo album design tool built with PyQt6 and OpenGL, offering a powerful yet user-friendly interface for creating custom photo layouts. It supports drag-and-drop image placement, template-based designs, and high-quality PDF export.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Visual Editor**: OpenGL-accelerated rendering with real-time preview
|
||||
- **Drag & Drop**: Direct image import from file explorer
|
||||
- **Template System**: Create and reuse page layouts
|
||||
- **Smart Layout Tools**: Alignment, distribution, and sizing operations
|
||||
- **Asset Management**: Automatic image organization with reference counting
|
||||
- **Project Files**: Save/load projects in portable ZIP format (.ppz)
|
||||
- **PDF Export**: High-quality export with configurable DPI
|
||||
- **Undo/Redo**: Complete command history for all operations
|
||||
- **Double-Page Spreads**: Design facing pages for book-style albums
|
||||
|
||||
## Installation
|
||||
|
||||
### Quick Install (Linux)
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://gitea.tourolle.paris/dtourolle/pyPhotoAlbum.git
|
||||
cd pyPhotoAlbum
|
||||
|
||||
# Run automated installer
|
||||
./install.sh
|
||||
```
|
||||
|
||||
**For detailed instructions:** See [INSTALLATION.md](INSTALLATION.md)
|
||||
|
||||
### Distribution Packages
|
||||
|
||||
**Fedora (RPM):**
|
||||
```bash
|
||||
rpmbuild -ba pyphotoalbum.spec
|
||||
sudo dnf install ~/rpmbuild/RPMS/noarch/pyphotoalbum-*.rpm
|
||||
```
|
||||
|
||||
**Arch/CachyOS (PKGBUILD):**
|
||||
```bash
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
See [INSTALLATION.md](INSTALLATION.md) for complete instructions.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Running the Application
|
||||
|
||||
After installation, launch pyPhotoAlbum:
|
||||
|
||||
```bash
|
||||
pyphotoalbum
|
||||
```
|
||||
|
||||
Or run directly from source:
|
||||
|
||||
```bash
|
||||
python pyPhotoAlbum/main.py
|
||||
```
|
||||
|
||||
### Basic Workflow
|
||||
|
||||
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
|
||||
|
||||
## Using Templates
|
||||
|
||||
pyPhotoAlbum includes a template system to help you quickly create consistent layouts:
|
||||
|
||||
- **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
|
||||
|
||||
## Architecture Highlights
|
||||
|
||||
pyPhotoAlbum is built with clean, maintainable design patterns:
|
||||
|
||||
### Mixin-Based Composition
|
||||
|
||||
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
|
||||
|
||||
### Declarative UI with Decorators
|
||||
|
||||
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
|
||||
|
||||
This approach keeps UI concerns separate from business logic and makes the codebase easier to maintain and extend.
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
- `Ctrl+Z` - Undo
|
||||
- `Ctrl+Y` - Redo
|
||||
- `Ctrl+S` - Save project
|
||||
- `Ctrl+O` - Open project
|
||||
- `Ctrl+N` - New project
|
||||
- `Ctrl+E` - Export to PDF
|
||||
- `Delete` - Delete selected element
|
||||
- `Arrow Keys` - Move selected element
|
||||
- `Shift+Arrow Keys` - Resize selected element
|
||||
- `Ctrl+D` - Duplicate selected element
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Built with:
|
||||
- PyQt6 for the GUI framework
|
||||
- OpenGL for hardware-accelerated rendering
|
||||
- ReportLab for PDF generation
|
||||
- Pillow for image processing
|
||||
205
TEST_ANALYSIS.md
Normal file
@ -0,0 +1,205 @@
|
||||
# Test Suite Analysis
|
||||
|
||||
## Overview
|
||||
**Total Test Files**: 43
|
||||
**Total Tests**: ~650
|
||||
**Test Collection Status**: ✅ All tests collect successfully
|
||||
|
||||
---
|
||||
|
||||
## Test Categories
|
||||
|
||||
### 1. ✅ **Proper Unit Tests** (Core Business Logic)
|
||||
These test pure logic with no external dependencies. Good unit tests!
|
||||
|
||||
| File | Tests | Description |
|
||||
|------|-------|-------------|
|
||||
| `test_alignment.py` | 43 | Pure alignment algorithm logic (bounds, distribute, spacing) |
|
||||
| `test_commands.py` | 39 | Command pattern implementation (with mocks) |
|
||||
| `test_snapping.py` | 30 | Snapping algorithm logic |
|
||||
| `test_page_layout.py` | 28 | Layout management logic |
|
||||
| `test_models.py` | 27 | Data model serialization/deserialization |
|
||||
| `test_zorder.py` | 18 | Z-order management logic |
|
||||
| `test_project.py` | 21 | Project lifecycle operations |
|
||||
| `test_project_serialization.py` | 21 | Serialization correctness |
|
||||
| `test_rotation_serialization.py` | 8 | Rotation data handling |
|
||||
| `test_merge.py` | 3 | Merge conflict resolution logic |
|
||||
|
||||
**Total**: ~258 tests
|
||||
**Status**: ✅ These are good unit tests!
|
||||
|
||||
---
|
||||
|
||||
### 2. ⚠️ **Integration Tests with Mocks** (UI Components)
|
||||
These test Qt widgets/mixins with mocked dependencies. Somewhat integration-y but still automated.
|
||||
|
||||
| File | Tests | Description |
|
||||
|------|-------|-------------|
|
||||
| `test_template_manager.py` | 35 | Template management with Qt |
|
||||
| `test_base_mixin.py` | 31 | Application state mixin (Qt + mocks) |
|
||||
| `test_view_ops_mixin.py` | 29 | View operations mixin (Qt + mocks) |
|
||||
| `test_element_selection_mixin.py` | 26 | Selection handling (Qt + mocks) |
|
||||
| `test_viewport_mixin.py` | 23 | Viewport rendering (Qt + mocks) |
|
||||
| `test_page_renderer.py` | 22 | Page rendering logic |
|
||||
| `test_interaction_undo_mixin.py` | 22 | Undo/redo system (Qt + mocks) |
|
||||
| `test_edit_ops_mixin.py` | 19 | Edit operations (Qt + mocks) |
|
||||
| `test_mouse_interaction_mixin.py` | 18 | Mouse event handling (Qt + mocks) |
|
||||
| `test_gl_widget_integration.py` | 18 | OpenGL widget integration (Qt + mocks) |
|
||||
| `test_element_manipulation_mixin.py` | 18 | Element manipulation (Qt + mocks) |
|
||||
| `test_zorder_ops_mixin.py` | 17 | Z-order operations mixin (Qt + mocks) |
|
||||
| `test_page_ops_mixin.py` | 17 | Page operations mixin (Qt + mocks) |
|
||||
| `test_page_navigation_mixin.py` | 16 | Page navigation (Qt + mocks) |
|
||||
| `test_size_ops_mixin.py` | 14 | Size operations mixin (Qt + mocks) |
|
||||
| `test_pdf_export.py` | 13 | PDF export functionality |
|
||||
| `test_image_pan_mixin.py` | 12 | Image panning (Qt + mocks) |
|
||||
| `test_alignment_ops_mixin.py` | 12 | Alignment ops mixin (Qt + mocks) |
|
||||
| `test_embedded_templates.py` | 11 | Template embedding |
|
||||
| `test_element_ops_mixin.py` | 11 | Element operations (Qt + mocks) |
|
||||
| `test_asset_drop_mixin.py` | 11 | Drag & drop handling (Qt + mocks) |
|
||||
| `test_distribution_ops_mixin.py` | 7 | Distribution operations (Qt + mocks) |
|
||||
| `test_multiselect.py` | 2 | Multi-selection (Qt + mocks) |
|
||||
| `test_loading_widget.py` | 2 | Loading UI widget (Qt) |
|
||||
|
||||
**Total**: ~405 tests
|
||||
**Status**: ⚠️ Proper tests but integration-heavy (Qt widgets)
|
||||
|
||||
---
|
||||
|
||||
### 3. ❌ **Not Really Tests** (Manual/Interactive Tests)
|
||||
These are scripts that were dumped into the test directory but aren't proper automated tests:
|
||||
|
||||
| File | Tests | Type | Issue |
|
||||
|------|-------|------|-------|
|
||||
| `test_drop_bug.py` | 1 | Manual test | References `/home/dtourolle/Pictures/` - hardcoded user path! |
|
||||
| `test_async_nonblocking.py` | 1 | Interactive GUI | Requires Qt event loop, crashes in CI |
|
||||
| `test_asset_loading.py` | 1 | Manual test | Requires `/home/dtourolle/Nextcloud/Photo Gallery/gr58/Album_pytool.ppz` |
|
||||
| `test_album6_compatibility.py` | 1 | Manual test | Requires `/home/dtourolle/Nextcloud/Photo Gallery/gr58/Album6.ppz` |
|
||||
| `test_version_roundtrip.py` | 1 | Demo script | Just converted to proper test - now OK! |
|
||||
| `test_page_setup.py` | 1 | Interactive | Requires Qt window |
|
||||
| `test_migration.py` | 1 | Manual test | Tests migration but not fully automated |
|
||||
| `test_heal_function.py` | 1 | Manual test | Interactive asset healing |
|
||||
| `test_zip_embedding.py` | 1 | Demo script | Content embedding demo |
|
||||
|
||||
**Total**: 9 "tests"
|
||||
**Status**: ❌ These should be:
|
||||
- Moved to `examples/` or `scripts/` directory, OR
|
||||
- Converted to proper automated tests with fixtures/mocks
|
||||
|
||||
---
|
||||
|
||||
### 4. 🔧 **Test Infrastructure**
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `test_gl_widget_fixtures.py` | Pytest fixtures for OpenGL testing (0 tests, just fixtures) |
|
||||
|
||||
---
|
||||
|
||||
## Problems Found
|
||||
|
||||
### 🔴 **Critical Issues**
|
||||
|
||||
1. **Hardcoded absolute paths** in tests:
|
||||
- `test_drop_bug.py`: `/home/dtourolle/Pictures/some_photo.jpg`
|
||||
- `test_asset_loading.py`: `/home/dtourolle/Nextcloud/Photo Gallery/gr58/Album_pytool.ppz`
|
||||
- `test_album6_compatibility.py`: `/home/dtourolle/Nextcloud/Photo Gallery/gr58/Album6.ppz`
|
||||
|
||||
2. **Interactive tests in CI**:
|
||||
- `test_async_nonblocking.py` - Creates Qt application and runs event loop
|
||||
- `test_page_setup.py` - Interactive GUI window
|
||||
- `test_loading_widget.py` - Interactive loading widget
|
||||
|
||||
3. **API mismatch** (FIXED):
|
||||
- ✅ `test_version_roundtrip.py` - Was using old `load_from_zip()` API
|
||||
- ✅ `test_asset_loading.py` - Was using old `load_from_zip()` API
|
||||
|
||||
### 🟡 **Medium Issues**
|
||||
|
||||
4. **Tests that look like demos**:
|
||||
- `test_heal_function.py` - Prints results but doesn't assert much
|
||||
- `test_zip_embedding.py` - More of a demo than a test
|
||||
- `test_migration.py` - Tests migration but could be more thorough
|
||||
|
||||
### 🟢 **Minor Issues**
|
||||
|
||||
5. **Test file naming**:
|
||||
- Some files have generic names like `test_multiselect.py` (2 tests)
|
||||
- Could be more descriptive
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
### Short Term (Fix Immediately)
|
||||
|
||||
1. **Mark problematic tests to skip on CI**:
|
||||
```python
|
||||
@pytest.mark.skip(reason="Requires user-specific files")
|
||||
def test_album6_compatibility():
|
||||
...
|
||||
```
|
||||
|
||||
2. **Add skip conditions for missing files**:
|
||||
```python
|
||||
@pytest.mark.skipif(not os.path.exists(TEST_FILE), reason="Test file not found")
|
||||
```
|
||||
|
||||
3. **Fix the crashing test**:
|
||||
- `test_async_nonblocking.py` needs `@pytest.mark.gui` or similar
|
||||
- Or mark as `@pytest.mark.skip` for now
|
||||
|
||||
### Medium Term (Cleanup)
|
||||
|
||||
4. **Move non-tests out of tests directory**:
|
||||
```
|
||||
tests/ → Keep only real automated tests
|
||||
examples/ → Move interactive demos here
|
||||
scripts/ → Move manual test scripts here
|
||||
```
|
||||
|
||||
5. **Create proper fixtures for file-based tests**:
|
||||
- Use `pytest.fixture` to create temporary test files
|
||||
- Don't rely on user's home directory
|
||||
|
||||
6. **Add proper test markers**:
|
||||
```python
|
||||
@pytest.mark.unit # Pure logic, no dependencies
|
||||
@pytest.mark.integration # Needs Qt, database, etc.
|
||||
@pytest.mark.slow # Takes >1 second
|
||||
@pytest.mark.gui # Needs display/X server
|
||||
```
|
||||
|
||||
### Long Term (Architecture)
|
||||
|
||||
7. **Separate test types**:
|
||||
```
|
||||
tests/unit/ # Pure unit tests (fast, no deps)
|
||||
tests/integration/ # Integration tests (Qt, mocks)
|
||||
tests/e2e/ # End-to-end tests (slow, full stack)
|
||||
```
|
||||
|
||||
8. **Add CI configuration**:
|
||||
```yaml
|
||||
# Run fast unit tests on every commit
|
||||
# Run integration tests on PR
|
||||
# Run GUI tests manually only
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Category | Count | Quality |
|
||||
|----------|-------|---------|
|
||||
| ✅ Good Unit Tests | ~258 | Excellent |
|
||||
| ⚠️ Integration Tests | ~405 | Good (but heavy) |
|
||||
| ❌ Not Real Tests | ~9 | Need fixing |
|
||||
| 🔧 Infrastructure | 1 | Good |
|
||||
| **Total** | **~673** | **Mixed** |
|
||||
|
||||
**Bottom Line**:
|
||||
- ~66% of tests are solid (unit + integration with mocks)
|
||||
- ~34% are integration tests that rely heavily on Qt
|
||||
- ~1.3% are broken/manual tests that need cleanup
|
||||
|
||||
The test suite is generally good, but needs cleanup of the manual/interactive tests that were dumped into the tests directory.
|
||||
179
VERSIONING.md
Normal file
@ -0,0 +1,179 @@
|
||||
# Data Format Versioning
|
||||
|
||||
pyPhotoAlbum uses a comprehensive versioning system to manage data format changes and ensure compatibility across versions.
|
||||
|
||||
## Current Version
|
||||
|
||||
**Data Format Version: 2.0**
|
||||
|
||||
## Version History
|
||||
|
||||
### Version 2.0 (Released: 2025-01-11)
|
||||
|
||||
**Description:** Fixed asset path handling - paths now stored relative to project folder
|
||||
|
||||
**Breaking Changes:**
|
||||
- Asset paths changed from absolute/full-project-relative to project-relative
|
||||
- Added automatic path normalization for legacy projects
|
||||
|
||||
**Compatibility:** Can read and migrate v1.0 files automatically
|
||||
|
||||
**Key Improvements:**
|
||||
- Asset paths now stored as `assets/image.jpg` instead of `./projects/ProjectName/assets/image.jpg`
|
||||
- Automatic path normalization when loading old projects
|
||||
- Added search path system for finding assets in multiple locations
|
||||
- ZIP file directory automatically added as a search path
|
||||
- New "Heal Assets" feature to reconnect missing images
|
||||
|
||||
### Version 1.0 (Released: 2024-01-01)
|
||||
|
||||
**Description:** Initial format with basic serialization
|
||||
|
||||
**Features:**
|
||||
- Basic project structure
|
||||
- Page layouts and elements
|
||||
- Asset management with reference counting
|
||||
- ZIP-based `.ppz` project format
|
||||
|
||||
## How Versioning Works
|
||||
|
||||
### File Format
|
||||
|
||||
Each `.ppz` file contains a `project.json` with version information:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "My Project",
|
||||
"data_version": "2.0",
|
||||
"serialization_version": "2.0",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
- `data_version`: Current versioning system (introduced in v2.0)
|
||||
- `serialization_version`: Legacy version field (for backward compatibility)
|
||||
|
||||
### Loading Process
|
||||
|
||||
When loading a project file:
|
||||
|
||||
1. **Version Detection:** The system reads both `data_version` (new) and `serialization_version` (legacy) fields
|
||||
2. **Compatibility Check:** Verifies if the file version is compatible with the current version
|
||||
3. **Migration (if needed):** Automatically migrates data from old versions to current format
|
||||
4. **Path Normalization:** Fixes asset paths to work with current project location
|
||||
5. **Asset Resolution:** Sets up search paths for finding images
|
||||
|
||||
### Compatibility Levels
|
||||
|
||||
The system supports multiple compatibility levels:
|
||||
|
||||
- **Full Compatibility:** Same version, no migration needed
|
||||
- **Backward Compatibility:** Older version can be read with automatic migration
|
||||
- **Incompatible:** Version cannot be loaded (future versions or corrupted files)
|
||||
|
||||
## For Developers
|
||||
|
||||
### Adding a New Version
|
||||
|
||||
When making breaking changes to the data format:
|
||||
|
||||
1. **Update version_manager.py:**
|
||||
```python
|
||||
CURRENT_DATA_VERSION = "3.0" # Increment major version
|
||||
|
||||
VERSION_HISTORY["3.0"] = {
|
||||
"description": "Description of changes",
|
||||
"released": "2025-XX-XX",
|
||||
"breaking_changes": [
|
||||
"List of breaking changes"
|
||||
],
|
||||
"compatible_with": ["2.0", "3.0"],
|
||||
}
|
||||
```
|
||||
|
||||
2. **Create a migration function:**
|
||||
```python
|
||||
@DataMigration.register_migration("2.0", "3.0")
|
||||
def migrate_2_0_to_3_0(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
# Perform data transformations
|
||||
data['data_version'] = "3.0"
|
||||
return data
|
||||
```
|
||||
|
||||
3. **Test the migration:**
|
||||
- Create test files with old format
|
||||
- Verify they load correctly with migration
|
||||
- Verify no migration needed for new files
|
||||
|
||||
### Version Compatibility Guidelines
|
||||
|
||||
**When to increment version:**
|
||||
|
||||
- **Major version (1.0 → 2.0):** Breaking changes to data structure
|
||||
- Field renames or removals
|
||||
- Changed data types
|
||||
- New required fields
|
||||
- Incompatible serialization changes
|
||||
|
||||
- **Minor version (2.0 → 2.1):** Backward-compatible additions
|
||||
- New optional fields
|
||||
- New features that don't break old data
|
||||
- Performance improvements
|
||||
|
||||
**Migration Best Practices:**
|
||||
|
||||
1. Always test migrations with real user data
|
||||
2. Log migration steps for debugging
|
||||
3. Preserve user data even if it can't be fully migrated
|
||||
4. Provide clear error messages for incompatible versions
|
||||
5. Document all breaking changes in VERSION_HISTORY
|
||||
|
||||
## For Users
|
||||
|
||||
### What You Need to Know
|
||||
|
||||
- **Automatic Updates:** Old project files are automatically updated when opened
|
||||
- **No Data Loss:** Your original `.ppz` file remains unchanged
|
||||
- **Backward Compatibility:** Newer versions can read older files
|
||||
- **Version Info:** Use File → About to see current version information
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
**"Incompatible file version" error:**
|
||||
- The file was created with a much newer or incompatible version
|
||||
- Solution: Use the version of pyPhotoAlbum that created the file, or upgrade
|
||||
|
||||
**Missing assets after loading:**
|
||||
- Use File → "Heal Assets" to add search paths
|
||||
- The directory containing the `.ppz` file is automatically searched
|
||||
- You can add additional locations where images might be found
|
||||
|
||||
**Want to ensure future compatibility:**
|
||||
- Keep your `.ppz` files
|
||||
- When upgrading, test opening your projects
|
||||
- Report any migration issues on GitHub
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Files Involved
|
||||
|
||||
- `version_manager.py`: Core versioning system and migrations
|
||||
- `project_serializer.py`: Handles loading/saving with version checks
|
||||
- `models.py`: Asset path resolution with search paths
|
||||
- `asset_heal_dialog.py`: UI for reconnecting missing assets
|
||||
|
||||
### Key Classes
|
||||
|
||||
- `VersionCompatibility`: Checks version compatibility
|
||||
- `DataMigration`: Manages migration functions
|
||||
- `AssetManager`: Handles asset storage and reference counting
|
||||
|
||||
### Search Path Priority
|
||||
|
||||
When resolving asset paths (in order):
|
||||
|
||||
1. Project folder (primary location)
|
||||
2. Additional search paths (from Heal Assets)
|
||||
3. Directory containing the `.ppz` file
|
||||
4. Current working directory (fallback)
|
||||
5. Parent of working directory (fallback)
|
||||
58
cov_info/coverage-docs.svg
Normal file
@ -0,0 +1,58 @@
|
||||
<svg width="140" height="20" viewBox="0 0 140 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<title>interrogate: 90.3%</title>
|
||||
<g transform="matrix(1,0,0,1,22,0)">
|
||||
<g id="backgrounds" transform="matrix(1.32789,0,0,1,-22.3892,0)">
|
||||
<rect x="0" y="0" width="71" height="20" style="fill:rgb(85,85,85);"/>
|
||||
</g>
|
||||
<rect x="71" y="0" width="47" height="20" data-interrogate="color" style="fill:#97CA00"/>
|
||||
<g transform="matrix(1.19746,0,0,1,-22.3744,-4.85723e-16)">
|
||||
<rect x="0" y="0" width="118" height="20" style="fill:url(#_Linear1);"/>
|
||||
</g>
|
||||
</g>
|
||||
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="110">
|
||||
<text x="590" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="610">interrogate</text>
|
||||
<text x="590" y="140" transform="scale(.1)" textLength="610">interrogate</text>
|
||||
<text x="1160" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370" data-interrogate="result">90.3%</text>
|
||||
<text x="1160" y="140" transform="scale(.1)" textLength="370" data-interrogate="result">90.3%</text>
|
||||
</g>
|
||||
<g id="logo-shadow" serif:id="logo shadow" transform="matrix(0.854876,0,0,0.854876,-6.73514,1.732)">
|
||||
<g transform="matrix(0.299012,0,0,0.299012,9.70229,-6.68582)">
|
||||
<path d="M50,64.25C52.76,64.25 55,61.13 55,59.75C55,58.37 52.76,57.25 50,57.25C47.24,57.25 45,58.37 45,59.75C45,61.13 47.24,64.25 50,64.25Z" style="fill:rgb(1,1,1);fill-opacity:0.3;fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.299012,0,0,0.299012,9.70229,-6.68582)">
|
||||
<path d="M88,49.05C86.506,43.475 83.018,38.638 78.2,35.46C72.969,32.002 66.539,30.844 60.43,32.26C56.576,33.145 52.995,34.958 50,37.54C46.998,34.958 43.411,33.149 39.55,32.27C33.441,30.853 27.011,32.011 21.78,35.47C16.97,38.652 13.489,43.489 12,49.06L12,49.13C11.82,49.79 11.66,50.46 11.53,51.13C11.146,53.207 11.021,55.323 11.16,57.43C11.16,58.03 11.26,58.63 11.34,59.23C11.34,59.51 11.43,59.79 11.48,60.07C11.53,60.35 11.58,60.68 11.64,60.98C11.7,61.28 11.8,61.69 11.89,62.05C11.98,62.41 11.99,62.47 12.05,62.68C12.16,63.07 12.28,63.46 12.41,63.84L12.58,64.34C12.72,64.74 12.88,65.14 13.04,65.53L13.23,65.98C13.403,66.373 13.583,66.767 13.77,67.16L13.99,67.59C14.19,67.97 14.39,68.35 14.61,68.73L14.87,69.15C15.1,69.52 15.33,69.89 15.58,70.26L15.58,70.32L15.99,70.93C16.14,71.14 16.29,71.36 16.45,71.57C20.206,75.83 25.086,78.95 30.53,80.57C36.839,82.48 43.41,83.385 50,83.25C56.599,83.374 63.177,82.456 69.49,80.53C74.644,78.978 79.303,76.102 83,72.19C83.34,71.78 83.65,71.35 84,70.92L84.18,70.66L84.33,70.44L84.41,70.32C84.55,70.12 84.67,69.9 84.81,69.7C85.07,69.3 85.32,68.89 85.55,68.48C85.78,68.07 86.02,67.65 86.23,67.22C86.31,67.05 86.39,66.88 86.47,66.7C86.67,66.28 86.85,65.87 87.03,65.44L87.23,64.92C87.397,64.487 87.55,64.05 87.69,63.61L87.85,63.09C87.98,62.64 88.1,62.19 88.21,61.74C88.21,61.57 88.3,61.39 88.33,61.22C88.43,60.75 88.52,60.22 88.6,59.79C88.6,59.64 88.66,59.49 88.68,59.33C88.77,58.71 88.84,58.08 88.88,57.45L88.88,54.17C88.817,53.164 88.693,52.162 88.51,51.17C88.38,50.5 88.23,49.84 88.05,49.17L88,49.05ZM85.89,56.44L85.89,57.23C85.89,57.78 85.79,58.32 85.72,58.86C85.72,59.01 85.72,59.15 85.65,59.3C85.59,59.7 85.51,60.11 85.43,60.51L85.32,60.99C85.23,61.38 85.12,61.77 85.01,62.16C85.01,62.31 84.93,62.46 84.88,62.6C84.74,63.04 84.59,63.47 84.42,63.9L84.27,64.28C84.1,64.71 83.91,65.14 83.71,65.56C83.51,65.98 83.43,66.12 83.28,66.4L83.01,66.91C82.83,67.223 82.643,67.537 82.45,67.85L82.35,68.01C79.121,68.047 75.918,67.434 72.93,66.21C64.27,62.74 59,55.52 61.18,50.11C62.18,47.6 64.7,45.82 68.26,45.11C72.489,44.395 76.835,44.908 80.78,46.59C82.141,47.144 83.453,47.813 84.7,48.59C84.76,48.76 84.82,48.93 84.88,49.1C84.94,49.27 85.05,49.63 85.12,49.9C85.28,50.5 85.44,51.1 85.55,51.73C85.691,52.507 85.792,53.292 85.85,54.08L85.85,55.89C85.85,56.12 85.91,56.25 85.91,56.45L85.89,56.44ZM17.66,68C16.668,66.435 15.869,64.756 15.28,63L15.17,62.68C15.06,62.35 14.96,62.01 14.87,61.68C14.823,61.493 14.777,61.31 14.73,61.13C14.66,60.84 14.59,60.55 14.53,60.27C14.47,59.99 14.43,59.72 14.38,59.44C14.33,59.16 14.3,59 14.27,58.78C14.2,58.27 14.15,57.78 14.11,57.23L14.11,57.03C14.008,55.236 14.122,53.437 14.45,51.67C14.56,51.06 14.71,50.46 14.88,49.87C14.96,49.59 15.04,49.32 15.13,49.05C15.22,48.78 15.24,48.72 15.3,48.55C16.548,47.774 17.859,47.105 19.22,46.55C27.86,43.09 36.65,44.67 38.82,50.08C40.99,55.49 35.73,62.74 27.09,66.2C24.101,67.431 20.893,68.043 17.66,68ZM68.57,77.68C62.554,79.508 56.287,80.376 50,80.25C43.737,80.37 37.495,79.506 31.5,77.69C27.185,76.38 23.243,74.062 20,70.93C22.815,70.706 25.58,70.055 28.2,69C38.37,64.92 44.39,56 41.6,49C38.81,42 28.27,39.72 18.1,43.8L17.43,44.09C18.973,41.648 21.019,39.561 23.43,37.97C26.671,35.824 30.473,34.68 34.36,34.68C35.884,34.681 37.404,34.852 38.89,35.19C42.694,36.049 46.191,37.935 49,40.64L50,41.64L51,40.64C53.797,37.937 57.279,36.049 61.07,35.18C66.402,33.947 72.014,34.968 76.57,38C78.98,39.588 81.026,41.671 82.57,44.11L81.9,43.82C77.409,41.921 72.464,41.355 67.66,42.19C63.08,43.12 59.79,45.54 58.39,49.02C55.6,55.97 61.62,64.94 71.79,69.02C74.414,70.07 77.182,70.714 80,70.93C76.776,74.05 72.859,76.363 68.57,77.68Z" style="fill:rgb(1,1,1);fill-opacity:0.3;fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.299012,0,0,0.299012,9.70229,-6.68582)">
|
||||
<circle cx="71.33" cy="56" r="5.16" style="fill:rgb(1,1,1);fill-opacity:0.3;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.299012,0,0,0.299012,9.70229,-6.68582)">
|
||||
<circle cx="28.67" cy="56" r="5.16" style="fill:rgb(1,1,1);fill-opacity:0.3;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.299012,0,0,0.299012,9.70229,-6.68582)">
|
||||
<path d="M58,66C55.912,68.161 53.003,69.339 50,69.24C46.997,69.339 44.088,68.161 42,66C41.714,65.677 41.302,65.491 40.87,65.491C40.042,65.491 39.361,66.172 39.361,67C39.361,67.368 39.496,67.724 39.74,68C42.403,70.804 46.134,72.35 50,72.25C53.862,72.347 57.59,70.802 60.25,68C60.495,67.725 60.63,67.369 60.63,67C60.63,66.174 59.951,65.495 59.125,65.495C58.695,65.495 58.285,65.679 58,66Z" style="fill:rgb(1,1,1);fill-opacity:0.3;fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="logo-pink" serif:id="logo pink" transform="matrix(0.854876,0,0,0.854876,-6.73514,0.877124)">
|
||||
<g transform="matrix(0.299012,0,0,0.299012,9.70229,-6.68582)">
|
||||
<path d="M50,64.25C52.76,64.25 55,61.13 55,59.75C55,58.37 52.76,57.25 50,57.25C47.24,57.25 45,58.37 45,59.75C45,61.13 47.24,64.25 50,64.25Z" style="fill:rgb(222,120,160);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.299012,0,0,0.299012,9.70229,-6.68582)">
|
||||
<path d="M88,49.05C86.506,43.475 83.018,38.638 78.2,35.46C72.969,32.002 66.539,30.844 60.43,32.26C56.576,33.145 52.995,34.958 50,37.54C46.998,34.958 43.411,33.149 39.55,32.27C33.441,30.853 27.011,32.011 21.78,35.47C16.97,38.652 13.489,43.489 12,49.06L12,49.13C11.82,49.79 11.66,50.46 11.53,51.13C11.146,53.207 11.021,55.323 11.16,57.43C11.16,58.03 11.26,58.63 11.34,59.23C11.34,59.51 11.43,59.79 11.48,60.07C11.53,60.35 11.58,60.68 11.64,60.98C11.7,61.28 11.8,61.69 11.89,62.05C11.98,62.41 11.99,62.47 12.05,62.68C12.16,63.07 12.28,63.46 12.41,63.84L12.58,64.34C12.72,64.74 12.88,65.14 13.04,65.53L13.23,65.98C13.403,66.373 13.583,66.767 13.77,67.16L13.99,67.59C14.19,67.97 14.39,68.35 14.61,68.73L14.87,69.15C15.1,69.52 15.33,69.89 15.58,70.26L15.58,70.32L15.99,70.93C16.14,71.14 16.29,71.36 16.45,71.57C20.206,75.83 25.086,78.95 30.53,80.57C36.839,82.48 43.41,83.385 50,83.25C56.599,83.374 63.177,82.456 69.49,80.53C74.644,78.978 79.303,76.102 83,72.19C83.34,71.78 83.65,71.35 84,70.92L84.18,70.66L84.33,70.44L84.41,70.32C84.55,70.12 84.67,69.9 84.81,69.7C85.07,69.3 85.32,68.89 85.55,68.48C85.78,68.07 86.02,67.65 86.23,67.22C86.31,67.05 86.39,66.88 86.47,66.7C86.67,66.28 86.85,65.87 87.03,65.44L87.23,64.92C87.397,64.487 87.55,64.05 87.69,63.61L87.85,63.09C87.98,62.64 88.1,62.19 88.21,61.74C88.21,61.57 88.3,61.39 88.33,61.22C88.43,60.75 88.52,60.22 88.6,59.79C88.6,59.64 88.66,59.49 88.68,59.33C88.77,58.71 88.84,58.08 88.88,57.45L88.88,54.17C88.817,53.164 88.693,52.162 88.51,51.17C88.38,50.5 88.23,49.84 88.05,49.17L88,49.05ZM85.89,56.44L85.89,57.23C85.89,57.78 85.79,58.32 85.72,58.86C85.72,59.01 85.72,59.15 85.65,59.3C85.59,59.7 85.51,60.11 85.43,60.51L85.32,60.99C85.23,61.38 85.12,61.77 85.01,62.16C85.01,62.31 84.93,62.46 84.88,62.6C84.74,63.04 84.59,63.47 84.42,63.9L84.27,64.28C84.1,64.71 83.91,65.14 83.71,65.56C83.51,65.98 83.43,66.12 83.28,66.4L83.01,66.91C82.83,67.223 82.643,67.537 82.45,67.85L82.35,68.01C79.121,68.047 75.918,67.434 72.93,66.21C64.27,62.74 59,55.52 61.18,50.11C62.18,47.6 64.7,45.82 68.26,45.11C72.489,44.395 76.835,44.908 80.78,46.59C82.141,47.144 83.453,47.813 84.7,48.59C84.76,48.76 84.82,48.93 84.88,49.1C84.94,49.27 85.05,49.63 85.12,49.9C85.28,50.5 85.44,51.1 85.55,51.73C85.691,52.507 85.792,53.292 85.85,54.08L85.85,55.89C85.85,56.12 85.91,56.25 85.91,56.45L85.89,56.44ZM17.66,68C16.668,66.435 15.869,64.756 15.28,63L15.17,62.68C15.06,62.35 14.96,62.01 14.87,61.68C14.823,61.493 14.777,61.31 14.73,61.13C14.66,60.84 14.59,60.55 14.53,60.27C14.47,59.99 14.43,59.72 14.38,59.44C14.33,59.16 14.3,59 14.27,58.78C14.2,58.27 14.15,57.78 14.11,57.23L14.11,57.03C14.008,55.236 14.122,53.437 14.45,51.67C14.56,51.06 14.71,50.46 14.88,49.87C14.96,49.59 15.04,49.32 15.13,49.05C15.22,48.78 15.24,48.72 15.3,48.55C16.548,47.774 17.859,47.105 19.22,46.55C27.86,43.09 36.65,44.67 38.82,50.08C40.99,55.49 35.73,62.74 27.09,66.2C24.101,67.431 20.893,68.043 17.66,68ZM68.57,77.68C62.554,79.508 56.287,80.376 50,80.25C43.737,80.37 37.495,79.506 31.5,77.69C27.185,76.38 23.243,74.062 20,70.93C22.815,70.706 25.58,70.055 28.2,69C38.37,64.92 44.39,56 41.6,49C38.81,42 28.27,39.72 18.1,43.8L17.43,44.09C18.973,41.648 21.019,39.561 23.43,37.97C26.671,35.824 30.473,34.68 34.36,34.68C35.884,34.681 37.404,34.852 38.89,35.19C42.694,36.049 46.191,37.935 49,40.64L50,41.64L51,40.64C53.797,37.937 57.279,36.049 61.07,35.18C66.402,33.947 72.014,34.968 76.57,38C78.98,39.588 81.026,41.671 82.57,44.11L81.9,43.82C77.409,41.921 72.464,41.355 67.66,42.19C63.08,43.12 59.79,45.54 58.39,49.02C55.6,55.97 61.62,64.94 71.79,69.02C74.414,70.07 77.182,70.714 80,70.93C76.776,74.05 72.859,76.363 68.57,77.68Z" style="fill:rgb(222,120,160);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.299012,0,0,0.299012,9.70229,-6.68582)">
|
||||
<circle cx="71.33" cy="56" r="5.16" style="fill:rgb(222,120,160);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.299012,0,0,0.299012,9.70229,-6.68582)">
|
||||
<circle cx="28.67" cy="56" r="5.16" style="fill:rgb(222,120,160);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.299012,0,0,0.299012,9.70229,-6.68582)">
|
||||
<path d="M58,66C55.912,68.161 53.003,69.339 50,69.24C46.997,69.339 44.088,68.161 42,66C41.714,65.677 41.302,65.491 40.87,65.491C40.042,65.491 39.361,66.172 39.361,67C39.361,67.368 39.496,67.724 39.74,68C42.403,70.804 46.134,72.35 50,72.25C53.862,72.347 57.59,70.802 60.25,68C60.495,67.725 60.63,67.369 60.63,67C60.63,66.174 59.951,65.495 59.125,65.495C58.695,65.495 58.285,65.679 58,66Z" style="fill:rgb(222,120,160);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.22465e-15,20,-20,1.22465e-15,0,0)">
|
||||
<stop offset="0" style="stop-color:rgb(187,187,187);stop-opacity:0.1"/>
|
||||
<stop offset="1" style="stop-color:black;stop-opacity:0.1"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
1
cov_info/coverage.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="20" role="img" aria-label="coverage: failed"><title>coverage: failed</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="100" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="39" height="20" fill="#e05d44"/><rect width="100" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="795" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="290">failed</text><text x="795" y="140" transform="scale(.1)" fill="#fff" textLength="290">failed</text></g></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
44
generate_icons.sh
Executable file
@ -0,0 +1,44 @@
|
||||
#!/bin/bash
|
||||
# Generate multiple icon sizes from the source icon for better GNOME integration
|
||||
|
||||
set -e
|
||||
|
||||
SOURCE_ICON="pyPhotoAlbum/icons/icon.png"
|
||||
ICONS_DIR="pyPhotoAlbum/icons"
|
||||
|
||||
# Check if source icon exists
|
||||
if [ ! -f "$SOURCE_ICON" ]; then
|
||||
echo "Error: Source icon not found at $SOURCE_ICON"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if ImageMagick is installed
|
||||
if ! command -v convert &> /dev/null; then
|
||||
echo "ImageMagick is not installed. Please install it:"
|
||||
echo " Fedora: sudo dnf install ImageMagick"
|
||||
echo " Arch/Cachy: sudo pacman -S imagemagick"
|
||||
echo " Ubuntu: sudo apt install imagemagick"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Generating icon sizes for GNOME integration..."
|
||||
|
||||
# Standard icon sizes for freedesktop.org icon theme specification
|
||||
SIZES=(16 22 24 32 48 64 128 256 512)
|
||||
|
||||
for size in "${SIZES[@]}"; do
|
||||
output_file="${ICONS_DIR}/icon-${size}x${size}.png"
|
||||
echo " Creating ${size}x${size} icon..."
|
||||
convert "$SOURCE_ICON" -resize "${size}x${size}" "$output_file"
|
||||
done
|
||||
|
||||
# Create scalable SVG if needed (optional)
|
||||
# This would require inkscape or another tool
|
||||
|
||||
echo ""
|
||||
echo "Icon generation complete!"
|
||||
echo "Generated icons:"
|
||||
ls -lh "${ICONS_DIR}"/icon-*.png
|
||||
|
||||
echo ""
|
||||
echo "You can now install these icons using ./install.sh"
|
||||
265
install-debian.sh
Executable 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 ""
|
||||
286
install.sh
Executable file
@ -0,0 +1,286 @@
|
||||
#!/bin/bash
|
||||
# Installation script for pyPhotoAlbum
|
||||
# Supports both system-wide and user-local installation
|
||||
|
||||
# Don't use set -e for dependency installation as some packages may already be installed
|
||||
# We'll handle errors individually where needed
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Print functions
|
||||
print_info() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
print_warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# Check if running as root
|
||||
is_root() {
|
||||
[ "$(id -u)" -eq 0 ]
|
||||
}
|
||||
|
||||
# Detect distribution
|
||||
detect_distro() {
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
echo "$ID"
|
||||
else
|
||||
echo "unknown"
|
||||
fi
|
||||
}
|
||||
|
||||
# Install system dependencies
|
||||
install_dependencies() {
|
||||
local distro=$(detect_distro)
|
||||
|
||||
print_info "Detected distribution: $distro"
|
||||
|
||||
case "$distro" in
|
||||
fedora)
|
||||
print_info "Installing dependencies for Fedora..."
|
||||
# Use --skip-unavailable and --allowerasing to handle already installed packages
|
||||
sudo dnf install -y --skip-unavailable python3 python3-pip python3-pyqt6 python3-pyopengl \
|
||||
python3-numpy python3-pillow python3-reportlab python3-lxml || {
|
||||
print_warn "Some packages may already be installed or unavailable, continuing..."
|
||||
}
|
||||
;;
|
||||
arch|cachyos)
|
||||
print_info "Installing dependencies for Arch/CachyOS..."
|
||||
sudo pacman -S --needed --noconfirm python python-pip python-pyqt6 \
|
||||
python-pyopengl python-numpy python-pillow python-reportlab python-lxml
|
||||
;;
|
||||
ubuntu|debian)
|
||||
print_info "Installing dependencies for Ubuntu/Debian..."
|
||||
sudo apt update
|
||||
sudo apt install -y python3 python3-pip python3-pyqt6 python3-opengl \
|
||||
python3-numpy python3-pil python3-reportlab python3-lxml
|
||||
;;
|
||||
*)
|
||||
print_warn "Unknown distribution. Please install dependencies manually."
|
||||
print_info "Required packages: python3, python3-pip, PyQt6, PyOpenGL, numpy, Pillow, reportlab, lxml"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Check if running in a virtual environment
|
||||
in_virtualenv() {
|
||||
[ -n "$VIRTUAL_ENV" ] || [ -n "$CONDA_DEFAULT_ENV" ]
|
||||
}
|
||||
|
||||
# Install Python package
|
||||
install_package() {
|
||||
local install_mode=$1
|
||||
|
||||
case "$install_mode" in
|
||||
system)
|
||||
print_info "Installing pyPhotoAlbum system-wide..."
|
||||
sudo pip install --upgrade .
|
||||
;;
|
||||
venv)
|
||||
print_info "Installing pyPhotoAlbum in virtual environment..."
|
||||
pip install --upgrade .
|
||||
;;
|
||||
user-force)
|
||||
print_info "Installing pyPhotoAlbum for current user (forcing --user)..."
|
||||
pip install --user --upgrade .
|
||||
;;
|
||||
*)
|
||||
print_info "Installing pyPhotoAlbum for current user..."
|
||||
pip install --user --upgrade .
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Install desktop integration
|
||||
install_desktop_integration() {
|
||||
local install_mode=$1
|
||||
|
||||
if [ "$install_mode" = "system" ]; then
|
||||
print_info "Installing desktop integration system-wide..."
|
||||
|
||||
# Install desktop file
|
||||
sudo install -Dm644 pyphotoalbum.desktop \
|
||||
/usr/share/applications/pyphotoalbum.desktop
|
||||
|
||||
# Install icons in multiple sizes for GNOME
|
||||
print_info "Installing application icons..."
|
||||
|
||||
# Install main icon (256x256)
|
||||
sudo install -Dm644 pyPhotoAlbum/icons/icon.png \
|
||||
/usr/share/icons/hicolor/256x256/apps/pyphotoalbum.png
|
||||
|
||||
# Install additional sizes if they exist
|
||||
for size in 16 22 24 32 48 64 128 512; do
|
||||
icon_file="pyPhotoAlbum/icons/icon-${size}x${size}.png"
|
||||
if [ -f "$icon_file" ]; then
|
||||
sudo install -Dm644 "$icon_file" \
|
||||
"/usr/share/icons/hicolor/${size}x${size}/apps/pyphotoalbum.png"
|
||||
fi
|
||||
done
|
||||
|
||||
# Update desktop database
|
||||
if command -v update-desktop-database &> /dev/null; then
|
||||
sudo update-desktop-database /usr/share/applications
|
||||
fi
|
||||
|
||||
# Update icon cache
|
||||
if command -v gtk-update-icon-cache &> /dev/null; then
|
||||
print_info "Updating icon cache..."
|
||||
sudo gtk-update-icon-cache -f /usr/share/icons/hicolor/
|
||||
fi
|
||||
else
|
||||
print_info "Installing desktop integration for current user..."
|
||||
|
||||
# Create directories if they don't exist
|
||||
mkdir -p ~/.local/share/applications
|
||||
|
||||
# Copy desktop file and update Exec paths to use full path
|
||||
cp pyphotoalbum.desktop ~/.local/share/applications/
|
||||
sed -i "s|Exec=pyphotoalbum|Exec=$HOME/.local/bin/pyphotoalbum|g" ~/.local/share/applications/pyphotoalbum.desktop
|
||||
|
||||
# Install icons in multiple sizes for GNOME
|
||||
print_info "Installing application icons..."
|
||||
|
||||
# Install main icon (256x256)
|
||||
mkdir -p ~/.local/share/icons/hicolor/256x256/apps
|
||||
cp pyPhotoAlbum/icons/icon.png ~/.local/share/icons/hicolor/256x256/apps/pyphotoalbum.png
|
||||
|
||||
# Generate and install additional sizes for better display
|
||||
if command -v magick &> /dev/null || command -v convert &> /dev/null; then
|
||||
for size in 48 64 128; do
|
||||
mkdir -p ~/.local/share/icons/hicolor/${size}x${size}/apps
|
||||
if command -v magick &> /dev/null; then
|
||||
magick pyPhotoAlbum/icons/icon.png -resize ${size}x${size} ~/.local/share/icons/hicolor/${size}x${size}/apps/pyphotoalbum.png 2>/dev/null
|
||||
else
|
||||
convert pyPhotoAlbum/icons/icon.png -resize ${size}x${size} ~/.local/share/icons/hicolor/${size}x${size}/apps/pyphotoalbum.png 2>/dev/null
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Install additional sizes if they exist
|
||||
for size in 16 22 24 32 48 64 128 512; do
|
||||
icon_file="pyPhotoAlbum/icons/icon-${size}x${size}.png"
|
||||
if [ -f "$icon_file" ]; then
|
||||
mkdir -p ~/.local/share/icons/hicolor/${size}x${size}/apps
|
||||
cp "$icon_file" ~/.local/share/icons/hicolor/${size}x${size}/apps/pyphotoalbum.png
|
||||
fi
|
||||
done
|
||||
|
||||
# Update desktop database
|
||||
if command -v update-desktop-database &> /dev/null; then
|
||||
update-desktop-database ~/.local/share/applications
|
||||
fi
|
||||
|
||||
# Update icon cache
|
||||
if command -v gtk-update-icon-cache &> /dev/null; then
|
||||
print_info "Updating icon cache..."
|
||||
gtk-update-icon-cache -f ~/.local/share/icons/hicolor/
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Main installation
|
||||
main() {
|
||||
echo "========================================"
|
||||
echo " pyPhotoAlbum Installation Script "
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Check for required files
|
||||
if [ ! -f "pyproject.toml" ]; then
|
||||
print_error "pyproject.toml not found. Please run this script from the project root."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Determine installation mode
|
||||
local install_mode="user"
|
||||
if is_root || [ "${1}" = "--system" ]; then
|
||||
install_mode="system"
|
||||
fi
|
||||
|
||||
# Check if in virtualenv and warn user
|
||||
if in_virtualenv; then
|
||||
print_warn "Running in a virtual environment"
|
||||
echo "Where do you want to install?"
|
||||
echo "1) Virtual environment (default)"
|
||||
echo "2) User installation (~/.local)"
|
||||
echo "3) System-wide (requires sudo)"
|
||||
echo ""
|
||||
read -p "Enter your choice [1-3]: " venv_choice
|
||||
|
||||
case "$venv_choice" in
|
||||
2)
|
||||
install_mode="user-force"
|
||||
;;
|
||||
3)
|
||||
install_mode="system"
|
||||
;;
|
||||
*)
|
||||
install_mode="venv"
|
||||
;;
|
||||
esac
|
||||
echo ""
|
||||
fi
|
||||
|
||||
print_info "Installation mode: $install_mode"
|
||||
echo ""
|
||||
|
||||
# Ask user what to install
|
||||
echo "What would you like to install?"
|
||||
echo "1) Dependencies only"
|
||||
echo "2) Application only (no dependencies)"
|
||||
echo "3) Everything (recommended)"
|
||||
echo "4) Exit"
|
||||
echo ""
|
||||
read -p "Enter your choice [1-4]: " choice
|
||||
|
||||
case "$choice" in
|
||||
1)
|
||||
install_dependencies
|
||||
;;
|
||||
2)
|
||||
install_package "$install_mode"
|
||||
install_desktop_integration "$install_mode"
|
||||
;;
|
||||
3)
|
||||
install_dependencies
|
||||
install_package "$install_mode"
|
||||
install_desktop_integration "$install_mode"
|
||||
;;
|
||||
4)
|
||||
print_info "Installation cancelled."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
print_error "Invalid choice. Exiting."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
print_info "Installation complete!"
|
||||
echo ""
|
||||
echo "You can now run pyPhotoAlbum by:"
|
||||
echo " 1) Running 'pyphotoalbum' in the terminal"
|
||||
echo " 2) Finding 'pyPhotoAlbum' in your application menu"
|
||||
echo ""
|
||||
|
||||
if [ "$install_mode" = "user" ]; then
|
||||
print_warn "Note: If the 'pyphotoalbum' command is not found, make sure ~/.local/bin is in your PATH"
|
||||
echo "Add this to your ~/.bashrc or ~/.zshrc:"
|
||||
echo " export PATH=\"\$HOME/.local/bin:\$PATH\""
|
||||
fi
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
25
install_desktop_integration.sh
Executable file
@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
# Install desktop integration files for pyPhotoAlbum
|
||||
|
||||
# Create directories if they don't exist
|
||||
mkdir -p ~/.local/share/applications
|
||||
mkdir -p ~/.local/share/icons/hicolor/256x256/apps
|
||||
|
||||
# Copy desktop file
|
||||
cp pyphotoalbum.desktop ~/.local/share/applications/
|
||||
|
||||
# Copy icon
|
||||
cp pyPhotoAlbum/icons/icon.png ~/.local/share/icons/hicolor/256x256/apps/pyphotoalbum.png
|
||||
|
||||
# Update desktop database
|
||||
if command -v update-desktop-database &> /dev/null; then
|
||||
update-desktop-database ~/.local/share/applications
|
||||
fi
|
||||
|
||||
# Update icon cache
|
||||
if command -v gtk-update-icon-cache &> /dev/null; then
|
||||
gtk-update-icon-cache ~/.local/share/icons/hicolor/
|
||||
fi
|
||||
|
||||
echo "Desktop integration installed!"
|
||||
echo "You may need to restart the application for changes to take effect."
|
||||
18
launch-pyphotoalbum.sh
Executable 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" "$@"
|
||||
269
pyPhotoAlbum/EMBEDDED_TEMPLATES.md
Normal file
@ -0,0 +1,269 @@
|
||||
# Embedded Templates Feature
|
||||
|
||||
## Overview
|
||||
|
||||
The embedded templates feature allows templates to be stored within project files (.ppz) so they travel with the document. When loading projects, embedded templates take priority over local filesystem templates, ensuring projects can be opened on any machine without missing custom templates.
|
||||
|
||||
## Key Benefits
|
||||
|
||||
✓ **Portability**: Templates travel with project files
|
||||
✓ **Self-contained**: No dependency on local template files
|
||||
✓ **Priority**: Embedded templates override filesystem templates
|
||||
✓ **Automatic**: Templates are auto-embedded when used
|
||||
✓ **Backward Compatible**: Projects without embedded templates work as before
|
||||
|
||||
## How It Works
|
||||
|
||||
### Template Priority Order
|
||||
|
||||
When loading a template by name, the system checks in this order:
|
||||
|
||||
1. **Embedded templates** in the current project (highest priority)
|
||||
2. **User templates** in `~/.pyphotoalbum/templates/`
|
||||
3. **Built-in templates** in `pyPhotoAlbum/templates/`
|
||||
|
||||
### Automatic Embedding
|
||||
|
||||
Templates are automatically embedded in projects when:
|
||||
|
||||
- Applying a template to a page with `apply_template_to_page()`
|
||||
- Creating a new page from a template with `create_page_from_template()`
|
||||
|
||||
You can disable auto-embedding by passing `auto_embed=False` to these methods.
|
||||
|
||||
### Template Naming
|
||||
|
||||
Templates are listed with prefixes indicating their source:
|
||||
|
||||
- `[Embedded] Template Name` - Embedded in current project
|
||||
- `[Built-in] Template Name` - Built-in template
|
||||
- `Template Name` - User template from filesystem
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```python
|
||||
from pyPhotoAlbum.project import Project, Page
|
||||
from pyPhotoAlbum.template_manager import TemplateManager, Template
|
||||
|
||||
# Create a project
|
||||
project = Project(name="My Album")
|
||||
|
||||
# Create template manager with project
|
||||
template_manager = TemplateManager(project=project)
|
||||
|
||||
# Create a page from a template (auto-embeds by default)
|
||||
template = template_manager.load_template("Grid_2x2")
|
||||
page = template_manager.create_page_from_template(template, page_number=1)
|
||||
project.add_page(page)
|
||||
|
||||
# The template is now embedded in the project!
|
||||
print(project.embedded_templates.keys())
|
||||
# Output: dict_keys(['Grid_2x2'])
|
||||
```
|
||||
|
||||
### Manual Embedding
|
||||
|
||||
```python
|
||||
# Manually embed a template
|
||||
template = Template(name="Custom Layout")
|
||||
# ... configure template ...
|
||||
template_manager.embed_template(template)
|
||||
```
|
||||
|
||||
### Saving Templates
|
||||
|
||||
```python
|
||||
# Save to filesystem (default)
|
||||
template_manager.save_template(template)
|
||||
|
||||
# Or embed in project instead
|
||||
template_manager.save_template(template, embed_in_project=True)
|
||||
```
|
||||
|
||||
### Listing Templates
|
||||
|
||||
```python
|
||||
# List all available templates
|
||||
templates = template_manager.list_templates()
|
||||
# Returns: ['[Embedded] Custom', '[Built-in] Grid_2x2', 'MyUserTemplate', ...]
|
||||
```
|
||||
|
||||
### Loading Templates
|
||||
|
||||
```python
|
||||
# Load embedded template (priority)
|
||||
template = template_manager.load_template("Custom")
|
||||
|
||||
# Load with explicit prefix
|
||||
template = template_manager.load_template("[Embedded] Custom")
|
||||
template = template_manager.load_template("[Built-in] Grid_2x2")
|
||||
```
|
||||
|
||||
### Disabling Auto-Embed
|
||||
|
||||
```python
|
||||
# Don't auto-embed when applying template
|
||||
template_manager.apply_template_to_page(
|
||||
template,
|
||||
page,
|
||||
auto_embed=False
|
||||
)
|
||||
|
||||
# Don't auto-embed when creating page
|
||||
page = template_manager.create_page_from_template(
|
||||
template,
|
||||
page_number=1,
|
||||
auto_embed=False
|
||||
)
|
||||
```
|
||||
|
||||
## Project Serialization
|
||||
|
||||
Embedded templates are automatically serialized when saving projects:
|
||||
|
||||
```python
|
||||
# Save project to ZIP file
|
||||
from pyPhotoAlbum.project_serializer import save_to_zip
|
||||
|
||||
save_to_zip(project, "myalbum.ppz")
|
||||
# Embedded templates are included in the .ppz file
|
||||
```
|
||||
|
||||
When loading:
|
||||
|
||||
```python
|
||||
from pyPhotoAlbum.project_serializer import load_from_zip
|
||||
|
||||
project = load_from_zip("myalbum.ppz")
|
||||
# Embedded templates are automatically restored
|
||||
|
||||
# Create template manager to access them
|
||||
template_manager = TemplateManager(project=project)
|
||||
templates = template_manager.list_templates()
|
||||
```
|
||||
|
||||
## Data Structure
|
||||
|
||||
Embedded templates are stored in the project's `embedded_templates` dictionary:
|
||||
|
||||
```python
|
||||
project.embedded_templates = {
|
||||
"Template Name": {
|
||||
"name": "Template Name",
|
||||
"description": "Template description",
|
||||
"page_size_mm": [210, 297],
|
||||
"elements": [
|
||||
{
|
||||
"type": "placeholder",
|
||||
"position": [10, 10],
|
||||
"size": [100, 100],
|
||||
# ... more element data
|
||||
},
|
||||
# ... more elements
|
||||
]
|
||||
},
|
||||
# ... more templates
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### TemplateManager
|
||||
|
||||
#### Constructor
|
||||
```python
|
||||
TemplateManager(project=None)
|
||||
```
|
||||
Create a template manager. Pass a `Project` instance to enable embedded template support.
|
||||
|
||||
#### Methods
|
||||
|
||||
**`embed_template(template: Template)`**
|
||||
Manually embed a template in the project.
|
||||
|
||||
**`load_template(name: str) -> Template`**
|
||||
Load a template by name. Checks embedded templates first, then filesystem.
|
||||
|
||||
**`list_templates() -> List[str]`**
|
||||
List all available templates with source prefixes.
|
||||
|
||||
**`save_template(template: Template, embed_in_project: bool = False)`**
|
||||
Save template to filesystem or embed in project.
|
||||
|
||||
**`delete_template(name: str)`**
|
||||
Delete a template (works with embedded and user templates).
|
||||
|
||||
**`apply_template_to_page(template, page, mode="replace", scale_mode="proportional", margin_percent=2.5, auto_embed=True)`**
|
||||
Apply template to a page. Auto-embeds by default.
|
||||
|
||||
**`create_page_from_template(template, page_number=1, target_size_mm=None, scale_mode="proportional", auto_embed=True) -> Page`**
|
||||
Create a new page from template. Auto-embeds by default.
|
||||
|
||||
### Project
|
||||
|
||||
#### Attributes
|
||||
|
||||
**`embedded_templates: Dict[str, Dict[str, Any]]`**
|
||||
Dictionary storing embedded template definitions.
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### Existing Projects
|
||||
|
||||
Existing projects without embedded templates will continue to work normally. Templates will be auto-embedded when:
|
||||
|
||||
1. You apply a template to a page
|
||||
2. You create a new page from a template
|
||||
3. You manually embed a template
|
||||
|
||||
### Sharing Projects
|
||||
|
||||
When sharing projects:
|
||||
|
||||
1. Templates are automatically embedded when used
|
||||
2. Save the project to .ppz format
|
||||
3. Share the .ppz file
|
||||
4. Recipients can open the project and all templates are available
|
||||
|
||||
No manual steps required!
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Let auto-embed work**: The default behavior of auto-embedding templates ensures portability
|
||||
2. **Save projects after using templates**: Embedded templates are saved with the project
|
||||
3. **Use descriptive template names**: This helps identify templates in the list
|
||||
4. **Test on different machines**: Verify templates work when opening projects elsewhere
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Q: Why isn't my template showing as embedded?**
|
||||
A: Ensure you're creating the TemplateManager with the project:
|
||||
```python
|
||||
template_manager = TemplateManager(project=project)
|
||||
```
|
||||
|
||||
**Q: Can I convert filesystem templates to embedded?**
|
||||
A: Yes, just load and embed them:
|
||||
```python
|
||||
template = template_manager.load_template("MyTemplate")
|
||||
template_manager.embed_template(template)
|
||||
```
|
||||
|
||||
**Q: What happens if I have templates with the same name?**
|
||||
A: Embedded templates take priority over filesystem templates with the same name.
|
||||
|
||||
**Q: Can I remove embedded templates?**
|
||||
A: Yes, use:
|
||||
```python
|
||||
template_manager.delete_template("[Embedded] Template Name")
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
- Templates are stored as JSON in the project's `embedded_templates` dictionary
|
||||
- Serialization includes embedded templates in the project.json within .ppz files
|
||||
- Template manager checks embedded templates first when loading
|
||||
- Auto-embed only happens if the template isn't already embedded
|
||||
- All template operations preserve element properties (position, size, rotation, etc.)
|
||||
153
pyPhotoAlbum/README.md
Normal file
@ -0,0 +1,153 @@
|
||||
# pyPhotoAlbum
|
||||
|
||||
A Python (PyQt6) application for designing photo albums and exporting them to PDF.
|
||||
|
||||
## Features
|
||||
|
||||
### Core Features
|
||||
- [x] Basic application structure with OpenGL rendering
|
||||
- [x] Menu system (File, Edit, View)
|
||||
- [x] Toolbar and status bar
|
||||
- [x] Page layout controls (custom sizes, DPI settings, page management)
|
||||
- [x] Template pages with grid layouts
|
||||
- [x] Image drag-and-drop from file explorer
|
||||
- [x] Image auto-scaling and center-crop fitting
|
||||
- [x] Image rendering with OpenGL textures
|
||||
- [x] Object selection, moving, and resizing
|
||||
- [x] Mouse wheel zoom (10%-500%)
|
||||
- [ ] Interactive cropping with constrained movement
|
||||
- [ ] Text box support with rotation
|
||||
- [ ] Undo/redo functionality
|
||||
- [ ] PDF export
|
||||
- [ ] XML project save/load
|
||||
|
||||
### Additional Features
|
||||
- [ ] Grid cell merging for spanning photos
|
||||
- [x] Double-page spread flag (rendering not yet implemented)
|
||||
- [ ] Default minimum distance between images
|
||||
- [x] Page numbering system
|
||||
- [x] Add/Remove pages dynamically
|
||||
- [ ] Background color/pattern options
|
||||
- [ ] Theme presets
|
||||
|
||||
## Technical Stack
|
||||
- Python 3.9+
|
||||
- PyQt6 for GUI
|
||||
- OpenGL for rendering
|
||||
- ReportLab for PDF generation
|
||||
- lxml for XML serialization
|
||||
- Pillow for image processing
|
||||
|
||||
## Installation
|
||||
|
||||
### For Users
|
||||
|
||||
Install the package using pip:
|
||||
```bash
|
||||
pip install .
|
||||
```
|
||||
|
||||
Or for an editable installation (development):
|
||||
```bash
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
After installation, you can run the application with:
|
||||
```bash
|
||||
pyphotoalbum
|
||||
```
|
||||
|
||||
### For Developers
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone <your-repo-url>
|
||||
cd pyPhotoAlbum
|
||||
```
|
||||
|
||||
2. Create virtual environment:
|
||||
```bash
|
||||
python -m venv venv
|
||||
```
|
||||
|
||||
3. Activate virtual environment:
|
||||
- Windows: `venv\Scripts\activate`
|
||||
- Linux/macOS: `source venv/bin/activate`
|
||||
|
||||
4. Install in development mode with dev dependencies:
|
||||
```bash
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
## Running the Application
|
||||
|
||||
After installation, run:
|
||||
```bash
|
||||
pyphotoalbum
|
||||
```
|
||||
|
||||
Or, for development, you can still run directly:
|
||||
```bash
|
||||
python pyPhotoAlbum/main.py
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Running Tests
|
||||
|
||||
Run all tests with coverage:
|
||||
```bash
|
||||
pytest
|
||||
```
|
||||
|
||||
Run tests with verbose output:
|
||||
```bash
|
||||
pytest -v
|
||||
```
|
||||
|
||||
Run specific test file:
|
||||
```bash
|
||||
pytest tests/test_models.py
|
||||
```
|
||||
|
||||
Run tests with coverage report:
|
||||
```bash
|
||||
pytest --cov=pyPhotoAlbum --cov-report=html
|
||||
```
|
||||
|
||||
Then open `htmlcov/index.html` in your browser to view the coverage report.
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
The project uses Gitea Actions for CI/CD:
|
||||
- **Tests**: Runs on Python 3.9, 3.10, and 3.11 on every push
|
||||
- **Linting**: Checks code quality with flake8, black, and mypy
|
||||
|
||||
View workflow status in your Gitea repository's Actions tab.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
pyPhotoAlbum/
|
||||
├── main.py # Main application entry point
|
||||
├── requirements.txt # Python dependencies
|
||||
└── README.md # Project documentation
|
||||
```
|
||||
|
||||
## Development Roadmap
|
||||
|
||||
1. **Phase 1: Core Functionality**
|
||||
- Complete page layout controls
|
||||
- Implement template system
|
||||
- Add image handling capabilities
|
||||
- Implement text box support
|
||||
|
||||
2. **Phase 2: Advanced Features**
|
||||
- Add undo/redo functionality
|
||||
- Implement PDF export
|
||||
- Add XML project serialization
|
||||
|
||||
3. **Phase 3: Polish and Optimization**
|
||||
- Optimize rendering performance
|
||||
- Add additional UI polish
|
||||
- Implement comprehensive testing
|
||||
119
pyPhotoAlbum/TEMPLATES_README.md
Normal file
@ -0,0 +1,119 @@
|
||||
# Template System Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The template system allows you to create reusable page layouts with placeholder blocks. Templates are saved as JSON files and can be applied to new or existing pages with flexible scaling options.
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Save Page as Template
|
||||
- Converts all images on the current page to placeholder blocks
|
||||
- Preserves positions, sizes, and layouts
|
||||
- Keeps text boxes and existing placeholders
|
||||
- Saves template as JSON file
|
||||
|
||||
### 2. Create Page from Template
|
||||
- Creates a new page with the template layout
|
||||
- Automatically scales to match project page size
|
||||
- All elements are placeholders ready for images
|
||||
|
||||
### 3. Apply Template to Existing Page
|
||||
- Two modes:
|
||||
- **Replace**: Clears page and adds template placeholders
|
||||
- **Reflow**: Repositions existing images to fit template slots
|
||||
- Three scaling options:
|
||||
- **Proportional**: Maintains aspect ratio (recommended)
|
||||
- **Stretch**: Stretches to fit entire page
|
||||
- **Center**: No scaling, centers template
|
||||
|
||||
## How to Use
|
||||
|
||||
### Creating a Template
|
||||
|
||||
1. Design a page with your desired layout (images, text, placeholders)
|
||||
2. Go to **Layout** tab → **Templates** group
|
||||
3. Click **Save as Template**
|
||||
4. Enter a name and optional description
|
||||
5. Template is saved to `~/.pyphotoalbum/templates/`
|
||||
|
||||
### Using a Template
|
||||
|
||||
**To create a new page:**
|
||||
1. Click **New from Template**
|
||||
2. Select a template from the list
|
||||
3. New page is created with placeholder blocks
|
||||
4. Drag and drop images onto placeholders
|
||||
|
||||
**To apply to current page:**
|
||||
1. Click **Apply Template**
|
||||
2. Select template and options:
|
||||
- Mode: Replace or Reflow
|
||||
- Scaling: Proportional, Stretch, or Center
|
||||
3. Click Apply
|
||||
|
||||
## Template Storage
|
||||
|
||||
- **User templates**: `~/.pyphotoalbum/templates/` (your custom templates)
|
||||
- **Built-in templates**: `pyPhotoAlbum/templates/` (shipped with app)
|
||||
- `Grid_2x2`: 2x2 grid layout
|
||||
- `Single_Large`: Single large image with title
|
||||
|
||||
## Template Format
|
||||
|
||||
Templates are JSON files with this structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Template Name",
|
||||
"description": "Description",
|
||||
"page_size_mm": [210, 297],
|
||||
"elements": [
|
||||
{
|
||||
"type": "placeholder",
|
||||
"position": [x, y],
|
||||
"size": [width, height],
|
||||
"rotation": 0,
|
||||
"z_index": 0,
|
||||
"placeholder_type": "image",
|
||||
"default_content": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- Templates scale automatically when page sizes differ
|
||||
- Use **Proportional** scaling to prevent distortion
|
||||
- **Reflow** mode is great for applying new layouts to existing content
|
||||
- Create templates for common layouts you use frequently
|
||||
- Built-in templates provide good starting points
|
||||
|
||||
## Workflow Example
|
||||
|
||||
1. Create a page with 4 images arranged in a grid
|
||||
2. Save it as "My Grid" template
|
||||
3. Later, create new page from "My Grid" template
|
||||
4. Drag your photos onto the placeholder blocks
|
||||
5. Repeat for multiple pages with consistent layout
|
||||
|
||||
## Advanced
|
||||
|
||||
### Scaling Behavior
|
||||
|
||||
When template and target page sizes differ:
|
||||
|
||||
- **Proportional**: `scale = min(width_ratio, height_ratio)`
|
||||
- Maintains aspect ratio
|
||||
- May leave empty space
|
||||
- Best for preserving design
|
||||
|
||||
- **Stretch**: `scale_x = width_ratio, scale_y = height_ratio`
|
||||
- Fills entire page
|
||||
- May distort layout
|
||||
- Good for flexible designs
|
||||
|
||||
- **Center**: No scaling
|
||||
- Template positioned at center
|
||||
- Original sizes preserved
|
||||
- May overflow or leave space
|
||||
12
pyPhotoAlbum/__init__.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""
|
||||
pyPhotoAlbum - A Python application for designing photo albums and exporting them to PDF
|
||||
|
||||
This package provides a PyQt6-based GUI application for creating photo album layouts
|
||||
with support for templates, image manipulation, and PDF export.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__author__ = "pyPhotoAlbum Developer"
|
||||
|
||||
# Version info
|
||||
VERSION = __version__
|
||||
875
pyPhotoAlbum/alignment.py
Normal file
@ -0,0 +1,875 @@
|
||||
"""
|
||||
Alignment and distribution manager for pyPhotoAlbum
|
||||
"""
|
||||
|
||||
from typing import List, Tuple
|
||||
from pyPhotoAlbum.models import BaseLayoutElement
|
||||
|
||||
|
||||
class ElementMaximizer:
|
||||
"""
|
||||
Handles element maximization using a crystal growth algorithm.
|
||||
Breaks down the complex maximize_pattern logic into atomic, testable methods.
|
||||
"""
|
||||
|
||||
def __init__(self, elements: List[BaseLayoutElement], page_size: Tuple[float, float], min_gap: float):
|
||||
"""
|
||||
Initialize the maximizer with elements and constraints.
|
||||
|
||||
Args:
|
||||
elements: List of elements to maximize
|
||||
page_size: (width, height) of the page in mm
|
||||
min_gap: Minimum gap to maintain between elements and borders (in mm)
|
||||
"""
|
||||
self.elements = elements
|
||||
self.page_width, self.page_height = page_size
|
||||
self.min_gap = min_gap
|
||||
self.changes: List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]] = []
|
||||
self._record_initial_states()
|
||||
|
||||
def _record_initial_states(self) -> None:
|
||||
"""Record initial positions and sizes for undo functionality."""
|
||||
for elem in self.elements:
|
||||
self.changes.append((elem, elem.position, elem.size))
|
||||
|
||||
def check_collision(self, elem_idx: int, new_size: Tuple[float, float]) -> bool:
|
||||
"""
|
||||
Check if element with new_size would collide with boundaries or other elements.
|
||||
|
||||
Args:
|
||||
elem_idx: Index of the element to check
|
||||
new_size: Proposed new size (width, height)
|
||||
|
||||
Returns:
|
||||
True if collision detected, False otherwise
|
||||
"""
|
||||
elem = self.elements[elem_idx]
|
||||
x, y = elem.position
|
||||
w, h = new_size
|
||||
|
||||
# Check page boundaries
|
||||
if x < self.min_gap or y < self.min_gap:
|
||||
return True
|
||||
if x + w > self.page_width - self.min_gap:
|
||||
return True
|
||||
if y + h > self.page_height - self.min_gap:
|
||||
return True
|
||||
|
||||
# Check collision with other elements
|
||||
for i, other in enumerate(self.elements):
|
||||
if i == elem_idx:
|
||||
continue
|
||||
|
||||
other_x, other_y = other.position
|
||||
other_w, other_h = other.size
|
||||
|
||||
# Calculate distances between rectangles
|
||||
horizontal_gap = max(
|
||||
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), 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:
|
||||
return True
|
||||
|
||||
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:
|
||||
"""
|
||||
Use binary search to find the maximum scale factor for an element.
|
||||
|
||||
Args:
|
||||
elem_idx: Index of the element
|
||||
current_scale: Current scale factor
|
||||
max_search_scale: Maximum scale to search up to (relative to current_scale)
|
||||
tolerance: Convergence tolerance for binary search
|
||||
max_iterations: Maximum binary search iterations
|
||||
|
||||
Returns:
|
||||
Maximum scale factor that doesn't cause collision
|
||||
"""
|
||||
old_size = self.changes[elem_idx][2]
|
||||
|
||||
# Binary search for maximum scale
|
||||
low, high = current_scale, current_scale * max_search_scale
|
||||
best_scale = current_scale
|
||||
|
||||
for _ in range(max_iterations):
|
||||
mid = (low + high) / 2.0
|
||||
test_size = (old_size[0] * mid, old_size[1] * mid)
|
||||
|
||||
if self.check_collision(elem_idx, test_size):
|
||||
high = mid
|
||||
else:
|
||||
best_scale = mid
|
||||
low = mid
|
||||
|
||||
if high - low < tolerance:
|
||||
break
|
||||
|
||||
return best_scale
|
||||
|
||||
def grow_iteration(self, scales: List[float], growth_rate: float) -> bool:
|
||||
"""
|
||||
Perform one iteration of the growth algorithm.
|
||||
|
||||
Args:
|
||||
scales: Current scale factors for each element
|
||||
growth_rate: Percentage to grow each iteration (0.05 = 5%)
|
||||
|
||||
Returns:
|
||||
True if any element grew, False otherwise
|
||||
"""
|
||||
any_growth = False
|
||||
|
||||
for i, elem in enumerate(self.elements):
|
||||
old_size = self.changes[i][2]
|
||||
|
||||
# Try to grow this element
|
||||
new_scale = scales[i] * (1.0 + growth_rate)
|
||||
new_size = (old_size[0] * new_scale, old_size[1] * new_scale)
|
||||
|
||||
if not self.check_collision(i, new_size):
|
||||
scales[i] = new_scale
|
||||
elem.size = new_size
|
||||
any_growth = True
|
||||
else:
|
||||
# Can't grow uniformly, try to find maximum possible scale
|
||||
max_scale = self.find_max_scale(i, scales[i])
|
||||
if max_scale > scales[i]:
|
||||
scales[i] = max_scale
|
||||
elem.size = (old_size[0] * max_scale, old_size[1] * max_scale)
|
||||
any_growth = True
|
||||
|
||||
return any_growth
|
||||
|
||||
def check_element_collision(self, elem: BaseLayoutElement, new_pos: Tuple[float, float]) -> bool:
|
||||
"""
|
||||
Check if moving an element to new_pos would cause collision with other elements.
|
||||
|
||||
Args:
|
||||
elem: The element to check
|
||||
new_pos: Proposed new position (x, y)
|
||||
|
||||
Returns:
|
||||
True if collision detected, False otherwise
|
||||
"""
|
||||
x, y = new_pos
|
||||
w, h = elem.size
|
||||
|
||||
for other in self.elements:
|
||||
if other is elem:
|
||||
continue
|
||||
ox, oy = other.position
|
||||
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
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def center_element_horizontally(self, elem: BaseLayoutElement) -> None:
|
||||
"""
|
||||
Micro-adjust element position to center horizontally in available space.
|
||||
|
||||
Args:
|
||||
elem: Element to center
|
||||
"""
|
||||
x, y = elem.position
|
||||
w, h = elem.size
|
||||
|
||||
# Calculate available space on each side
|
||||
space_left = x - self.min_gap
|
||||
space_right = (self.page_width - self.min_gap) - (x + w)
|
||||
|
||||
if space_left >= 0 and space_right >= 0:
|
||||
adjust_x = (space_right - space_left) / 4.0 # Gentle centering
|
||||
new_x = max(self.min_gap, min(self.page_width - w - self.min_gap, x + adjust_x))
|
||||
|
||||
# Verify this doesn't cause collision
|
||||
old_pos = elem.position
|
||||
new_pos = (new_x, y)
|
||||
|
||||
if not self.check_element_collision(elem, new_pos):
|
||||
elem.position = new_pos
|
||||
|
||||
def center_element_vertically(self, elem: BaseLayoutElement) -> None:
|
||||
"""
|
||||
Micro-adjust element position to center vertically in available space.
|
||||
|
||||
Args:
|
||||
elem: Element to center
|
||||
"""
|
||||
x, y = elem.position
|
||||
w, h = elem.size
|
||||
|
||||
# Calculate available space on each side
|
||||
space_top = y - self.min_gap
|
||||
space_bottom = (self.page_height - self.min_gap) - (y + h)
|
||||
|
||||
if space_top >= 0 and space_bottom >= 0:
|
||||
adjust_y = (space_bottom - space_top) / 4.0
|
||||
new_y = max(self.min_gap, min(self.page_height - h - self.min_gap, y + adjust_y))
|
||||
|
||||
# Verify this doesn't cause collision
|
||||
old_pos = elem.position
|
||||
new_pos = (x, new_y)
|
||||
|
||||
if not self.check_element_collision(elem, new_pos):
|
||||
elem.position = new_pos
|
||||
|
||||
def center_elements(self) -> None:
|
||||
"""Center all elements slightly within their constrained space."""
|
||||
for elem in self.elements:
|
||||
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]]]:
|
||||
"""
|
||||
Execute the maximization algorithm.
|
||||
|
||||
Args:
|
||||
max_iterations: Maximum number of growth iterations
|
||||
growth_rate: Percentage to grow each iteration (0.05 = 5%)
|
||||
|
||||
Returns:
|
||||
List of (element, old_position, old_size) tuples for undo
|
||||
"""
|
||||
scales = [1.0] * len(self.elements)
|
||||
|
||||
# Growth algorithm - iterative expansion
|
||||
for _ in range(max_iterations):
|
||||
if not self.grow_iteration(scales, growth_rate):
|
||||
break
|
||||
|
||||
# Center elements slightly within their constrained space
|
||||
self.center_elements()
|
||||
|
||||
return self.changes
|
||||
|
||||
|
||||
class AlignmentManager:
|
||||
"""Manages alignment and distribution operations on multiple elements"""
|
||||
|
||||
@staticmethod
|
||||
def get_bounds(elements: List[BaseLayoutElement]) -> Tuple[float, float, float, float]:
|
||||
"""
|
||||
Get the bounding box of multiple elements.
|
||||
|
||||
Returns:
|
||||
(min_x, min_y, max_x, max_y)
|
||||
"""
|
||||
if not elements:
|
||||
return (0, 0, 0, 0)
|
||||
|
||||
min_x = min(elem.position[0] for elem in elements)
|
||||
min_y = min(elem.position[1] for elem in elements)
|
||||
max_x = max(elem.position[0] + elem.size[0] for elem in elements)
|
||||
max_y = max(elem.position[1] + elem.size[1] for elem in elements)
|
||||
|
||||
return (min_x, min_y, max_x, max_y)
|
||||
|
||||
@staticmethod
|
||||
def align_left(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
|
||||
"""
|
||||
Align all elements to the leftmost element.
|
||||
|
||||
Returns:
|
||||
List of (element, old_position) tuples for undo
|
||||
"""
|
||||
if len(elements) < 2:
|
||||
return []
|
||||
|
||||
min_x = min(elem.position[0] for elem in elements)
|
||||
changes = []
|
||||
|
||||
for elem in elements:
|
||||
old_pos = elem.position
|
||||
elem.position = (min_x, elem.position[1])
|
||||
changes.append((elem, old_pos))
|
||||
|
||||
return changes
|
||||
|
||||
@staticmethod
|
||||
def align_right(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
|
||||
"""
|
||||
Align all elements to the rightmost element.
|
||||
|
||||
Returns:
|
||||
List of (element, old_position) tuples for undo
|
||||
"""
|
||||
if len(elements) < 2:
|
||||
return []
|
||||
|
||||
max_right = max(elem.position[0] + elem.size[0] for elem in elements)
|
||||
changes = []
|
||||
|
||||
for elem in elements:
|
||||
old_pos = elem.position
|
||||
new_x = max_right - elem.size[0]
|
||||
elem.position = (new_x, elem.position[1])
|
||||
changes.append((elem, old_pos))
|
||||
|
||||
return changes
|
||||
|
||||
@staticmethod
|
||||
def align_top(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
|
||||
"""
|
||||
Align all elements to the topmost element.
|
||||
|
||||
Returns:
|
||||
List of (element, old_position) tuples for undo
|
||||
"""
|
||||
if len(elements) < 2:
|
||||
return []
|
||||
|
||||
min_y = min(elem.position[1] for elem in elements)
|
||||
changes = []
|
||||
|
||||
for elem in elements:
|
||||
old_pos = elem.position
|
||||
elem.position = (elem.position[0], min_y)
|
||||
changes.append((elem, old_pos))
|
||||
|
||||
return changes
|
||||
|
||||
@staticmethod
|
||||
def align_bottom(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
|
||||
"""
|
||||
Align all elements to the bottommost element.
|
||||
|
||||
Returns:
|
||||
List of (element, old_position) tuples for undo
|
||||
"""
|
||||
if len(elements) < 2:
|
||||
return []
|
||||
|
||||
max_bottom = max(elem.position[1] + elem.size[1] for elem in elements)
|
||||
changes = []
|
||||
|
||||
for elem in elements:
|
||||
old_pos = elem.position
|
||||
new_y = max_bottom - elem.size[1]
|
||||
elem.position = (elem.position[0], new_y)
|
||||
changes.append((elem, old_pos))
|
||||
|
||||
return changes
|
||||
|
||||
@staticmethod
|
||||
def align_horizontal_center(
|
||||
elements: List[BaseLayoutElement],
|
||||
) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
|
||||
"""
|
||||
Align all elements to horizontal center.
|
||||
|
||||
Returns:
|
||||
List of (element, old_position) tuples for undo
|
||||
"""
|
||||
if len(elements) < 2:
|
||||
return []
|
||||
|
||||
# Calculate average center
|
||||
centers = [elem.position[0] + elem.size[0] / 2 for elem in elements]
|
||||
avg_center = sum(centers) / len(centers)
|
||||
|
||||
changes = []
|
||||
for elem in elements:
|
||||
old_pos = elem.position
|
||||
new_x = avg_center - elem.size[0] / 2
|
||||
elem.position = (new_x, elem.position[1])
|
||||
changes.append((elem, old_pos))
|
||||
|
||||
return changes
|
||||
|
||||
@staticmethod
|
||||
def align_vertical_center(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
|
||||
"""
|
||||
Align all elements to vertical center.
|
||||
|
||||
Returns:
|
||||
List of (element, old_position) tuples for undo
|
||||
"""
|
||||
if len(elements) < 2:
|
||||
return []
|
||||
|
||||
# Calculate average center
|
||||
centers = [elem.position[1] + elem.size[1] / 2 for elem in elements]
|
||||
avg_center = sum(centers) / len(centers)
|
||||
|
||||
changes = []
|
||||
for elem in elements:
|
||||
old_pos = elem.position
|
||||
new_y = avg_center - elem.size[1] / 2
|
||||
elem.position = (elem.position[0], new_y)
|
||||
changes.append((elem, old_pos))
|
||||
|
||||
return changes
|
||||
|
||||
@staticmethod
|
||||
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.
|
||||
|
||||
Returns:
|
||||
List of (element, old_position, old_size) tuples for undo
|
||||
"""
|
||||
if len(elements) < 2:
|
||||
return []
|
||||
|
||||
target_size = elements[0].size
|
||||
changes = []
|
||||
|
||||
for elem in elements[1:]:
|
||||
old_pos = elem.position
|
||||
old_size = elem.size
|
||||
elem.size = target_size
|
||||
changes.append((elem, old_pos, old_size))
|
||||
|
||||
return changes
|
||||
|
||||
@staticmethod
|
||||
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.
|
||||
|
||||
Returns:
|
||||
List of (element, old_position, old_size) tuples for undo
|
||||
"""
|
||||
if len(elements) < 2:
|
||||
return []
|
||||
|
||||
target_width = elements[0].size[0]
|
||||
changes = []
|
||||
|
||||
for elem in elements[1:]:
|
||||
old_pos = elem.position
|
||||
old_size = elem.size
|
||||
elem.size = (target_width, elem.size[1])
|
||||
changes.append((elem, old_pos, old_size))
|
||||
|
||||
return changes
|
||||
|
||||
@staticmethod
|
||||
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.
|
||||
|
||||
Returns:
|
||||
List of (element, old_position, old_size) tuples for undo
|
||||
"""
|
||||
if len(elements) < 2:
|
||||
return []
|
||||
|
||||
target_height = elements[0].size[1]
|
||||
changes = []
|
||||
|
||||
for elem in elements[1:]:
|
||||
old_pos = elem.position
|
||||
old_size = elem.size
|
||||
elem.size = (elem.size[0], target_height)
|
||||
changes.append((elem, old_pos, old_size))
|
||||
|
||||
return changes
|
||||
|
||||
@staticmethod
|
||||
def distribute_horizontally(
|
||||
elements: List[BaseLayoutElement],
|
||||
) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
|
||||
"""
|
||||
Distribute elements evenly across horizontal span.
|
||||
|
||||
Returns:
|
||||
List of (element, old_position) tuples for undo
|
||||
"""
|
||||
if len(elements) < 3:
|
||||
return []
|
||||
|
||||
# Sort by x position
|
||||
sorted_elements = sorted(elements, key=lambda e: e.position[0])
|
||||
|
||||
# Get leftmost and rightmost positions
|
||||
min_x = sorted_elements[0].position[0]
|
||||
max_x = sorted_elements[-1].position[0]
|
||||
|
||||
# Calculate spacing between centers
|
||||
total_span = max_x - min_x
|
||||
spacing = total_span / (len(sorted_elements) - 1)
|
||||
|
||||
changes = []
|
||||
for i, elem in enumerate(sorted_elements):
|
||||
old_pos = elem.position
|
||||
new_x = min_x + (i * spacing)
|
||||
elem.position = (new_x, elem.position[1])
|
||||
changes.append((elem, old_pos))
|
||||
|
||||
return changes
|
||||
|
||||
@staticmethod
|
||||
def distribute_vertically(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
|
||||
"""
|
||||
Distribute elements evenly across vertical span.
|
||||
|
||||
Returns:
|
||||
List of (element, old_position) tuples for undo
|
||||
"""
|
||||
if len(elements) < 3:
|
||||
return []
|
||||
|
||||
# Sort by y position
|
||||
sorted_elements = sorted(elements, key=lambda e: e.position[1])
|
||||
|
||||
# Get topmost and bottommost positions
|
||||
min_y = sorted_elements[0].position[1]
|
||||
max_y = sorted_elements[-1].position[1]
|
||||
|
||||
# Calculate spacing between centers
|
||||
total_span = max_y - min_y
|
||||
spacing = total_span / (len(sorted_elements) - 1)
|
||||
|
||||
changes = []
|
||||
for i, elem in enumerate(sorted_elements):
|
||||
old_pos = elem.position
|
||||
new_y = min_y + (i * spacing)
|
||||
elem.position = (elem.position[0], new_y)
|
||||
changes.append((elem, old_pos))
|
||||
|
||||
return changes
|
||||
|
||||
@staticmethod
|
||||
def space_horizontally(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
|
||||
"""
|
||||
Distribute elements with equal spacing between them horizontally.
|
||||
|
||||
Returns:
|
||||
List of (element, old_position) tuples for undo
|
||||
"""
|
||||
if len(elements) < 3:
|
||||
return []
|
||||
|
||||
# Sort by x position
|
||||
sorted_elements = sorted(elements, key=lambda e: e.position[0])
|
||||
|
||||
# Get leftmost and rightmost boundaries
|
||||
min_x = sorted_elements[0].position[0]
|
||||
max_right = sorted_elements[-1].position[0] + sorted_elements[-1].size[0]
|
||||
|
||||
# Calculate total width of all elements
|
||||
total_width = sum(elem.size[0] for elem in sorted_elements)
|
||||
|
||||
# Calculate available space and spacing
|
||||
available_space = max_right - min_x - total_width
|
||||
spacing = available_space / (len(sorted_elements) - 1)
|
||||
|
||||
changes = []
|
||||
current_x = min_x
|
||||
|
||||
for elem in sorted_elements:
|
||||
old_pos = elem.position
|
||||
elem.position = (current_x, elem.position[1])
|
||||
changes.append((elem, old_pos))
|
||||
current_x += elem.size[0] + spacing
|
||||
|
||||
return changes
|
||||
|
||||
@staticmethod
|
||||
def space_vertically(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
|
||||
"""
|
||||
Distribute elements with equal spacing between them vertically.
|
||||
|
||||
Returns:
|
||||
List of (element, old_position) tuples for undo
|
||||
"""
|
||||
if len(elements) < 3:
|
||||
return []
|
||||
|
||||
# Sort by y position
|
||||
sorted_elements = sorted(elements, key=lambda e: e.position[1])
|
||||
|
||||
# Get topmost and bottommost boundaries
|
||||
min_y = sorted_elements[0].position[1]
|
||||
max_bottom = sorted_elements[-1].position[1] + sorted_elements[-1].size[1]
|
||||
|
||||
# Calculate total height of all elements
|
||||
total_height = sum(elem.size[1] for elem in sorted_elements)
|
||||
|
||||
# Calculate available space and spacing
|
||||
available_space = max_bottom - min_y - total_height
|
||||
spacing = available_space / (len(sorted_elements) - 1)
|
||||
|
||||
changes = []
|
||||
current_y = min_y
|
||||
|
||||
for elem in sorted_elements:
|
||||
old_pos = elem.position
|
||||
elem.position = (elem.position[0], current_y)
|
||||
changes.append((elem, old_pos))
|
||||
current_y += elem.size[1] + spacing
|
||||
|
||||
return changes
|
||||
|
||||
@staticmethod
|
||||
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.
|
||||
|
||||
Args:
|
||||
element: The element to resize
|
||||
page_width: The page width in mm
|
||||
|
||||
Returns:
|
||||
Tuple of (element, old_position, old_size) for undo
|
||||
"""
|
||||
old_pos = element.position
|
||||
old_size = element.size
|
||||
|
||||
# Calculate aspect ratio
|
||||
aspect_ratio = old_size[1] / old_size[0]
|
||||
|
||||
# Set new size
|
||||
new_width = page_width
|
||||
new_height = page_width * aspect_ratio
|
||||
element.size = (new_width, new_height)
|
||||
|
||||
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]]:
|
||||
"""
|
||||
Resize element to fit page height while maintaining aspect ratio.
|
||||
|
||||
Args:
|
||||
element: The element to resize
|
||||
page_height: The page height in mm
|
||||
|
||||
Returns:
|
||||
Tuple of (element, old_position, old_size) for undo
|
||||
"""
|
||||
old_pos = element.position
|
||||
old_size = element.size
|
||||
|
||||
# Calculate aspect ratio
|
||||
aspect_ratio = old_size[0] / old_size[1]
|
||||
|
||||
# Set new size
|
||||
new_height = page_height
|
||||
new_width = page_height * aspect_ratio
|
||||
element.size = (new_width, new_height)
|
||||
|
||||
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]]:
|
||||
"""
|
||||
Resize element to fit within page dimensions while maintaining aspect ratio.
|
||||
|
||||
Args:
|
||||
element: The element to resize
|
||||
page_width: The page width in mm
|
||||
page_height: The page height in mm
|
||||
|
||||
Returns:
|
||||
Tuple of (element, old_position, old_size) for undo
|
||||
"""
|
||||
old_pos = element.position
|
||||
old_size = element.size
|
||||
|
||||
# Calculate aspect ratios
|
||||
element_aspect = old_size[0] / old_size[1]
|
||||
page_aspect = page_width / page_height
|
||||
|
||||
# Determine which dimension to fit to
|
||||
if element_aspect > page_aspect:
|
||||
# Element is wider than page - fit to width
|
||||
new_width = page_width
|
||||
new_height = page_width / element_aspect
|
||||
else:
|
||||
# Element is taller than page - fit to height
|
||||
new_height = page_height
|
||||
new_width = page_height * element_aspect
|
||||
|
||||
element.size = (new_width, new_height)
|
||||
|
||||
return (element, old_pos, old_size)
|
||||
|
||||
@staticmethod
|
||||
def maximize_pattern(
|
||||
elements: List[BaseLayoutElement],
|
||||
page_size: Tuple[float, float],
|
||||
min_gap: float = 2.0,
|
||||
max_iterations: int = 100,
|
||||
growth_rate: float = 0.05,
|
||||
) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
|
||||
"""
|
||||
Maximize element sizes using a crystal growth algorithm.
|
||||
Elements grow until they are close to borders or each other.
|
||||
|
||||
Args:
|
||||
elements: List of elements to maximize
|
||||
page_size: (width, height) of the page in mm
|
||||
min_gap: Minimum gap to maintain between elements and borders (in mm)
|
||||
max_iterations: Maximum number of growth iterations
|
||||
growth_rate: Percentage to grow each iteration (0.05 = 5%)
|
||||
|
||||
Returns:
|
||||
List of (element, old_position, old_size) tuples for undo
|
||||
"""
|
||||
if not elements:
|
||||
return []
|
||||
|
||||
maximizer = ElementMaximizer(elements, page_size, min_gap)
|
||||
return maximizer.maximize(max_iterations, growth_rate)
|
||||
|
||||
@staticmethod
|
||||
def expand_to_bounds(
|
||||
element: BaseLayoutElement,
|
||||
page_size: Tuple[float, float],
|
||||
other_elements: List[BaseLayoutElement],
|
||||
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.
|
||||
|
||||
This function expands an element from its current position and size, growing it
|
||||
in all directions (up, down, left, right) until it reaches:
|
||||
- The page boundaries (with min_gap margin)
|
||||
- Another element on the same page (with min_gap spacing)
|
||||
|
||||
The element expands independently in width and height to fill all available space.
|
||||
|
||||
Args:
|
||||
element: The element to expand
|
||||
page_size: (width, height) of the page in mm
|
||||
other_elements: List of other elements on the same page (excluding the target element)
|
||||
min_gap: Minimum gap to maintain between element and boundaries/other elements (in mm)
|
||||
|
||||
Returns:
|
||||
Tuple of (element, old_position, old_size) for undo
|
||||
"""
|
||||
page_width, page_height = page_size
|
||||
old_pos = element.position
|
||||
old_size = element.size
|
||||
|
||||
x, y = element.position
|
||||
w, h = element.size
|
||||
|
||||
# Calculate maximum expansion in each direction
|
||||
# Start with page boundaries
|
||||
max_left = x - min_gap # How much we can expand left
|
||||
max_right = (page_width - min_gap) - (x + w) # How much we can expand right
|
||||
max_top = y - min_gap # How much we can expand up
|
||||
max_bottom = (page_height - min_gap) - (y + h) # How much we can expand down
|
||||
|
||||
# Check constraints from other elements
|
||||
# We need to be conservative and check ALL elements against ALL expansion directions
|
||||
for other in other_elements:
|
||||
ox, oy = other.position
|
||||
ow, oh = other.size
|
||||
|
||||
# Calculate the other element's bounds
|
||||
other_left = ox
|
||||
other_right = ox + ow
|
||||
other_top = oy
|
||||
other_bottom = oy + oh
|
||||
|
||||
# Calculate current element's bounds
|
||||
elem_left = x
|
||||
elem_right = x + w
|
||||
elem_top = y
|
||||
elem_bottom = y + h
|
||||
|
||||
# Check leftward expansion
|
||||
# An element blocks leftward expansion if:
|
||||
# 1. It's to the left of our left edge (other_right <= elem_left)
|
||||
# 2. Its vertical range would overlap with ANY part of our vertical extent
|
||||
if other_right <= elem_left:
|
||||
# Check if vertical ranges overlap (current OR after any vertical expansion)
|
||||
# Conservative: assume we might expand vertically to page bounds
|
||||
if not (other_bottom <= elem_top - min_gap or other_top >= elem_bottom + min_gap):
|
||||
# This element blocks leftward expansion
|
||||
available_left = elem_left - other_right - min_gap
|
||||
max_left = min(max_left, available_left)
|
||||
|
||||
# Check rightward expansion
|
||||
if other_left >= elem_right:
|
||||
# Check if vertical ranges overlap
|
||||
if not (other_bottom <= elem_top - min_gap or other_top >= elem_bottom + min_gap):
|
||||
# This element blocks rightward expansion
|
||||
available_right = other_left - elem_right - min_gap
|
||||
max_right = min(max_right, available_right)
|
||||
|
||||
# Check upward expansion
|
||||
if other_bottom <= elem_top:
|
||||
# Check if horizontal ranges overlap
|
||||
if not (other_right <= elem_left - min_gap or other_left >= elem_right + min_gap):
|
||||
# This element blocks upward expansion
|
||||
available_top = elem_top - other_bottom - min_gap
|
||||
max_top = min(max_top, available_top)
|
||||
|
||||
# Check downward expansion
|
||||
if other_top >= elem_bottom:
|
||||
# Check if horizontal ranges overlap
|
||||
if not (other_right <= elem_left - min_gap or other_left >= elem_right + min_gap):
|
||||
# This element blocks downward expansion
|
||||
available_bottom = other_top - elem_bottom - min_gap
|
||||
max_bottom = min(max_bottom, available_bottom)
|
||||
|
||||
# Ensure non-negative expansion
|
||||
max_left = max(0, max_left)
|
||||
max_right = max(0, max_right)
|
||||
max_top = max(0, max_top)
|
||||
max_bottom = max(0, max_bottom)
|
||||
|
||||
# Expand to fill all available space (no aspect ratio constraint)
|
||||
width_increase = max_left + max_right
|
||||
height_increase = max_top + max_bottom
|
||||
|
||||
# Calculate new size
|
||||
new_width = w + width_increase
|
||||
new_height = h + height_increase
|
||||
|
||||
# Calculate new position (expand from center to maintain relative position)
|
||||
# Distribute the expansion proportionally to available space on each side
|
||||
if max_left + max_right > 0:
|
||||
left_ratio = max_left / (max_left + max_right)
|
||||
new_x = x - (width_increase * left_ratio)
|
||||
else:
|
||||
new_x = x
|
||||
|
||||
if max_top + max_bottom > 0:
|
||||
top_ratio = max_top / (max_top + max_bottom)
|
||||
new_y = y - (height_increase * top_ratio)
|
||||
else:
|
||||
new_y = y
|
||||
|
||||
# Apply the new position and size
|
||||
element.position = (new_x, new_y)
|
||||
element.size = (new_width, new_height)
|
||||
|
||||
return (element, old_pos, old_size)
|
||||
255
pyPhotoAlbum/asset_heal_dialog.py
Normal file
@ -0,0 +1,255 @@
|
||||
"""
|
||||
Asset healing dialog for reconnecting missing images
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from typing import List, Dict, Set
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QFileDialog,
|
||||
QGroupBox,
|
||||
QMessageBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
|
||||
class AssetHealDialog(QDialog):
|
||||
"""Dialog for healing missing asset paths"""
|
||||
|
||||
def __init__(self, project, parent=None):
|
||||
super().__init__(parent)
|
||||
self.project = project
|
||||
self.search_paths: List[str] = []
|
||||
self.missing_assets: Set[str] = set()
|
||||
|
||||
self.setWindowTitle("Heal Missing Assets")
|
||||
self.resize(600, 500)
|
||||
|
||||
self._init_ui()
|
||||
self._scan_missing_assets()
|
||||
|
||||
def _init_ui(self):
|
||||
"""Initialize the UI"""
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Missing assets group
|
||||
missing_group = QGroupBox("Missing Assets")
|
||||
missing_layout = QVBoxLayout()
|
||||
|
||||
self.missing_list = QListWidget()
|
||||
missing_layout.addWidget(self.missing_list)
|
||||
|
||||
missing_group.setLayout(missing_layout)
|
||||
layout.addWidget(missing_group)
|
||||
|
||||
# Search paths group
|
||||
search_group = QGroupBox("Search Paths")
|
||||
search_layout = QVBoxLayout()
|
||||
|
||||
self.search_list = QListWidget()
|
||||
search_layout.addWidget(self.search_list)
|
||||
|
||||
# Add/Remove buttons
|
||||
button_layout = QHBoxLayout()
|
||||
add_path_btn = QPushButton("Add Search Path...")
|
||||
add_path_btn.clicked.connect(self._add_search_path)
|
||||
button_layout.addWidget(add_path_btn)
|
||||
|
||||
remove_path_btn = QPushButton("Remove Selected")
|
||||
remove_path_btn.clicked.connect(self._remove_search_path)
|
||||
button_layout.addWidget(remove_path_btn)
|
||||
|
||||
search_layout.addLayout(button_layout)
|
||||
search_group.setLayout(search_layout)
|
||||
layout.addWidget(search_group)
|
||||
|
||||
# Action buttons
|
||||
action_layout = QHBoxLayout()
|
||||
|
||||
heal_btn = QPushButton("Attempt Healing")
|
||||
heal_btn.clicked.connect(self._attempt_healing)
|
||||
action_layout.addWidget(heal_btn)
|
||||
|
||||
close_btn = QPushButton("Close")
|
||||
close_btn.clicked.connect(self.accept)
|
||||
action_layout.addWidget(close_btn)
|
||||
|
||||
layout.addLayout(action_layout)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def _scan_missing_assets(self):
|
||||
"""Scan project for missing assets - only assets in project's assets folder are valid"""
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
self.missing_assets.clear()
|
||||
self.missing_list.clear()
|
||||
|
||||
# Check all pages for images that need healing
|
||||
# Images MUST be in the project's assets folder - absolute paths or external paths need healing
|
||||
for page in self.project.pages:
|
||||
for element in page.layout.elements:
|
||||
if isinstance(element, ImageData) and element.image_path:
|
||||
needs_healing = False
|
||||
reason = ""
|
||||
|
||||
# Absolute paths need healing (should be relative to assets/)
|
||||
if os.path.isabs(element.image_path):
|
||||
needs_healing = True
|
||||
reason = "absolute path"
|
||||
# Paths not starting with assets/ need healing
|
||||
elif not element.image_path.startswith("assets/"):
|
||||
needs_healing = True
|
||||
reason = "not in assets folder"
|
||||
else:
|
||||
# Relative path in assets/ - check if file exists
|
||||
full_path = os.path.join(self.project.folder_path, element.image_path)
|
||||
if not os.path.exists(full_path):
|
||||
needs_healing = True
|
||||
reason = "file missing"
|
||||
|
||||
if needs_healing:
|
||||
self.missing_assets.add(element.image_path)
|
||||
print(f"Asset needs healing: {element.image_path} ({reason})")
|
||||
|
||||
# Display missing assets
|
||||
if self.missing_assets:
|
||||
for asset in sorted(self.missing_assets):
|
||||
self.missing_list.addItem(asset)
|
||||
else:
|
||||
item = QListWidgetItem("No missing assets found!")
|
||||
item.setForeground(Qt.GlobalColor.darkGreen)
|
||||
self.missing_list.addItem(item)
|
||||
|
||||
def _add_search_path(self):
|
||||
"""Add a search path"""
|
||||
directory = QFileDialog.getExistingDirectory(
|
||||
self, "Select Search Path for Assets", "", QFileDialog.Option.ShowDirsOnly
|
||||
)
|
||||
|
||||
if directory:
|
||||
if directory not in self.search_paths:
|
||||
self.search_paths.append(directory)
|
||||
self.search_list.addItem(directory)
|
||||
|
||||
def _remove_search_path(self):
|
||||
"""Remove selected search path"""
|
||||
current_row = self.search_list.currentRow()
|
||||
if current_row >= 0:
|
||||
self.search_paths.pop(current_row)
|
||||
self.search_list.takeItem(current_row)
|
||||
|
||||
def _attempt_healing(self):
|
||||
"""Attempt to heal missing assets by resolving stored paths and using search paths"""
|
||||
from pyPhotoAlbum.models import ImageData, set_asset_resolution_context
|
||||
|
||||
healed_count = 0
|
||||
imported_count = 0
|
||||
still_missing = []
|
||||
|
||||
# Update asset resolution context with search paths (for rendering after heal)
|
||||
set_asset_resolution_context(self.project.folder_path, self.search_paths)
|
||||
|
||||
# Build mapping of missing paths to elements
|
||||
path_to_elements: Dict[str, List] = {}
|
||||
for page in self.project.pages:
|
||||
for element in page.layout.elements:
|
||||
if isinstance(element, ImageData) and element.image_path:
|
||||
if element.image_path in self.missing_assets:
|
||||
if element.image_path not in path_to_elements:
|
||||
path_to_elements[element.image_path] = []
|
||||
path_to_elements[element.image_path].append(element)
|
||||
|
||||
# Try to find and import each missing asset
|
||||
for asset_path in self.missing_assets:
|
||||
found_path = None
|
||||
filename = os.path.basename(asset_path)
|
||||
|
||||
# FIRST: Try to resolve the stored path directly from project folder
|
||||
# This handles paths like "../../home/user/Photos/image.jpg"
|
||||
if not os.path.isabs(asset_path):
|
||||
resolved = os.path.normpath(os.path.join(self.project.folder_path, asset_path))
|
||||
if os.path.exists(resolved):
|
||||
found_path = resolved
|
||||
print(f"Resolved relative path: {asset_path} → {resolved}")
|
||||
|
||||
# SECOND: If it's an absolute path, check if it exists directly
|
||||
if not found_path and os.path.isabs(asset_path):
|
||||
if os.path.exists(asset_path):
|
||||
found_path = asset_path
|
||||
print(f"Found at absolute path: {asset_path}")
|
||||
|
||||
# THIRD: Search in user-provided search paths
|
||||
if not found_path:
|
||||
for search_path in self.search_paths:
|
||||
# Try direct match by filename
|
||||
candidate = os.path.join(search_path, filename)
|
||||
if os.path.exists(candidate):
|
||||
found_path = candidate
|
||||
break
|
||||
|
||||
# Try with same relative path structure
|
||||
candidate = os.path.join(search_path, asset_path)
|
||||
if os.path.exists(candidate):
|
||||
found_path = candidate
|
||||
break
|
||||
|
||||
if found_path:
|
||||
healed_count += 1
|
||||
|
||||
# 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/"):
|
||||
# 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)
|
||||
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
|
||||
shutil.copy2(found_path, dest_path)
|
||||
print(f"Restored: {asset_path} from {found_path}")
|
||||
else:
|
||||
# It's an absolute path or external path - need to import it
|
||||
try:
|
||||
new_asset_path = self.project.asset_manager.import_asset(found_path)
|
||||
imported_count += 1
|
||||
|
||||
# Update all elements using this path
|
||||
if asset_path in path_to_elements:
|
||||
for element in path_to_elements[asset_path]:
|
||||
element.image_path = new_asset_path
|
||||
|
||||
print(f"Imported and updated: {asset_path} → {new_asset_path}")
|
||||
except Exception as e:
|
||||
print(f"Error importing {found_path}: {e}")
|
||||
still_missing.append(asset_path)
|
||||
continue
|
||||
else:
|
||||
still_missing.append(asset_path)
|
||||
|
||||
# Report results
|
||||
message = f"Healing complete!\n\n"
|
||||
message += f"Assets found: {healed_count}\n"
|
||||
if imported_count > 0:
|
||||
message += f"Assets imported to project: {imported_count}\n"
|
||||
message += f"Still missing: {len(still_missing)}"
|
||||
|
||||
if still_missing:
|
||||
message += f"\n\nStill missing:\n"
|
||||
message += "\n".join(f" - {asset}" for asset in still_missing[:10])
|
||||
if len(still_missing) > 10:
|
||||
message += f"\n ... and {len(still_missing) - 10} more"
|
||||
|
||||
QMessageBox.information(self, "Healing Results", message)
|
||||
|
||||
# Reset asset resolution context to project folder only (no search paths for rendering)
|
||||
set_asset_resolution_context(self.project.folder_path)
|
||||
|
||||
# Rescan to update the list
|
||||
self._scan_missing_assets()
|
||||
430
pyPhotoAlbum/asset_manager.py
Normal file
@ -0,0 +1,430 @@
|
||||
"""
|
||||
Asset management system for pyPhotoAlbum with automatic reference counting
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
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"""
|
||||
|
||||
def __init__(self, project_folder: str):
|
||||
"""
|
||||
Initialize AssetManager.
|
||||
|
||||
Args:
|
||||
project_folder: Root folder for the project
|
||||
"""
|
||||
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)
|
||||
|
||||
def import_asset(self, source_path: str) -> str:
|
||||
"""
|
||||
Import an asset into the project by copying it to the assets folder.
|
||||
|
||||
Args:
|
||||
source_path: Path to the source file
|
||||
|
||||
Returns:
|
||||
Relative path to the imported asset (e.g., "assets/photo_001.jpg")
|
||||
"""
|
||||
if not os.path.exists(source_path):
|
||||
raise FileNotFoundError(f"Source file not found: {source_path}")
|
||||
|
||||
# Get filename and extension
|
||||
filename = os.path.basename(source_path)
|
||||
name, ext = os.path.splitext(filename)
|
||||
|
||||
# Find a unique filename if there's a collision
|
||||
counter = 1
|
||||
dest_filename = filename
|
||||
dest_path = os.path.join(self.assets_folder, dest_filename)
|
||||
|
||||
while os.path.exists(dest_path):
|
||||
dest_filename = f"{name}_{counter:03d}{ext}"
|
||||
dest_path = os.path.join(self.assets_folder, dest_filename)
|
||||
counter += 1
|
||||
|
||||
# Copy the file
|
||||
shutil.copy2(source_path, dest_path)
|
||||
|
||||
# Get relative path from project folder (for storage/serialization)
|
||||
relative_path = os.path.relpath(dest_path, self.project_folder)
|
||||
|
||||
# Initialize reference count
|
||||
self.reference_counts[relative_path] = 1
|
||||
|
||||
print(f"AssetManager: Imported {source_path} → {dest_path} (stored as {relative_path}, refs=1)")
|
||||
|
||||
# Return relative path for storage in elements
|
||||
return relative_path
|
||||
|
||||
def acquire_reference(self, asset_path: str):
|
||||
"""
|
||||
Increment the reference count for an asset.
|
||||
|
||||
Args:
|
||||
asset_path: Relative path to the asset
|
||||
"""
|
||||
if not asset_path:
|
||||
return
|
||||
|
||||
if asset_path in self.reference_counts:
|
||||
self.reference_counts[asset_path] += 1
|
||||
print(f"AssetManager: Acquired reference to {asset_path} (refs={self.reference_counts[asset_path]})")
|
||||
else:
|
||||
# Asset might exist from a loaded project
|
||||
full_path = os.path.join(self.project_folder, asset_path)
|
||||
if os.path.exists(full_path):
|
||||
self.reference_counts[asset_path] = 1
|
||||
print(f"AssetManager: Acquired reference to existing asset {asset_path} (refs=1)")
|
||||
else:
|
||||
print(f"AssetManager: Warning - asset not found: {asset_path}")
|
||||
|
||||
def release_reference(self, asset_path: str):
|
||||
"""
|
||||
Decrement the reference count for an asset.
|
||||
If count reaches zero, delete the asset file.
|
||||
|
||||
Args:
|
||||
asset_path: Relative path to the asset
|
||||
"""
|
||||
if not asset_path:
|
||||
return
|
||||
|
||||
if asset_path not in self.reference_counts:
|
||||
print(f"AssetManager: Warning - attempting to release unknown asset: {asset_path}")
|
||||
return
|
||||
|
||||
self.reference_counts[asset_path] -= 1
|
||||
print(f"AssetManager: Released reference to {asset_path} (refs={self.reference_counts[asset_path]})")
|
||||
|
||||
if self.reference_counts[asset_path] <= 0:
|
||||
# No more references - safe to delete
|
||||
full_path = os.path.join(self.project_folder, asset_path)
|
||||
try:
|
||||
if os.path.exists(full_path):
|
||||
os.remove(full_path)
|
||||
print(f"AssetManager: Deleted unused asset {asset_path}")
|
||||
del self.reference_counts[asset_path]
|
||||
except Exception as e:
|
||||
print(f"AssetManager: Error deleting asset {asset_path}: {e}")
|
||||
|
||||
def get_absolute_path(self, relative_path: str) -> str:
|
||||
"""
|
||||
Convert a relative asset path to an absolute path.
|
||||
|
||||
Args:
|
||||
relative_path: Relative path from project folder
|
||||
|
||||
Returns:
|
||||
Absolute path to the asset
|
||||
"""
|
||||
return os.path.join(self.project_folder, relative_path)
|
||||
|
||||
def get_reference_count(self, asset_path: str) -> int:
|
||||
"""
|
||||
Get the current reference count for an asset.
|
||||
|
||||
Args:
|
||||
asset_path: Relative path to the asset
|
||||
|
||||
Returns:
|
||||
Reference count (0 if not tracked)
|
||||
"""
|
||||
return self.reference_counts.get(asset_path, 0)
|
||||
|
||||
def serialize(self) -> Dict:
|
||||
"""Serialize asset manager state"""
|
||||
return {
|
||||
"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 = []
|
||||
|
||||
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)
|
||||
785
pyPhotoAlbum/async_backend.py
Normal file
@ -0,0 +1,785 @@
|
||||
"""
|
||||
Async backend for non-blocking image loading and PDF generation.
|
||||
|
||||
This module provides:
|
||||
- AsyncImageLoader: Load and process images in background
|
||||
- AsyncPDFGenerator: Generate PDFs without blocking UI
|
||||
- ImageCache: Intelligent caching with LRU eviction
|
||||
- WorkerPool: Thread pool for CPU-bound operations
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass, field
|
||||
from enum import IntEnum
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable, Dict, Any, Tuple, Union
|
||||
from concurrent.futures import Future
|
||||
from collections import OrderedDict
|
||||
import threading
|
||||
|
||||
from PIL import Image
|
||||
from PyQt6.QtCore import QObject, pyqtSignal
|
||||
|
||||
from pyPhotoAlbum.image_utils import convert_to_rgba, resize_to_fit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def get_image_dimensions(image_path: str, max_size: Optional[int] = None) -> Optional[Tuple[int, int]]:
|
||||
"""
|
||||
Extract image dimensions without loading the full image.
|
||||
|
||||
Uses PIL's lazy loading to read only the header, making this a fast
|
||||
operation suitable for UI code that needs dimensions before async loading.
|
||||
|
||||
Args:
|
||||
image_path: Path to the image file (absolute or relative)
|
||||
max_size: Optional maximum dimension - if provided, dimensions are
|
||||
scaled down proportionally to fit within this limit
|
||||
|
||||
Returns:
|
||||
Tuple of (width, height) or None if the image cannot be read
|
||||
|
||||
Example:
|
||||
# Get raw dimensions
|
||||
dims = get_image_dimensions("/path/to/image.jpg")
|
||||
|
||||
# Get dimensions scaled to fit within 300px
|
||||
dims = get_image_dimensions("/path/to/image.jpg", max_size=300)
|
||||
"""
|
||||
try:
|
||||
with Image.open(image_path) as img:
|
||||
width, height = img.size
|
||||
|
||||
if max_size and (width > max_size or height > max_size):
|
||||
scale = min(max_size / width, max_size / height)
|
||||
width = int(width * scale)
|
||||
height = int(height * scale)
|
||||
|
||||
return (width, height)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not extract dimensions for {image_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@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)
|
||||
target_size: Optional[Tuple[int, int]] = field(default=None, compare=False)
|
||||
callback: Optional[Callable] = field(default=None, compare=False)
|
||||
user_data: Any = field(default=None, compare=False)
|
||||
|
||||
|
||||
class ImageCache:
|
||||
"""
|
||||
Thread-safe LRU cache for PIL images with memory management.
|
||||
|
||||
Caches both original images and scaled variants to avoid redundant processing.
|
||||
"""
|
||||
|
||||
def __init__(self, max_memory_mb: int = 512):
|
||||
"""
|
||||
Initialize cache.
|
||||
|
||||
Args:
|
||||
max_memory_mb: Maximum memory to use for cached images (default 512MB)
|
||||
"""
|
||||
self.max_memory_bytes = max_memory_mb * 1024 * 1024
|
||||
self.current_memory_bytes = 0
|
||||
self._cache: OrderedDict[str, Tuple[Image.Image, int]] = OrderedDict()
|
||||
self._lock = threading.Lock()
|
||||
|
||||
logger.info(f"ImageCache initialized with {max_memory_mb}MB limit")
|
||||
|
||||
def _estimate_image_size(self, img: Image.Image) -> int:
|
||||
"""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}
|
||||
bytes_per_pixel = mode_sizes.get(img.mode, 4)
|
||||
return img.width * img.height * bytes_per_pixel
|
||||
|
||||
def _make_key(self, path: Path, target_size: Optional[Tuple[int, int]] = None) -> str:
|
||||
"""Create cache key from path and optional target size."""
|
||||
if target_size:
|
||||
return f"{path}:{target_size[0]}x{target_size[1]}"
|
||||
return str(path)
|
||||
|
||||
def get(self, path: Path, target_size: Optional[Tuple[int, int]] = None) -> Optional[Image.Image]:
|
||||
"""
|
||||
Get image from cache.
|
||||
|
||||
Args:
|
||||
path: Path to image file
|
||||
target_size: Optional target size (width, height)
|
||||
|
||||
Returns:
|
||||
Cached PIL Image or None if not found
|
||||
"""
|
||||
key = self._make_key(path, target_size)
|
||||
|
||||
with self._lock:
|
||||
if key in self._cache:
|
||||
# Move to end (most recently used)
|
||||
img, size = self._cache.pop(key)
|
||||
self._cache[key] = (img, size)
|
||||
logger.debug(f"Cache HIT: {key}")
|
||||
return img.copy() # Return copy to avoid external modifications
|
||||
|
||||
logger.debug(f"Cache MISS: {key}")
|
||||
return None
|
||||
|
||||
def put(self, path: Path, img: Image.Image, target_size: Optional[Tuple[int, int]] = None):
|
||||
"""
|
||||
Add image to cache with LRU eviction.
|
||||
|
||||
Args:
|
||||
path: Path to image file
|
||||
img: PIL Image to cache
|
||||
target_size: Optional target size used for this variant
|
||||
"""
|
||||
key = self._make_key(path, target_size)
|
||||
img_size = self._estimate_image_size(img)
|
||||
|
||||
with self._lock:
|
||||
# Remove if already exists (update size)
|
||||
if key in self._cache:
|
||||
_, old_size = self._cache.pop(key)
|
||||
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:
|
||||
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)")
|
||||
|
||||
# Add new image
|
||||
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)}]"
|
||||
)
|
||||
|
||||
def clear(self):
|
||||
"""Clear entire cache."""
|
||||
with self._lock:
|
||||
self._cache.clear()
|
||||
self.current_memory_bytes = 0
|
||||
logger.info("Cache cleared")
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""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,
|
||||
}
|
||||
|
||||
|
||||
class AsyncImageLoader(QObject):
|
||||
"""
|
||||
Asynchronous image loader with priority queue and caching.
|
||||
|
||||
Loads images in background threads and emits signals when complete.
|
||||
Supports concurrent loading, priority-based scheduling, and cancellation.
|
||||
|
||||
Example:
|
||||
loader = AsyncImageLoader()
|
||||
loader.image_loaded.connect(on_image_ready)
|
||||
loader.start()
|
||||
loader.request_load(Path("photo.jpg"), priority=LoadPriority.HIGH)
|
||||
"""
|
||||
|
||||
# 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)
|
||||
|
||||
def __init__(self, cache: Optional[ImageCache] = None, max_workers: int = 4):
|
||||
"""
|
||||
Initialize async image loader.
|
||||
|
||||
Args:
|
||||
cache: ImageCache instance (creates new if None)
|
||||
max_workers: Maximum concurrent worker threads (default 4)
|
||||
"""
|
||||
super().__init__()
|
||||
|
||||
self.cache = cache or ImageCache()
|
||||
self.max_workers = max_workers
|
||||
self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="ImageLoader")
|
||||
|
||||
# Priority queue and tracking
|
||||
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
|
||||
self._lock = threading.Lock()
|
||||
self._shutdown = False
|
||||
|
||||
# Event loop for async operations
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self._loop_thread: Optional[threading.Thread] = None
|
||||
|
||||
logger.info(f"AsyncImageLoader initialized with {max_workers} workers")
|
||||
|
||||
def start(self):
|
||||
"""Start the async backend event loop."""
|
||||
if self._loop_thread is not None:
|
||||
logger.warning("AsyncImageLoader already started")
|
||||
return
|
||||
|
||||
self._shutdown = False
|
||||
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")
|
||||
|
||||
def stop(self):
|
||||
"""Stop the async backend and cleanup resources."""
|
||||
if self._loop is None:
|
||||
return
|
||||
|
||||
logger.info("Stopping AsyncImageLoader...")
|
||||
self._shutdown = True
|
||||
|
||||
# Cancel all active tasks and wait for them to finish
|
||||
if self._loop and not self._loop.is_closed():
|
||||
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)
|
||||
|
||||
# Wait for thread to finish
|
||||
if self._loop_thread:
|
||||
self._loop_thread.join(timeout=5.0)
|
||||
|
||||
# Shutdown executor
|
||||
self.executor.shutdown(wait=True)
|
||||
|
||||
logger.info("AsyncImageLoader stopped")
|
||||
|
||||
def _run_event_loop(self):
|
||||
"""Run asyncio event loop in background thread."""
|
||||
self._loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self._loop)
|
||||
|
||||
# Create priority queue
|
||||
self._queue = asyncio.PriorityQueue()
|
||||
|
||||
# Start queue processor as background task
|
||||
self._loop.create_task(self._process_queue())
|
||||
|
||||
# Run event loop forever (until stopped)
|
||||
self._loop.run_forever()
|
||||
|
||||
# Cleanup after loop stops
|
||||
self._loop.close()
|
||||
|
||||
async def _process_queue(self):
|
||||
"""Process load requests from priority queue."""
|
||||
logger.info("Queue processor started")
|
||||
|
||||
while not self._shutdown:
|
||||
try:
|
||||
# Wait for request with timeout to check shutdown flag
|
||||
request = await asyncio.wait_for(self._queue.get(), timeout=0.5)
|
||||
|
||||
# Skip if already cancelled
|
||||
if request.path not in self._pending_requests:
|
||||
continue
|
||||
|
||||
# Process request
|
||||
task = asyncio.create_task(self._load_image(request))
|
||||
self._active_tasks[request.path] = task
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
continue # Check shutdown flag
|
||||
except Exception as e:
|
||||
logger.error(f"Queue processor error: {e}", exc_info=True)
|
||||
|
||||
logger.info("Queue processor stopped")
|
||||
|
||||
async def _cancel_all_tasks(self):
|
||||
"""Cancel all active loading tasks."""
|
||||
tasks = list(self._active_tasks.values())
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
self._active_tasks.clear()
|
||||
self._pending_requests.clear()
|
||||
|
||||
async def _load_image(self, request: LoadRequest):
|
||||
"""
|
||||
Load and process image asynchronously.
|
||||
|
||||
Args:
|
||||
request: LoadRequest containing path, size, and callback info
|
||||
"""
|
||||
path = request.path
|
||||
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:
|
||||
logger.debug(f"Loaded from cache: {path}")
|
||||
self._emit_loaded(path, cached_img, request.user_data)
|
||||
return
|
||||
|
||||
# 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)
|
||||
|
||||
# Check again if shutting down before emitting
|
||||
if self._shutdown:
|
||||
return
|
||||
|
||||
# Cache result
|
||||
self.cache.put(path, img, target_size)
|
||||
|
||||
# Emit success signal
|
||||
self._emit_loaded(path, img, request.user_data)
|
||||
|
||||
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:
|
||||
# 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
|
||||
with self._lock:
|
||||
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":
|
||||
"""
|
||||
Load image from disk and process (runs in thread pool).
|
||||
|
||||
Args:
|
||||
path: Path to image file
|
||||
target_size: Optional target size for downsampling
|
||||
|
||||
Returns:
|
||||
Processed PIL Image
|
||||
"""
|
||||
img = Image.open(path)
|
||||
img = convert_to_rgba(img)
|
||||
|
||||
# Downsample if target size specified (preserving aspect ratio)
|
||||
if target_size:
|
||||
max_size = target_size[0] # Assume square target (2048, 2048)
|
||||
original_size = img.size
|
||||
img = resize_to_fit(img, max_size)
|
||||
if img.size != original_size:
|
||||
logger.debug(f"Downsampled {path}: {original_size} -> {img.size}")
|
||||
|
||||
return img
|
||||
|
||||
def _emit_loaded(self, path: Path, img: Image.Image, user_data: Any):
|
||||
"""Emit image_loaded signal (thread-safe)."""
|
||||
# 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)."""
|
||||
# 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:
|
||||
"""
|
||||
Request image load with specified priority.
|
||||
|
||||
Args:
|
||||
path: Path to image file
|
||||
priority: Load priority level
|
||||
target_size: Optional target size (width, height) for downsampling
|
||||
user_data: Optional user data passed to callback
|
||||
|
||||
Returns:
|
||||
True if request was queued, False if already pending/active
|
||||
"""
|
||||
if not self._loop or self._shutdown:
|
||||
logger.warning("Cannot request load: backend not started")
|
||||
return False
|
||||
|
||||
path = Path(path)
|
||||
|
||||
with self._lock:
|
||||
# Skip if already pending or active
|
||||
if path in self._pending_requests or path in self._active_tasks:
|
||||
logger.debug(f"Load already pending: {path}")
|
||||
return False
|
||||
|
||||
# Create request
|
||||
request = LoadRequest(
|
||||
priority=priority,
|
||||
request_id=self._next_request_id,
|
||||
path=path,
|
||||
target_size=target_size,
|
||||
user_data=user_data,
|
||||
)
|
||||
self._next_request_id += 1
|
||||
|
||||
# Track as pending
|
||||
self._pending_requests[path] = request
|
||||
|
||||
# Submit to queue (thread-safe)
|
||||
asyncio.run_coroutine_threadsafe(self._queue.put(request), self._loop)
|
||||
|
||||
logger.debug(f"Queued load: {path} (priority: {priority.name})")
|
||||
return True
|
||||
|
||||
def cancel_load(self, path: Path) -> bool:
|
||||
"""
|
||||
Cancel pending image load.
|
||||
|
||||
Args:
|
||||
path: Path to image file
|
||||
|
||||
Returns:
|
||||
True if load was cancelled, False if not found
|
||||
"""
|
||||
path = Path(path)
|
||||
|
||||
with self._lock:
|
||||
# Remove from pending
|
||||
if path in self._pending_requests:
|
||||
del self._pending_requests[path]
|
||||
logger.debug(f"Cancelled pending load: {path}")
|
||||
return True
|
||||
|
||||
# Cancel active task
|
||||
if path in self._active_tasks:
|
||||
task = self._active_tasks[path]
|
||||
task.cancel()
|
||||
logger.debug(f"Cancelled active load: {path}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""Get loader statistics."""
|
||||
with self._lock:
|
||||
return {
|
||||
"pending": len(self._pending_requests),
|
||||
"active": len(self._active_tasks),
|
||||
"cache": self.cache.get_stats(),
|
||||
}
|
||||
|
||||
|
||||
class AsyncPDFGenerator(QObject):
|
||||
"""
|
||||
Asynchronous PDF generator that doesn't block the UI.
|
||||
|
||||
Generates PDFs in background thread with progress updates.
|
||||
Uses shared ImageCache to avoid redundant image loading.
|
||||
|
||||
Example:
|
||||
generator = AsyncPDFGenerator(image_cache)
|
||||
generator.progress_updated.connect(on_progress)
|
||||
generator.export_complete.connect(on_complete)
|
||||
generator.start()
|
||||
generator.export_pdf(project, "output.pdf")
|
||||
"""
|
||||
|
||||
# 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)
|
||||
|
||||
def __init__(self, image_cache: Optional[ImageCache] = None, max_workers: int = 2):
|
||||
"""
|
||||
Initialize async PDF generator.
|
||||
|
||||
Args:
|
||||
image_cache: Shared ImageCache instance (creates new if None)
|
||||
max_workers: Maximum concurrent workers for PDF generation (default 2)
|
||||
"""
|
||||
super().__init__()
|
||||
|
||||
self.image_cache = image_cache or ImageCache()
|
||||
self.max_workers = max_workers
|
||||
self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="PDFGenerator")
|
||||
|
||||
# Export state
|
||||
self._current_export: Optional[Future[Any]] = None
|
||||
self._cancel_requested = False
|
||||
self._lock = threading.RLock() # Use RLock to allow re-entrant locking
|
||||
self._shutdown = False
|
||||
|
||||
# Event loop for async operations
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self._loop_thread: Optional[threading.Thread] = None
|
||||
|
||||
logger.info(f"AsyncPDFGenerator initialized with {max_workers} workers")
|
||||
|
||||
def start(self):
|
||||
"""Start the async PDF generator event loop."""
|
||||
if self._loop_thread is not None:
|
||||
logger.warning("AsyncPDFGenerator already started")
|
||||
return
|
||||
|
||||
self._shutdown = False
|
||||
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")
|
||||
|
||||
def stop(self):
|
||||
"""Stop the async PDF generator and cleanup resources."""
|
||||
if self._loop is None:
|
||||
return
|
||||
|
||||
logger.info("Stopping AsyncPDFGenerator...")
|
||||
self._shutdown = True
|
||||
|
||||
# Cancel active export
|
||||
if self._current_export and not self._current_export.done():
|
||||
self._current_export.cancel()
|
||||
|
||||
# Stop the event loop
|
||||
if self._loop and not self._loop.is_closed():
|
||||
self._loop.call_soon_threadsafe(self._loop.stop)
|
||||
|
||||
# Wait for thread to finish
|
||||
if self._loop_thread:
|
||||
self._loop_thread.join(timeout=5.0)
|
||||
|
||||
# Shutdown executor
|
||||
self.executor.shutdown(wait=True)
|
||||
|
||||
logger.info("AsyncPDFGenerator stopped")
|
||||
|
||||
def _run_event_loop(self):
|
||||
"""Run asyncio event loop in background thread."""
|
||||
self._loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self._loop)
|
||||
|
||||
# Run event loop forever (until stopped)
|
||||
self._loop.run_forever()
|
||||
|
||||
# Cleanup after loop stops
|
||||
self._loop.close()
|
||||
|
||||
def export_pdf(self, project, output_path: str, export_dpi: int = 300) -> bool:
|
||||
"""
|
||||
Request PDF export (non-blocking).
|
||||
|
||||
Args:
|
||||
project: Project instance to export
|
||||
output_path: Path where PDF should be saved
|
||||
export_dpi: Target DPI for images (default 300)
|
||||
|
||||
Returns:
|
||||
True if export started, False if already exporting or backend not started
|
||||
"""
|
||||
if not self._loop or self._shutdown:
|
||||
logger.warning("Cannot export: backend not started")
|
||||
return False
|
||||
|
||||
with self._lock:
|
||||
if self._current_export and not self._current_export.done():
|
||||
logger.warning("Export already in progress")
|
||||
return False
|
||||
|
||||
self._cancel_requested = False
|
||||
|
||||
# Submit export task
|
||||
self._current_export = asyncio.run_coroutine_threadsafe(
|
||||
self._export_pdf_async(project, output_path, export_dpi), self._loop
|
||||
)
|
||||
|
||||
logger.info(f"PDF export started: {output_path}")
|
||||
return True
|
||||
|
||||
def cancel_export(self):
|
||||
"""Request cancellation of current export."""
|
||||
with self._lock:
|
||||
self._cancel_requested = True
|
||||
if self._current_export and not self._current_export.done():
|
||||
self._current_export.cancel()
|
||||
logger.info("PDF export cancellation requested")
|
||||
|
||||
async def _export_pdf_async(self, project, output_path: str, export_dpi: int):
|
||||
"""
|
||||
Perform PDF export asynchronously.
|
||||
|
||||
Args:
|
||||
project: Project to export
|
||||
output_path: Output PDF file path
|
||||
export_dpi: Export DPI setting
|
||||
"""
|
||||
try:
|
||||
# Import PDF exporter (lazy import to avoid circular dependencies)
|
||||
from pyPhotoAlbum.pdf_exporter import PDFExporter
|
||||
|
||||
# Create exporter
|
||||
exporter = PDFExporter(project, export_dpi=export_dpi)
|
||||
|
||||
# Progress callback wrapper
|
||||
def progress_callback(current, total, message):
|
||||
if self._cancel_requested or self._shutdown:
|
||||
return False # Signal cancellation
|
||||
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
|
||||
)
|
||||
|
||||
# Emit completion signal
|
||||
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")
|
||||
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)
|
||||
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: Any, output_path: str, progress_callback: Any) -> Tuple[bool, list[Any]]:
|
||||
"""
|
||||
Run PDF export with image cache integration.
|
||||
|
||||
This method patches the exporter to use our cached images.
|
||||
|
||||
Args:
|
||||
exporter: PDFExporter instance
|
||||
output_path: Output file path
|
||||
progress_callback: Progress callback function
|
||||
|
||||
Returns:
|
||||
Tuple of (success, warnings)
|
||||
"""
|
||||
# Store original Image.open
|
||||
original_open = Image.open
|
||||
|
||||
# Patch Image.open to use cache
|
||||
def cached_open(path, *args, **kwargs):
|
||||
# Only use cache for file paths, not BytesIO or other file-like objects
|
||||
is_file_path = isinstance(path, (str, Path))
|
||||
|
||||
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)
|
||||
finally:
|
||||
# Restore original
|
||||
Image.open = original_open
|
||||
|
||||
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()
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""Get generator statistics."""
|
||||
with self._lock:
|
||||
return {"exporting": self.is_exporting(), "cache": self.image_cache.get_stats()}
|
||||
248
pyPhotoAlbum/async_project_loader.py
Normal file
@ -0,0 +1,248 @@
|
||||
"""
|
||||
Async project loader for pyPhotoAlbum
|
||||
|
||||
Loads projects asynchronously with progress updates to prevent UI freezing.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import zipfile
|
||||
import tempfile
|
||||
from typing import Optional, Tuple
|
||||
from pathlib import Path
|
||||
from PyQt6.QtCore import QThread, pyqtSignal
|
||||
|
||||
from pyPhotoAlbum.project import Project
|
||||
from pyPhotoAlbum.models import ImageData, set_asset_resolution_context
|
||||
from pyPhotoAlbum.version_manager import (
|
||||
CURRENT_DATA_VERSION,
|
||||
check_version_compatibility,
|
||||
VersionCompatibility,
|
||||
DataMigration,
|
||||
)
|
||||
|
||||
|
||||
class AsyncProjectLoader(QThread):
|
||||
"""
|
||||
Async worker thread for loading projects from ZIP files.
|
||||
|
||||
Signals:
|
||||
progress_updated(int, int, str): Emitted with (current, total, message)
|
||||
load_complete(Project): Emitted when loading succeeds
|
||||
load_failed(str): Emitted when loading fails with error message
|
||||
"""
|
||||
|
||||
progress_updated = pyqtSignal(int, int, str) # current, total, message
|
||||
load_complete = pyqtSignal(object) # Project instance
|
||||
load_failed = pyqtSignal(str) # error message
|
||||
|
||||
def __init__(self, zip_path: str, extract_to: Optional[str] = None):
|
||||
super().__init__()
|
||||
self.zip_path = zip_path
|
||||
self.extract_to = extract_to
|
||||
self._cancelled = False
|
||||
|
||||
def cancel(self):
|
||||
"""Cancel the loading operation"""
|
||||
self._cancelled = True
|
||||
|
||||
def run(self):
|
||||
"""Run the async loading operation"""
|
||||
try:
|
||||
if not os.path.exists(self.zip_path):
|
||||
self.load_failed.emit(f"ZIP file not found: {self.zip_path}")
|
||||
return
|
||||
|
||||
if self._cancelled:
|
||||
return
|
||||
|
||||
# Progress: Starting
|
||||
self.progress_updated.emit(0, 100, "Preparing to load...")
|
||||
|
||||
# Track if we created a temp directory
|
||||
temp_dir_obj = None
|
||||
|
||||
# Determine extraction directory
|
||||
if self.extract_to is None:
|
||||
zip_basename = os.path.splitext(os.path.basename(self.zip_path))[0]
|
||||
temp_dir_obj = tempfile.TemporaryDirectory(prefix=f"pyPhotoAlbum_{zip_basename}_")
|
||||
extract_to = temp_dir_obj.name
|
||||
else:
|
||||
os.makedirs(self.extract_to, exist_ok=True)
|
||||
extract_to = self.extract_to
|
||||
|
||||
if self._cancelled:
|
||||
return
|
||||
|
||||
# Progress: Extracting ZIP
|
||||
self.progress_updated.emit(10, 100, "Extracting project files...")
|
||||
|
||||
# Extract ZIP contents with progress
|
||||
with zipfile.ZipFile(self.zip_path, "r") as zipf:
|
||||
file_list = zipf.namelist()
|
||||
total_files = len(file_list)
|
||||
|
||||
for i, filename in enumerate(file_list):
|
||||
if self._cancelled:
|
||||
return
|
||||
|
||||
zipf.extract(filename, extract_to)
|
||||
|
||||
# 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})")
|
||||
|
||||
if self._cancelled:
|
||||
return
|
||||
|
||||
# Progress: Loading project data
|
||||
self.progress_updated.emit(45, 100, "Loading project data...")
|
||||
|
||||
# Load 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:
|
||||
project_data = json.load(f)
|
||||
|
||||
if self._cancelled:
|
||||
return
|
||||
|
||||
# Progress: Checking version
|
||||
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"))
|
||||
|
||||
is_compatible, error_msg = check_version_compatibility(file_version, self.zip_path)
|
||||
if not is_compatible:
|
||||
self.load_failed.emit(error_msg)
|
||||
return
|
||||
|
||||
# Apply migrations if needed
|
||||
if VersionCompatibility.needs_migration(file_version):
|
||||
self.progress_updated.emit(60, 100, f"Migrating from version {file_version}...")
|
||||
try:
|
||||
project_data = DataMigration.migrate(project_data, file_version, CURRENT_DATA_VERSION)
|
||||
except Exception as e:
|
||||
self.load_failed.emit(f"Migration failed: {str(e)}")
|
||||
return
|
||||
|
||||
if self._cancelled:
|
||||
return
|
||||
|
||||
# Progress: Creating project
|
||||
self.progress_updated.emit(70, 100, "Creating project...")
|
||||
|
||||
# Create new project
|
||||
project_name = project_data.get("name", "Untitled Project")
|
||||
project = Project(name=project_name, folder_path=extract_to)
|
||||
|
||||
# Deserialize project data
|
||||
project.deserialize(project_data)
|
||||
|
||||
# Update folder path to extraction location
|
||||
project.folder_path = extract_to
|
||||
project.asset_manager.project_folder = extract_to
|
||||
project.asset_manager.assets_folder = os.path.join(extract_to, "assets")
|
||||
|
||||
# Attach temporary directory to project (if we created one)
|
||||
if temp_dir_obj is not None:
|
||||
project._temp_dir = temp_dir_obj
|
||||
|
||||
if self._cancelled:
|
||||
return
|
||||
|
||||
# Progress: Normalizing paths
|
||||
self.progress_updated.emit(85, 100, "Normalizing asset paths...")
|
||||
|
||||
# Normalize asset paths
|
||||
self._normalize_asset_paths(project, extract_to)
|
||||
|
||||
# Progress: Setting up asset resolution
|
||||
self.progress_updated.emit(95, 100, "Setting up asset resolution...")
|
||||
|
||||
# Set asset resolution context
|
||||
# Only set project folder - search paths are reserved for healing functionality
|
||||
set_asset_resolution_context(extract_to)
|
||||
|
||||
if self._cancelled:
|
||||
return
|
||||
|
||||
# Progress: Complete
|
||||
self.progress_updated.emit(100, 100, "Loading complete!")
|
||||
|
||||
# Emit success
|
||||
self.load_complete.emit(project)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error loading project: {str(e)}"
|
||||
self.load_failed.emit(error_msg)
|
||||
|
||||
def _normalize_asset_paths(self, project: Project, project_folder: str):
|
||||
"""
|
||||
Normalize asset paths in a loaded project to be relative to the project folder.
|
||||
"""
|
||||
normalized_count = 0
|
||||
|
||||
for page in project.pages:
|
||||
for element in page.layout.elements:
|
||||
if isinstance(element, ImageData) and element.image_path:
|
||||
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/"):
|
||||
continue
|
||||
|
||||
# Pattern 1: "./projects/XXX/assets/filename.jpg" -> "assets/filename.jpg"
|
||||
if "/assets/" in original_path:
|
||||
parts = original_path.split("/assets/")
|
||||
if len(parts) == 2:
|
||||
new_path = os.path.join("assets", parts[1])
|
||||
element.image_path = new_path
|
||||
normalized_count += 1
|
||||
continue
|
||||
|
||||
# Pattern 2: Absolute path - try to make it relative
|
||||
if os.path.isabs(original_path):
|
||||
try:
|
||||
new_path = os.path.relpath(original_path, project_folder)
|
||||
element.image_path = new_path
|
||||
normalized_count += 1
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if normalized_count > 0:
|
||||
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:
|
||||
"""
|
||||
Load a project from a ZIP file asynchronously.
|
||||
|
||||
Args:
|
||||
zip_path: Path to the ZIP file to load
|
||||
extract_to: Optional directory to extract to. If None, uses a temporary directory.
|
||||
progress_callback: Optional callback(current, total, message) for progress updates
|
||||
complete_callback: Optional callback(project) when loading completes
|
||||
error_callback: Optional callback(error_msg) when loading fails
|
||||
|
||||
Returns:
|
||||
AsyncProjectLoader instance (already started)
|
||||
"""
|
||||
loader = AsyncProjectLoader(zip_path, extract_to)
|
||||
|
||||
if progress_callback:
|
||||
loader.progress_updated.connect(progress_callback)
|
||||
if complete_callback:
|
||||
loader.load_complete.connect(complete_callback)
|
||||
if error_callback:
|
||||
loader.load_failed.connect(error_callback)
|
||||
|
||||
loader.start()
|
||||
return loader
|
||||
245
pyPhotoAlbum/autosave_manager.py
Normal file
@ -0,0 +1,245 @@
|
||||
"""
|
||||
Autosave and checkpoint management for pyPhotoAlbum.
|
||||
|
||||
This module provides automatic checkpoint creation and recovery functionality
|
||||
to prevent data loss from crashes or unexpected exits.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Optional, List, Tuple
|
||||
from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip
|
||||
|
||||
|
||||
class AutosaveManager:
|
||||
"""Manages autosave checkpoints for projects."""
|
||||
|
||||
CHECKPOINT_DIR = Path.home() / ".pyphotoalbum" / "checkpoints"
|
||||
CHECKPOINT_PREFIX = "checkpoint_"
|
||||
CHECKPOINT_EXTENSION = ".ppz"
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the autosave manager."""
|
||||
self._ensure_checkpoint_directory()
|
||||
|
||||
def _ensure_checkpoint_directory(self):
|
||||
"""Ensure the checkpoint directory exists."""
|
||||
self.CHECKPOINT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _get_checkpoint_path(self, project_name: str, timestamp: Optional[datetime] = None) -> Path:
|
||||
"""
|
||||
Get the path for a checkpoint file.
|
||||
|
||||
Args:
|
||||
project_name: Name of the project
|
||||
timestamp: Optional timestamp, defaults to current time
|
||||
|
||||
Returns:
|
||||
Path to the checkpoint file
|
||||
"""
|
||||
if timestamp is None:
|
||||
timestamp = datetime.now()
|
||||
|
||||
# Sanitize project name for filename
|
||||
safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in project_name)
|
||||
timestamp_str = timestamp.strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"{self.CHECKPOINT_PREFIX}{safe_name}_{timestamp_str}{self.CHECKPOINT_EXTENSION}"
|
||||
|
||||
return self.CHECKPOINT_DIR / filename
|
||||
|
||||
def create_checkpoint(self, project) -> Tuple[bool, str]:
|
||||
"""
|
||||
Create a checkpoint for the given project.
|
||||
|
||||
Args:
|
||||
project: Project instance to checkpoint
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, message: str)
|
||||
"""
|
||||
try:
|
||||
checkpoint_path = self._get_checkpoint_path(project.name)
|
||||
success, message = save_to_zip(project, str(checkpoint_path))
|
||||
|
||||
if success:
|
||||
# Also save metadata about this checkpoint
|
||||
self._save_checkpoint_metadata(project, checkpoint_path)
|
||||
return True, f"Checkpoint created: {checkpoint_path.name}"
|
||||
else:
|
||||
return False, f"Checkpoint failed: {message}"
|
||||
|
||||
except Exception as e:
|
||||
return False, f"Checkpoint error: {str(e)}"
|
||||
|
||||
def _save_checkpoint_metadata(self, project, checkpoint_path: Path):
|
||||
"""
|
||||
Save metadata about a checkpoint.
|
||||
|
||||
Args:
|
||||
project: Project instance
|
||||
checkpoint_path: Path to the checkpoint file
|
||||
"""
|
||||
metadata = {
|
||||
"project_name": project.name,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"checkpoint_path": str(checkpoint_path),
|
||||
"original_path": getattr(project, "file_path", None),
|
||||
}
|
||||
|
||||
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]]:
|
||||
"""
|
||||
List available checkpoints.
|
||||
|
||||
Args:
|
||||
project_name: Optional filter by project name
|
||||
|
||||
Returns:
|
||||
List of tuples (checkpoint_path, metadata)
|
||||
"""
|
||||
checkpoints = []
|
||||
|
||||
for checkpoint_file in self.CHECKPOINT_DIR.glob(f"{self.CHECKPOINT_PREFIX}*{self.CHECKPOINT_EXTENSION}"):
|
||||
metadata_file = checkpoint_file.with_suffix(".json")
|
||||
|
||||
# Try to load metadata
|
||||
metadata = {}
|
||||
if metadata_file.exists():
|
||||
try:
|
||||
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:
|
||||
checkpoints.append((checkpoint_file, metadata))
|
||||
|
||||
# Sort by timestamp (newest first)
|
||||
checkpoints.sort(key=lambda x: x[1].get("timestamp", ""), reverse=True)
|
||||
return checkpoints
|
||||
|
||||
def load_checkpoint(self, checkpoint_path: Path):
|
||||
"""
|
||||
Load a project from a checkpoint.
|
||||
|
||||
Args:
|
||||
checkpoint_path: Path to the checkpoint file
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, project or error_message)
|
||||
"""
|
||||
try:
|
||||
project = load_from_zip(str(checkpoint_path))
|
||||
return True, project
|
||||
except Exception as e:
|
||||
return False, f"Failed to load checkpoint: {str(e)}"
|
||||
|
||||
def delete_checkpoint(self, checkpoint_path: Path) -> bool:
|
||||
"""
|
||||
Delete a checkpoint file and its metadata.
|
||||
|
||||
Args:
|
||||
checkpoint_path: Path to the checkpoint file
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
try:
|
||||
# Delete checkpoint file
|
||||
if checkpoint_path.exists():
|
||||
checkpoint_path.unlink()
|
||||
|
||||
# Delete metadata file
|
||||
metadata_path = checkpoint_path.with_suffix(".json")
|
||||
if metadata_path.exists():
|
||||
metadata_path.unlink()
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error deleting checkpoint: {e}")
|
||||
return False
|
||||
|
||||
def delete_all_checkpoints(self, project_name: Optional[str] = None):
|
||||
"""
|
||||
Delete all checkpoints, optionally filtered by project name.
|
||||
|
||||
Args:
|
||||
project_name: Optional filter by project name
|
||||
"""
|
||||
checkpoints = self.list_checkpoints(project_name)
|
||||
for checkpoint_path, _ in checkpoints:
|
||||
self.delete_checkpoint(checkpoint_path)
|
||||
|
||||
def cleanup_old_checkpoints(self, max_age_hours: int = 24 * 7, max_count: int = 50):
|
||||
"""
|
||||
Clean up old checkpoints to prevent unlimited growth.
|
||||
|
||||
Args:
|
||||
max_age_hours: Maximum age in hours (default: 7 days)
|
||||
max_count: Maximum number of checkpoints to keep per project
|
||||
"""
|
||||
now = datetime.now()
|
||||
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")
|
||||
if project_name not in checkpoints_by_project:
|
||||
checkpoints_by_project[project_name] = []
|
||||
checkpoints_by_project[project_name].append((checkpoint_path, metadata))
|
||||
|
||||
# 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)
|
||||
|
||||
for idx, (checkpoint_path, metadata) in enumerate(checkpoints):
|
||||
# Delete if too old
|
||||
timestamp_str = metadata.get("timestamp")
|
||||
if timestamp_str:
|
||||
try:
|
||||
timestamp = datetime.fromisoformat(timestamp_str)
|
||||
age = now - timestamp
|
||||
if age > timedelta(hours=max_age_hours):
|
||||
self.delete_checkpoint(checkpoint_path)
|
||||
continue
|
||||
except:
|
||||
pass
|
||||
|
||||
# Delete if beyond max count
|
||||
if idx >= max_count:
|
||||
self.delete_checkpoint(checkpoint_path)
|
||||
|
||||
def has_checkpoints(self, project_name: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Check if there are any checkpoints available.
|
||||
|
||||
Args:
|
||||
project_name: Optional filter by project name
|
||||
|
||||
Returns:
|
||||
True if checkpoints exist
|
||||
"""
|
||||
return len(self.list_checkpoints(project_name)) > 0
|
||||
|
||||
def get_latest_checkpoint(self, project_name: Optional[str] = None) -> Optional[Tuple[Path, dict]]:
|
||||
"""
|
||||
Get the most recent checkpoint.
|
||||
|
||||
Args:
|
||||
project_name: Optional filter by project name
|
||||
|
||||
Returns:
|
||||
Tuple of (checkpoint_path, metadata) or None
|
||||
"""
|
||||
checkpoints = self.list_checkpoints(project_name)
|
||||
if checkpoints:
|
||||
return checkpoints[0] # Already sorted newest first
|
||||
return None
|
||||
772
pyPhotoAlbum/commands.py
Normal file
@ -0,0 +1,772 @@
|
||||
"""
|
||||
Command pattern implementation for undo/redo functionality
|
||||
"""
|
||||
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Any, List, Optional
|
||||
from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData
|
||||
|
||||
|
||||
def _normalize_asset_path(image_path: str, asset_manager) -> str:
|
||||
"""
|
||||
Convert absolute path to relative for asset manager.
|
||||
|
||||
Args:
|
||||
image_path: Image path (absolute or relative)
|
||||
asset_manager: AssetManager instance
|
||||
|
||||
Returns:
|
||||
Relative path suitable for asset manager
|
||||
"""
|
||||
if not asset_manager or not image_path:
|
||||
return image_path
|
||||
|
||||
if os.path.isabs(image_path):
|
||||
return os.path.relpath(image_path, asset_manager.project_folder)
|
||||
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")
|
||||
|
||||
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"""
|
||||
|
||||
@abstractmethod
|
||||
def execute(self):
|
||||
"""Execute the command"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def undo(self):
|
||||
"""Undo the command"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def redo(self):
|
||||
"""Redo the command (default implementation calls execute)"""
|
||||
self.execute()
|
||||
|
||||
@abstractmethod
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
"""Serialize command to dictionary for saving"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def deserialize(data: Dict[str, Any], project) -> "Command":
|
||||
"""Deserialize command from dictionary"""
|
||||
pass
|
||||
|
||||
|
||||
class AddElementCommand(Command):
|
||||
"""Command for adding an element to a page"""
|
||||
|
||||
def __init__(self, page_layout, element: BaseLayoutElement, asset_manager=None):
|
||||
self.page_layout = page_layout
|
||||
self.element = element
|
||||
self.executed = False
|
||||
self.asset_manager = asset_manager
|
||||
|
||||
# Acquire reference to asset when command is created
|
||||
if self.asset_manager and isinstance(self.element, ImageData) and self.element.image_path:
|
||||
rel_path = _normalize_asset_path(self.element.image_path, self.asset_manager)
|
||||
self.asset_manager.acquire_reference(rel_path)
|
||||
|
||||
def execute(self):
|
||||
"""Add the element to the page"""
|
||||
if not self.executed:
|
||||
self.page_layout.add_element(self.element)
|
||||
self.executed = True
|
||||
|
||||
def undo(self):
|
||||
"""Remove the element from the page"""
|
||||
if self.executed:
|
||||
self.page_layout.remove_element(self.element)
|
||||
self.executed = False
|
||||
|
||||
def redo(self):
|
||||
"""Re-add the element"""
|
||||
self.execute()
|
||||
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary"""
|
||||
return {"type": "add_element", "element": self.element.serialize(), "executed": self.executed}
|
||||
|
||||
@staticmethod
|
||||
def deserialize(data: Dict[str, Any], project) -> "AddElementCommand":
|
||||
"""Deserialize from dictionary"""
|
||||
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
|
||||
|
||||
|
||||
class DeleteElementCommand(Command):
|
||||
"""Command for deleting an element from a page"""
|
||||
|
||||
def __init__(self, page_layout, element: BaseLayoutElement, asset_manager=None):
|
||||
self.page_layout = page_layout
|
||||
self.element = element
|
||||
self.executed = False
|
||||
self.asset_manager = asset_manager
|
||||
|
||||
# Acquire reference to asset to keep it alive while in undo history
|
||||
if self.asset_manager and isinstance(self.element, ImageData) and self.element.image_path:
|
||||
rel_path = _normalize_asset_path(self.element.image_path, self.asset_manager)
|
||||
self.asset_manager.acquire_reference(rel_path)
|
||||
|
||||
def execute(self):
|
||||
"""Remove the element from the page"""
|
||||
if not self.executed:
|
||||
self.page_layout.remove_element(self.element)
|
||||
self.executed = True
|
||||
|
||||
def undo(self):
|
||||
"""Re-add the element to the page"""
|
||||
if self.executed:
|
||||
self.page_layout.add_element(self.element)
|
||||
self.executed = False
|
||||
|
||||
def redo(self):
|
||||
"""Re-remove the element"""
|
||||
self.execute()
|
||||
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary"""
|
||||
return {"type": "delete_element", "element": self.element.serialize(), "executed": self.executed}
|
||||
|
||||
@staticmethod
|
||||
def deserialize(data: Dict[str, Any], project) -> "DeleteElementCommand":
|
||||
"""Deserialize from dictionary"""
|
||||
element = _deserialize_element(data["element"])
|
||||
cmd = DeleteElementCommand(None, element)
|
||||
cmd.executed = data.get("executed", False)
|
||||
return cmd
|
||||
|
||||
|
||||
class MoveElementCommand(Command):
|
||||
"""Command for moving an element"""
|
||||
|
||||
def __init__(self, element: BaseLayoutElement, old_position: tuple, new_position: tuple):
|
||||
self.element = element
|
||||
self.old_position = old_position
|
||||
self.new_position = new_position
|
||||
|
||||
def execute(self):
|
||||
"""Move element to new position"""
|
||||
self.element.position = self.new_position
|
||||
|
||||
def undo(self):
|
||||
"""Move element back to old position"""
|
||||
self.element.position = self.old_position
|
||||
|
||||
def redo(self):
|
||||
"""Move element to new position again"""
|
||||
self.execute()
|
||||
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary"""
|
||||
return {
|
||||
"type": "move_element",
|
||||
"element": self.element.serialize(),
|
||||
"old_position": self.old_position,
|
||||
"new_position": self.new_position,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def deserialize(data: Dict[str, Any], project) -> "MoveElementCommand":
|
||||
"""Deserialize from dictionary"""
|
||||
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
|
||||
):
|
||||
self.element = element
|
||||
self.old_position = old_position
|
||||
self.old_size = old_size
|
||||
self.new_position = new_position
|
||||
self.new_size = new_size
|
||||
|
||||
def execute(self):
|
||||
"""Resize element to new size"""
|
||||
self.element.position = self.new_position
|
||||
self.element.size = self.new_size
|
||||
|
||||
def undo(self):
|
||||
"""Resize element back to old size"""
|
||||
self.element.position = self.old_position
|
||||
self.element.size = self.old_size
|
||||
|
||||
def redo(self):
|
||||
"""Resize element to new size again"""
|
||||
self.execute()
|
||||
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary"""
|
||||
return {
|
||||
"type": "resize_element",
|
||||
"element": self.element.serialize(),
|
||||
"old_position": self.old_position,
|
||||
"old_size": self.old_size,
|
||||
"new_position": self.new_position,
|
||||
"new_size": self.new_size,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def deserialize(data: Dict[str, Any], project) -> "ResizeElementCommand":
|
||||
"""Deserialize from dictionary"""
|
||||
element = _deserialize_element(data["element"])
|
||||
return ResizeElementCommand(
|
||||
element,
|
||||
tuple(data["old_position"]),
|
||||
tuple(data["old_size"]),
|
||||
tuple(data["new_position"]),
|
||||
tuple(data["new_size"]),
|
||||
)
|
||||
|
||||
|
||||
class RotateElementCommand(Command):
|
||||
"""Command for rotating an element"""
|
||||
|
||||
def __init__(self, element: BaseLayoutElement, old_rotation: float, new_rotation: float):
|
||||
self.element = element
|
||||
self.old_rotation = old_rotation
|
||||
self.new_rotation = new_rotation
|
||||
|
||||
# Store old position, size, and PIL rotation state
|
||||
self.old_position = element.position
|
||||
self.old_size = element.size
|
||||
|
||||
# For ImageData, store the old PIL rotation state
|
||||
if hasattr(element, "pil_rotation_90"):
|
||||
self.old_pil_rotation = element.pil_rotation_90
|
||||
else:
|
||||
self.old_pil_rotation = None
|
||||
|
||||
def execute(self):
|
||||
"""Rotate element by physically rotating the PIL image data"""
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
# Calculate rotation delta
|
||||
delta = (self.new_rotation - self.old_rotation) % 360
|
||||
|
||||
# For ImageData, rotate the actual PIL image
|
||||
if isinstance(self.element, ImageData):
|
||||
# Update PIL rotation counter
|
||||
if delta == 90:
|
||||
self.element.pil_rotation_90 = (self.element.pil_rotation_90 + 1) % 4
|
||||
elif delta == 270:
|
||||
self.element.pil_rotation_90 = (self.element.pil_rotation_90 + 3) % 4
|
||||
elif delta == 180:
|
||||
self.element.pil_rotation_90 = (self.element.pil_rotation_90 + 2) % 4
|
||||
|
||||
# For 90° or 270° rotations, swap dimensions
|
||||
if delta == 90 or delta == 270:
|
||||
w, h = self.element.size
|
||||
x, y = self.element.position
|
||||
|
||||
# Swap dimensions
|
||||
self.element.size = (h, w)
|
||||
|
||||
# Adjust position to keep center in same place
|
||||
center_x = x + w / 2
|
||||
center_y = y + h / 2
|
||||
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"):
|
||||
del self.element._texture_id
|
||||
if hasattr(self.element, "_async_load_requested"):
|
||||
self.element._async_load_requested = False
|
||||
|
||||
# Keep visual rotation at 0
|
||||
self.element.rotation = 0
|
||||
else:
|
||||
# For non-image elements, use old visual rotation
|
||||
if delta == 90 or delta == 270:
|
||||
w, h = self.element.size
|
||||
x, y = self.element.position
|
||||
self.element.size = (h, w)
|
||||
center_x = x + w / 2
|
||||
center_y = y + h / 2
|
||||
self.element.position = (center_x - h / 2, center_y - w / 2)
|
||||
self.element.rotation = 0
|
||||
else:
|
||||
self.element.rotation = self.new_rotation
|
||||
|
||||
def undo(self):
|
||||
"""Restore element back to old state"""
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
# Restore original rotation, position, and size
|
||||
self.element.rotation = self.old_rotation
|
||||
self.element.position = self.old_position
|
||||
self.element.size = self.old_size
|
||||
|
||||
# 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"):
|
||||
self.element._texture_id = None
|
||||
self.element._async_load_requested = False
|
||||
|
||||
def redo(self):
|
||||
"""Rotate element to new angle again"""
|
||||
self.execute()
|
||||
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary"""
|
||||
return {
|
||||
"type": "rotate_element",
|
||||
"element": self.element.serialize(),
|
||||
"old_rotation": self.old_rotation,
|
||||
"new_rotation": self.new_rotation,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def deserialize(data: Dict[str, Any], project) -> "RotateElementCommand":
|
||||
"""Deserialize from dictionary"""
|
||||
element = _deserialize_element(data["element"])
|
||||
return RotateElementCommand(element, data["old_rotation"], data["new_rotation"])
|
||||
|
||||
|
||||
class AdjustImageCropCommand(Command):
|
||||
"""Command for adjusting image crop/pan within frame"""
|
||||
|
||||
def __init__(self, element: ImageData, old_crop_info: tuple, new_crop_info: tuple):
|
||||
self.element = element
|
||||
self.old_crop_info = old_crop_info
|
||||
self.new_crop_info = new_crop_info
|
||||
|
||||
def execute(self):
|
||||
"""Apply new crop info"""
|
||||
self.element.crop_info = self.new_crop_info
|
||||
|
||||
def undo(self):
|
||||
"""Restore old crop info"""
|
||||
self.element.crop_info = self.old_crop_info
|
||||
|
||||
def redo(self):
|
||||
"""Apply new crop info again"""
|
||||
self.execute()
|
||||
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary"""
|
||||
return {
|
||||
"type": "adjust_image_crop",
|
||||
"element": self.element.serialize(),
|
||||
"old_crop_info": self.old_crop_info,
|
||||
"new_crop_info": self.new_crop_info,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
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"]))
|
||||
|
||||
|
||||
class AlignElementsCommand(Command):
|
||||
"""Command for aligning multiple elements"""
|
||||
|
||||
def __init__(self, changes: List[tuple]):
|
||||
"""
|
||||
Args:
|
||||
changes: List of (element, old_position) tuples
|
||||
"""
|
||||
self.changes = changes
|
||||
|
||||
def execute(self):
|
||||
"""Positions have already been set by AlignmentManager"""
|
||||
pass
|
||||
|
||||
def undo(self):
|
||||
"""Restore old positions"""
|
||||
for element, old_position in self.changes:
|
||||
element.position = old_position
|
||||
|
||||
def redo(self):
|
||||
"""Re-apply alignment (positions are stored in current state)"""
|
||||
# Store current positions and restore them
|
||||
new_positions = [(elem, elem.position) for elem, _ in self.changes]
|
||||
for element, old_position in self.changes:
|
||||
element.position = old_position
|
||||
# Then re-apply new positions
|
||||
for element, new_position in new_positions:
|
||||
element.position = new_position
|
||||
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary"""
|
||||
return {
|
||||
"type": "align_elements",
|
||||
"changes": [{"element": elem.serialize(), "old_position": old_pos} for elem, old_pos in self.changes],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def deserialize(data: Dict[str, Any], project) -> "AlignElementsCommand":
|
||||
"""Deserialize from dictionary"""
|
||||
changes = []
|
||||
for change_data in data.get("changes", []):
|
||||
try:
|
||||
element = _deserialize_element(change_data["element"])
|
||||
old_position = tuple(change_data["old_position"])
|
||||
changes.append((element, old_position))
|
||||
except ValueError:
|
||||
continue
|
||||
return AlignElementsCommand(changes)
|
||||
|
||||
|
||||
class ResizeElementsCommand(Command):
|
||||
"""Command for resizing multiple elements"""
|
||||
|
||||
def __init__(self, changes: List[tuple]):
|
||||
"""
|
||||
Args:
|
||||
changes: List of (element, old_position, old_size) tuples
|
||||
"""
|
||||
self.changes = changes
|
||||
self.new_states = [(elem, elem.position, elem.size) for elem, _, _ in changes]
|
||||
|
||||
def execute(self):
|
||||
"""Sizes have already been set by AlignmentManager"""
|
||||
pass
|
||||
|
||||
def undo(self):
|
||||
"""Restore old positions and sizes"""
|
||||
for element, old_position, old_size in self.changes:
|
||||
element.position = old_position
|
||||
element.size = old_size
|
||||
|
||||
def redo(self):
|
||||
"""Re-apply new sizes"""
|
||||
for element, new_position, new_size in self.new_states:
|
||||
element.position = new_position
|
||||
element.size = new_size
|
||||
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary"""
|
||||
return {
|
||||
"type": "resize_elements",
|
||||
"changes": [
|
||||
{"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":
|
||||
"""Deserialize from dictionary"""
|
||||
changes = []
|
||||
for change_data in data.get("changes", []):
|
||||
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
|
||||
return ResizeElementsCommand(changes)
|
||||
|
||||
|
||||
class ChangeZOrderCommand(Command):
|
||||
"""Command for changing element z-order (list position)"""
|
||||
|
||||
def __init__(self, page_layout, element: BaseLayoutElement, old_index: int, new_index: int):
|
||||
self.page_layout = page_layout
|
||||
self.element = element
|
||||
self.old_index = old_index
|
||||
self.new_index = new_index
|
||||
|
||||
def execute(self):
|
||||
"""Move element to new position in list"""
|
||||
elements = self.page_layout.elements
|
||||
if self.element in elements:
|
||||
elements.remove(self.element)
|
||||
elements.insert(self.new_index, self.element)
|
||||
|
||||
def undo(self):
|
||||
"""Move element back to old position in list"""
|
||||
elements = self.page_layout.elements
|
||||
if self.element in elements:
|
||||
elements.remove(self.element)
|
||||
elements.insert(self.old_index, self.element)
|
||||
|
||||
def redo(self):
|
||||
"""Move element to new position again"""
|
||||
self.execute()
|
||||
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary"""
|
||||
return {
|
||||
"type": "change_zorder",
|
||||
"element": self.element.serialize(),
|
||||
"old_index": self.old_index,
|
||||
"new_index": self.new_index,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def deserialize(data: Dict[str, Any], project) -> "ChangeZOrderCommand":
|
||||
"""Deserialize from dictionary"""
|
||||
element = _deserialize_element(data["element"])
|
||||
return ChangeZOrderCommand(
|
||||
None, element, data["old_index"], data["new_index"] # page_layout will be set by CommandHistory
|
||||
)
|
||||
|
||||
|
||||
class StateChangeCommand(Command):
|
||||
"""
|
||||
Generic command for operations that change state.
|
||||
|
||||
This command captures before/after snapshots of state and can restore them.
|
||||
Used by the @undoable_operation decorator.
|
||||
"""
|
||||
|
||||
def __init__(self, description: str, restore_func, before_state: Any, after_state: Any = None):
|
||||
"""
|
||||
Args:
|
||||
description: Human-readable description of the operation
|
||||
restore_func: Function to restore state: restore_func(state)
|
||||
before_state: State before the operation
|
||||
after_state: State after the operation (captured during execute)
|
||||
"""
|
||||
self.description = description
|
||||
self.restore_func = restore_func
|
||||
self.before_state = before_state
|
||||
self.after_state = after_state
|
||||
|
||||
def execute(self):
|
||||
"""State is already applied, just store after_state if not set"""
|
||||
# After state is captured by decorator after operation runs
|
||||
pass
|
||||
|
||||
def undo(self):
|
||||
"""Restore to before state"""
|
||||
self.restore_func(self.before_state)
|
||||
|
||||
def redo(self):
|
||||
"""Restore to after state"""
|
||||
self.restore_func(self.after_state)
|
||||
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
"""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}
|
||||
|
||||
@staticmethod
|
||||
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")
|
||||
|
||||
|
||||
class CommandHistory:
|
||||
"""Manages undo/redo command history"""
|
||||
|
||||
def __init__(self, max_history: int = 100, asset_manager=None, project=None):
|
||||
self.undo_stack: List[Command] = []
|
||||
self.redo_stack: List[Command] = []
|
||||
self.max_history = max_history
|
||||
self.asset_manager = asset_manager
|
||||
self.project = project # Reference to project for dirty flag tracking
|
||||
|
||||
def execute(self, command: Command):
|
||||
"""Execute a command and add it to history"""
|
||||
command.execute()
|
||||
|
||||
# When clearing redo stack, release asset references
|
||||
for cmd in self.redo_stack:
|
||||
self._release_command_assets(cmd)
|
||||
self.redo_stack.clear()
|
||||
|
||||
self.undo_stack.append(command)
|
||||
|
||||
# Mark project as dirty
|
||||
if self.project:
|
||||
self.project.mark_dirty()
|
||||
|
||||
# Limit history size - release assets from old commands
|
||||
if len(self.undo_stack) > self.max_history:
|
||||
old_cmd = self.undo_stack.pop(0)
|
||||
self._release_command_assets(old_cmd)
|
||||
|
||||
def _release_command_assets(self, command: Command):
|
||||
"""Release asset references held by a command"""
|
||||
if not self.asset_manager:
|
||||
return
|
||||
|
||||
# Release asset references for commands that hold them
|
||||
if isinstance(command, (AddElementCommand, DeleteElementCommand)):
|
||||
if isinstance(command.element, ImageData) and command.element.image_path:
|
||||
# Convert absolute path to relative for asset manager
|
||||
asset_path = command.element.image_path
|
||||
if os.path.isabs(asset_path):
|
||||
asset_path = os.path.relpath(asset_path, self.asset_manager.project_folder)
|
||||
self.asset_manager.release_reference(asset_path)
|
||||
|
||||
def undo(self) -> bool:
|
||||
"""Undo the last command"""
|
||||
if not self.can_undo():
|
||||
return False
|
||||
|
||||
command = self.undo_stack.pop()
|
||||
command.undo()
|
||||
self.redo_stack.append(command)
|
||||
|
||||
# Mark project as dirty
|
||||
if self.project:
|
||||
self.project.mark_dirty()
|
||||
|
||||
return True
|
||||
|
||||
def redo(self) -> bool:
|
||||
"""Redo the last undone command"""
|
||||
if not self.can_redo():
|
||||
return False
|
||||
|
||||
command = self.redo_stack.pop()
|
||||
command.redo()
|
||||
self.undo_stack.append(command)
|
||||
|
||||
# Mark project as dirty
|
||||
if self.project:
|
||||
self.project.mark_dirty()
|
||||
|
||||
return True
|
||||
|
||||
def can_undo(self) -> bool:
|
||||
"""Check if undo is available"""
|
||||
return len(self.undo_stack) > 0
|
||||
|
||||
def can_redo(self) -> bool:
|
||||
"""Check if redo is available"""
|
||||
return len(self.redo_stack) > 0
|
||||
|
||||
def clear(self):
|
||||
"""Clear all history and release asset references"""
|
||||
# Release all asset references
|
||||
for cmd in self.undo_stack:
|
||||
self._release_command_assets(cmd)
|
||||
for cmd in self.redo_stack:
|
||||
self._release_command_assets(cmd)
|
||||
|
||||
self.undo_stack.clear()
|
||||
self.redo_stack.clear()
|
||||
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
"""Serialize history to dictionary"""
|
||||
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,
|
||||
}
|
||||
|
||||
def deserialize(self, data: Dict[str, Any], project):
|
||||
"""Deserialize history from dictionary"""
|
||||
self.max_history = data.get("max_history", 100)
|
||||
|
||||
# Deserialize undo stack
|
||||
self.undo_stack = []
|
||||
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
|
||||
self.redo_stack = []
|
||||
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,
|
||||
"delete_element": DeleteElementCommand.deserialize,
|
||||
"move_element": MoveElementCommand.deserialize,
|
||||
"resize_element": ResizeElementCommand.deserialize,
|
||||
"rotate_element": RotateElementCommand.deserialize,
|
||||
"align_elements": AlignElementsCommand.deserialize,
|
||||
"resize_elements": ResizeElementsCommand.deserialize,
|
||||
"change_zorder": ChangeZOrderCommand.deserialize,
|
||||
"adjust_image_crop": AdjustImageCropCommand.deserialize,
|
||||
}
|
||||
|
||||
def _deserialize_command(self, data: Dict[str, Any], project) -> Optional[Command]:
|
||||
"""Deserialize a single command using registry pattern"""
|
||||
cmd_type = data.get("type")
|
||||
|
||||
deserializer = self._COMMAND_DESERIALIZERS.get(cmd_type)
|
||||
if not deserializer:
|
||||
print(f"Warning: Unknown command type: {cmd_type}")
|
||||
return None
|
||||
|
||||
try:
|
||||
return deserializer(data, project)
|
||||
except Exception as e:
|
||||
print(f"Error deserializing command: {e}")
|
||||
return None
|
||||
421
pyPhotoAlbum/decorators.py
Normal file
@ -0,0 +1,421 @@
|
||||
"""
|
||||
Decorator system for pyPhotoAlbum ribbon UI
|
||||
"""
|
||||
|
||||
import copy
|
||||
from functools import wraps
|
||||
from typing import Any, Optional, Callable
|
||||
|
||||
|
||||
class RibbonAction:
|
||||
"""
|
||||
Decorator to mark methods as ribbon actions.
|
||||
|
||||
This decorator stores metadata about UI actions that should appear in the ribbon.
|
||||
The metadata is used to auto-generate the ribbon configuration.
|
||||
|
||||
Example:
|
||||
@RibbonAction(
|
||||
label="New",
|
||||
tooltip="Create a new project",
|
||||
tab="Home",
|
||||
group="File",
|
||||
icon="new.png",
|
||||
shortcut="Ctrl+N"
|
||||
)
|
||||
def new_project(self):
|
||||
...
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
label: str,
|
||||
tooltip: str,
|
||||
tab: str,
|
||||
group: str,
|
||||
icon: Optional[str] = None,
|
||||
shortcut: Optional[str] = None,
|
||||
requires_page: bool = False,
|
||||
requires_selection: bool = False,
|
||||
min_selection: int = 0,
|
||||
):
|
||||
"""
|
||||
Initialize the ribbon action decorator.
|
||||
|
||||
Args:
|
||||
label: Button label text
|
||||
tooltip: Tooltip text shown on hover
|
||||
tab: Ribbon tab name (e.g., "Home", "Insert", "Layout")
|
||||
group: Group name within the tab (e.g., "File", "Edit")
|
||||
icon: Optional icon filename or path
|
||||
shortcut: Optional keyboard shortcut (e.g., "Ctrl+N", "Ctrl+Shift+S")
|
||||
requires_page: Whether this action requires an active page
|
||||
requires_selection: Whether this action requires selected elements
|
||||
min_selection: Minimum number of selected elements required
|
||||
"""
|
||||
self.label = label
|
||||
self.tooltip = tooltip
|
||||
self.tab = tab
|
||||
self.group = group
|
||||
self.icon = icon
|
||||
self.shortcut = shortcut
|
||||
self.requires_page = requires_page
|
||||
self.requires_selection = requires_selection
|
||||
self.min_selection = min_selection
|
||||
|
||||
def __call__(self, func: Callable) -> Callable:
|
||||
"""
|
||||
Decorate the function with ribbon action metadata.
|
||||
|
||||
Args:
|
||||
func: The function to decorate
|
||||
|
||||
Returns:
|
||||
The decorated function with metadata attached
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
return func(*args, **kwargs)
|
||||
|
||||
# Store metadata on wrapper function
|
||||
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
|
||||
|
||||
|
||||
def ribbon_action(
|
||||
label: str,
|
||||
tooltip: str,
|
||||
tab: str,
|
||||
group: str,
|
||||
icon: Optional[str] = None,
|
||||
shortcut: Optional[str] = None,
|
||||
requires_page: bool = False,
|
||||
requires_selection: bool = False,
|
||||
min_selection: int = 0,
|
||||
) -> Callable:
|
||||
"""
|
||||
Convenience function for the RibbonAction decorator.
|
||||
|
||||
This provides a lowercase function-style interface to the decorator.
|
||||
|
||||
Args:
|
||||
label: Button label text
|
||||
tooltip: Tooltip text shown on hover
|
||||
tab: Ribbon tab name
|
||||
group: Group name within the tab
|
||||
icon: Optional icon filename or path
|
||||
shortcut: Optional keyboard shortcut
|
||||
requires_page: Whether this action requires an active page
|
||||
requires_selection: Whether this action requires selected elements
|
||||
min_selection: Minimum number of selected elements required
|
||||
|
||||
Returns:
|
||||
RibbonAction decorator instance
|
||||
"""
|
||||
return RibbonAction(
|
||||
label=label,
|
||||
tooltip=tooltip,
|
||||
tab=tab,
|
||||
group=group,
|
||||
icon=icon,
|
||||
shortcut=shortcut,
|
||||
requires_page=requires_page,
|
||||
requires_selection=requires_selection,
|
||||
min_selection=min_selection,
|
||||
)
|
||||
|
||||
|
||||
class NumericalInput:
|
||||
"""
|
||||
Decorator to mark methods that require numerical width/height inputs.
|
||||
|
||||
This decorator stores metadata about numerical input fields that should
|
||||
be presented in dialogs for methods that work with page dimensions.
|
||||
|
||||
Example:
|
||||
@numerical_input(
|
||||
fields=[
|
||||
('width', 'Width', 'mm', 10, 1000),
|
||||
('height', 'Height', 'mm', 10, 1000)
|
||||
]
|
||||
)
|
||||
def set_page_size(self, width, height):
|
||||
...
|
||||
"""
|
||||
|
||||
def __init__(self, fields: list):
|
||||
"""
|
||||
Initialize the numerical input decorator.
|
||||
|
||||
Args:
|
||||
fields: List of tuples, each containing:
|
||||
(param_name, label, unit, min_value, max_value)
|
||||
"""
|
||||
self.fields = fields
|
||||
|
||||
def __call__(self, func: Callable) -> Callable:
|
||||
"""
|
||||
Decorate the function with numerical input metadata.
|
||||
|
||||
Args:
|
||||
func: The function to decorate
|
||||
|
||||
Returns:
|
||||
The decorated function with metadata attached
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
return func(*args, **kwargs)
|
||||
|
||||
# Store metadata on wrapper function
|
||||
wrapper._numerical_input = {"fields": self.fields} # type: ignore[attr-defined]
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def numerical_input(fields: list) -> Callable:
|
||||
"""
|
||||
Convenience function for the NumericalInput decorator.
|
||||
|
||||
This provides a lowercase function-style interface to the decorator.
|
||||
|
||||
Args:
|
||||
fields: List of tuples, each containing:
|
||||
(param_name, label, unit, min_value, max_value)
|
||||
|
||||
Returns:
|
||||
NumericalInput decorator instance
|
||||
"""
|
||||
return NumericalInput(fields=fields)
|
||||
|
||||
|
||||
class UndoableOperation:
|
||||
"""
|
||||
Decorator to automatically create undo/redo commands for operations.
|
||||
|
||||
This decorator captures state before and after an operation, then creates
|
||||
a StateChangeCommand for undo/redo functionality.
|
||||
|
||||
Example:
|
||||
@undoable_operation(capture='page_elements')
|
||||
def apply_template(self):
|
||||
# Just implement the operation
|
||||
self.template_manager.apply_template(...)
|
||||
# Decorator handles undo/redo automatically
|
||||
"""
|
||||
|
||||
def __init__(self, capture: str = "page_elements", description: Optional[str] = None):
|
||||
"""
|
||||
Initialize the undoable operation decorator.
|
||||
|
||||
Args:
|
||||
capture: What to capture for undo/redo:
|
||||
- 'page_elements': Capture elements of current page
|
||||
- 'custom': Operation provides its own capture logic
|
||||
description: Human-readable description (defaults to function name)
|
||||
"""
|
||||
self.capture = capture
|
||||
self.description = description
|
||||
|
||||
def __call__(self, func: Callable) -> Callable:
|
||||
"""
|
||||
Decorate the function with automatic undo/redo.
|
||||
|
||||
Args:
|
||||
func: The function to decorate
|
||||
|
||||
Returns:
|
||||
The decorated function
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(self_instance, *args, **kwargs):
|
||||
# Get description
|
||||
description = self.description or func.__name__.replace("_", " ").title()
|
||||
|
||||
# Capture before state
|
||||
before_state = self._capture_state(self_instance, self.capture)
|
||||
|
||||
# Execute the operation
|
||||
result = func(self_instance, *args, **kwargs)
|
||||
|
||||
# Capture after state
|
||||
after_state = self._capture_state(self_instance, self.capture)
|
||||
|
||||
# Create restore function
|
||||
def restore_state(state):
|
||||
self._restore_state(self_instance, self.capture, state)
|
||||
# Update view after restoring
|
||||
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"):
|
||||
self_instance.project.history.execute(cmd)
|
||||
print(f"Undoable operation '{description}' executed")
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
def _capture_state(self, instance, capture_type: str):
|
||||
"""Capture current state based on capture type"""
|
||||
if capture_type == "page_elements":
|
||||
# Capture elements from current page
|
||||
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]
|
||||
return []
|
||||
|
||||
return None
|
||||
|
||||
def _restore_state(self, instance, capture_type: str, state):
|
||||
"""Restore state based on capture type"""
|
||||
if capture_type == "page_elements":
|
||||
# Restore elements to current page
|
||||
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 BaseLayoutElement, ImageData, PlaceholderData, TextBoxData
|
||||
|
||||
for elem_data in state:
|
||||
elem_type = elem_data.get("type")
|
||||
elem: BaseLayoutElement
|
||||
if elem_type == "image":
|
||||
elem = ImageData()
|
||||
elif elem_type == "placeholder":
|
||||
elem = PlaceholderData()
|
||||
elif elem_type == "textbox":
|
||||
elem = TextBoxData()
|
||||
else:
|
||||
continue
|
||||
|
||||
elem.deserialize(elem_data)
|
||||
current_page.layout.add_element(elem)
|
||||
|
||||
|
||||
def undoable_operation(capture: str = "page_elements", description: Optional[str] = None) -> Callable:
|
||||
"""
|
||||
Convenience function for the UndoableOperation decorator.
|
||||
|
||||
This provides a lowercase function-style interface to the decorator.
|
||||
|
||||
Args:
|
||||
capture: What to capture for undo/redo
|
||||
description: Human-readable description of the operation
|
||||
|
||||
Returns:
|
||||
UndoableOperation decorator instance
|
||||
"""
|
||||
return UndoableOperation(capture=capture, description=description)
|
||||
|
||||
|
||||
class DialogAction:
|
||||
"""
|
||||
Decorator to mark methods that should open a dialog.
|
||||
|
||||
This decorator automatically handles dialog creation and result processing,
|
||||
separating UI presentation from business logic.
|
||||
|
||||
Example:
|
||||
@dialog_action(dialog_class=PageSetupDialog)
|
||||
def page_setup(self, values):
|
||||
# Just implement the business logic
|
||||
# Dialog presentation is handled automatically
|
||||
self.apply_page_setup(values)
|
||||
"""
|
||||
|
||||
def __init__(self, dialog_class: type, requires_pages: bool = True):
|
||||
"""
|
||||
Initialize the dialog action decorator.
|
||||
|
||||
Args:
|
||||
dialog_class: The dialog class to instantiate
|
||||
requires_pages: Whether this action requires pages to exist
|
||||
"""
|
||||
self.dialog_class = dialog_class
|
||||
self.requires_pages = requires_pages
|
||||
|
||||
def __call__(self, func: Callable) -> Callable:
|
||||
"""
|
||||
Decorate the function with automatic dialog handling.
|
||||
|
||||
Args:
|
||||
func: The function to decorate (receives dialog values)
|
||||
|
||||
Returns:
|
||||
The decorated function
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(self_instance, *args, **kwargs):
|
||||
# Check preconditions
|
||||
if self.requires_pages and not self_instance.project.pages:
|
||||
return
|
||||
|
||||
# Get initial page index if available
|
||||
initial_page_index = 0
|
||||
if hasattr(self_instance, "_get_most_visible_page_index"):
|
||||
initial_page_index = self_instance._get_most_visible_page_index()
|
||||
|
||||
# Create and show dialog
|
||||
from pyPhotoAlbum.mixins.dialog_mixin import DialogMixin
|
||||
|
||||
# Create dialog
|
||||
dialog = self.dialog_class(
|
||||
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"):
|
||||
values = dialog.get_values()
|
||||
# Call the decorated function with values
|
||||
return func(self_instance, values, *args, **kwargs)
|
||||
else:
|
||||
return func(self_instance, *args, **kwargs)
|
||||
|
||||
return None
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def dialog_action(dialog_class: type, requires_pages: bool = True) -> Callable:
|
||||
"""
|
||||
Convenience function for the DialogAction decorator.
|
||||
|
||||
This provides a lowercase function-style interface to the decorator.
|
||||
|
||||
Args:
|
||||
dialog_class: The dialog class to instantiate
|
||||
requires_pages: Whether this action requires pages to exist
|
||||
|
||||
Returns:
|
||||
DialogAction decorator instance
|
||||
"""
|
||||
return DialogAction(dialog_class=dialog_class, requires_pages=requires_pages)
|
||||
10
pyPhotoAlbum/dialogs/__init__.py
Normal file
@ -0,0 +1,10 @@
|
||||
"""
|
||||
Dialog classes for pyPhotoAlbum
|
||||
|
||||
This package contains reusable dialog classes that encapsulate
|
||||
UI presentation logic separately from business logic.
|
||||
"""
|
||||
|
||||
from .page_setup_dialog import PageSetupDialog
|
||||
|
||||
__all__ = ["PageSetupDialog"]
|
||||
352
pyPhotoAlbum/dialogs/frame_picker_dialog.py
Normal file
@ -0,0 +1,352 @@
|
||||
"""
|
||||
Frame picker dialog for pyPhotoAlbum
|
||||
|
||||
Dialog for selecting decorative frames to apply to images.
|
||||
"""
|
||||
|
||||
from typing import Optional, Tuple
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QTabWidget,
|
||||
QWidget,
|
||||
QGridLayout,
|
||||
QScrollArea,
|
||||
QFrame,
|
||||
QGroupBox,
|
||||
QCheckBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
from PyQt6.QtGui import QPainter, QColor, QPen
|
||||
|
||||
from pyPhotoAlbum.frame_manager import get_frame_manager, FrameCategory, FrameDefinition, FrameType
|
||||
|
||||
|
||||
class FramePreviewWidget(QFrame):
|
||||
"""Widget that shows a preview of a frame"""
|
||||
|
||||
clicked = pyqtSignal(str) # Emits frame name when clicked
|
||||
|
||||
def __init__(self, frame: FrameDefinition, parent=None):
|
||||
super().__init__(parent)
|
||||
self.frame = frame
|
||||
self.selected = False
|
||||
self.setFixedSize(100, 100)
|
||||
self.setFrameStyle(QFrame.Shape.Box)
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
|
||||
def paintEvent(self, event):
|
||||
super().paintEvent(event)
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
|
||||
# Background
|
||||
if self.selected:
|
||||
painter.fillRect(self.rect(), QColor(200, 220, 255))
|
||||
else:
|
||||
painter.fillRect(self.rect(), QColor(245, 245, 245))
|
||||
|
||||
# Draw a simple preview of the frame style
|
||||
margin = 15
|
||||
rect = self.rect().adjusted(margin, margin, -margin, -margin)
|
||||
|
||||
# Draw "photo" placeholder
|
||||
painter.fillRect(rect, QColor(180, 200, 220))
|
||||
|
||||
# Draw frame preview based on type
|
||||
pen = QPen(QColor(80, 80, 80))
|
||||
pen.setWidth(2)
|
||||
painter.setPen(pen)
|
||||
|
||||
if self.frame.frame_type.value == "corners":
|
||||
# Draw corner decorations
|
||||
corner_size = 12
|
||||
x, y, w, h = rect.x(), rect.y(), rect.width(), rect.height()
|
||||
|
||||
# Top-left
|
||||
painter.drawLine(x, y + corner_size, x, y)
|
||||
painter.drawLine(x, y, x + corner_size, y)
|
||||
|
||||
# Top-right
|
||||
painter.drawLine(x + w - corner_size, y, x + w, y)
|
||||
painter.drawLine(x + w, y, x + w, y + corner_size)
|
||||
|
||||
# Bottom-right
|
||||
painter.drawLine(x + w, y + h - corner_size, x + w, y + h)
|
||||
painter.drawLine(x + w, y + h, x + w - corner_size, y + h)
|
||||
|
||||
# Bottom-left
|
||||
painter.drawLine(x + corner_size, y + h, x, y + h)
|
||||
painter.drawLine(x, y + h, x, y + h - corner_size)
|
||||
|
||||
else:
|
||||
# Draw full border
|
||||
painter.drawRect(rect.adjusted(-3, -3, 3, 3))
|
||||
painter.drawRect(rect)
|
||||
|
||||
# Draw frame name
|
||||
painter.setPen(QColor(0, 0, 0))
|
||||
text_rect = self.rect().adjusted(0, 0, 0, 0)
|
||||
text_rect.setTop(self.rect().bottom() - 20)
|
||||
painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, self.frame.display_name)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
self.clicked.emit(self.frame.name)
|
||||
|
||||
def set_selected(self, selected: bool):
|
||||
self.selected = selected
|
||||
self.update()
|
||||
|
||||
|
||||
class FramePickerDialog(QDialog):
|
||||
"""Dialog for selecting a decorative frame"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent,
|
||||
current_frame: Optional[str] = None,
|
||||
current_color: Tuple[int, int, int] = (0, 0, 0),
|
||||
current_corners: Tuple[bool, bool, bool, bool] = (True, True, True, True),
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Select Frame")
|
||||
self.setMinimumSize(500, 500)
|
||||
|
||||
self.selected_frame: Optional[str] = current_frame
|
||||
self.frame_color = current_color
|
||||
self.frame_corners = current_corners # (TL, TR, BR, BL)
|
||||
self.frame_widgets: dict[str, FramePreviewWidget] = {}
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Tab widget for categories
|
||||
self.tab_widget = QTabWidget()
|
||||
|
||||
# All frames tab
|
||||
all_tab = self._create_category_tab(None)
|
||||
self.tab_widget.addTab(all_tab, "All")
|
||||
|
||||
# Category tabs
|
||||
for category in FrameCategory:
|
||||
tab = self._create_category_tab(category)
|
||||
self.tab_widget.addTab(tab, category.value.title())
|
||||
|
||||
layout.addWidget(self.tab_widget)
|
||||
|
||||
# Selected frame info
|
||||
info_group = QGroupBox("Selected Frame")
|
||||
info_layout = QVBoxLayout(info_group)
|
||||
|
||||
# Frame name and color row
|
||||
name_color_layout = QHBoxLayout()
|
||||
self.selected_label = QLabel("None")
|
||||
name_color_layout.addWidget(self.selected_label)
|
||||
|
||||
# Color button
|
||||
from pyPhotoAlbum.dialogs.style_dialogs import ColorButton
|
||||
|
||||
name_color_layout.addWidget(QLabel("Color:"))
|
||||
self.color_btn = ColorButton(self.frame_color)
|
||||
name_color_layout.addWidget(self.color_btn)
|
||||
name_color_layout.addStretch()
|
||||
info_layout.addLayout(name_color_layout)
|
||||
|
||||
# Corner selection (for corner-type frames)
|
||||
self.corners_group = QGroupBox("Corner Decorations")
|
||||
corners_layout = QGridLayout(self.corners_group)
|
||||
|
||||
# Create a visual grid for corner checkboxes
|
||||
self.corner_tl = QCheckBox("Top-Left")
|
||||
self.corner_tl.setChecked(self.frame_corners[0])
|
||||
self.corner_tl.stateChanged.connect(self._update_corners)
|
||||
|
||||
self.corner_tr = QCheckBox("Top-Right")
|
||||
self.corner_tr.setChecked(self.frame_corners[1])
|
||||
self.corner_tr.stateChanged.connect(self._update_corners)
|
||||
|
||||
self.corner_br = QCheckBox("Bottom-Right")
|
||||
self.corner_br.setChecked(self.frame_corners[2])
|
||||
self.corner_br.stateChanged.connect(self._update_corners)
|
||||
|
||||
self.corner_bl = QCheckBox("Bottom-Left")
|
||||
self.corner_bl.setChecked(self.frame_corners[3])
|
||||
self.corner_bl.stateChanged.connect(self._update_corners)
|
||||
|
||||
corners_layout.addWidget(self.corner_tl, 0, 0)
|
||||
corners_layout.addWidget(self.corner_tr, 0, 1)
|
||||
corners_layout.addWidget(self.corner_bl, 1, 0)
|
||||
corners_layout.addWidget(self.corner_br, 1, 1)
|
||||
|
||||
# Quick selection buttons
|
||||
quick_btns_layout = QHBoxLayout()
|
||||
all_btn = QPushButton("All")
|
||||
all_btn.clicked.connect(self._select_all_corners)
|
||||
none_btn = QPushButton("None")
|
||||
none_btn.clicked.connect(self._select_no_corners)
|
||||
diag_btn = QPushButton("Diagonal")
|
||||
diag_btn.clicked.connect(self._select_diagonal_corners)
|
||||
quick_btns_layout.addWidget(all_btn)
|
||||
quick_btns_layout.addWidget(none_btn)
|
||||
quick_btns_layout.addWidget(diag_btn)
|
||||
quick_btns_layout.addStretch()
|
||||
corners_layout.addLayout(quick_btns_layout, 2, 0, 1, 2)
|
||||
|
||||
info_layout.addWidget(self.corners_group)
|
||||
|
||||
layout.addWidget(info_group)
|
||||
|
||||
# Update corners group visibility based on frame type
|
||||
self._update_corners_visibility()
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
clear_btn = QPushButton("No Frame")
|
||||
clear_btn.clicked.connect(self._clear_selection)
|
||||
button_layout.addWidget(clear_btn)
|
||||
|
||||
button_layout.addStretch()
|
||||
|
||||
ok_btn = QPushButton("OK")
|
||||
ok_btn.clicked.connect(self.accept)
|
||||
button_layout.addWidget(ok_btn)
|
||||
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
button_layout.addWidget(cancel_btn)
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
# Update selection display
|
||||
self._update_selection_display()
|
||||
|
||||
def _create_category_tab(self, category: Optional[FrameCategory]) -> QWidget:
|
||||
"""Create a tab for a frame category"""
|
||||
widget = QWidget()
|
||||
layout = QVBoxLayout(widget)
|
||||
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
|
||||
content = QWidget()
|
||||
grid = QGridLayout(content)
|
||||
grid.setSpacing(10)
|
||||
|
||||
frame_manager = get_frame_manager()
|
||||
|
||||
if category:
|
||||
frames = frame_manager.get_frames_by_category(category)
|
||||
else:
|
||||
frames = frame_manager.get_all_frames()
|
||||
|
||||
row, col = 0, 0
|
||||
max_cols = 4
|
||||
|
||||
for frame in frames:
|
||||
preview = FramePreviewWidget(frame)
|
||||
preview.clicked.connect(self._on_frame_clicked)
|
||||
|
||||
if frame.name == self.selected_frame:
|
||||
preview.set_selected(True)
|
||||
|
||||
grid.addWidget(preview, row, col)
|
||||
self.frame_widgets[frame.name] = preview
|
||||
|
||||
col += 1
|
||||
if col >= max_cols:
|
||||
col = 0
|
||||
row += 1
|
||||
|
||||
# Add stretch at the bottom
|
||||
grid.setRowStretch(row + 1, 1)
|
||||
|
||||
scroll.setWidget(content)
|
||||
layout.addWidget(scroll)
|
||||
|
||||
return widget
|
||||
|
||||
def _on_frame_clicked(self, frame_name: str):
|
||||
"""Handle frame selection"""
|
||||
# Deselect previous
|
||||
if self.selected_frame and self.selected_frame in self.frame_widgets:
|
||||
self.frame_widgets[self.selected_frame].set_selected(False)
|
||||
|
||||
# Select new
|
||||
self.selected_frame = frame_name
|
||||
if frame_name in self.frame_widgets:
|
||||
self.frame_widgets[frame_name].set_selected(True)
|
||||
|
||||
self._update_selection_display()
|
||||
self._update_corners_visibility()
|
||||
|
||||
def _clear_selection(self):
|
||||
"""Clear frame selection"""
|
||||
if self.selected_frame and self.selected_frame in self.frame_widgets:
|
||||
self.frame_widgets[self.selected_frame].set_selected(False)
|
||||
self.selected_frame = None
|
||||
self._update_selection_display()
|
||||
self._update_corners_visibility()
|
||||
|
||||
def _update_selection_display(self):
|
||||
"""Update the selected frame label"""
|
||||
if self.selected_frame:
|
||||
frame = get_frame_manager().get_frame(self.selected_frame)
|
||||
if frame:
|
||||
self.selected_label.setText(f"{frame.display_name} - {frame.description}")
|
||||
else:
|
||||
self.selected_label.setText(self.selected_frame)
|
||||
else:
|
||||
self.selected_label.setText("None")
|
||||
|
||||
def _update_corners(self):
|
||||
"""Update corner selection from checkboxes"""
|
||||
self.frame_corners = (
|
||||
self.corner_tl.isChecked(),
|
||||
self.corner_tr.isChecked(),
|
||||
self.corner_br.isChecked(),
|
||||
self.corner_bl.isChecked(),
|
||||
)
|
||||
|
||||
def _update_corners_visibility(self):
|
||||
"""Show/hide corners group based on selected frame type"""
|
||||
if self.selected_frame:
|
||||
frame = get_frame_manager().get_frame(self.selected_frame)
|
||||
if frame and frame.frame_type == FrameType.CORNERS:
|
||||
self.corners_group.setVisible(True)
|
||||
return
|
||||
self.corners_group.setVisible(False)
|
||||
|
||||
def _select_all_corners(self):
|
||||
"""Select all corners"""
|
||||
self.corner_tl.setChecked(True)
|
||||
self.corner_tr.setChecked(True)
|
||||
self.corner_br.setChecked(True)
|
||||
self.corner_bl.setChecked(True)
|
||||
self._update_corners()
|
||||
|
||||
def _select_no_corners(self):
|
||||
"""Deselect all corners"""
|
||||
self.corner_tl.setChecked(False)
|
||||
self.corner_tr.setChecked(False)
|
||||
self.corner_br.setChecked(False)
|
||||
self.corner_bl.setChecked(False)
|
||||
self._update_corners()
|
||||
|
||||
def _select_diagonal_corners(self):
|
||||
"""Select diagonal corners (TL and BR)"""
|
||||
self.corner_tl.setChecked(True)
|
||||
self.corner_tr.setChecked(False)
|
||||
self.corner_br.setChecked(True)
|
||||
self.corner_bl.setChecked(False)
|
||||
self._update_corners()
|
||||
|
||||
def get_values(self) -> Tuple[Optional[str], Tuple[int, int, int], Tuple[bool, bool, bool, bool]]:
|
||||
"""Get selected frame name, color, and corner configuration"""
|
||||
return self.selected_frame, self.color_btn.get_color(), self.frame_corners
|
||||
322
pyPhotoAlbum/dialogs/page_setup_dialog.py
Normal file
@ -0,0 +1,322 @@
|
||||
"""
|
||||
Page Setup Dialog for pyPhotoAlbum
|
||||
|
||||
Encapsulates all UI logic for page setup configuration,
|
||||
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,
|
||||
)
|
||||
from pyPhotoAlbum.project import Project
|
||||
|
||||
|
||||
class PageSetupDialog(QDialog):
|
||||
"""
|
||||
Dialog for configuring page settings.
|
||||
|
||||
This dialog handles all UI presentation logic for page setup,
|
||||
including page size, DPI settings, and cover configuration.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, project: Project, initial_page_index: int = 0):
|
||||
"""
|
||||
Initialize the page setup dialog.
|
||||
|
||||
Args:
|
||||
parent: Parent widget
|
||||
project: Project instance containing pages and settings
|
||||
initial_page_index: Index of page to initially select
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.project = project
|
||||
self.initial_page_index = initial_page_index
|
||||
|
||||
self._setup_ui()
|
||||
self._connect_signals()
|
||||
self._initialize_values()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Create and layout all UI components."""
|
||||
self.setWindowTitle("Page Setup")
|
||||
self.setMinimumWidth(450)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Page selection group
|
||||
self._page_select_group = self._create_page_selection_group()
|
||||
layout.addWidget(self._page_select_group)
|
||||
|
||||
# Cover settings group
|
||||
self._cover_group = self._create_cover_settings_group()
|
||||
layout.addWidget(self._cover_group)
|
||||
|
||||
# Page size group
|
||||
self._size_group = self._create_page_size_group()
|
||||
layout.addWidget(self._size_group)
|
||||
|
||||
# DPI settings group
|
||||
self._dpi_group = self._create_dpi_settings_group()
|
||||
layout.addWidget(self._dpi_group)
|
||||
|
||||
# Buttons
|
||||
button_layout = self._create_button_layout()
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def _create_page_selection_group(self) -> QGroupBox:
|
||||
"""Create the page selection group."""
|
||||
group = QGroupBox("Select Page")
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Page combo box
|
||||
self.page_combo = QComboBox()
|
||||
for i, page in enumerate(self.project.pages):
|
||||
page_label = self.project.get_page_display_name(page)
|
||||
if page.is_double_spread and not page.is_cover:
|
||||
page_label += " (Double Spread)"
|
||||
if page.manually_sized:
|
||||
page_label += " *"
|
||||
self.page_combo.addItem(page_label, i)
|
||||
layout.addWidget(self.page_combo)
|
||||
|
||||
# Info label
|
||||
info_label = QLabel("* = Manually sized page")
|
||||
info_label.setStyleSheet("font-size: 9pt; color: gray;")
|
||||
layout.addWidget(info_label)
|
||||
|
||||
group.setLayout(layout)
|
||||
return group
|
||||
|
||||
def _create_cover_settings_group(self) -> QGroupBox:
|
||||
"""Create the cover settings group."""
|
||||
group = QGroupBox("Cover Settings")
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# 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")
|
||||
layout.addWidget(self.cover_checkbox)
|
||||
|
||||
# Paper thickness
|
||||
thickness_layout = QHBoxLayout()
|
||||
thickness_layout.addWidget(QLabel("Paper Thickness:"))
|
||||
self.thickness_spinbox = QDoubleSpinBox()
|
||||
self.thickness_spinbox.setRange(0.05, 1.0)
|
||||
self.thickness_spinbox.setSingleStep(0.05)
|
||||
self.thickness_spinbox.setValue(self.project.paper_thickness_mm)
|
||||
self.thickness_spinbox.setSuffix(" mm")
|
||||
self.thickness_spinbox.setToolTip("Thickness of paper for spine calculation")
|
||||
thickness_layout.addWidget(self.thickness_spinbox)
|
||||
layout.addLayout(thickness_layout)
|
||||
|
||||
# Bleed margin
|
||||
bleed_layout = QHBoxLayout()
|
||||
bleed_layout.addWidget(QLabel("Bleed Margin:"))
|
||||
self.bleed_spinbox = QDoubleSpinBox()
|
||||
self.bleed_spinbox.setRange(0, 10)
|
||||
self.bleed_spinbox.setSingleStep(0.5)
|
||||
self.bleed_spinbox.setValue(self.project.cover_bleed_mm)
|
||||
self.bleed_spinbox.setSuffix(" mm")
|
||||
self.bleed_spinbox.setToolTip("Extra margin around cover for printing bleed")
|
||||
bleed_layout.addWidget(self.bleed_spinbox)
|
||||
layout.addLayout(bleed_layout)
|
||||
|
||||
# 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.setWordWrap(True)
|
||||
layout.addWidget(self.spine_info_label)
|
||||
|
||||
group.setLayout(layout)
|
||||
return group
|
||||
|
||||
def _create_page_size_group(self) -> QGroupBox:
|
||||
"""Create the page size group."""
|
||||
group = QGroupBox("Page Size")
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Width
|
||||
width_layout = QHBoxLayout()
|
||||
width_layout.addWidget(QLabel("Width:"))
|
||||
self.width_spinbox = QDoubleSpinBox()
|
||||
self.width_spinbox.setRange(10, 1000)
|
||||
self.width_spinbox.setSuffix(" mm")
|
||||
width_layout.addWidget(self.width_spinbox)
|
||||
layout.addLayout(width_layout)
|
||||
|
||||
# Height
|
||||
height_layout = QHBoxLayout()
|
||||
height_layout.addWidget(QLabel("Height:"))
|
||||
self.height_spinbox = QDoubleSpinBox()
|
||||
self.height_spinbox.setRange(10, 1000)
|
||||
self.height_spinbox.setSuffix(" mm")
|
||||
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)
|
||||
|
||||
group.setLayout(layout)
|
||||
return group
|
||||
|
||||
def _create_dpi_settings_group(self) -> QGroupBox:
|
||||
"""Create the DPI settings group."""
|
||||
group = QGroupBox("DPI Settings")
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Working DPI
|
||||
working_dpi_layout = QHBoxLayout()
|
||||
working_dpi_layout.addWidget(QLabel("Working DPI:"))
|
||||
self.working_dpi_spinbox = QSpinBox()
|
||||
self.working_dpi_spinbox.setRange(72, 1200)
|
||||
self.working_dpi_spinbox.setValue(self.project.working_dpi)
|
||||
working_dpi_layout.addWidget(self.working_dpi_spinbox)
|
||||
layout.addLayout(working_dpi_layout)
|
||||
|
||||
# Export DPI
|
||||
export_dpi_layout = QHBoxLayout()
|
||||
export_dpi_layout.addWidget(QLabel("Export DPI:"))
|
||||
self.export_dpi_spinbox = QSpinBox()
|
||||
self.export_dpi_spinbox.setRange(72, 1200)
|
||||
self.export_dpi_spinbox.setValue(self.project.export_dpi)
|
||||
export_dpi_layout.addWidget(self.export_dpi_spinbox)
|
||||
layout.addLayout(export_dpi_layout)
|
||||
|
||||
group.setLayout(layout)
|
||||
return group
|
||||
|
||||
def _create_button_layout(self) -> QHBoxLayout:
|
||||
"""Create dialog button layout."""
|
||||
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)
|
||||
|
||||
layout.addStretch()
|
||||
layout.addWidget(cancel_btn)
|
||||
layout.addWidget(ok_btn)
|
||||
|
||||
return layout
|
||||
|
||||
def _connect_signals(self):
|
||||
"""Connect widget signals to handlers."""
|
||||
self.page_combo.currentIndexChanged.connect(self._on_page_changed)
|
||||
self.cover_checkbox.stateChanged.connect(self._update_spine_info)
|
||||
self.thickness_spinbox.valueChanged.connect(self._update_spine_info)
|
||||
self.bleed_spinbox.valueChanged.connect(self._update_spine_info)
|
||||
|
||||
def _initialize_values(self):
|
||||
"""Initialize dialog values based on current page."""
|
||||
# Set initial page selection
|
||||
if 0 <= self.initial_page_index < len(self.project.pages):
|
||||
self.page_combo.setCurrentIndex(self.initial_page_index)
|
||||
|
||||
# Trigger initial page change to populate values
|
||||
self._on_page_changed(self.initial_page_index)
|
||||
|
||||
def _on_page_changed(self, index: int):
|
||||
"""
|
||||
Handle page selection change.
|
||||
|
||||
Args:
|
||||
index: Index of selected page
|
||||
"""
|
||||
if index < 0 or index >= len(self.project.pages):
|
||||
return
|
||||
|
||||
selected_page = self.project.pages[index]
|
||||
is_first_page = index == 0
|
||||
|
||||
# Show/hide cover settings based on page selection
|
||||
self._cover_group.setVisible(is_first_page)
|
||||
|
||||
# Update cover checkbox
|
||||
if is_first_page:
|
||||
self.cover_checkbox.setChecked(selected_page.is_cover)
|
||||
self._update_spine_info()
|
||||
|
||||
# Get display width (accounting for double spreads and covers)
|
||||
if selected_page.is_cover:
|
||||
# For covers, show the full calculated width
|
||||
display_width = selected_page.layout.size[0]
|
||||
elif selected_page.is_double_spread:
|
||||
display_width = (
|
||||
selected_page.layout.base_width
|
||||
if hasattr(selected_page.layout, "base_width")
|
||||
else selected_page.layout.size[0] / 2
|
||||
)
|
||||
else:
|
||||
display_width = selected_page.layout.size[0]
|
||||
|
||||
self.width_spinbox.setValue(display_width)
|
||||
self.height_spinbox.setValue(selected_page.layout.size[1])
|
||||
|
||||
# Disable size editing for covers (auto-calculated)
|
||||
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)
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
self.spine_info_label.setText(
|
||||
f"Cover Layout: Front ({page_width:.0f}mm) + "
|
||||
f"Spine ({spine_width:.2f}mm) + "
|
||||
f"Back ({page_width:.0f}mm) + "
|
||||
f"Bleed ({self.bleed_spinbox.value():.1f}mm × 2)\n"
|
||||
f"Total Width: {total_width:.1f}mm | "
|
||||
f"Content Pages: {content_pages} | Sheets: {sheets}"
|
||||
)
|
||||
else:
|
||||
self.spine_info_label.setText("")
|
||||
|
||||
def get_values(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get dialog values.
|
||||
|
||||
Returns:
|
||||
Dictionary containing all dialog values
|
||||
"""
|
||||
selected_index = self.page_combo.currentData()
|
||||
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(),
|
||||
}
|
||||
297
pyPhotoAlbum/dialogs/style_dialogs.py
Normal file
@ -0,0 +1,297 @@
|
||||
"""
|
||||
Style dialogs for pyPhotoAlbum
|
||||
|
||||
Dialogs for configuring image styling options:
|
||||
- Corner radius
|
||||
- Border (width and color)
|
||||
- Drop shadow
|
||||
"""
|
||||
|
||||
from typing import Tuple
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QSlider,
|
||||
QSpinBox,
|
||||
QDoubleSpinBox,
|
||||
QPushButton,
|
||||
QCheckBox,
|
||||
QColorDialog,
|
||||
QGroupBox,
|
||||
QFormLayout,
|
||||
QWidget,
|
||||
)
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtGui import QColor
|
||||
|
||||
|
||||
class CornerRadiusDialog(QDialog):
|
||||
"""Dialog for setting corner radius"""
|
||||
|
||||
def __init__(self, parent, current_radius: float = 0.0):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Corner Radius")
|
||||
self.setMinimumWidth(300)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Slider with label
|
||||
slider_layout = QHBoxLayout()
|
||||
slider_layout.addWidget(QLabel("Radius:"))
|
||||
|
||||
self.slider = QSlider(Qt.Orientation.Horizontal)
|
||||
self.slider.setMinimum(0)
|
||||
self.slider.setMaximum(50)
|
||||
self.slider.setValue(int(current_radius))
|
||||
self.slider.valueChanged.connect(self._on_slider_changed)
|
||||
slider_layout.addWidget(self.slider)
|
||||
|
||||
self.value_label = QLabel(f"{int(current_radius)}%")
|
||||
self.value_label.setMinimumWidth(40)
|
||||
slider_layout.addWidget(self.value_label)
|
||||
|
||||
layout.addLayout(slider_layout)
|
||||
|
||||
# Preset buttons
|
||||
preset_layout = QHBoxLayout()
|
||||
for value, label in [(0, "None"), (5, "Slight"), (15, "Medium"), (25, "Large"), (50, "Circle")]:
|
||||
btn = QPushButton(label)
|
||||
btn.clicked.connect(lambda checked, v=value: self.slider.setValue(v))
|
||||
preset_layout.addWidget(btn)
|
||||
layout.addLayout(preset_layout)
|
||||
|
||||
# OK/Cancel buttons
|
||||
button_layout = QHBoxLayout()
|
||||
ok_btn = QPushButton("OK")
|
||||
ok_btn.clicked.connect(self.accept)
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
button_layout.addStretch()
|
||||
button_layout.addWidget(ok_btn)
|
||||
button_layout.addWidget(cancel_btn)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
def _on_slider_changed(self, value):
|
||||
self.value_label.setText(f"{value}%")
|
||||
|
||||
def get_value(self) -> float:
|
||||
return float(self.slider.value())
|
||||
|
||||
|
||||
class ColorButton(QPushButton):
|
||||
"""Button that shows a color and opens color picker on click"""
|
||||
|
||||
def __init__(self, color: Tuple[int, int, int], parent=None):
|
||||
super().__init__(parent)
|
||||
self.setFixedSize(40, 25)
|
||||
self._color = color
|
||||
self._update_style()
|
||||
self.clicked.connect(self._pick_color)
|
||||
|
||||
def _update_style(self):
|
||||
r, g, b = self._color
|
||||
self.setStyleSheet(f"background-color: rgb({r}, {g}, {b}); border: 1px solid #666;")
|
||||
|
||||
def _pick_color(self):
|
||||
r, g, b = self._color
|
||||
initial = QColor(r, g, b)
|
||||
color = QColorDialog.getColor(initial, self, "Select Color")
|
||||
if color.isValid():
|
||||
self._color = (color.red(), color.green(), color.blue())
|
||||
self._update_style()
|
||||
|
||||
def get_color(self) -> Tuple[int, int, int]:
|
||||
return self._color
|
||||
|
||||
|
||||
class BorderDialog(QDialog):
|
||||
"""Dialog for configuring border"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent,
|
||||
current_width: float = 0.0,
|
||||
current_color: Tuple[int, int, int] = (0, 0, 0),
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Border Settings")
|
||||
self.setMinimumWidth(300)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Border width
|
||||
width_layout = QHBoxLayout()
|
||||
width_layout.addWidget(QLabel("Width (mm):"))
|
||||
self.width_spin = QDoubleSpinBox()
|
||||
self.width_spin.setRange(0, 20)
|
||||
self.width_spin.setSingleStep(0.5)
|
||||
self.width_spin.setValue(current_width)
|
||||
self.width_spin.setDecimals(1)
|
||||
width_layout.addWidget(self.width_spin)
|
||||
layout.addLayout(width_layout)
|
||||
|
||||
# Border color
|
||||
color_layout = QHBoxLayout()
|
||||
color_layout.addWidget(QLabel("Color:"))
|
||||
self.color_btn = ColorButton(current_color)
|
||||
color_layout.addWidget(self.color_btn)
|
||||
color_layout.addStretch()
|
||||
layout.addLayout(color_layout)
|
||||
|
||||
# Preset buttons
|
||||
preset_layout = QHBoxLayout()
|
||||
presets = [
|
||||
("None", 0, (0, 0, 0)),
|
||||
("Thin Black", 0.5, (0, 0, 0)),
|
||||
("White", 2, (255, 255, 255)),
|
||||
("Gold", 1.5, (212, 175, 55)),
|
||||
]
|
||||
for label, width, color in presets:
|
||||
btn = QPushButton(label)
|
||||
btn.clicked.connect(lambda checked, w=width, c=color: self._apply_preset(w, c))
|
||||
preset_layout.addWidget(btn)
|
||||
layout.addLayout(preset_layout)
|
||||
|
||||
# OK/Cancel buttons
|
||||
button_layout = QHBoxLayout()
|
||||
ok_btn = QPushButton("OK")
|
||||
ok_btn.clicked.connect(self.accept)
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
button_layout.addStretch()
|
||||
button_layout.addWidget(ok_btn)
|
||||
button_layout.addWidget(cancel_btn)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
def _apply_preset(self, width, color):
|
||||
self.width_spin.setValue(width)
|
||||
self.color_btn._color = color
|
||||
self.color_btn._update_style()
|
||||
|
||||
def get_values(self) -> Tuple[float, Tuple[int, int, int]]:
|
||||
return self.width_spin.value(), self.color_btn.get_color()
|
||||
|
||||
|
||||
class ShadowDialog(QDialog):
|
||||
"""Dialog for configuring drop shadow"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent,
|
||||
enabled: bool = False,
|
||||
offset: Tuple[float, float] = (2.0, 2.0),
|
||||
blur: float = 3.0,
|
||||
color: Tuple[int, int, int, int] = (0, 0, 0, 128),
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Shadow Settings")
|
||||
self.setMinimumWidth(350)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Enable checkbox
|
||||
self.enabled_check = QCheckBox("Enable Drop Shadow")
|
||||
self.enabled_check.setChecked(enabled)
|
||||
self.enabled_check.stateChanged.connect(self._update_controls)
|
||||
layout.addWidget(self.enabled_check)
|
||||
|
||||
# Settings group
|
||||
self.settings_group = QGroupBox("Shadow Settings")
|
||||
form = QFormLayout(self.settings_group)
|
||||
|
||||
# Offset X
|
||||
self.offset_x = QDoubleSpinBox()
|
||||
self.offset_x.setRange(-20, 20)
|
||||
self.offset_x.setSingleStep(0.5)
|
||||
self.offset_x.setValue(offset[0])
|
||||
self.offset_x.setDecimals(1)
|
||||
form.addRow("Offset X (mm):", self.offset_x)
|
||||
|
||||
# Offset Y
|
||||
self.offset_y = QDoubleSpinBox()
|
||||
self.offset_y.setRange(-20, 20)
|
||||
self.offset_y.setSingleStep(0.5)
|
||||
self.offset_y.setValue(offset[1])
|
||||
self.offset_y.setDecimals(1)
|
||||
form.addRow("Offset Y (mm):", self.offset_y)
|
||||
|
||||
# Blur
|
||||
self.blur_spin = QDoubleSpinBox()
|
||||
self.blur_spin.setRange(0, 20)
|
||||
self.blur_spin.setSingleStep(0.5)
|
||||
self.blur_spin.setValue(blur)
|
||||
self.blur_spin.setDecimals(1)
|
||||
form.addRow("Blur (mm):", self.blur_spin)
|
||||
|
||||
# Color
|
||||
color_widget = QWidget()
|
||||
color_layout = QHBoxLayout(color_widget)
|
||||
color_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.color_btn = ColorButton(color[:3])
|
||||
color_layout.addWidget(self.color_btn)
|
||||
color_layout.addStretch()
|
||||
form.addRow("Color:", color_widget)
|
||||
|
||||
# Opacity
|
||||
self.opacity_slider = QSlider(Qt.Orientation.Horizontal)
|
||||
self.opacity_slider.setRange(0, 255)
|
||||
self.opacity_slider.setValue(color[3] if len(color) > 3 else 128)
|
||||
opacity_layout = QHBoxLayout()
|
||||
opacity_layout.addWidget(self.opacity_slider)
|
||||
self.opacity_label = QLabel(f"{self.opacity_slider.value()}")
|
||||
self.opacity_label.setMinimumWidth(30)
|
||||
opacity_layout.addWidget(self.opacity_label)
|
||||
self.opacity_slider.valueChanged.connect(lambda v: self.opacity_label.setText(str(v)))
|
||||
form.addRow("Opacity:", opacity_layout)
|
||||
|
||||
layout.addWidget(self.settings_group)
|
||||
|
||||
# Preset buttons
|
||||
preset_layout = QHBoxLayout()
|
||||
presets = [
|
||||
("Subtle", True, (1.0, 1.0), 2.0, (0, 0, 0, 60)),
|
||||
("Normal", True, (2.0, 2.0), 3.0, (0, 0, 0, 100)),
|
||||
("Strong", True, (3.0, 3.0), 5.0, (0, 0, 0, 150)),
|
||||
]
|
||||
for label, en, off, bl, col in presets:
|
||||
btn = QPushButton(label)
|
||||
btn.clicked.connect(lambda checked, e=en, o=off, b=bl, c=col: self._apply_preset(e, o, b, c))
|
||||
preset_layout.addWidget(btn)
|
||||
layout.addLayout(preset_layout)
|
||||
|
||||
# OK/Cancel buttons
|
||||
button_layout = QHBoxLayout()
|
||||
ok_btn = QPushButton("OK")
|
||||
ok_btn.clicked.connect(self.accept)
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
button_layout.addStretch()
|
||||
button_layout.addWidget(ok_btn)
|
||||
button_layout.addWidget(cancel_btn)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
self._update_controls()
|
||||
|
||||
def _update_controls(self):
|
||||
self.settings_group.setEnabled(self.enabled_check.isChecked())
|
||||
|
||||
def _apply_preset(self, enabled, offset, blur, color):
|
||||
self.enabled_check.setChecked(enabled)
|
||||
self.offset_x.setValue(offset[0])
|
||||
self.offset_y.setValue(offset[1])
|
||||
self.blur_spin.setValue(blur)
|
||||
self.color_btn._color = color[:3]
|
||||
self.color_btn._update_style()
|
||||
self.opacity_slider.setValue(color[3] if len(color) > 3 else 128)
|
||||
|
||||
def get_values(self) -> Tuple[bool, Tuple[float, float], float, Tuple[int, int, int, int]]:
|
||||
color_rgb = self.color_btn.get_color()
|
||||
color_rgba = color_rgb + (self.opacity_slider.value(),)
|
||||
return (
|
||||
self.enabled_check.isChecked(),
|
||||
(self.offset_x.value(), self.offset_y.value()),
|
||||
self.blur_spin.value(),
|
||||
color_rgba,
|
||||
)
|
||||
939
pyPhotoAlbum/frame_manager.py
Normal file
@ -0,0 +1,939 @@
|
||||
"""
|
||||
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.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.FLIP_LEFT_RIGHT)
|
||||
|
||||
# Flip vertically if v position differs
|
||||
if asset_v != target_v:
|
||||
img = img.transpose(Image.FLIP_TOP_BOTTOM)
|
||||
|
||||
return img
|
||||
|
||||
# Note: PDF Y-axis is bottom-up, so corners are positioned differently
|
||||
# Top-left in screen coordinates = high Y in PDF
|
||||
if tl:
|
||||
img = get_flipped_image("tl")
|
||||
img_reader = ImageReader(img)
|
||||
canvas.drawImage(img_reader, x, y + h - cs, cs, cs, mask="auto")
|
||||
|
||||
# Top-right
|
||||
if tr:
|
||||
img = get_flipped_image("tr")
|
||||
img_reader = ImageReader(img)
|
||||
canvas.drawImage(img_reader, x + w - cs, y + h - cs, cs, cs, mask="auto")
|
||||
|
||||
# Bottom-right
|
||||
if br:
|
||||
img = get_flipped_image("br")
|
||||
img_reader = ImageReader(img)
|
||||
canvas.drawImage(img_reader, x + w - cs, y, cs, cs, mask="auto")
|
||||
|
||||
# Bottom-left
|
||||
if bl:
|
||||
img = get_flipped_image("bl")
|
||||
img_reader = ImageReader(img)
|
||||
canvas.drawImage(img_reader, x, y, cs, cs, mask="auto")
|
||||
|
||||
return True
|
||||
|
||||
def _render_corner_frame_pdf(
|
||||
self,
|
||||
canvas,
|
||||
x: float,
|
||||
y: float,
|
||||
w: float,
|
||||
h: float,
|
||||
thickness: float,
|
||||
frame_name: str,
|
||||
corners: Tuple[bool, bool, bool, bool] = (True, True, True, True),
|
||||
):
|
||||
"""Render corner-style frame on PDF (programmatic fallback)."""
|
||||
corner_size = thickness * 2
|
||||
tl, tr, br, bl = corners
|
||||
|
||||
path = canvas.beginPath()
|
||||
|
||||
if tl:
|
||||
path.moveTo(x, y + h - corner_size)
|
||||
path.lineTo(x, y + h)
|
||||
path.lineTo(x + corner_size, y + h)
|
||||
|
||||
if tr:
|
||||
path.moveTo(x + w - corner_size, y + h)
|
||||
path.lineTo(x + w, y + h)
|
||||
path.lineTo(x + w, y + h - corner_size)
|
||||
|
||||
if br:
|
||||
path.moveTo(x + w, y + corner_size)
|
||||
path.lineTo(x + w, y)
|
||||
path.lineTo(x + w - corner_size, y)
|
||||
|
||||
if bl:
|
||||
path.moveTo(x + corner_size, y)
|
||||
path.lineTo(x, y)
|
||||
path.lineTo(x, y + corner_size)
|
||||
|
||||
canvas.drawPath(path, stroke=1, fill=0)
|
||||
|
||||
def _render_full_frame_pdf(self, canvas, x: float, y: float, w: float, h: float, thickness: float):
|
||||
"""Render full-border frame on PDF"""
|
||||
canvas.rect(
|
||||
x - thickness * 0.5,
|
||||
y - thickness * 0.5,
|
||||
w + thickness,
|
||||
h + thickness,
|
||||
stroke=1,
|
||||
fill=0,
|
||||
)
|
||||
|
||||
canvas.rect(
|
||||
x + thickness * 0.3,
|
||||
y + thickness * 0.3,
|
||||
w - thickness * 0.6,
|
||||
h - thickness * 0.6,
|
||||
stroke=1,
|
||||
fill=0,
|
||||
)
|
||||
|
||||
|
||||
# Global frame manager instance
|
||||
_frame_manager: Optional[FrameManager] = None
|
||||
|
||||
|
||||
def get_frame_manager() -> FrameManager:
|
||||
"""Get the global frame manager instance"""
|
||||
global _frame_manager
|
||||
if _frame_manager is None:
|
||||
_frame_manager = FrameManager()
|
||||
return _frame_manager
|
||||
23
pyPhotoAlbum/frames/CREDITS.txt
Normal file
@ -0,0 +1,23 @@
|
||||
Decorative Frame Assets - Credits and Licenses
|
||||
===============================================
|
||||
|
||||
All decorative corner SVG assets in this directory are sourced from FreeSVG.org
|
||||
and are released under the Creative Commons Zero (CC0) Public Domain license.
|
||||
|
||||
This means you can copy, modify, distribute, and use them for commercial purposes,
|
||||
all without asking permission or providing attribution.
|
||||
|
||||
However, we gratefully acknowledge the following sources:
|
||||
|
||||
Corner Decorations
|
||||
------------------
|
||||
- corner_decoration.svg - FreeSVG.org (OpenClipart)
|
||||
- corner_ornament.svg - FreeSVG.org (RebeccaRead/OpenClipart)
|
||||
- floral_corner.svg - FreeSVG.org (OpenClipart)
|
||||
- floral_flourish.svg - FreeSVG.org (OpenClipart)
|
||||
- ornate_corner.svg - FreeSVG.org (OpenClipart)
|
||||
- simple_corner.svg - FreeSVG.org (OpenClipart)
|
||||
|
||||
Source: https://freesvg.org
|
||||
License: CC0 1.0 Universal (Public Domain)
|
||||
License URL: https://creativecommons.org/publicdomain/zero/1.0/
|
||||
63
pyPhotoAlbum/frames/corners/corner_decoration.svg
Normal file
|
After Width: | Height: | Size: 14 KiB |
40
pyPhotoAlbum/frames/corners/corner_ornament.svg
Normal file
@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 1841.389 1732.463" enable-background="new 0 0 1841.389 1732.463" xml:space="preserve">
|
||||
<path d="M184.653,96.295c1.15-14.484,13.733-31.753,38.55-29.904c29.204,4.076,42.908,45.411,8.953,64.39
|
||||
c0.023,60.887,0.07,552.723-0.005,570.255c38.034,27.832,49.897,75.373,48.848,122.498
|
||||
c-16.504,188.412-190.187,324.389-180.679,564.55c1.477,12.405-1.462,120.691,59.234,189.029
|
||||
c41.404,45.424,106.154,65.484,144.66,61.721c0.01-0.217,0.025-0.653,0.035-0.87c-101.171-51.399-226.038-227.923-76.517-543.644
|
||||
c63.962-135.204,126.934-191.678,97.933-292.904c25.013,1.679,55.741,13.99,68.385,38.53
|
||||
c37.641,72.201-74.811,159.089-91.294,182.904c-0.638,1.483,1.241,1.122,1.804,0.386c66.486-54.828,80.583-14.788,224.163-55.398
|
||||
c-2.803,29.004-17.49,60.919-43.306,81.97c-40.102,32.437-92.657,27.109-152.846,63.669c-24.711,15.401-40.752,35.538-47.473,52.427
|
||||
c4.069-1.374,22.147-11.821,53.51-20.842c109.773-32.011,219.625,2.926,259.841,99.243
|
||||
c73.343,179.044-170.407,316.569-276.348,182.592c-34.819-44.759-25.714-103.207,4.652-123.823c1.622-1.177,3.614-1.933,4.761-3.653
|
||||
c-30.783-3.947-65.948,51.188-47.226,114.716c38.729,133.524,279.285,176.476,398.262,57.781
|
||||
c38.612-37.569,68.479-108.457,44.547-155.743c-18.193-37.729-57.937-36.345-62.804-82.464
|
||||
c-2.762-50.859,60.605-60.299,84.303-15.711c0.771,1.285,1.29,2.966,2.857,3.51c-7.765-45.051-47.815-113.135-83.839-140.67
|
||||
c-0.01-0.227-0.025-0.682-0.035-0.91c30.333-7.572,51.561-4.551,59.704-4.4c-37.721-112.279,18.498-176.688,80.517-183.161
|
||||
c27.057-4.285,78.192,10.172,77.007,48.813c0.526,20.185-15.404,39.847-20.195,22.592c-1.56-4.961,0.958-21.982-13.669-33.003
|
||||
c-15.829-12.263-42.279-8.734-55.245,11.192c-55.269,81.238,181.193,219.377,102.995,317.394
|
||||
c33.196-1.605,52.222,21.494,57.9,45.521c-18.135-0.985-21.631-11.204-71.475,71.109c-25.625,41.334-60.584,78.848-95.881,105.694
|
||||
c-1.518,1.216-3.505,2.121-4.158,4.118c11.689-2.368,46.189-28.835,57.296-37.957c94.629-77.732,128.727-135.385,239.424-110.534
|
||||
c21.531,5.01,30.999,9.577,34.833,10.718c-8.894,26.039-24.603,36.121-44.893,42.545c-0.114,0.267-0.341,0.801-0.455,1.068
|
||||
c28.557,2.119,53.529,23.403,59.704,50.736c192.237,0.044,384.469,0.025,576.706,0.01c15.283-26.042,52.749-21.042,61.592,5.947
|
||||
c13.052,39.741-43.46,63.559-63.071,24.535c-291.078,0.076-576.278-0.056-578.026,0.084c-1.33,1.127-1.953,2.828-2.951,4.232
|
||||
c-22.205,31.744-58.788,21.901-64.816,18.573c2.645-0.292,5.314,0.049,7.974-0.143c42.13-2.471,40.518-54.133,11.672-72.681
|
||||
c-10.145-7.151-30.452-11.674-43.336-12.779c-136.137-4.945-250.616,166.126-515.979,168.048
|
||||
c1.288,50.475-52.655,93.797-141.526,83.018c-34.4-4.311-23.027-7.397-34.64-3.915c-73.552,24.828-155.421-4.746-198.095-56.308
|
||||
c-55.492-62.957-83.424-182.369-66.126-297.437c3.442-23.872,15.723-70.315,5.596-122.873
|
||||
c-4.835-25.755-15.503-52.649-15.518-78.341c-1.172-50.249,19.305-90.939,15.933-118.681c-0.198-2.333-0.539-4.657-0.593-6.99
|
||||
c14.591,7.231,41.682,29.066,50.919,62.982c5.006,18.17,2.906,32.105,3.179,35.03c1.463-1.582,2.155-3.658,3.03-5.581
|
||||
c34.931-81.401-63.977-103.566-14.129-222.571c50.281,12.177,83.149,48.884,78.129,111.483c-0.45,4.805-1.364,9.551-1.963,14.341
|
||||
c4.373-3.68,46.006-80.086,40.829-149.831c-2.328-35.437-11.496-82.418-47.004-80.808c-15.512,2.457-19.603,12.066-29.662,15.36
|
||||
c-26.231,8.804-40.365-43.123,11.029-60.757c6.946-2.229,14.084-4.331,21.455-4.192c0.01-31.06,0.073-537.774-0.04-562.953
|
||||
C190.727,123.194,184.282,112.876,184.653,96.295 M396.031,1642.443c66.063,10.096,95.962-36.85,72.859-69.235
|
||||
c-1.117-1.913-3.658-1.577-5.517-2.096c-170.088-34.001-211.965-148.234-205.194-199.84c1.73-71.28,57.756-112.691,104.834-103.786
|
||||
c60.029,9.739,75.038,74.317,29.731,83.428c-6.238,1.475-20.58,2.308-28.099-4.123c-7.742-7.076-7.962-21.503-6.946-31.901
|
||||
c-37.145,11.637-45.122,83.882,9.803,110.628c113.072,56.562,191.342-87.317,141.392-172.651
|
||||
c-23.058-40.393-81.69-75.012-149.945-74.075C108.948,1186.177,135.34,1608.76,396.031,1642.443z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
6
pyPhotoAlbum/frames/corners/floral_corner.svg
Normal file
|
After Width: | Height: | Size: 32 KiB |
522
pyPhotoAlbum/frames/corners/floral_flourish.svg
Normal file
@ -0,0 +1,522 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="120.2629 30.938 896.7742 728.0339" enable-background="new 120.2629 30.938 896.7742 728.0339" xml:space="preserve">
|
||||
<g>
|
||||
|
||||
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="-1080.8132" y1="8.0304" x2="-1080.8132" y2="776.3375" gradientTransform="matrix(-1 0 0 -1 -212.8987 792)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_1_)" d="M887.6567,523.3495c-16.137-11.164-32.957-20.432-47.223-26.9
|
||||
c-7.1289-15.531-16.168-41.627-11.465-72.766c6.3125-41.775,24.479-67.732,57.168-81.688
|
||||
c24.383-10.408,52.9821-8.3306,78.465,5.7022c10.8361,5.9668,19.912,14.484,26.936,24.746
|
||||
c-4.6406,5.5117-10.207,10.029-16.2791,13.287c-17.0291,9.1406-46.418,8.1289-57.965,6.6602
|
||||
c-0.2773-0.043-1.3047-0.2051-2.8066-0.4258c-0.2695-0.0508-0.5176-0.0996-0.7344-0.1484l-0.0098,0.041
|
||||
c-3.7168-0.5332-9.623-1.3164-14.525-1.6465c-1.0234-0.0703-2.0586-0.123-3.0762-0.1621
|
||||
c-10.799-0.3965-18.965,1.0449-23.828,2.2871c5.1758-3.1816,15.377-7.5859,31.406-6.7969c8.4746,0.4141,17.084,2.7754,17.17,2.7988
|
||||
l0.7441,0.207l0.3184-0.707c0.1758-0.3848,4.2246-9.5527-1.7324-15.789c-3.1777-3.3247-8.127-5.2017-14.312-5.4292
|
||||
c-12.57-0.4639-27.473,5.9546-37.082,15.972c-7.873,8.207-18.135,22.557-18.236,22.701l-1.5586,2.1836l2.5586-0.791
|
||||
c0.1387-0.043,14.328-4.3965,22.896-3.791c9.7988,0.6973,17.693,4.4238,23.457,7.1465c2.9004,1.3691,5.1914,2.4512,6.9023,2.7305
|
||||
c1.248,0.2031,2.4668,0.3281,3.6191,0.3691c4.8418,0.1797,8.7012-1.0391,11.158-3.5254c1.8262-1.8496,2.7246-4.2695,2.6035-7
|
||||
c-0.1738-3.8262-1.5469-6.5098-2.7227-8.1348c2.582,0.2695,5.6953,0.498,9.1641,0.625c14.15,0.5215,34.152-0.6426,47.4431-7.7773
|
||||
c6.3184-3.3906,11.8979-7.8945,16.52-13.246c14.283,21.924,19.432,51.359,12.631,80.906
|
||||
c-8.6328,37.512-44.062,64.533-84.244,64.252c-36.223-0.2656-67.562-16.895-72.91-38.713
|
||||
c-3.7637-15.354,2.3691-24.92,7.5488-29.959l0.0254,0.0117l0.0645-0.1035c0.5957-0.5742,1.1777-1.0859,1.7285-1.5449
|
||||
c2.8086-1.8633,9.2441-5.5059,15.807-6.8203c3.9023-0.7813,7.0644-1.2832,10.41-1.8144c4.3496-0.6895,8.8496-1.4004,15.631-2.8164
|
||||
c5.6602-1.1836,10.158-2.918,13.488-4.5469c-3.1035,2.7109-6.9863,5.5938-10.473,6.6992l-1.6777,0.5352
|
||||
c-5.2676,1.6836-8.748,2.7949-17.201,4.209c-11.451,1.9121-19.67,6.2305-22.549,11.844c-1.2578,2.4551-1.3672,5.0059-0.3047,7.1816
|
||||
c2.043,4.1836,7.7832,8.8184,15.555,9.1035c4.0254,0.1484,8.0352-0.8809,11.92-3.0625c36.307-20.379,43.799-52.762,43.869-53.086
|
||||
l0.8613-3.9121l-2.4551,3.1602c-0.0508,0.0664-5.2461,6.6504-13.275,7.8477c-2.4941,0.3711-5.7891,0.4746-10.076,0.3164
|
||||
c-5.9394-0.2168-12.641-0.9023-18.551-1.5059c-2.0371-0.209-3.9707-0.4063-5.7344-0.5703
|
||||
c-13.723-1.2734-23.355,1.1894-28.506,7.2832c-3.9004,4.6172-4.4121,10.596-3.5879,14.652c0.6895,3.3887,2.4492,5.2168,3.502,6.041
|
||||
c-5.4219,5.5-11.617,15.609-7.7734,31.289c5.3574,21.865,35.566,38.709,71.049,40.016c1.1953,0.0449,2.3984,0.0703,3.6035,0.0781
|
||||
c41.0179,0.2871,77.191-27.328,86.016-65.658c5.2832-22.953,3.7148-45.812-4.5371-66.105
|
||||
c-2.4004-5.9062-5.332-11.459-8.7188-16.578c4.0332-4.9663,7.2734-10.602,9.5352-16.73c5.5-14.891,4.834-31.301-1.9297-47.455
|
||||
c-7.7324-18.471-23.158-27.004-34.736-30.91c-13.209-4.456-28.936-5.2168-41.045-1.9829
|
||||
c-13.154,3.5132-26.213,11.133-35.379,17.344c8.5-7.2988,16.848-13.414,24.262-18.844c0.5332-0.3906,1.0606-0.7759,1.5879-1.1626
|
||||
c6.459-4.0015,27.248-16.797,42.1541-25.012c11.25-6.2007,25.412-16.56,29-30.694c0.8828-3.4795,3.2715-15.614-3.0625-25.656
|
||||
c-2.6211-4.1582-6.3281-7.2202-11.051-9.1616c1.957-2.3613,3.8008-4.686,5.5293-6.9717c14.191-18.764,18.873-37.575,20.082-51.382
|
||||
c1.2148,0.7315,3.1914,1.6401,5.6563,1.7305c2.8086,0.104,5.5313-0.876,8.0898-2.9106c4.8496-3.8599,6.0352-12.814,2.6445-19.961
|
||||
c-5.9023-12.432-15.283-21.835-22.137-28.7c-2.1426-2.1484-3.9922-4.0029-5.4609-5.6636l-3.0273-3.4228l1.498,4.3223
|
||||
c0.0352,0.1016,3.4941,10.234,1.5137,17.759c-1.5293,5.8066-2.584,7.8555-4.502,11.574c-0.7031,1.3599-1.498,2.9014-2.4766,4.9248
|
||||
c-2.8906,5.977-3.5,11.614-1.6699,15.467c1.2168,2.5644,3.4746,4.2998,6.5254,5.0176c0.5664,0.1333,1.1973,0.2129,1.875,0.2383
|
||||
c2.9551,0.1089,5.9922-0.8457,6.1211-0.8862l0.4746-0.1509l0.125-0.4824c0.5234-2.0376,1.2617-6.0317,1.2168-14.041
|
||||
c-0.0098-1.9228,0.3887-14.694-2.5098-27.918c4.6328,10.495,4.375,43.917,4.457,39.991c-0.7637,13.745-4.9551,33.593-19.934,53.4
|
||||
c-1.832,2.4209-3.7949,4.8862-5.8848,7.394c-1.2734-0.4258-2.6133-0.7773-4.0176-1.0537c-0.3613-0.0708-0.7285-0.1299-1.0977-0.186
|
||||
c0.0215-4.8535,0.0039-10.68-0.0156-17.41c-0.0176-6.0903-0.0371-12.909-0.0313-20.393c0.0098-16.619,0.4004-29.825,2.5117-55.891
|
||||
c2.373-29.333,11.838-51.479,11.934-51.699l1.8203-4.2051l-3.2793,3.1948c-0.9746,0.9526-23.986,23.555-30.176,44.466
|
||||
c-5.4961,18.577-3.8594,39.297-3.4141,43.785c-2.6699,0.4761-10.564,2.5776-16.4301,11.286
|
||||
c-6.0606,8.999-1.293,23.991-1.0898,24.625l0.9765,2.9976l0.7598-3.0586c0.0215-0.0791,2.0391-7.9541,7.8887-11.056
|
||||
c3.0078-1.5952,6.5527-1.6572,10.537-0.1812c3.5098,1.2988,13.688,7.3154,15.561,33.27c-13.799-0.9946-46.008,6.3477-70.326,36.879
|
||||
c3.9102-4.9927,7.3789-14.938,27.74-30.707c8.4316-6.5317,18.264-7.8892,18.264-7.8892c1.1367-3.2876-6.3691-17.875-21.438-12.273
|
||||
c-12.324,4.582-16.855,12.851-21.545,24.757c-3.2168,8.1719-5.4668,15.952-9.1914,28.84c-1.3516,4.6768-2.8828,9.978-4.7441,16.287
|
||||
l-1.4258,4.8286l3.0137-4.0273c0.1133-0.1514,5.252-7.3745,19.725-15.907c4.1856-2.4683,17.297-8.1352,22.029-9.2612
|
||||
c9.1719-2.1831,16.174-2.5225,19.846-5.5674c2.8105-2.3306,4.4844-5.5024,4.709-8.9316c0.2031-3.1079-0.7891-6.0884-2.7227-8.1782
|
||||
c-2.082-2.2471-4.293-3.3369-7.1113-3.5244c6.4824-1.9678,15.619-4.1001,23.295-3.4971c0.1387,2.4917,0.2012,5.1562,0.1777,8.0083
|
||||
c-13.08,14.355-30.537,30.826-50.672,48.077c-12.467,10.68-48.738,44.198-72.203,96.934
|
||||
c-11.709,26.317-15.332,39.254-19.951,57.201c-1.459,5.6641-2.8398,13.082-3.4727,20.01c-0.207,2.2832-1.8496,16.404-0.002,34.145
|
||||
c1.8652,17.945,6.5996,31.643,14.631,46.486c-21.939-9.2871-36.643-9.3301-48.248-10.055
|
||||
c-32.658-2.0391-52.726,8.5039-61.72,29.123c-5.4033,12.389-4.5,24.01,1.0869,35.9c3.3643,7.1602,10.064,11.18,17.334,13.498
|
||||
c8.7446,2.793,16.02-0.4844,19.534-2.6445c0.8672,3.2246,3.9492,10.986,13.627,11.344c0.3984,0.0156,0.8105,0.0156,1.2324,0.0059
|
||||
c13.764-0.377,17.842-10.854,21.119-19.273c0.8262-2.1211,1.6055-4.127,2.4824-5.8398l0.1152-0.2285
|
||||
c6.7012-13.107,9.7598-19.092,21.262-22.375c6.0449-1.7285,13.666-2.1699,13.74-2.1758l3.4609-0.1914l-3.1133-1.5273
|
||||
c-0.5137-0.25-12.666-6.1719-21.795-7.7891c-6.4883-1.1504-12.277-1.8066-17.695-2.0059
|
||||
c-5.0879-0.1895-9.9414,0.0371-14.836,0.6855c-15.584,2.0664-23.102,7.7285-28.398,12.994
|
||||
c-4.875,4.8457-8.6128,13.119-4.4844,21.801c2.4648,5.1856,9.5,9.4707,9.7988,9.6504l0.3145,0.1895l0.3594-0.084
|
||||
c0.3828-0.0918,3.916-1.1582,9.8047-10.352c2.416-3.7754,8.3125-10.816,13.096-14.217c4.8555-3.4512,10.521-5.4082,15.316-6.5215
|
||||
c-0.3496,0.1523-0.7051,0.3125-1.0664,0.4805c-0.4297,0.2012-0.8535,0.3926-1.2793,0.582
|
||||
c-5.1328,2.2988-10.441,4.6777-23.02,22.174c-4.4883,6.2402-9.9375,10.402-9.9922,10.443l-0.2754,0.2051
|
||||
c-2.8672,1.8887-10.127,5.6992-18.759,2.9453c-6.7812-2.166-12.225-7.0547-15.327-13.771
|
||||
c-4.1641-9.0117-5.8398-22.045-0.2314-33.775c8.2344-17.229,23.646-26.377,56.033-26.543
|
||||
c10.865-0.0547,30.229,1.8496,52.756,11.723c1.3711,2.3906,3.7227,6.9023,5.2539,9.2012c1.0742,1.6152,69.076,108.13,82.693,148.95
|
||||
c16.91,50.688,24.33,92.812,5.0742,103.44l1.8145-0.123c21.67-4.6777,19.443-52.178,1.2344-103.01
|
||||
c-24.691-68.932-68.994-120.9-83.703-146.78c-1.8887-2.9297-2.3828-3.9492-4.377-7.7285c13.576,6.3418,29.008,13.719,43.758,24.844
|
||||
c32.684,24.654,46.0861,42.58,59.084,75.105c13.699,34.275,14.963,51.723,14.066,86.943
|
||||
c-0.6035,23.672-11.3199,69.805-28.109,70.697l2.7832,0.1016c20.881-4.6816,32.105-39.494,33.541-70.293
|
||||
c1.4961-32.117-4.2012-64.631-18.553-95.975c-14.8-32.2-31.7-48.67-61.61-69.39L887.6567,523.3495z M982.6667,192.5796
|
||||
c4.7246,7.4936,4.5625,17.436,2.8359,24.241c-3.4258,13.504-17.182,23.524-28.1169,29.553
|
||||
c-8.4844,4.6753-18.865,10.828-27.438,16.007c1.1211-0.9297,2.2109-1.855,3.2383-2.7759c13.639-12.22,28.078-28.297,31.746-54.893
|
||||
c0.4004-2.9141,0.6738-5.9526,0.8086-9.0322c0.1523-1.1787,0.2617-2.8867,0.3379-5.0913c2.043-2.2583,3.9902-4.4854,5.8418-6.6807
|
||||
c4.7,1.79,8.4,4.69,10.9,8.67L982.6667,192.5796z M966.1667,182.3696c0.2539,0.042,0.5098,0.0786,0.7598,0.1279
|
||||
c1.0762,0.2119,2.1094,0.4761,3.1016,0.7803c-1.2637,1.4912-2.5762,2.998-3.9297,4.5176v-5.43L966.1667,182.3696z
|
||||
M847.6767,331.0195c25.152-51.59,53.029-77.839,65.832-89.074c19.3051-16.936,37.5291-35.109,50.479-49.119
|
||||
c-0.0215,0.895-0.0527,1.8018-0.0918,2.7319c-0.1328,3.0371-0.4004,6.0322-0.7949,8.9033c-3.5879,26.014-17.766,41.789-31.16,53.79
|
||||
c-4.7578,4.2617-10.631,8.626-16.889,13.212c-1.207,0.7476-1.9004,1.1807-1.9434,1.2065l0.0938,0.1494
|
||||
c-14.523,10.634-32.506,22.65-48.373,42.287c-12.816,15.861-21.521,26.165-33.588,57.312c6.1-19.26,8.76-25.57,16.47-41.4
|
||||
L847.6767,331.0195z M822.6867,412.3695c0.8906-5.25,1.6719-10.775,3.209-16.295l0.0078,0.002
|
||||
c0.0039-0.0137,0.0234-0.0938,0.0547-0.2207c7.5781-27.081,22.033-56.765,40.232-78.393c14.195-16.869,36.232-33.77,59.859-40.081
|
||||
c21.879-5.8442,60.418,0.2354,73.652,31.843c10.146,24.24,5.0332,46.57-7.0508,61.845c-7.3145-10.55-16.6021-19.137-27.184-24.963
|
||||
c-25.973-14.302-55.145-16.409-80.039-5.7812c-16.062,6.8564-28.406,16.28-37.74,28.811
|
||||
c-10.389,13.943-18.254,30.246-21.666,52.822c-4.1445,27.439-0.875,41.41,4.1465,57.277
|
||||
c-5.5644-16.051-6.6074-20.389-7.7832-34.848c-1.49-18.4,0.06-30.6,0.3-32.03L822.6867,412.3695z"/>
|
||||
|
||||
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="-1083.3602" y1="8.0304" x2="-1083.3602" y2="776.3354" gradientTransform="matrix(-1 0 0 -1 -212.8987 792)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_2_)" d="M895.0468,653.3495c-7.2559-5.9922-17.59-13.248-27.584-20.268
|
||||
c-10.689-7.5078-20.867-14.656-26.676-19.793c-2.6934-3.1387-5.6543-7.3594-8.4727-12.965
|
||||
c-2.0606-5.1543-3.8691-11.217-5.3359-16.834c3.8242,9.2109,9.1211,19.686,15.174,25.275
|
||||
c3.5508,3.2754,7.8555,5.0977,12.451,5.2695c7.8945,0.291,15.178-4.4805,16.945-11.096c2.7969-10.477-2.2344-18.893-16.312-27.285
|
||||
c-1.1523-0.6855-2.2676-1.3457-3.3477-1.9863c-10.969-6.4883-18.215-10.777-23.34-19.176
|
||||
c-4.2168-6.9102-7.0391-23.127-7.0684-23.291l-0.5938-3.4492l-1.1484,3.3066c-0.0723,0.2051-7.1348,20.604-10.883,37.168
|
||||
c-1.2383,5.4707-3.373,18.004-3.5293,30.279c-0.1523,11.867,2.2109,22.33,6.4844,28.703c1.5644,2.3359,7.4453,9.9844,17.381,10.35
|
||||
c2.1348,0.0801,4.3125-0.2012,6.4785-0.834c11.539-3.3691,14.426-10.637,15.109-13.295c4.7344,3.4727,10.123,7.2578,15.646,11.137
|
||||
c9.9668,7,20.271,14.236,27.473,20.182c17.137,14.145,26.83,28.451,34.516,57.582c11.121,42.156-2.5918,46.002-2.5859,46.26
|
||||
l1.0137,0.1133c11.9771-7.2266,9.6074-25.158,5.0254-46.818c-6.2-29.98-19.3-44.12-36.72-58.52L895.0468,653.3495z"/>
|
||||
|
||||
<linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="-1128.5793" y1="8.0304" x2="-1128.5793" y2="776.3384" gradientTransform="matrix(-1 0 0 -1 -212.8987 792)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_3_)" d="M948.0667,731.4296c0.0313-0.1367,2.7715-4.4941,5.7246-24.129
|
||||
c1.2852-8.5527,2.0664-20.654,1.9512-25.713c-0.6738-29.553-3.9863-45.898-18.449-77.033
|
||||
c-22.656-48.785-61.686-68.453-61.686-68.453s22.504,14.578,33.693,37.957c4.4102,9.2207,7.2617,21.076,8.7988,29.814
|
||||
c0.7578,4.2969,0.8125,9.9219,3.377,16.66c4.7031,12.359,16.303,32.861,20.084,48.09c7.1,29.08,6.4,62.8,6.4,62.8
|
||||
L948.0667,731.4296z"/>
|
||||
|
||||
<linearGradient id="SVGID_4_" gradientUnits="userSpaceOnUse" x1="-1200.0969" y1="-67.3895" x2="-1200.0969" y2="907.8705" gradientTransform="matrix(-1 0 0 -1 -212.8987 792)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_4_)" d="M1008.8668,508.6495l3.0723-4.6523l-4.3809,3.4375c-0.1953,0.1523-19.572,15.455-32.545,34.041
|
||||
c-6.9043,9.8926-15.6071,26.627-11.496,57.619c1.1426,8.6191,2.2285,14.58,3.5156,19.766
|
||||
c12.426,50.041,6.4258,70.041,6.4258,70.041s7.1123-38.802,6.666-58.995c-0.2539-11.492-1.6992-19.513-1.7891-29.157
|
||||
c-0.0605-6.627-0.125-13.482,0.6934-20.758c1.6602-14.709,5.1738-26.893,7.9473-34.775c-1.8203,9.0527-4.1602,22.227-4.584,31.951
|
||||
c-0.3496,8.0215,0.0898,14.385,0.5117,20.537c0.5547,8.0938,2.1602,19.493,2.0547,30.369
|
||||
c-0.334,34.495-10.667,70.333-10.667,70.333s15.152-24.767,22.5-49.5c4.5674-15.373,7.2471-38.265,3.8057-58.909
|
||||
c-2.7578-16.529-4.9355-36.346-0.1895-57.979c3.4-15.63,8.4-23.3,8.5-23.37L1008.8668,508.6495z"/>
|
||||
|
||||
<linearGradient id="SVGID_5_" gradientUnits="userSpaceOnUse" x1="-998.1218" y1="8.0204" x2="-998.1218" y2="776.3414" gradientTransform="matrix(-1 0 0 -1 -212.8987 792)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_5_)" d="M751.7667,448.5796c3.0098-1.9727,7.8574-3.0352,12.969-2.8477
|
||||
c4.377,0.1602,8.5898,1.207,12.182,3.0254c6.3438,3.209,11.037,8.002,16.006,13.076c2.457,2.5078,4.9961,5.1016,7.793,7.5449
|
||||
c10.066,8.7949,18.037,13.512,18.117,13.561l2.2598,1.3281l-0.9648-2.4453c-0.0371-0.0957-3.8144-9.7441-6.0547-22.213
|
||||
c-5.0664-28.221-4.6875-40.334-1.0781-60.305c1.6309-9.0254,5.6289-20.043,9.8633-31.709
|
||||
c3.9707-10.948,8.0781-22.269,10.584-32.915c6.7949-28.878-9.3496-54.282-9.5117-54.534l-2.3535-3.6377l0.7031,4.2788
|
||||
c0.0254,0.1479,2.4199,14.868,1.2109,24.768c-1.6758,13.729-3.0859,19.245-8.8672,34.718
|
||||
c-1.7871,4.7832-4.2383,9.2188-6.8301,13.915c-5.6894,10.301-12.137,21.978-14.945,42.494
|
||||
c-2.6504,19.359,1.4922,35.984,4.541,44.898c-4.8652-6.2344-13.207-15.242-21.916-17.031
|
||||
c-2.2695-0.4648-4.4844-0.7422-6.582-0.8203c-10.648-0.3906-19.08,4.2168-25.772,14.09c-6.0478,8.9238-7.3975,24.15-7.4512,24.795
|
||||
l-0.335,3.9629l2.0117-3.4277c0.05-0.09,5.01-8.41,14.42-14.57L751.7667,448.5796z"/>
|
||||
|
||||
<linearGradient id="SVGID_6_" gradientUnits="userSpaceOnUse" x1="-1059.9657" y1="8.0304" x2="-1059.9657" y2="776.3395" gradientTransform="matrix(-1 0 0 -1 -212.8987 792)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_6_)" d="M856.2968,289.8695c8.7832-21.179,10.922-45.943,12.801-76.116
|
||||
c2.041-32.708,21.543-49.919,21.74-50.089l3.7051-3.2017l-4.6016,1.6626c-0.2988,0.1084-30.117,11.15-40.463,37.731
|
||||
c-2.9648,7.6221-2.0273,18.878-1.5039,23.263c-2.916-1.5288-9.4668-4.4551-17.334-4.7451
|
||||
c-1.4824-0.0547-2.9609-0.0107-4.3906,0.1309c-20.393,2.0176-26.082,20.516-26.137,20.703l-0.5215,1.7671l1.7129-0.6655
|
||||
c0.0684-0.0259,6.7637-2.585,14.959-2.2827c8.4883,0.3125,19.975,3.8267,26.486,18.792"/>
|
||||
|
||||
<linearGradient id="SVGID_7_" gradientUnits="userSpaceOnUse" x1="-1181.3455" y1="8.0304" x2="-1181.3455" y2="776.3375" gradientTransform="matrix(-1 0 0 -1 -212.8987 792)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_7_)" d="M996.2668,217.6096c0,0,1.1719,19.598-12.859,32.867c-9.6035,9.0815-53.432,19.486-53.432,19.486
|
||||
s18.441-2.8774,30.1331-3.0195c12.355-0.1484,28.031,2.2422,39.104,15.141c4.8594,5.6631,7.6641,9.9522,7.6641,9.9522
|
||||
s0.5781-9.5391-2.6113-16.313c-5.3086-11.276-14.7321-14.344-14.7321-14.344s3.6563-1.1738,8.2012-10.072
|
||||
c6.1-12.12-1.5-33.71-1.5-33.71L996.2668,217.6096z"/>
|
||||
|
||||
<linearGradient id="SVGID_8_" gradientUnits="userSpaceOnUse" x1="-1130.5813" y1="8.0304" x2="-1130.5813" y2="776.3375" gradientTransform="matrix(-1 0 0 -1 -212.8987 792)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_8_)" d="M856.1967,355.4395c0,0,22.207-22.299,51.486-23.94c22.107-1.2383,32.951,0.9126,46.9059,6.4131
|
||||
c8.5195,3.3589,10.699,4.9731,10.699,4.9731s8.2129-3.4805,11.359-10.959c7.7578-18.45-4.8594-31.607-4.8594-31.607
|
||||
s-9.998,19.097-30.4919,18.216c-24.66-1.0596-33.445,1.1504-44.785,5.4507c-22.13,8.4-40.29,31.46-40.29,31.46L856.1967,355.4395z"
|
||||
/>
|
||||
|
||||
<linearGradient id="SVGID_9_" gradientUnits="userSpaceOnUse" x1="-1174.0514" y1="8.0304" x2="-1174.0514" y2="776.3375" gradientTransform="matrix(-1 0 0 -1 -212.8987 792)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_9_)" d="M1004.6667,420.8896c0,0,6.4766,32.723-16.9139,62.533c-18.324,23.355-38.486,29.982-49.66,31.562
|
||||
c-6.3594,0.8984-8.0059,0.6113-8.0059,0.6113c-2.9863-1.6172-7.2305-4.0449-11.564-13.117
|
||||
c-4.4219-9.2559,1.1445-21.414,1.1445-21.414s14.932,10.25,29.324,7.9102c14.395-2.3418,28.068-5.2812,39.965-21.826
|
||||
c13.4-18.53,15.8-46.25,15.8-46.25L1004.6667,420.8896z"/>
|
||||
<path fill="#1B2851" d="M766.9367,413.7395c0,0-0.3809,0.3477-0.9785,0.9746c0.0449-0.043,0.0977-0.0918,0.1406-0.1328
|
||||
c0.36-0.34,0.76-0.73,0.84-0.84L766.9367,413.7395z"/>
|
||||
<path fill="#1B2851" d="M766.0167,426.3695c0,0-0.3809,0.3477-0.9785,0.9746c0.0449-0.043,0.0977-0.0918,0.1387-0.1328
|
||||
c0.35-0.34,0.76-0.72,0.84-0.84L766.0167,426.3695z"/>
|
||||
|
||||
<linearGradient id="SVGID_10_" gradientUnits="userSpaceOnUse" x1="-876.9413" y1="1101.4695" x2="-876.9413" y2="1869.7794" gradientTransform="matrix(0.022 -0.9998 0.9998 0.022 -985.1675 -309.9955)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_10_)" d="M356.8567,616.4695c11.516-15.887,21.152-32.499,27.933-46.619
|
||||
c15.685-6.7852,41.974-15.247,73.001-9.8594c41.626,7.2305,67.177,25.963,80.409,58.953c9.8691,24.605,7.1621,53.152-7.4277,78.32
|
||||
c-6.2041,10.701-14.92,19.588-25.333,26.383c-5.4082-4.7598-9.8018-10.424-12.926-16.566
|
||||
c-8.7637-17.228-7.1045-46.586-5.3818-58.099c0.0488-0.2764,0.2334-1.2998,0.4873-2.7959
|
||||
c0.0566-0.2686,0.1104-0.5156,0.1641-0.7314l-0.041-0.0107c0.6152-3.7041,1.5283-9.5918,1.9668-14.485
|
||||
c0.0928-1.0215,0.168-2.0557,0.2295-3.0713c0.6338-10.788-0.627-18.984-1.7617-23.873c3.0664,5.2441,7.2451,15.54,6.1035,31.548
|
||||
c-0.6006,8.4639-3.1504,17.019-3.1758,17.104l-0.2236,0.7393l0.6992,0.334c0.3809,0.1846,9.458,4.4336,15.824-1.3838
|
||||
c3.3936-3.1045,5.3789-8.0117,5.7433-14.19c0.7402-12.558-5.3486-27.597-15.152-37.425c-8.0312-8.0518-22.151-18.627-22.294-18.731
|
||||
l-2.1494-1.6064l0.7354,2.5762c0.04,0.1387,4.0801,14.421,3.2861,22.975c-0.9131,9.7803-4.8125,17.591-7.6621,23.293
|
||||
c-1.4326,2.8701-2.5644,5.1367-2.8809,6.8408c-0.2314,1.2432-0.3828,2.459-0.4492,3.6104
|
||||
c-0.2861,4.8359,0.8477,8.7217,3.2783,11.232c1.8096,1.8672,4.209,2.8184,6.9414,2.7578c3.8291-0.0898,6.543-1.4033,8.1934-2.543
|
||||
c-0.3271,2.5752-0.624,5.6826-0.8271,9.1484c-0.833,14.135-0.1094,34.158,6.7305,47.603c3.251,6.3916,7.6309,12.069,12.88,16.808
|
||||
c-22.233,13.797-51.775,18.295-81.165,10.846c-37.312-9.457-63.547-45.473-62.382-85.639
|
||||
c1.0635-36.208,18.379-67.174,40.309-72.039c15.434-3.4258,24.862,2.917,29.786,8.206l-0.0117,0.0254l0.1016,0.0664
|
||||
c0.5605,0.6084,1.0596,1.2012,1.5068,1.7627c1.8008,2.8486,5.3008,9.3623,6.4697,15.952c0.6953,3.919,1.1279,7.0908,1.5859,10.448
|
||||
c0.5928,4.3633,1.2041,8.8779,2.4707,15.688c1.0596,5.6846,2.6943,10.22,4.249,13.585c-2.6426-3.1621-5.4385-7.1074-6.4668-10.617
|
||||
l-0.499-1.6894c-1.5664-5.3027-2.6006-8.8066-3.8281-17.289c-1.6602-11.49-5.7959-19.803-11.345-22.805
|
||||
c-2.4268-1.3115-4.9746-1.4766-7.1738-0.4629c-4.2275,1.9512-8.9873,7.5879-9.4434,15.352
|
||||
c-0.2363,4.0205,0.7041,8.0527,2.7998,11.984c19.574,36.746,51.784,44.949,52.107,45.027l3.8916,0.9473l-3.1055-2.5244
|
||||
c-0.0654-0.0518-6.5332-5.3916-7.5537-13.445c-0.3164-2.501-0.3467-5.7978-0.0947-10.081c0.3477-5.9336,1.1807-12.617,1.915-18.513
|
||||
c0.2529-2.0322,0.4932-3.96,0.6953-5.7197c1.5762-13.692-0.6738-23.377-6.6533-28.66c-4.5303-4.001-10.496-4.6445-14.569-3.9102
|
||||
c-3.4033,0.6152-5.2695,2.334-6.1172,3.3691c-5.3789-5.542-15.35-11.959-31.11-8.4609c-21.978,4.875-39.482,34.705-41.57,70.15
|
||||
c-0.0713,1.1943-0.123,2.3965-0.1572,3.6016c-1.1904,41.001,25.622,77.773,63.748,87.439c22.832,5.7871,45.72,4.7227,66.189-3.0801
|
||||
c5.958-2.2695,11.573-5.0781,16.767-8.3516c4.876,4.1416,10.4391,7.5059,16.516,9.9014c14.766,5.8272,31.188,5.5215,47.486-0.8838
|
||||
c18.636-7.3242,27.508-22.559,31.667-34.048c4.7461-13.108,5.8525-28.813,2.8862-40.991
|
||||
c-3.2227-13.229-10.553-26.452-16.562-35.752c7.1104,8.6582,13.04,17.139,18.305,24.67c0.3789,0.541,0.7524,1.0781,1.127,1.6133
|
||||
c3.8584,6.5459,16.194,27.611,24.078,42.695c5.9512,11.383,15.996,25.77,30.048,29.668c3.459,0.959,15.538,3.6152,25.717-2.4961
|
||||
c4.2148-2.5293,7.3574-6.168,9.4023-10.847c2.3184,2.0088,4.6016,3.9033,6.8486,5.6807c18.446,14.602,37.149,19.697,50.928,21.209
|
||||
c-0.7578,1.1982-1.7109,3.1543-1.8555,5.6172c-0.166,2.8057,0.7539,5.5488,2.7324,8.1514c3.752,4.9336,12.678,6.3154,19.898,3.084
|
||||
c12.559-5.6279,22.166-14.8,29.18-21.499c2.1953-2.0957,4.0898-3.9043,5.7832-5.3359l3.4883-2.9512l-4.3535,1.4023
|
||||
c-0.1035,0.0332-10.309,3.2686-17.789,1.123c-5.7715-1.6572-7.7969-2.7568-11.473-4.7559
|
||||
c-1.3438-0.7334-2.8672-1.5625-4.8691-2.584c-5.9121-3.0225-11.533-3.7559-15.426-2.0107
|
||||
c-2.5898,1.1602-4.375,3.3789-5.1602,6.4131c-0.1445,0.5635-0.2383,1.1924-0.2793,1.8691
|
||||
c-0.1738,2.9522,0.7129,6.0098,0.752,6.1396l0.1387,0.4775l0.4805,0.1357c2.0254,0.5684,6.002,1.3945,14.012,1.5264
|
||||
c1.9219,0.0322,14.682,0.7109,27.967-1.8945c-10.596,4.4004-44.004,3.4062-40.08,3.5752
|
||||
c-13.724-1.0664-33.475-5.6943-52.949-21.104c-2.3789-1.8848-4.8008-3.9023-7.2617-6.0469
|
||||
c0.4531-1.2637,0.834-2.5957,1.1416-3.9922c0.0791-0.3604,0.1455-0.7266,0.21-1.0938c4.8516,0.1279,10.678,0.2383,17.406,0.3672
|
||||
c6.0889,0.1172,12.906,0.2471,20.389,0.418c16.615,0.375,29.808,1.0566,55.822,3.7422c29.273,3.0176,51.205,12.968,51.424,13.068
|
||||
l4.1641,1.9121l-3.123-3.3477c-0.9297-0.9961-23.02-24.5-43.791-31.148c-18.451-5.9043-39.203-4.7227-43.697-4.377
|
||||
c-0.418-2.6797-2.3457-10.619-10.921-16.675c-8.8633-6.2568-23.957-1.8203-24.595-1.6318l-3.0186,0.9111l3.041,0.8271
|
||||
c0.0781,0.0225,7.9082,2.2129,10.88,8.1289c1.5283,3.043,1.5127,6.5879-0.0508,10.539c-1.376,3.4805-7.6143,13.523-33.604,14.824
|
||||
c1.2988-13.773-5.332-46.137-35.321-71.121c4.9053,4.0195,14.771,7.707,30.089,28.41c6.3438,8.5732,7.4854,18.433,7.4854,18.433
|
||||
c3.2617,1.209,18.011-5.9736,12.741-21.162c-4.3086-12.422-12.476-17.134-24.275-22.085
|
||||
c-8.0996-3.3965-15.828-5.8164-28.631-9.8242c-4.6465-1.4531-9.9121-3.1016-16.179-5.1016l-4.7959-1.5312l3.96,3.1016
|
||||
c0.1484,0.1172,7.2568,5.4121,15.468,20.07c2.3755,4.2383,7.7529,17.471,8.7739,22.228c1.981,9.2178,2.166,16.226,5.1289,19.964
|
||||
c2.2686,2.8613,5.4033,4.6035,8.8262,4.9043c3.1025,0.2715,6.1045-0.6553,8.2363-2.543c2.292-2.0312,3.4307-4.2178,3.6797-7.0312
|
||||
c1.8252,6.5244,3.7549,15.705,2.9834,23.366c-2.4941,0.084-5.1592,0.0869-8.0098,0.001c-14.064-13.393-30.146-31.208-46.95-51.718
|
||||
c-10.403-12.699-43.115-49.7-95.321-74.319c-26.053-12.286-38.907-16.193-56.748-21.206c-5.6309-1.583-13.017-3.1279-19.929-3.9121
|
||||
c-2.2773-0.2568-16.359-2.21-34.136-0.7539c-17.982,1.4697-31.78,5.9014-46.797,13.604c9.7676-21.73,10.134-36.429,11.114-48.016
|
||||
c2.7578-32.605-7.3408-52.9-27.758-62.347c-12.267-5.6748-23.904-5.0283-35.915,0.2959c-7.2324,3.206-11.398,9.8164-13.877,17.033
|
||||
c-2.9844,8.6816,0.1318,16.026,2.2148,19.587c-3.2441,0.7959-11.071,3.706-11.643,13.374
|
||||
c-0.0244,0.3984-0.0332,0.8105-0.0322,1.2324c0.0732,13.768,10.458,18.076,18.805,21.537c2.1016,0.873,4.0898,1.6973,5.7832,2.6113
|
||||
l0.2256,0.1201c12.957,6.9883,18.872,10.178,21.902,21.749c1.5938,6.082,1.8672,13.711,1.8721,13.785l0.1152,3.4639l1.5957-3.0791
|
||||
c0.2617-0.5078,6.4492-12.526,8.2666-21.618c1.293-6.4609,2.0762-12.234,2.3945-17.646c0.3018-5.083,0.1816-9.9404-0.3584-14.847
|
||||
c-1.7227-15.627-7.2178-23.267-12.365-28.678c-4.7373-4.9805-12.927-8.9004-21.697-4.9639
|
||||
c-5.2383,2.3506-9.6777,9.2891-9.8643,9.584l-0.1963,0.3096l0.0762,0.3613c0.083,0.3848,1.0723,3.9414,10.134,10.031
|
||||
c3.7217,2.498,10.631,8.5488,13.925,13.404c3.3438,4.9316,5.1758,10.639,6.1826,15.457c-0.1445-0.3535-0.2969-0.7119-0.457-1.0772
|
||||
c-0.1914-0.4346-0.373-0.8613-0.5537-1.292c-2.1846-5.1816-4.4463-10.541-21.661-23.502c-6.1406-4.624-10.182-10.164-10.221-10.22
|
||||
l-0.1992-0.2803c-1.8252-2.9072-5.4746-10.25-2.5312-18.818c2.3144-6.7324,7.3213-12.066,14.104-15.021
|
||||
c9.1016-3.9639,22.169-5.3525,33.772,0.5127c17.044,8.6123,25.851,24.222,25.304,56.604c-0.1846,10.863-2.5146,30.18-12.882,52.484
|
||||
c-2.4199,1.3184-6.9824,3.5703-9.3144,5.0508c-1.6387,1.0371-109.62,66.678-150.74,79.393
|
||||
c-51.048,15.791-93.326,22.281-103.52,2.7959l0.083,1.8174c4.1992,21.768,51.736,20.587,102.96,3.502
|
||||
c69.458-23.168,122.39-66.316,148.58-80.451c2.9717-1.8242,4.001-2.2959,7.8232-4.207c-6.6387,13.434-14.354,28.699-25.801,43.201
|
||||
c-25.368,32.133-43.585,45.137-76.389,57.416c-34.568,12.941-52.039,13.82-87.231,12.148
|
||||
c-23.653-1.124-69.539-12.854-70.062-29.659l-0.1631,2.7803c4.2207,20.979,38.778,32.968,69.538,35.08
|
||||
c32.076,2.2041,64.707-2.7764,96.359-16.434c32.44-14.01,49.28-30.48,70.65-59.95L356.8567,616.4695z M685.4567,718.6995
|
||||
c-7.5967,4.5586-17.532,4.1777-24.298,2.3018c-13.426-3.7227-23.141-17.696-28.927-28.761
|
||||
c-4.4873-8.5859-10.409-19.1-15.399-27.783c0.9053,1.1406,1.8057,2.25,2.7041,3.2978c11.916,13.905,27.672,28.694,54.181,32.947
|
||||
c2.9038,0.4648,5.936,0.8047,9.0112,1.0068c1.1758,0.1787,2.8809,0.3262,5.084,0.4502c2.2119,2.0928,4.3955,4.0879,6.5488,5.9873
|
||||
c-1.87,4.58-4.86,8.11-8.89,10.54L685.4567,718.6995z M696.0267,702.4796c-0.0469,0.2539-0.0889,0.5078-0.1436,0.7578
|
||||
c-0.2354,1.0703-0.5225,2.0977-0.8486,3.082c-1.4629-1.2949-2.9414-2.6406-4.4297-4.0273c1.61,0.07,3.42,0.13,5.42,0.19
|
||||
L696.0267,702.4796z M550.0167,580.7296c51.025,26.282,76.654,54.73,87.603,67.777c16.507,19.674,34.274,38.293,47.997,51.548
|
||||
c-0.8945-0.041-1.8008-0.0928-2.7305-0.1514c-3.0332-0.2002-6.0215-0.5332-8.8833-0.9912
|
||||
c-25.929-4.1602-41.388-18.682-53.09-32.337c-4.1558-4.8506-8.3896-10.818-12.836-17.176
|
||||
c-0.7212-1.2227-1.1392-1.9258-1.1641-1.9688l-0.1514,0.0898c-10.311-14.754-21.929-32.996-41.211-49.293
|
||||
c-15.575-13.162-25.685-22.092-56.558-34.841c19.12,6.49,25.37,9.28,41.0201,17.34L550.0167,580.7296z M469.2367,553.9595
|
||||
c5.2285,1.0059,10.735,1.9082,16.22,3.5674l-0.002,0.0078c0.0137,0.0049,0.0938,0.0254,0.2197,0.0596
|
||||
c26.907,8.1719,56.266,23.277,77.488,41.949c16.552,14.562,32.964,36.967,38.753,60.727c5.3608,22.002-1.5654,60.398-33.458,72.934
|
||||
c-24.458,9.6094-46.669,4.0059-61.675-8.4102c10.709-7.081,19.4991-16.177,25.557-26.629c14.87-25.65,17.618-54.77,7.542-79.892
|
||||
c-6.502-16.21-15.651-28.758-27.974-38.365c-13.711-10.693-29.837-18.915-52.332-22.823c-27.342-4.748-41.381-1.7871-57.354,2.8838
|
||||
c16.169-5.21,20.529-6.1572,35.011-7.0146c18.41-1.11,30.57,0.72,31.99,0.99L469.2367,553.9595z"/>
|
||||
|
||||
<linearGradient id="SVGID_11_" gradientUnits="userSpaceOnUse" x1="-879.4864" y1="1101.4695" x2="-879.4864" y2="1869.7815" gradientTransform="matrix(0.022 -0.9998 0.9998 0.022 -985.1675 -309.9955)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_11_)" d="M226.7267,620.9896c6.1514-7.1221,13.633-17.294,20.87-27.131
|
||||
c7.7412-10.521,15.111-20.54,20.375-26.233c3.1973-2.623,7.4824-5.4902,13.148-8.1856c5.1992-1.9453,11.3-3.6211,16.948-4.9639
|
||||
c-9.294,3.6201-19.882,8.6865-25.604,14.614c-3.3535,3.4775-5.2705,7.7412-5.543,12.332
|
||||
c-0.4648,7.8867,4.1455,15.272,10.721,17.186c10.412,3.0273,18.937-1.8184,27.638-15.708
|
||||
c0.7109-1.1367,1.3955-2.2373,2.0586-3.3037c6.7285-10.822,11.177-17.973,19.686-22.912c7.001-4.0625,23.276-6.5273,23.441-6.5527
|
||||
l3.4609-0.5176l-3.2803-1.2207c-0.2031-0.0781-20.441-7.5879-36.92-11.699c-5.4424-1.3584-17.926-3.7686-30.193-4.1953
|
||||
c-11.862-0.4141-22.374,1.7188-28.839,5.8516c-2.3711,1.5117-10.146,7.2227-10.73,17.148
|
||||
c-0.127,2.1328,0.1064,4.3164,0.6914,6.4951c3.1143,11.61,10.315,14.657,12.959,15.398c-3.5762,4.6572-7.4785,9.96-11.479,15.397
|
||||
c-7.2168,9.8106-14.679,19.953-20.781,27.021c-14.518,16.821-29.034,26.197-58.327,33.24
|
||||
c-42.392,10.189-45.935-3.6045-46.192-3.6045l-0.1357,1.0107c6.9609,12.133,24.941,10.159,46.697,6.0547
|
||||
c30.12-5.69,44.54-18.4,59.32-35.53L226.7267,620.9896z"/>
|
||||
|
||||
<linearGradient id="SVGID_12_" gradientUnits="userSpaceOnUse" x1="-924.6873" y1="1101.4695" x2="-924.6873" y2="1869.7784" gradientTransform="matrix(0.022 -0.9998 0.9998 0.022 -985.1675 -309.9955)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_12_)" d="M147.4967,672.2595c0.1348,0.0342,4.4316,2.8691,23.997,6.2539
|
||||
c8.5215,1.4736,20.603,2.5215,25.663,2.5176c29.561-0.0234,45.976-2.9746,77.421-16.749c49.272-21.577,69.795-60.163,69.795-60.163
|
||||
s-15.07,22.178-38.69,32.85c-9.3144,4.2051-21.23,6.7949-30,8.1396c-4.3125,0.6631-9.9385,0.5947-16.731,3.0088
|
||||
c-12.46,4.4307-33.212,15.576-48.52,19.021c-29.23,6.58-62.93,5.12-62.93,5.12L147.4967,672.2595z"/>
|
||||
|
||||
<linearGradient id="SVGID_13_" gradientUnits="userSpaceOnUse" x1="-996.1837" y1="1026.0594" x2="-996.1837" y2="2001.3094" gradientTransform="matrix(0.022 -0.9998 0.9998 0.022 -985.1675 -309.9955)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_13_)" d="M368.8867,737.9296l4.583,3.1738l-3.3398-4.4551c-0.1475-0.1992-15.021-19.908-33.316-33.287
|
||||
c-9.7373-7.1211-26.276-16.19-57.352-12.762c-8.6426,0.9521-14.626,1.9062-19.838,3.0801
|
||||
c-50.303,11.32-70.166,4.8809-70.166,4.8809s38.636,7.9648,58.834,7.9639c11.494-0.001,19.545-1.2695,29.189-1.1475
|
||||
c6.626,0.0859,13.482,0.1719,20.737,1.1504c14.669,1.9844,26.772,5.7656,34.592,8.7109c-9.0098-2.0186-22.129-4.6484-31.842-5.2852
|
||||
c-8.0127-0.5273-14.383-0.2275-20.544,0.0586c-8.1035,0.377-19.536,1.7305-30.406,1.3857
|
||||
c-34.48-1.0928-70.082-12.213-70.082-12.213s24.428,15.694,48.993,23.585c15.269,4.9043,38.097,8.0869,58.811,5.1016
|
||||
c16.586-2.3936,36.445-4.1348,57.969,1.0869c15.57,3.78,23.12,8.93,23.19,8.98L368.8867,737.9296z"/>
|
||||
|
||||
<linearGradient id="SVGID_14_" gradientUnits="userSpaceOnUse" x1="-794.2529" y1="1101.4695" x2="-794.2529" y2="1869.7834" gradientTransform="matrix(0.022 -0.9998 0.9998 0.022 -985.1675 -309.9955)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_14_)" d="M434.6067,482.2596c1.9053,3.0527,2.8613,7.9219,2.5606,13.027
|
||||
c-0.2559,4.373-1.3955,8.5625-3.292,12.113c-3.3486,6.2715-8.2432,10.857-13.426,15.713c-2.5615,2.4023-5.21,4.8828-7.7148,7.625
|
||||
c-9.0146,9.8711-13.905,17.736-13.956,17.814l-1.3779,2.2305l2.4668-0.9102c0.0957-0.0352,9.8252-3.5996,22.341-5.5644
|
||||
c28.325-4.4443,40.427-3.7988,60.313,0.25c8.9873,1.8281,19.914,6.0684,31.484,10.559c10.858,4.2109,22.085,8.5664,32.674,11.307
|
||||
c28.722,7.4287,54.475-8.1523,54.731-8.3086l3.6885-2.2734l-4.2935,0.6094c-0.1484,0.0215-14.917,2.0918-24.789,0.6641
|
||||
c-13.688-1.9766-19.172-3.5088-34.514-9.6289c-4.7432-1.8926-9.123-4.4404-13.761-7.1348
|
||||
c-10.174-5.9141-21.706-12.617-42.154-15.877c-19.297-3.0762-36.009,0.6992-44.988,3.5508
|
||||
c6.3398-4.7266,15.529-12.868,17.51-21.535c0.5146-2.2588,0.8408-4.4668,0.9648-6.5625c0.625-10.638-3.7959-19.169-13.519-26.076
|
||||
c-8.7881-6.2422-23.981-7.9277-24.625-7.9951l-3.9541-0.4229l3.3818,2.0879c0.08,0.04,8.3,5.18,14.25,14.73L434.6067,482.2596z"/>
|
||||
|
||||
<linearGradient id="SVGID_15_" gradientUnits="userSpaceOnUse" x1="-856.0887" y1="1101.4695" x2="-856.0887" y2="1869.7834" gradientTransform="matrix(0.022 -0.9998 0.9998 0.022 -985.1675 -309.9955)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_15_)" d="M590.9667,590.2496c20.98,9.2471,45.691,11.931,75.816,14.473
|
||||
c32.655,2.7617,49.433,22.638,49.599,22.838l3.1191,3.7754l-1.5615-4.6367c-0.1016-0.3018-10.484-30.355-36.831-41.284
|
||||
c-7.5547-3.1318-18.829-2.4424-23.224-2.0156c1.5923-2.8818,4.6621-9.3662,5.125-17.226c0.0874-1.4805,0.0762-2.96-0.0342-4.3926
|
||||
c-1.5679-20.432-19.936-26.527-20.122-26.586l-1.7554-0.5605l0.6274,1.7275c0.0244,0.0693,2.4356,6.8193,1.9531,15.006
|
||||
c-0.4995,8.4795-4.2656,19.886-19.371,26.065"/>
|
||||
|
||||
<linearGradient id="SVGID_16_" gradientUnits="userSpaceOnUse" x1="-977.4203" y1="1101.4695" x2="-977.4203" y2="1869.7804" gradientTransform="matrix(0.022 -0.9998 0.9998 0.022 -985.1675 -309.9955)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_16_)" d="M660.1367,731.7296c0,0-19.619,0.7402-32.576-13.58c-8.8677-9.8008-18.305-53.848-18.305-53.848
|
||||
s2.4712,18.501,2.355,30.192c-0.1235,12.356-2.8584,27.976-15.998,38.761c-5.7686,4.7334-10.118,7.4434-10.118,7.4434
|
||||
s9.5234,0.7881,16.367-2.252c11.391-5.0586,14.665-14.412,14.665-14.412s1.0928,3.6807,9.8892,8.4199
|
||||
c11.96,6.46,33.72-0.72,33.72-0.72L660.1367,731.7296z"/>
|
||||
|
||||
<linearGradient id="SVGID_17_" gradientUnits="userSpaceOnUse" x1="-926.7069" y1="1101.4695" x2="-926.7069" y2="1869.7804" gradientTransform="matrix(0.022 -0.9998 0.9998 0.022 -985.1675 -309.9955)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_17_)" d="M525.4268,588.7095c0,0,21.806,22.692,22.801,52c0.752,22.13-1.6377,32.924-7.4434,46.754
|
||||
c-3.5459,8.4443-5.208,10.588-5.208,10.588s3.2988,8.2871,10.707,11.598c18.275,8.1621,31.706-4.1621,31.706-4.1621
|
||||
s-18.873-10.416-17.541-30.887c1.6025-24.63-0.4136-33.462-4.4634-44.894c-7.93-22.33-30.57-41-30.57-41L525.4268,588.7095z"/>
|
||||
|
||||
<linearGradient id="SVGID_18_" gradientUnits="userSpaceOnUse" x1="-970.2051" y1="1101.4695" x2="-970.2051" y2="1869.7834" gradientTransform="matrix(0.022 -0.9998 0.9998 0.022 -985.1675 -309.9955)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_18_)" d="M456.7167,735.7296c0,0-32.857,5.7549-62.146-18.286c-22.946-18.835-29.128-39.138-30.461-50.344
|
||||
c-0.7588-6.377-0.4355-8.0176-0.4355-8.0176c1.6836-2.9492,4.2031-7.1387,13.369-11.272c9.3506-4.2168,21.384,1.6162,21.384,1.6162
|
||||
s-10.576,14.702-8.5537,29.143c2.0244,14.442,4.6611,28.178,20.94,40.436c18.25,13.73,45.91,16.72,45.91,16.72L456.7167,735.7296z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 43 KiB |
167
pyPhotoAlbum/frames/corners/ornate_corner.svg
Normal file
@ -0,0 +1,167 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 1770.836 1767.559" enable-background="new 0 0 1770.836 1767.559" xml:space="preserve">
|
||||
<g>
|
||||
<path d="M581.339,577.203c44.942-59.167,42.215-56.099,46.471-56.441c123.901-9.935,230.483-81.742,241.81-94.921
|
||||
c-3.087,1.369-5.2,2.187-7.207,3.213c-129.752,66.308-299.555,62.547-361.954-78.95c-4.085-9.263-3.783-11.716-7.578-10.81
|
||||
c-19.053,4.548-20.912-4.169-26.85,2.614c-40.082,45.784-126.308,24.947-137.509-44.772
|
||||
c-12.43-77.368,53.514-141.431,162.087-109.046c4.33,1.292,4.391,1.328,6.326-2.864c39.507-85.613,139.834-123.431,223.317-86.321
|
||||
c37.392,16.622,37.781,31.949,46.748,28.7c45.624-16.532,96.74-1.747,113.556,50.571c6.239,19.411,1.795,23.955,6.997,23.547
|
||||
c0.119-0.009,114.077-0.006,114.196-0.006c4.172,0,5.993,0.783,7.211-3.192c17.641-57.562-11.78-112.342-12.135-114.747
|
||||
c3.443,1.772,21.983,39.836,23.666,90.662c0.908,27.445-4.043,27.066,2.533,27.266c0.367,0.011,639.538,0.017,639.906,0.017
|
||||
c3.814,0,7.271,0.237,10.243-3.505c2.777-3.496,8.56-2.555,11.588,0.906c7.609,8.697-6.286,19.623-12.229,11.579
|
||||
c-3.485-4.717,39.974-3.161-647.676-3.161c-6.368,0-6.188-0.251-6.876,4.487c-3.417,23.52-11.87,58.076-35.57,107.404
|
||||
c-88.591,184.391-331.918,285.362-343.41,280.728c-17.691-7.134-35.45-14.1-53.185-21.126
|
||||
C584.581,578.548,583.357,578.031,581.339,577.203z M631.913,190.091c-23.601,14.765-39.329,32.907-41.861,64.971
|
||||
c-0.976,12.354,0.277,24.409,4.452,36.111c1.506,4.22,1.551,4.239,5.584,3.022c21.898-6.608,45.765,1.158,54.818,22.119
|
||||
c9.277,21.479-3.17,28.458,6.11,29.896c26.184,4.059,56.961-2.668,82.131-22.12c5.477-4.232,0.383-1.697-12.615-18.92
|
||||
c-36.537-48.415-35.512-119.543,4.404-158.47c2.614-2.549,3.421-2.466-5.359-8.527c-62.56-43.189-159.113-24.698-204.584,60.753
|
||||
c-1.978,3.716-1.906,3.777,1.74,6.212c23.045,15.395,40.17,35.768,51.452,61.076c0.584,1.311,0.679,3.006,2.747,3.767
|
||||
C582.835,233.585,596.349,204.616,631.913,190.091z M938.08,320.493c-32.176,18.305-65.482,34.755-114.22,31.341
|
||||
c-20.006-1.401-38.943-6.418-56.621-15.917c-2.406-1.293-3.978-1.373-6.214,0.465c-24.683,20.291-56.966,29.39-84.444,27.819
|
||||
c-24.309-1.39-25.306-6.399-28.922-1.288c-0.911,1.288-2.061,2.726-4.675,3.412c5.391-7.012,5.669-6.672,0.711-8.386
|
||||
c-22.368-7.731-39.034-22.304-50.754-43.385c-0.684-1.23-0.939-3.444-2.806-3.036c-3.402,0.744-0.314,11.258-3.993,19.797
|
||||
c0.197-4.902,1.109-9.785,0.375-14.712c-1.923,0.134-2.321,1.347-2.925,2.288c-22.272,34.733,4.918,110.538,121.601,116.928
|
||||
c98.146,5.375,177.658-42.337,183.697-49.801c-2.304,0.649-4.461,1.503-6.524,2.543c-84.976,42.834-171.348,51.75-196.296,22.111
|
||||
c-7.609-9.039-12.348-29.646,14.1-39.017c50.03-17.728,125.734,15.205,213.639-33.718C922.593,333.049,931,327.599,938.08,320.493z
|
||||
M843.413,312.188c-0.829-4.699,11.709-31.161,41.137-44.212c20.746-9.2,45.623-5.403,75.558-17.329
|
||||
c33.867-13.493,45.367-40.564,44.348-42.727c-1.189-0.597-118.66-0.359-117.658-0.353c-2.911-0.018-1.692,1.69-4.162,12.415
|
||||
c-7.855,34.096-41.484,53.235-74.653,42.439c-2.345-0.763-3.172-0.524-3.709,2.04c-5.444,25.949-10.818,25.159-7.115,30.5
|
||||
c29.812,43.004,124.738,35.865,178.051-23.44c2.118-2.356,4.103-4.854,5.31-7.94C942.399,301.4,895.43,313.668,843.413,312.188z
|
||||
M393.944,312.826c-10.028-4.157-21.494-20.052-18.889-42.201c0.24-2.039,0.362-3.629-1.724-5.173
|
||||
c-35.155-26.018,11.766-100.299,115.319-69.334c1.196,0.358,2.874,1.399,3.406-0.327c0.886-2.876-46.599-16.258-86.888-6.001
|
||||
c-50.612,12.885-74.173,57.368-63.702,104.618c12.035,54.311,76.906,73.782,111.753,41.086c5.781-5.424-6.045-3.312-13.391-22.677
|
||||
c-8.253-21.758-6.256-42.356,7.666-61.388c2.429-3.32,2.56-3.373-1.128-5.355c-11.179-6.009-23.048-7.782-35.499-5.36
|
||||
c-4.094,0.796-4.069,0.924-3.496,5.173c2.665,19.751-15.011,25.646-24.396,23.628C376.271,268.071,374.657,295.402,393.944,312.826
|
||||
z M943.8,351.357c-0.18-0.172-0.359-0.344-0.539-0.516c-9.226,8.306-3.896,4.775-23.744,21.217
|
||||
c-56.717,46.985-129.558,73.428-197.025,76.511c-136.459,6.235-182.276-91.208-145.958-138.041
|
||||
c4.951-6.384,8.846-7.158,7.68-10.957c-13.144-42.838-52.027-25.525-72.581-36.339c-0.909-0.478-1.872-1.605-3.197-0.543
|
||||
c-4.475,18.01,11.5,11.088,13.647,34.539c1.92,20.97-8.606,22.575-5.799,31.782C574.368,519.53,846.717,470.437,943.8,351.357z
|
||||
M735.745,234.632c4.688,2.176,13.478,12.658,38.321,17.02c8.608,1.511,7.767,1.063,7.922-6.562
|
||||
c0.628-30.928-10.299-60.484-27.067-82.425c-1.315-1.721-2.504-2.029-4.069-0.43c-37.536,38.343-32.088,107.977,5.764,143.531
|
||||
c2.196,2.063,2.181,3.696,6.898-3.415c1.012-1.525-0.38-2.443-1.254-3.397C741.505,276.295,735.713,244.397,735.745,234.632z
|
||||
M868.717,200.862c-7.499-40.566-38.855-67.805-87.516-54.803c-2.524,0.674-2.444,1.746-1.26,3.515
|
||||
c9.021,13.472,15.775,28.015,20.395,43.543c1.444,4.855,2.821,0.433,11.082-0.924c7.768-1.277,14.833,0.837,17.424,7.481
|
||||
c1.042,2.671-0.679,2.091,36.21,2.031C866.041,201.703,867.073,201.862,868.717,200.862z M808.571,255.428
|
||||
c32.022,15.303,59.976-8.376,61.023-43.346c0.181-6.04,2.764-4.469-37.165-4.545c-1.6-0.003-2.64,0.414-3.637,1.941
|
||||
c-5.715,8.762-13.263,8.731-18.545-0.067c-1.199-1.997-2.379-1.858-4.125-1.149c-2.254,0.916-1.616,2.624-1.299,4.229
|
||||
C810.464,241.085,802.771,252.656,808.571,255.428z M376.153,261.612c3.73-1.317,7.729-28.02,45.006-30.11
|
||||
c12.21-0.684,23.585,2.207,34.047,8.51c1.859,1.12,3.222,1.11,4.988-0.126c15.336-10.728,25.645-6.796,23.856-14.969
|
||||
c-0.91-4.159-16.66,0.867-45.536,3.319c-34.704,2.946-36.347-34.704,24.813-30.092c19.779,1.491,26,5.922,26.682,3.366
|
||||
c2.11-7.91-115.752-21.011-121.913,39.944C367.28,249.535,369.708,256.513,376.153,261.612z M604.583,302.371
|
||||
c-4.24,1.334-4.448,1.876-2.211,5.495c11.015,17.82,26.419,29.974,46.622,35.855c4.138,1.205,4.724,0.863,5.006-3.353
|
||||
C655.955,311.067,631.296,293.968,604.583,302.371z M449.987,289.848c-0.321-17.755,5.917-26.476,8.809-31.387
|
||||
c1.248-2.119-1.083-5.304-3.46-2.575c-14.702,16.873-14.464,50.417,3.781,66.368c2.752,2.406,3.18,2.366,4.829-0.667
|
||||
c6.331-11.637,8.988-24.067,7.593-37.266c-0.575-5.444-0.95-5.49-6.292-3.755C459.77,282.345,455.11,285.483,449.987,289.848z
|
||||
M927.206,314.612c-1.535-0.335-3.851,0.561-5.358,1.134c-33.207,12.632-70.952,20.019-105.685,12.826
|
||||
c-30.95-6.409-33.554-18.306-37.898-9.004c-2.93,6.273,66.388,35.459,143.154-1.168
|
||||
C923.494,317.409,925.617,316.484,927.206,314.612z M381.827,264.443c22.916,8.787,28.944-24.669,20.513-21.077
|
||||
C392.667,247.488,386.072,254.542,381.827,264.443z M483.512,300.816c-3.197,25.179-12.333,28.842-5.452,29.266
|
||||
c20.232,1.246,13.272,1.189,7.303-27.197C485.202,302.12,485.183,301.174,483.512,300.816z M558.424,258.015
|
||||
c-6.271-7.025-20.366-15.085-41.937-16.866c-3.646-0.301-6.115-0.348-6.198,2.895c-0.1,3.89,1.721,0.561,20.065,3.695
|
||||
C540.336,249.444,549.472,253.455,558.424,258.015z M512.208,307.608c4.142-9.577,2.272-21.835-3.877-25.76
|
||||
C509.282,290.673,509.663,299.268,512.208,307.608z M469.559,247.877c8.952-5.253,11.338-6.099,11.339-6.099
|
||||
c1.386-0.782,0.932-3.32-1.235-2.558C460.161,246.08,465.178,250.448,469.559,247.877z M479.564,268.224
|
||||
c0.343-0.034,0.685-0.067,1.028-0.101c-0.193-4.81,0.229-9.639-0.315-14.973C473.967,259.056,474.954,258.264,479.564,268.224z
|
||||
M513.944,227.064c1.969,4.04-1.752,0.476,19.047,7.103C527.316,230.808,521.272,228.571,513.944,227.064z M512.941,236.148
|
||||
c-0.076,0.468-0.151,0.936-0.227,1.404c5.039,0.723,10.078,1.446,15.117,2.169C523.072,237.27,518.048,236.449,512.941,236.148z
|
||||
M521.469,209.177c1.742,1.073,3.038,3.051,6.412,3.154C525.306,210.54,523.773,209.102,521.469,209.177z"/>
|
||||
<path d="M83.935,992.123c35.367,16.879,74.429,24.872,113.07,15.002c5.713-1.46,8.102-1.525,8.087-6.051
|
||||
c-0.454-131.154,1.858-118.95-4.03-119.625c-61.312-7.033-90.004-59.237-70.523-116.641c1.166-3.434,0.88-5.655-1.698-8.323
|
||||
C53.24,678.233,87.274,536.832,189.97,493.101c6.469-2.755-2.979-6.53-6.243-45.074c-4.867-57.468,17.059-106.087,72.758-120.623
|
||||
c96.57-25.202,147.083,82.706,87.924,135.999c-1.699,1.531-2.26,2.938-1.782,5.23c4.073,19.531-4.213,22.657,2.625,25.289
|
||||
c117.932,45.39,150.278,166.653,114.115,295.087c-13.066,46.402-25.929,66.824-28.941,74.296c-0.172,0.428-0.197,0.915-0.424,2.043
|
||||
c14.431-10.928,87.326-128.7,94.13-241.25c0.246-4.061-2.834-0.63,52.991-43.564c0.908-0.699,1.944-1.231,2.818-1.778
|
||||
c1.912,1.006-0.084-1.138,23.906,58.108c3.172,7.833-109.576,303.365-352.996,370.348c-41.352,11.38-40.036,4.473-40.171,12.642
|
||||
c-0.002,0.106-0.008,639.828-0.008,639.933c0,3.811-0.259,7.233,3.605,10.174c3.726,2.836,2.93,8.355-0.561,11.55
|
||||
c-8.815,8.069-19.545-6.199-11.829-12.004c4.64-3.491,3.112,40.21,3.111-646.768c0-11.9-1.347-2.125-40.614-6.099
|
||||
c-18.185-1.841-36.078-5.319-53.355-11.524C101.559,1001.714,92.35,997.781,83.935,992.123z M334.846,582.521l-0.097-0.071
|
||||
c-0.711,2.357-11.494,2.516-15.316,2.663c-1.785,0.069-4.226-0.449-4.688,1.816c-0.414,2.032,2.098,2.246,3.435,2.99
|
||||
c20.643,11.475,35.122,28.167,43.024,50.492c0.434,1.227,0.508,2.674,1.813,3.624c2.376-0.118,3.587-2.938,6.682-3.285
|
||||
c-12.229,14.205,0.178,8.533-2.267,44.639c-1.819,26.869-10.738,51.003-27.535,72.1c-1.848,2.321-2.147,3.973-0.728,6.642
|
||||
c26.97,50.732,19.015,112.934-10.608,162.181c-1.755,2.918-3.35,5.932-5.407,9.591c2.014-1.166,1.143-0.32,3.207-2.911
|
||||
c72.019-90.41,25.351-200.07,52.731-244.961c16.482-27.023,71.917-9.784,45.328,102.787
|
||||
c-12.275,51.969-32.994,87.239-35.848,96.223c15.471-17.316,65.77-113.3,46.604-216.522
|
||||
c-16.403-88.339-80.118-111.105-113.613-89.775c-0.903,0.575-2.262,0.989-1.486,3.079
|
||||
C324.917,583.396,329.881,582.959,334.846,582.521z M273.304,577.737c-0.732-1.981-2.083-1.976-3.095-2.424
|
||||
c-16.424-7.266-31.122-17.041-43.835-29.784c-19.828-19.873-17.82-27.752-23.565-24.087c-1.893,1.208-27.029,12.235-48.556,38.407
|
||||
c-47.594,57.864-43.084,128.274-9.667,170.799c5.941,7.56,2.563-2.715,28.308-16.308c48.522-25.62,113.377-13.628,149.551,26.005
|
||||
c2.186,2.395,2.658,2.389,4.645-0.103c17.15-21.508,26.846-51.935,22.353-83.031c-0.421-2.911-1.705-3.396-3.965-2.839
|
||||
c-31.059,7.654-58.459-22.336-48.218-57.139c1.305-4.433,1.315-4.495-2.857-5.946c-34.806-12.104-77.622-1.778-97.727,32.526
|
||||
c-1.109,1.893-1.202,2.616-2.714,1.776C209.974,592.323,238.036,579.366,273.304,577.737z M266.718,977.59
|
||||
c55.713-34.538,82.912-147.65,31.578-183.571c-4.146-2.901-0.93-0.969-33.154,7.94c0,4.103,6.879,11.9,1.681,34.035
|
||||
c-6.329,26.952-25.362,41.52-52.593,45.125c-4.69,0.621-3.55,1.934-3.55,19.229c-0.001,112.084-2.282,103.548,5.715,99.372
|
||||
c73.272-38.261,18.805-117.312,90.1-156.519c2.609-1.435,5.266-2.842,8.845-3.489C316.932,892.245,304.659,939.333,266.718,977.59z
|
||||
M316.673,391.814c-11.13-12.102-23.661-18.903-41.394-15.465c-4.474,0.867-1.732,2.59-2.406,9.197
|
||||
c-1.211,11.868-9.238,20.353-23.567,18.571c-4.524-0.563-4.661-0.566-5.476,3.938c-2.258,12.481-0.398,24.346,5.734,35.479
|
||||
c1.794,3.256,1.925,3.207,5.167,0.73c26.63-20.344,63.823-14.189,79.641,6.01c1.421,1.815,2.63,1.732,4.024,0.102
|
||||
c13.011-15.212,18.942-31.594,15.43-52.591c-13.618-81.418-137.389-85.06-160.393,2.813c-10.137,38.721,1.422,82.83,4.211,87.667
|
||||
c0.948,1.644,2.655,0.645,2.362-0.962c-0.409-2.237-9.803-28.568-6.858-61.003c2.894-31.876,17.957-63.773,50.066-66.946
|
||||
c22.222-2.196,22.124,13.434,31.254,12.434C297.567,369.26,312.985,381.829,316.673,391.814z M265.805,505.309
|
||||
c-0.436,1.16-0.003,1.889,0.393,2.626c3.484,6.498,5.015,13.434,4.777,20.796c-0.817,25.174,6.484,44.131,30.393,51.736
|
||||
c2.819,0.897,4.582,0.507,6.593-1.669c30.144-32.627,98.698-20.692,128.916,48.679c35.566,81.649,6.742,210.443-66.865,295.174
|
||||
c-5.453,6.277-11.092,12.382-16.59,18.642c62.834-43.994,110.917-165.368,105.993-255.314
|
||||
c-8.012-146.375-128.436-177.388-134.545-172.247c-5.405,4.549-33.32,11.841-46.376-7.055c-0.72-1.042-1.611-1.677-2.907-1.644
|
||||
C272.283,505.117,268.952,504.667,265.805,505.309z M238,732.49c11.127,0.254,41.214,6.196,63.139,25.657
|
||||
c3.538,3.141,3.548,3.13,7.415,0.16c2.88-2.212,2.923-2.252,0.464-4.888c-35.98-38.588-108.57-42.523-143.566-5.704
|
||||
c-2.268,2.386-0.741,3.191,2.929,5.834c26.073,18.783,57.429,26.683,82.982,25.177c4.261-0.251,4.695-0.7,4.076-4.887
|
||||
c-1.961-13.275-6.296-25.649-14.352-36.551C240.122,735.982,238.692,734.985,238,732.49z M202.113,865.075
|
||||
c2.663,0.567,2.856,0.359,2.871-2.936c0.163-36.712,0.749-35.138-2.261-36.553c-8.42-3.958-8.25-12.387-6.326-21.018
|
||||
c0.497-2.231,3.228-4.289,1.837-6.397c-1.204-1.826-4.206-1.663-6.4-2.397c-39.67-13.27-40.181-25.487-42.7-17.025
|
||||
C137.115,819.124,157.209,855.508,202.113,865.075z M210.68,846.021c0.003,0,0.006,0,0.009,0c0,18.528-1.005,20.492,3.211,20.523
|
||||
c7.654,0.057,19.859-2.456,30.94-8.964c27.275-16.019,19.247-54.385,9.271-54.377c-1.845,0.001-22.425,1.707-38.964-1.73
|
||||
c-1.756-0.365-3.147-0.108-3.899,2.042c-0.93,2.66,0.853,3.073,2.493,3.967c9.048,4.931,5.005,15.159-0.256,17.788
|
||||
C209.816,827.102,210.68,827.946,210.68,846.021z M226.913,480.611c11.973-0.3,3.487-5.221,15.362-22.245
|
||||
c1.845-2.646,2.112-4.581,0.383-7.596c-15.767-27.493-8.012-64.386,19.904-75.927c2.723-1.126,2.837-1.843,0.754-3.787
|
||||
c-26.312-24.56-83.943,20.82-60.559,113.411c0.289,1.143,0.827,2.702,1.975,2.457c1.02-0.218,1.098-0.803-0.012-5.995
|
||||
c-17.205-80.508,30.929-86.069,26.642-43.584c-0.89,8.82-2.189,17.587-3.428,26.366
|
||||
C227.153,469.261,225.923,474.91,226.913,480.611z M347.399,649.722c0.313-1.686-4.551-31.477-37.55-51.239
|
||||
c-2.235-1.339-2.856-0.988-3.864,1.943C294.441,633.992,324.807,657.569,347.399,649.722z M292.847,446.917
|
||||
c-3.777,4.954-6.918,7.958-9.615,16.781c-0.984,3.221-0.689,3.589,2.741,4.327c6.046,1.301,22.847,1.488,38.778-7.277
|
||||
c3.073-1.691,3.109-2.077,0.772-4.823c-23.936-28.121-71.012-6.827-67.26-1.358c2.058,2.999,3.635,0.627,7.236-1.354
|
||||
C273.715,448.692,282.473,446.484,292.847,446.917z M316.29,926.535c7.921-7.576,39.474-78.366,13.564-141.221
|
||||
c-4.632-11.238-5.494-10.824-6.595-10.512c-3.084,0.872-4.625,4.119-3.086,6.831C346.326,827.686,332.537,880.506,316.29,926.535z
|
||||
M264.942,379.829c-8.185,4.102-14.339,10.219-18.06,18.65c-1.205,2.73-0.722,3.526,2.258,3.902
|
||||
C270.563,405.083,273.107,375.737,264.942,379.829z M303.632,480.505c2.654,2.831,5.121,0.996,26.344,7.89
|
||||
c3.036,0.986,4.143-1.268,3.251-14.051C332.828,468.622,329.88,476.593,303.632,480.505z M260.395,553.949
|
||||
c-2.842-7.221-11.251-21.515-10.9-43.934c0.021-1.31,0.253-2.879-1.753-2.955c-1.863-0.07-3.346,0.473-3.34,2.743
|
||||
C244.438,525.314,250.137,544.629,260.395,553.949z M311.606,508.749c-8.753-2.134-17.597-3.51-27.254-3.605
|
||||
C290.037,511.947,303.005,513.61,311.606,508.749z M252.256,464.191c-0.661-2.02-2.291-2.52-3.412-0.827
|
||||
c-1.807,2.725-8.634,14.224-5.432,14.898c2.85,0.6,1.732-3.671,8.136-12.494C251.918,465.257,252.08,464.595,252.256,464.191z
|
||||
M272.541,477.34c0.073-0.199,0.146-0.399,0.219-0.599c-9.27-4.581-11.783-7.01-16.404,0.599
|
||||
C261.89,477.34,267.215,477.34,272.541,477.34z M236.903,528.473c-4.691-18.019-4.092-17.397-5.296-17.194
|
||||
C228.472,511.807,235.169,525.827,236.903,528.473z M240.888,509.475c-0.488,0.078-0.976,0.156-1.465,0.234
|
||||
c0.232,4.678,0.867,9.285,3.301,13.586C242.112,518.688,241.5,514.081,240.888,509.475z M217.399,527.494
|
||||
c-2.853-7.291-2.931-7.399-6.104-9.129C213.554,521.744,215.476,524.619,217.399,527.494z"/>
|
||||
<path d="M645.459,486.168c-0.211,28.555-43.356,27.735-42.94-0.111C602.958,456.661,645.664,458.351,645.459,486.168z"/>
|
||||
<path d="M489.465,599.331c28.961,0.387,27.377,43.85-0.745,42.89C460.296,641.251,461.365,598.956,489.465,599.331z"/>
|
||||
<path d="M685.994,490.81c0.105,23.478-35.311,23.196-35.225,0.044C650.857,467.215,685.892,467.82,685.994,490.81z"/>
|
||||
<path d="M476.517,665.094c0.432-23.729,35.149-23.029,35.081,0.216C511.528,688.69,476.096,688.166,476.517,665.094z"/>
|
||||
<path d="M723.616,487.827c0.016,19.216-28.856,19.832-29.392,0.177C693.708,469.119,723.6,468.064,723.616,487.827z"/>
|
||||
<path d="M476.542,705.965c0.045-19.954,29.043-19.695,29.176-0.345C505.852,725.154,476.499,725.371,476.542,705.965z"/>
|
||||
<path d="M743.963,489.152c-13.001,0.245-13.61-19.558-0.405-19.707C756.829,469.296,756.701,488.913,743.963,489.152z"/>
|
||||
<path d="M459.953,580.311c-0.195-12.765,19.554-13.366,19.691-0.231C479.781,593.223,460.147,592.983,459.953,580.311z"/>
|
||||
<path d="M492.26,740.837c-0.297,12.813-19.848,12.908-19.435-0.727C473.217,727.139,492.559,727.923,492.26,740.837z"/>
|
||||
<path d="M583.48,476.4c-12.928,0.128-13.033-19.149-0.442-19.617C595.879,456.306,596.721,476.269,583.48,476.4z"/>
|
||||
<path d="M454.597,566.748c-10.2-0.288-10.001-15.633,0.198-15.682C465.208,551.016,465.382,567.052,454.597,566.748z"/>
|
||||
<path d="M476.218,775.724c-9.794,0.142-11.066-15.52-0.491-15.828C486.939,759.568,486.185,775.579,476.218,775.724z"/>
|
||||
<path d="M763.071,472.894c-0.021-10.682,15.303-10.306,15.82-0.295C779.403,482.528,763.092,484.011,763.071,472.894z"/>
|
||||
<path d="M569.917,451.795c-0.243,10.364-15.475,9.987-15.603-0.12C554.182,441.23,570.166,441.181,569.917,451.795z"/>
|
||||
<path d="M432.333,541.463c0.035-7.331,11.043-5.962,10.455,0.371C442.206,548.117,432.301,548.364,432.333,541.463z"/>
|
||||
<path d="M544.65,439.569c-6.622-0.13-6.782-10.909,0.418-10.503C551.906,429.452,550.792,439.69,544.65,439.569z"/>
|
||||
<path d="M788.051,463.284c0.103-7.053,10.519-6.542,10.1,0.379C797.774,469.886,787.949,470.269,788.051,463.284z"/>
|
||||
<path d="M471.667,789.755c0.144,7.093-10.453,6.779-10.251-0.022C461.602,783.487,471.528,782.949,471.667,789.755z"/>
|
||||
<path d="M424.25,525.992c3.566,0.215,3.8,5.345,0.125,5.978C420.431,532.65,419.618,525.713,424.25,525.992z"/>
|
||||
<path d="M532.613,423.831c-3.656,0.543-4.905-5.085-0.81-5.953C535.224,417.154,536.78,423.213,532.613,423.831z"/>
|
||||
<path d="M462.717,808.436c-0.363,3.943-6.073,3.617-5.823-0.328C457.165,803.842,463.05,804.818,462.717,808.436z"/>
|
||||
<path d="M811.432,459.43c-3.84,0.046-4.153-5.743-0.042-5.754C814.736,453.667,815.983,459.375,811.432,459.43z"/>
|
||||
<path d="M830.087,448.685c-0.315,3.024-4.487,2.669-4.407,0.032C825.745,446.567,829.374,445.987,830.087,448.685z"/>
|
||||
<path d="M454.057,824.646c-0.678,3.451-4.188,2.629-3.997-0.172C450.221,822.126,453.485,821.431,454.057,824.646z"/>
|
||||
<path d="M407.777,518.303c-0.099-2.854,3.921-3.198,4.314-0.719C412.448,519.829,408.865,520.751,407.777,518.303z"/>
|
||||
<path d="M523.013,406.786c-0.515,3.097-3.925,2.523-3.757-0.121C519.427,403.976,522.706,403.9,523.013,406.786z"/>
|
||||
<path d="M334.846,582.521c1.135-0.135,2.27-0.27,3.406-0.406c-1.123,0.615-2.279,0.817-3.487,0.348
|
||||
C334.749,582.451,334.846,582.521,334.846,582.521z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 18 KiB |
2999
pyPhotoAlbum/frames/corners/simple_corner.svg
Normal file
|
After Width: | Height: | Size: 138 KiB |
110
pyPhotoAlbum/gl_imports.py
Normal file
@ -0,0 +1,110 @@
|
||||
"""
|
||||
Centralized OpenGL imports for pyPhotoAlbum.
|
||||
|
||||
Provides a single point of import for all OpenGL functions used throughout
|
||||
the application. This centralizes GL dependency management and provides
|
||||
graceful handling when OpenGL is not available (e.g., during testing).
|
||||
|
||||
Usage:
|
||||
from pyPhotoAlbum.gl_imports import glBegin, glEnd, GL_QUADS, GL_AVAILABLE
|
||||
|
||||
if GL_AVAILABLE:
|
||||
# Safe to use GL functions
|
||||
glBegin(GL_QUADS)
|
||||
...
|
||||
"""
|
||||
|
||||
try:
|
||||
from OpenGL.GL import (
|
||||
# Drawing primitives
|
||||
glBegin,
|
||||
glEnd,
|
||||
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,
|
||||
GL_DEPTH_TEST,
|
||||
GL_BLEND,
|
||||
GL_SRC_ALPHA,
|
||||
GL_ONE_MINUS_SRC_ALPHA,
|
||||
glBlendFunc,
|
||||
# Textures
|
||||
glGenTextures,
|
||||
glBindTexture,
|
||||
glTexImage2D,
|
||||
glTexParameteri,
|
||||
glDeleteTextures,
|
||||
GL_TEXTURE_2D,
|
||||
GL_RGBA,
|
||||
GL_UNSIGNED_BYTE,
|
||||
GL_TEXTURE_MIN_FILTER,
|
||||
GL_TEXTURE_MAG_FILTER,
|
||||
GL_LINEAR,
|
||||
glTexCoord2f,
|
||||
# Matrix operations
|
||||
glPushMatrix,
|
||||
glPopMatrix,
|
||||
glScalef,
|
||||
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:
|
||||
GL_AVAILABLE = False
|
||||
|
||||
# Define dummy functions/constants for when OpenGL is not available
|
||||
# This allows the code to be imported without OpenGL for testing
|
||||
def _gl_stub(*args, **kwargs):
|
||||
pass
|
||||
|
||||
glBegin = glEnd = glVertex2f = _gl_stub
|
||||
glColor3f = glColor4f = _gl_stub
|
||||
glLineWidth = glLineStipple = _gl_stub
|
||||
glEnable = glDisable = glBlendFunc = _gl_stub
|
||||
glGenTextures = glBindTexture = glTexImage2D = _gl_stub
|
||||
glTexParameteri = glDeleteTextures = glTexCoord2f = _gl_stub
|
||||
glPushMatrix = glPopMatrix = glScalef = glTranslatef = _gl_stub
|
||||
glLoadIdentity = glRotatef = _gl_stub
|
||||
glClear = glClearColor = glFlush = _gl_stub
|
||||
glViewport = glMatrixMode = glOrtho = _gl_stub
|
||||
glGetString = _gl_stub
|
||||
|
||||
# Constants
|
||||
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
|
||||
GL_TEXTURE_MIN_FILTER = GL_TEXTURE_MAG_FILTER = GL_LINEAR = 0
|
||||
GL_COLOR_BUFFER_BIT = GL_DEPTH_BUFFER_BIT = 0
|
||||
GL_PROJECTION = GL_MODELVIEW = 0
|
||||
GL_VERSION = 0
|
||||
342
pyPhotoAlbum/gl_widget.py
Normal file
@ -0,0 +1,342 @@
|
||||
"""
|
||||
OpenGL widget for pyPhotoAlbum rendering - refactored with mixins
|
||||
"""
|
||||
|
||||
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
|
||||
from PyQt6.QtCore import Qt
|
||||
from pyPhotoAlbum.gl_imports import *
|
||||
|
||||
# Import all mixins
|
||||
from pyPhotoAlbum.mixins.viewport import ViewportMixin
|
||||
from pyPhotoAlbum.mixins.rendering import RenderingMixin
|
||||
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
|
||||
from pyPhotoAlbum.mixins.asset_drop import AssetDropMixin
|
||||
from pyPhotoAlbum.mixins.page_navigation import PageNavigationMixin
|
||||
from pyPhotoAlbum.mixins.image_pan import ImagePanMixin
|
||||
from pyPhotoAlbum.mixins.element_manipulation import ElementManipulationMixin
|
||||
from pyPhotoAlbum.mixins.element_selection import ElementSelectionMixin
|
||||
from pyPhotoAlbum.mixins.mouse_interaction import MouseInteractionMixin
|
||||
from pyPhotoAlbum.mixins.interaction_undo import UndoableInteractionMixin
|
||||
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||
from pyPhotoAlbum.mixins.keyboard_navigation import KeyboardNavigationMixin
|
||||
|
||||
|
||||
class GLWidget(
|
||||
AsyncLoadingMixin,
|
||||
ViewportMixin,
|
||||
RenderingMixin,
|
||||
AssetPathMixin,
|
||||
AssetDropMixin,
|
||||
PageNavigationMixin,
|
||||
ImagePanMixin,
|
||||
ElementManipulationMixin,
|
||||
ElementSelectionMixin,
|
||||
MouseInteractionMixin,
|
||||
UndoableInteractionMixin,
|
||||
KeyboardNavigationMixin,
|
||||
QOpenGLWidget,
|
||||
):
|
||||
"""OpenGL widget for pyPhotoAlbum rendering and user interaction
|
||||
|
||||
This widget orchestrates multiple mixins to provide:
|
||||
- Async image loading (non-blocking)
|
||||
- Viewport control (zoom, pan)
|
||||
- Page rendering (OpenGL)
|
||||
- Element selection and manipulation
|
||||
- Mouse interaction handling
|
||||
- Drag-and-drop asset management
|
||||
- Image panning within frames
|
||||
- Page navigation and ghost pages
|
||||
- Undo/redo integration
|
||||
"""
|
||||
|
||||
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()
|
||||
|
||||
# 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
|
||||
self.setMouseTracking(True)
|
||||
self.setAcceptDrops(True)
|
||||
|
||||
# Enable keyboard focus
|
||||
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
|
||||
self._cleanup_async_loading()
|
||||
super().closeEvent(event)
|
||||
|
||||
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)
|
||||
return None
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
"""Handle key press events"""
|
||||
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"):
|
||||
main_window.delete_selected_element()
|
||||
|
||||
elif event.key() == Qt.Key.Key_Escape:
|
||||
self.selected_element = None
|
||||
self.rotation_mode = False
|
||||
self.update()
|
||||
|
||||
elif event.key() == Qt.Key.Key_Tab:
|
||||
# Toggle rotation mode when an element is selected
|
||||
if self.selected_element:
|
||||
self.rotation_mode = not self.rotation_mode
|
||||
main_window = self.window()
|
||||
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}")
|
||||
self.update()
|
||||
event.accept()
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
|
||||
elif event.key() == Qt.Key.Key_PageDown:
|
||||
# Navigate to next page
|
||||
self._navigate_to_next_page()
|
||||
event.accept()
|
||||
|
||||
elif event.key() == Qt.Key.Key_PageUp:
|
||||
# Navigate to previous page
|
||||
self._navigate_to_previous_page()
|
||||
event.accept()
|
||||
|
||||
elif event.key() in (Qt.Key.Key_Up, Qt.Key.Key_Down, Qt.Key.Key_Left, Qt.Key.Key_Right):
|
||||
# Arrow key handling
|
||||
if self.selected_elements:
|
||||
# Move selected elements
|
||||
self._move_selected_elements_with_arrow_keys(event.key())
|
||||
event.accept()
|
||||
else:
|
||||
# Move viewport
|
||||
self._move_viewport_with_arrow_keys(event.key())
|
||||
event.accept()
|
||||
|
||||
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()
|
||||
BIN
pyPhotoAlbum/icons/icon.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
435
pyPhotoAlbum/image_utils.py
Normal file
@ -0,0 +1,435 @@
|
||||
"""
|
||||
Centralized image processing utilities for pyPhotoAlbum.
|
||||
|
||||
This module consolidates common image operations to avoid code duplication
|
||||
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.
|
||||
|
||||
Args:
|
||||
image: PIL Image to rotate
|
||||
pil_rotation_90: Number of 90-degree rotations (0, 1, 2, or 3)
|
||||
|
||||
Returns:
|
||||
Rotated PIL Image (or original if no rotation needed)
|
||||
"""
|
||||
if pil_rotation_90 <= 0:
|
||||
return image
|
||||
|
||||
angle = pil_rotation_90 * 90
|
||||
if angle == 90:
|
||||
return image.transpose(Image.Transpose.ROTATE_270) # CCW 90 = rotate right
|
||||
elif angle == 180:
|
||||
return image.transpose(Image.Transpose.ROTATE_180)
|
||||
elif angle == 270:
|
||||
return image.transpose(Image.Transpose.ROTATE_90) # CCW 270 = rotate left
|
||||
|
||||
return image
|
||||
|
||||
|
||||
def convert_to_rgba(image: Image.Image) -> Image.Image:
|
||||
"""
|
||||
Convert image to RGBA mode if not already.
|
||||
|
||||
Args:
|
||||
image: PIL Image in any mode
|
||||
|
||||
Returns:
|
||||
PIL Image in RGBA mode
|
||||
"""
|
||||
if image.mode != "RGBA":
|
||||
return image.convert("RGBA")
|
||||
return image
|
||||
|
||||
|
||||
def calculate_center_crop_coords(
|
||||
img_width: int,
|
||||
img_height: int,
|
||||
target_width: float,
|
||||
target_height: float,
|
||||
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.
|
||||
|
||||
This implements the center-crop algorithm used for fitting images into frames
|
||||
while preserving aspect ratio. The image is scaled to cover the target area,
|
||||
then the excess is cropped equally from both sides.
|
||||
|
||||
Args:
|
||||
img_width: Source image width in pixels
|
||||
img_height: Source image height in pixels
|
||||
target_width: Target frame width (any unit, only ratio matters)
|
||||
target_height: Target frame height (any unit, only ratio matters)
|
||||
crop_info: Additional crop range as (x_min, y_min, x_max, y_max) in 0-1 range
|
||||
Default (0, 0, 1, 1) means no additional cropping
|
||||
|
||||
Returns:
|
||||
Tuple of (tx_min, ty_min, tx_max, ty_max) texture coordinates in 0-1 range
|
||||
"""
|
||||
crop_x_min, crop_y_min, crop_x_max, crop_y_max = crop_info
|
||||
|
||||
img_aspect = img_width / img_height
|
||||
target_aspect = target_width / target_height
|
||||
|
||||
# Calculate base texture coordinates for center crop
|
||||
if img_aspect > target_aspect:
|
||||
# Image is wider than target - crop horizontally
|
||||
scale = target_aspect / img_aspect
|
||||
tx_offset = (1.0 - scale) / 2.0
|
||||
tx_min_base = tx_offset
|
||||
tx_max_base = 1.0 - tx_offset
|
||||
ty_min_base = 0.0
|
||||
ty_max_base = 1.0
|
||||
else:
|
||||
# Image is taller than target - crop vertically
|
||||
scale = img_aspect / target_aspect
|
||||
ty_offset = (1.0 - scale) / 2.0
|
||||
tx_min_base = 0.0
|
||||
tx_max_base = 1.0
|
||||
ty_min_base = ty_offset
|
||||
ty_max_base = 1.0 - ty_offset
|
||||
|
||||
# Apply additional crop from crop_info (for spanning elements, user crops, etc.)
|
||||
tx_range = tx_max_base - tx_min_base
|
||||
ty_range = ty_max_base - ty_min_base
|
||||
|
||||
tx_min = tx_min_base + crop_x_min * tx_range
|
||||
tx_max = tx_min_base + crop_x_max * tx_range
|
||||
ty_min = ty_min_base + crop_y_min * ty_range
|
||||
ty_max = ty_min_base + crop_y_max * ty_range
|
||||
|
||||
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:
|
||||
"""
|
||||
Crop an image using normalized texture coordinates.
|
||||
|
||||
Args:
|
||||
image: PIL Image to crop
|
||||
coords: Tuple of (tx_min, ty_min, tx_max, ty_max) in 0-1 range
|
||||
|
||||
Returns:
|
||||
Cropped PIL Image
|
||||
"""
|
||||
tx_min, ty_min, tx_max, ty_max = coords
|
||||
img_width, img_height = image.size
|
||||
|
||||
crop_left_px = int(tx_min * img_width)
|
||||
crop_right_px = int(tx_max * img_width)
|
||||
crop_top_px = int(ty_min * img_height)
|
||||
crop_bottom_px = int(ty_max * img_height)
|
||||
|
||||
return image.crop((crop_left_px, crop_top_px, crop_right_px, crop_bottom_px))
|
||||
|
||||
|
||||
def resize_to_fit(
|
||||
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.
|
||||
|
||||
Args:
|
||||
image: PIL Image to resize
|
||||
max_size: Maximum dimension (width or height)
|
||||
resample: Resampling filter (default LANCZOS for quality)
|
||||
|
||||
Returns:
|
||||
Resized PIL Image, or original if already smaller
|
||||
"""
|
||||
if image.width <= max_size and image.height <= max_size:
|
||||
return image
|
||||
|
||||
scale = min(max_size / image.width, max_size / image.height)
|
||||
new_width = int(image.width * scale)
|
||||
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
|
||||
186
pyPhotoAlbum/loading_widget.py
Normal file
@ -0,0 +1,186 @@
|
||||
"""
|
||||
Loading progress widget for pyPhotoAlbum
|
||||
|
||||
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 # type: ignore[attr-defined]
|
||||
from PyQt6.QtGui import QPalette, QColor
|
||||
|
||||
|
||||
class LoadingWidget(QWidget):
|
||||
"""
|
||||
A widget that displays loading progress in the lower-right corner.
|
||||
|
||||
Features:
|
||||
- Fade in/out animations
|
||||
- Progress bar with percentage
|
||||
- Status message display
|
||||
- Compact, non-intrusive design
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
# Widget configuration
|
||||
self.setWindowFlags(Qt.WindowType.ToolTip | Qt.WindowType.FramelessWindowHint)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, False)
|
||||
self.setFixedSize(280, 80)
|
||||
|
||||
# Styling
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
QWidget {
|
||||
background-color: rgba(50, 50, 50, 230);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(100, 100, 100, 180);
|
||||
}
|
||||
QLabel {
|
||||
color: white;
|
||||
background-color: transparent;
|
||||
font-size: 11pt;
|
||||
}
|
||||
QProgressBar {
|
||||
border: 1px solid rgba(80, 80, 80, 180);
|
||||
border-radius: 4px;
|
||||
background-color: rgba(30, 30, 30, 200);
|
||||
text-align: center;
|
||||
color: white;
|
||||
font-size: 10pt;
|
||||
}
|
||||
QProgressBar::chunk {
|
||||
background-color: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 rgba(70, 130, 180, 220),
|
||||
stop:1 rgba(100, 160, 210, 220));
|
||||
border-radius: 3px;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
# Layout
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(12, 10, 12, 10)
|
||||
layout.setSpacing(8)
|
||||
|
||||
# Status label
|
||||
self._status_label = QLabel("Loading...")
|
||||
self._status_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
layout.addWidget(self._status_label)
|
||||
|
||||
# Progress bar with percentage label
|
||||
progress_layout = QHBoxLayout()
|
||||
progress_layout.setSpacing(8)
|
||||
|
||||
self._progress_bar = QProgressBar()
|
||||
self._progress_bar.setMinimum(0)
|
||||
self._progress_bar.setMaximum(100)
|
||||
self._progress_bar.setValue(0)
|
||||
self._progress_bar.setTextVisible(True)
|
||||
self._progress_bar.setFormat("%p%")
|
||||
progress_layout.addWidget(self._progress_bar, 1)
|
||||
|
||||
layout.addLayout(progress_layout)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
# Animation for fade in/out
|
||||
self._opacity = 1.0
|
||||
self._fade_animation = QPropertyAnimation(self, b"opacity")
|
||||
self._fade_animation.setDuration(300)
|
||||
self._fade_animation.setEasingCurve(QEasingCurve.Type.InOutQuad)
|
||||
|
||||
# Initially hidden
|
||||
self.hide()
|
||||
|
||||
@pyqtProperty(float)
|
||||
def opacity(self):
|
||||
"""Get opacity for animation"""
|
||||
return self._opacity
|
||||
|
||||
@opacity.setter # type: ignore[no-redef]
|
||||
def opacity(self, value: float) -> None:
|
||||
"""Set opacity for animation"""
|
||||
self._opacity = value
|
||||
self.setWindowOpacity(value)
|
||||
|
||||
def show_loading(self, message: str = "Loading..."):
|
||||
"""
|
||||
Show the loading widget with a fade-in animation.
|
||||
|
||||
Args:
|
||||
message: Initial status message
|
||||
"""
|
||||
self.set_status(message)
|
||||
self.set_progress(0)
|
||||
|
||||
# Position in lower-right corner of parent
|
||||
self._reposition()
|
||||
|
||||
# Fade in
|
||||
self.show()
|
||||
self._fade_animation.stop()
|
||||
self._fade_animation.setStartValue(0.0)
|
||||
self._fade_animation.setEndValue(1.0)
|
||||
self._fade_animation.start()
|
||||
|
||||
def hide_loading(self):
|
||||
"""Hide the loading widget with a fade-out animation."""
|
||||
self._fade_animation.stop()
|
||||
self._fade_animation.setStartValue(1.0)
|
||||
self._fade_animation.setEndValue(0.0)
|
||||
self._fade_animation.finished.connect(self.hide)
|
||||
self._fade_animation.start()
|
||||
|
||||
def set_status(self, message: str):
|
||||
"""
|
||||
Update the status message.
|
||||
|
||||
Args:
|
||||
message: Status message to display
|
||||
"""
|
||||
self._status_label.setText(message)
|
||||
|
||||
def set_progress(self, value: int, maximum: int = 100):
|
||||
"""
|
||||
Update the progress bar.
|
||||
|
||||
Args:
|
||||
value: Current progress value
|
||||
maximum: Maximum progress value (default: 100)
|
||||
"""
|
||||
self._progress_bar.setMaximum(maximum)
|
||||
self._progress_bar.setValue(value)
|
||||
|
||||
def set_indeterminate(self, indeterminate: bool = True):
|
||||
"""
|
||||
Set the progress bar to indeterminate mode (busy indicator).
|
||||
|
||||
Args:
|
||||
indeterminate: True for indeterminate, False for normal progress
|
||||
"""
|
||||
if indeterminate:
|
||||
self._progress_bar.setMinimum(0)
|
||||
self._progress_bar.setMaximum(0)
|
||||
else:
|
||||
self._progress_bar.setMinimum(0)
|
||||
self._progress_bar.setMaximum(100)
|
||||
|
||||
def _reposition(self):
|
||||
"""Position the widget in the lower-right corner of the parent."""
|
||||
if self.parent():
|
||||
parent_rect = self.parent().rect()
|
||||
margin = 20
|
||||
x = parent_rect.width() - self.width() - margin
|
||||
y = parent_rect.height() - self.height() - margin
|
||||
self.move(x, y)
|
||||
|
||||
def showEvent(self, event):
|
||||
"""Handle show event to reposition."""
|
||||
super().showEvent(event)
|
||||
self._reposition()
|
||||
|
||||
def resizeParent(self):
|
||||
"""Call this when parent is resized to reposition the widget."""
|
||||
if self.isVisible():
|
||||
self._reposition()
|
||||
451
pyPhotoAlbum/main.py
Normal file
@ -0,0 +1,451 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Refactored main application entry point for pyPhotoAlbum
|
||||
|
||||
This version uses the mixin architecture with auto-generated ribbon configuration.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication,
|
||||
QMainWindow,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
QStatusBar,
|
||||
QScrollBar,
|
||||
QHBoxLayout,
|
||||
QMessageBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QSize, QTimer
|
||||
from PyQt6.QtGui import QIcon
|
||||
|
||||
from pyPhotoAlbum.project import Project
|
||||
from pyPhotoAlbum.template_manager import TemplateManager
|
||||
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
|
||||
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
|
||||
from pyPhotoAlbum.mixins.operations import (
|
||||
FileOperationsMixin,
|
||||
EditOperationsMixin,
|
||||
ElementOperationsMixin,
|
||||
PageOperationsMixin,
|
||||
TemplateOperationsMixin,
|
||||
ViewOperationsMixin,
|
||||
AlignmentOperationsMixin,
|
||||
DistributionOperationsMixin,
|
||||
SizeOperationsMixin,
|
||||
ZOrderOperationsMixin,
|
||||
MergeOperationsMixin,
|
||||
StyleOperationsMixin,
|
||||
)
|
||||
|
||||
|
||||
class MainWindow(
|
||||
QMainWindow,
|
||||
ApplicationStateMixin,
|
||||
AssetPathMixin,
|
||||
FileOperationsMixin,
|
||||
EditOperationsMixin,
|
||||
ElementOperationsMixin,
|
||||
PageOperationsMixin,
|
||||
TemplateOperationsMixin,
|
||||
ViewOperationsMixin,
|
||||
AlignmentOperationsMixin,
|
||||
DistributionOperationsMixin,
|
||||
SizeOperationsMixin,
|
||||
ZOrderOperationsMixin,
|
||||
MergeOperationsMixin,
|
||||
StyleOperationsMixin,
|
||||
):
|
||||
"""
|
||||
Main application window using mixin architecture.
|
||||
|
||||
This class composes functionality from multiple mixins rather than
|
||||
implementing everything directly. The ribbon configuration is
|
||||
automatically generated from decorated methods in the mixins.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
# Initialize autosave manager
|
||||
self._autosave_manager = AutosaveManager()
|
||||
|
||||
# Initialize shared state first
|
||||
self._init_state()
|
||||
|
||||
# Initialize UI
|
||||
self._init_ui()
|
||||
|
||||
# Check for checkpoint recovery
|
||||
self._check_checkpoint_recovery()
|
||||
|
||||
# Setup autosave timer (every 5 minutes)
|
||||
self._autosave_timer = QTimer(self)
|
||||
self._autosave_timer.timeout.connect(self._perform_autosave)
|
||||
self._autosave_timer.start(5 * 60 * 1000) # 5 minutes in milliseconds
|
||||
|
||||
# Add a sample page for demonstration
|
||||
# self._add_sample_page()
|
||||
|
||||
def _init_state(self):
|
||||
"""Initialize shared application state"""
|
||||
# Initialize project
|
||||
self._project = Project("My Photo Album")
|
||||
|
||||
# Set asset resolution context
|
||||
from pyPhotoAlbum.models import set_asset_resolution_context
|
||||
|
||||
set_asset_resolution_context(self._project.folder_path)
|
||||
|
||||
# Initialize template manager
|
||||
self._template_manager = TemplateManager()
|
||||
|
||||
def _init_ui(self):
|
||||
"""Initialize user interface"""
|
||||
# Basic window setup
|
||||
self.setWindowTitle("pyPhotoAlbum")
|
||||
self.resize(1200, 800)
|
||||
|
||||
# Set window icon
|
||||
icon_path = Path(__file__).parent / "icons" / "icon.png"
|
||||
print(f"Window icon path: {icon_path}")
|
||||
print(f"Icon exists: {icon_path.exists()}")
|
||||
if icon_path.exists():
|
||||
icon = QIcon(str(icon_path))
|
||||
print(f"Icon is null: {icon.isNull()}")
|
||||
self.setWindowIcon(icon)
|
||||
|
||||
# Create main widget with layout
|
||||
main_widget = QWidget()
|
||||
main_layout = QVBoxLayout()
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.setSpacing(0)
|
||||
main_widget.setLayout(main_layout)
|
||||
|
||||
# Build ribbon config from decorated methods
|
||||
ribbon_config = build_ribbon_config(self.__class__)
|
||||
|
||||
# Print summary (for debugging)
|
||||
print_ribbon_summary(ribbon_config)
|
||||
|
||||
# Create ribbon with auto-generated config
|
||||
self.ribbon = RibbonWidget(self, ribbon_config)
|
||||
main_layout.addWidget(self.ribbon, 0)
|
||||
|
||||
# Create canvas area with GL widget and scroll bars
|
||||
canvas_widget = QWidget()
|
||||
canvas_layout = QVBoxLayout()
|
||||
canvas_layout.setContentsMargins(0, 0, 0, 0)
|
||||
canvas_layout.setSpacing(0)
|
||||
|
||||
# Top row: GL widget + vertical scrollbar
|
||||
top_layout = QHBoxLayout()
|
||||
top_layout.setContentsMargins(0, 0, 0, 0)
|
||||
top_layout.setSpacing(0)
|
||||
|
||||
# Create OpenGL widget
|
||||
self._gl_widget = GLWidget(self)
|
||||
top_layout.addWidget(self._gl_widget, 1)
|
||||
|
||||
# Vertical scrollbar
|
||||
self._v_scrollbar = QScrollBar(Qt.Orientation.Vertical)
|
||||
self._v_scrollbar.setRange(-10000, 10000)
|
||||
self._v_scrollbar.setValue(0)
|
||||
self._v_scrollbar.valueChanged.connect(self._on_vertical_scroll)
|
||||
top_layout.addWidget(self._v_scrollbar, 0)
|
||||
|
||||
canvas_layout.addLayout(top_layout, 1)
|
||||
|
||||
# Bottom row: horizontal scrollbar
|
||||
self._h_scrollbar = QScrollBar(Qt.Orientation.Horizontal)
|
||||
self._h_scrollbar.setRange(-10000, 10000)
|
||||
self._h_scrollbar.setValue(0)
|
||||
self._h_scrollbar.valueChanged.connect(self._on_horizontal_scroll)
|
||||
canvas_layout.addWidget(self._h_scrollbar, 0)
|
||||
|
||||
canvas_widget.setLayout(canvas_layout)
|
||||
main_layout.addWidget(canvas_widget, 1)
|
||||
|
||||
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)
|
||||
|
||||
# Register keyboard shortcuts
|
||||
self._register_shortcuts()
|
||||
|
||||
# Track scrollbar updates to prevent feedback loops
|
||||
self._updating_scrollbars = False
|
||||
# Track scrollbar visibility changes to prevent resize-triggered recentering
|
||||
self._updating_scrollbar_visibility = False
|
||||
|
||||
def _on_vertical_scroll(self, value):
|
||||
"""Handle vertical scrollbar changes"""
|
||||
if not self._updating_scrollbars:
|
||||
# Invert scrollbar value to pan offset (scrolling down = negative pan)
|
||||
self._gl_widget.pan_offset[1] = -value
|
||||
self._gl_widget.update()
|
||||
|
||||
def _on_horizontal_scroll(self, value):
|
||||
"""Handle horizontal scrollbar changes"""
|
||||
if not self._updating_scrollbars:
|
||||
# Invert scrollbar value to pan offset (scrolling right = negative pan)
|
||||
self._gl_widget.pan_offset[0] = -value
|
||||
self._gl_widget.update()
|
||||
|
||||
def update_scrollbars(self):
|
||||
"""Update scrollbar positions and ranges based on current content and pan offset"""
|
||||
self._updating_scrollbars = True
|
||||
|
||||
# Block signals to prevent feedback loop
|
||||
self._v_scrollbar.blockSignals(True)
|
||||
self._h_scrollbar.blockSignals(True)
|
||||
|
||||
# Get content bounds
|
||||
bounds = self._gl_widget.get_content_bounds()
|
||||
viewport_width = self._gl_widget.width()
|
||||
viewport_height = self._gl_widget.height()
|
||||
|
||||
content_height = bounds["height"]
|
||||
content_width = bounds["width"]
|
||||
|
||||
# Vertical scrollbar
|
||||
# Scrollbar value 0 = top of content
|
||||
# Scrollbar value max = bottom of content
|
||||
# Pan offset is inverted: positive pan = content moved down = view at top
|
||||
# negative pan = content moved up = view at bottom
|
||||
v_range = int(max(0, content_height - viewport_height))
|
||||
self._v_scrollbar.setRange(0, v_range)
|
||||
self._v_scrollbar.setPageStep(int(viewport_height))
|
||||
# Invert pan_offset for scrollbar position
|
||||
self._v_scrollbar.setValue(int(max(0, min(v_range, -self._gl_widget.pan_offset[1]))))
|
||||
|
||||
# Show/hide vertical scrollbar based on whether scrolling is needed
|
||||
# Set flag to prevent resizeGL from recentering when scrollbar visibility changes
|
||||
self._updating_scrollbar_visibility = True
|
||||
self._v_scrollbar.setVisible(v_range > 0)
|
||||
|
||||
# Horizontal scrollbar
|
||||
h_range = int(max(0, content_width - viewport_width))
|
||||
self._h_scrollbar.setRange(0, h_range)
|
||||
self._h_scrollbar.setPageStep(int(viewport_width))
|
||||
# Invert pan_offset for scrollbar position
|
||||
self._h_scrollbar.setValue(int(max(0, min(h_range, -self._gl_widget.pan_offset[0]))))
|
||||
|
||||
# Show/hide horizontal scrollbar based on whether scrolling is needed
|
||||
self._h_scrollbar.setVisible(h_range > 0)
|
||||
self._updating_scrollbar_visibility = False
|
||||
|
||||
# Unblock signals
|
||||
self._v_scrollbar.blockSignals(False)
|
||||
self._h_scrollbar.blockSignals(False)
|
||||
|
||||
self._updating_scrollbars = False
|
||||
|
||||
def _register_shortcuts(self):
|
||||
"""Register keyboard shortcuts from decorated methods"""
|
||||
from PyQt6.QtGui import QShortcut, QKeySequence
|
||||
from pyPhotoAlbum.ribbon_builder import get_keyboard_shortcuts
|
||||
|
||||
shortcuts = get_keyboard_shortcuts(self.__class__)
|
||||
|
||||
for shortcut_str, method_name in shortcuts.items():
|
||||
if hasattr(self, method_name):
|
||||
shortcut = QShortcut(QKeySequence(shortcut_str), self)
|
||||
method = getattr(self, method_name)
|
||||
shortcut.activated.connect(method)
|
||||
print(f"Registered shortcut: {shortcut_str} -> {method_name}")
|
||||
|
||||
# Register additional Ctrl+Shift+Z shortcut for 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")
|
||||
|
||||
def resizeEvent(self, event):
|
||||
"""Handle window resize to reposition loading widget"""
|
||||
super().resizeEvent(event)
|
||||
if hasattr(self, "_loading_widget"):
|
||||
self._loading_widget.resizeParent()
|
||||
|
||||
def _add_sample_page(self):
|
||||
"""Add a sample page with some elements for demonstration"""
|
||||
from pyPhotoAlbum.project import Page
|
||||
from pyPhotoAlbum.page_layout import PageLayout, GridLayout
|
||||
from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
|
||||
|
||||
# Create a page with project default size
|
||||
width_mm, height_mm = self.project.page_size_mm
|
||||
page_layout = PageLayout(width=width_mm, height=height_mm)
|
||||
grid = GridLayout(rows=2, columns=2, spacing=20.0)
|
||||
page_layout.set_grid_layout(grid)
|
||||
|
||||
# Add some sample elements (scaled to new default size)
|
||||
image = ImageData(image_path="sample.jpg", x=20, y=20, width=50, height=50)
|
||||
page_layout.add_element(image)
|
||||
|
||||
text_box = TextBoxData(text_content="Sample Text", x=80, y=20, width=50, height=20)
|
||||
page_layout.add_element(text_box)
|
||||
|
||||
placeholder = PlaceholderData(placeholder_type="image", x=20, y=80, width=50, height=50)
|
||||
page_layout.add_element(placeholder)
|
||||
|
||||
# Create and add the page
|
||||
page = Page(layout=page_layout, page_number=1)
|
||||
page.manually_sized = False # Not manually sized, uses defaults
|
||||
self.project.add_page(page)
|
||||
|
||||
def _perform_autosave(self):
|
||||
"""Perform automatic checkpoint save"""
|
||||
if self.project and self.project.is_dirty():
|
||||
success, message = self._autosave_manager.create_checkpoint(self.project)
|
||||
if success:
|
||||
print(f"Autosave: {message}")
|
||||
else:
|
||||
print(f"Autosave failed: {message}")
|
||||
|
||||
def _check_checkpoint_recovery(self):
|
||||
"""Check for available checkpoints on startup and offer recovery"""
|
||||
if not self._autosave_manager.has_checkpoints():
|
||||
return
|
||||
|
||||
# Get the latest checkpoint
|
||||
checkpoint_info = self._autosave_manager.get_latest_checkpoint()
|
||||
if not checkpoint_info:
|
||||
return
|
||||
|
||||
checkpoint_path, metadata = checkpoint_info
|
||||
project_name = metadata.get("project_name", "Unknown")
|
||||
timestamp_str = metadata.get("timestamp", "Unknown time")
|
||||
|
||||
# Parse timestamp for better display
|
||||
try:
|
||||
timestamp = datetime.fromisoformat(timestamp_str)
|
||||
time_display = timestamp.strftime("%Y-%m-%d %H:%M:%S")
|
||||
except:
|
||||
time_display = timestamp_str
|
||||
|
||||
# Ask user if they want to recover
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Checkpoint Recovery",
|
||||
f"A checkpoint was found:\n\n"
|
||||
f"Project: {project_name}\n"
|
||||
f"Time: {time_display}\n\n"
|
||||
f"Would you like to recover this checkpoint?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.Yes,
|
||||
)
|
||||
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
# Load the checkpoint
|
||||
success, result = self._autosave_manager.load_checkpoint(checkpoint_path)
|
||||
|
||||
if success:
|
||||
# Replace current project with recovered one
|
||||
if hasattr(self, "_project") and self._project:
|
||||
self._project.cleanup()
|
||||
|
||||
self._project = result
|
||||
self.gl_widget.current_page_index = 0
|
||||
self.update_view()
|
||||
|
||||
self.show_status(f"Recovered checkpoint: {project_name}")
|
||||
print(f"Successfully recovered checkpoint: {project_name}")
|
||||
else:
|
||||
error_msg = f"Failed to recover checkpoint: {result}"
|
||||
self.show_error("Recovery Failed", error_msg)
|
||||
print(error_msg)
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Handle window close event"""
|
||||
# Check if project has unsaved changes
|
||||
if self.project and self.project.is_dirty():
|
||||
reply = QMessageBox.question(
|
||||
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,
|
||||
)
|
||||
|
||||
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
|
||||
elif reply == QMessageBox.StandardButton.Cancel:
|
||||
# User cancelled exit
|
||||
event.ignore()
|
||||
return
|
||||
# If Discard, continue with exit
|
||||
|
||||
# Clean up checkpoints on successful exit
|
||||
if self.project:
|
||||
self._autosave_manager.delete_all_checkpoints(self.project.name)
|
||||
self.project.cleanup()
|
||||
|
||||
# Stop autosave timer
|
||||
if hasattr(self, "_autosave_timer"):
|
||||
self._autosave_timer.stop()
|
||||
|
||||
# Cleanup old checkpoints
|
||||
self._autosave_manager.cleanup_old_checkpoints()
|
||||
|
||||
event.accept()
|
||||
|
||||
|
||||
def main():
|
||||
"""Application entry point"""
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
# Set application identity for proper taskbar/window manager integration
|
||||
app.setApplicationName("pyPhotoAlbum")
|
||||
app.setApplicationDisplayName("pyPhotoAlbum")
|
||||
app.setDesktopFileName("pyphotoalbum.desktop")
|
||||
|
||||
# Set application icon
|
||||
icon_path = Path(__file__).parent / "icons" / "icon.png"
|
||||
print(f"Application icon path: {icon_path}")
|
||||
print(f"Icon exists: {icon_path.exists()}")
|
||||
if icon_path.exists():
|
||||
icon = QIcon(str(icon_path))
|
||||
print(f"Icon is null: {icon.isNull()}")
|
||||
app.setWindowIcon(icon)
|
||||
|
||||
# Enable high DPI scaling
|
||||
try:
|
||||
app.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True)
|
||||
app.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps, True)
|
||||
except AttributeError:
|
||||
pass # Qt version doesn't support these attributes
|
||||
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
368
pyPhotoAlbum/merge_dialog.py
Normal file
@ -0,0 +1,368 @@
|
||||
"""
|
||||
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,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QSize, pyqtSignal
|
||||
from PyQt6.QtGui import QPixmap, QPainter, QColor, QFont, QPen
|
||||
|
||||
from typing import Dict, Any, List, Optional
|
||||
from pyPhotoAlbum.merge_manager import MergeManager, ConflictInfo, MergeStrategy
|
||||
from pyPhotoAlbum.page_renderer import PageRenderer
|
||||
|
||||
|
||||
class PagePreviewWidget(QWidget):
|
||||
"""Widget to render a page preview"""
|
||||
|
||||
def __init__(self, page_data: Dict[str, Any], parent=None):
|
||||
super().__init__(parent)
|
||||
self.page_data = page_data
|
||||
self.setMinimumSize(200, 280)
|
||||
self.setSizePolicy(self.sizePolicy().Policy.Expanding, self.sizePolicy().Policy.Expanding)
|
||||
|
||||
def paintEvent(self, event):
|
||||
"""Render the page preview"""
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
|
||||
# Draw white background
|
||||
painter.fillRect(self.rect(), QColor(255, 255, 255))
|
||||
|
||||
# Draw border
|
||||
painter.setPen(QPen(QColor(200, 200, 200), 2))
|
||||
painter.drawRect(self.rect().adjusted(1, 1, -1, -1))
|
||||
|
||||
# Draw placeholder text
|
||||
painter.setPen(QColor(100, 100, 100))
|
||||
font = QFont("Arial", 10)
|
||||
painter.setFont(font)
|
||||
|
||||
# Page info
|
||||
page_num = self.page_data.get("page_number", "?")
|
||||
element_count = len(self.page_data.get("layout", {}).get("elements", []))
|
||||
last_modified = self.page_data.get("last_modified", "Unknown")
|
||||
|
||||
# Draw simplified representation
|
||||
y_offset = 20
|
||||
painter.drawText(10, y_offset, f"Page {page_num}")
|
||||
y_offset += 20
|
||||
painter.drawText(10, y_offset, f"Elements: {element_count}")
|
||||
y_offset += 20
|
||||
|
||||
# Draw element representations
|
||||
elements = self.page_data.get("layout", {}).get("elements", [])
|
||||
for i, elem in enumerate(elements[:5]): # Show first 5 elements
|
||||
elem_type = elem.get("type", "unknown")
|
||||
deleted = elem.get("deleted", False)
|
||||
|
||||
color = QColor(200, 200, 200) if deleted else QColor(100, 150, 200)
|
||||
painter.setBrush(color)
|
||||
painter.setPen(QPen(color.darker(120), 1))
|
||||
|
||||
# Draw small rectangle representing element
|
||||
x = 10 + (i % 3) * 60
|
||||
y = y_offset + (i // 3) * 60
|
||||
painter.drawRect(x, y, 50, 50)
|
||||
|
||||
# Draw type label
|
||||
painter.setPen(QColor(0, 0, 0))
|
||||
painter.drawText(x + 5, y + 25, elem_type[:3].upper())
|
||||
|
||||
# Draw timestamp at bottom
|
||||
painter.setPen(QColor(100, 100, 100))
|
||||
painter.setFont(QFont("Arial", 8))
|
||||
modified_text = last_modified[:19] if last_modified else "No timestamp"
|
||||
painter.drawText(10, self.height() - 10, modified_text)
|
||||
|
||||
|
||||
class ConflictItemWidget(QWidget):
|
||||
"""Widget for displaying and resolving a single conflict"""
|
||||
|
||||
resolution_changed = pyqtSignal(int, str) # conflict_index, choice ("ours" or "theirs")
|
||||
|
||||
def __init__(self, conflict_index: int, conflict: ConflictInfo, parent=None):
|
||||
super().__init__(parent)
|
||||
self.conflict_index = conflict_index
|
||||
self.conflict = conflict
|
||||
|
||||
self._init_ui()
|
||||
|
||||
def _init_ui(self):
|
||||
"""Initialize the UI"""
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Conflict description
|
||||
desc_label = QLabel(f"<b>Conflict {self.conflict_index + 1}:</b> {self.conflict.description}")
|
||||
desc_label.setWordWrap(True)
|
||||
layout.addWidget(desc_label)
|
||||
|
||||
# Splitter for side-by-side comparison
|
||||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
|
||||
# Our version
|
||||
our_widget = QGroupBox("Your Version")
|
||||
our_layout = QVBoxLayout()
|
||||
|
||||
if self.conflict.conflict_type.name.startswith("PAGE"):
|
||||
# Show page preview
|
||||
our_preview = PagePreviewWidget(self.conflict.our_version)
|
||||
our_layout.addWidget(our_preview)
|
||||
elif self.conflict.conflict_type.name.startswith("ELEMENT"):
|
||||
# Show element details
|
||||
our_details = self._create_element_details(self.conflict.our_version)
|
||||
our_layout.addWidget(our_details)
|
||||
else:
|
||||
# Show settings
|
||||
our_details = self._create_settings_details(self.conflict.our_version)
|
||||
our_layout.addWidget(our_details)
|
||||
|
||||
our_widget.setLayout(our_layout)
|
||||
splitter.addWidget(our_widget)
|
||||
|
||||
# Their version
|
||||
their_widget = QGroupBox("Other Version")
|
||||
their_layout = QVBoxLayout()
|
||||
|
||||
if self.conflict.conflict_type.name.startswith("PAGE"):
|
||||
# Show page preview
|
||||
their_preview = PagePreviewWidget(self.conflict.their_version)
|
||||
their_layout.addWidget(their_preview)
|
||||
elif self.conflict.conflict_type.name.startswith("ELEMENT"):
|
||||
# Show element details
|
||||
their_details = self._create_element_details(self.conflict.their_version)
|
||||
their_layout.addWidget(their_details)
|
||||
else:
|
||||
# Show settings
|
||||
their_details = self._create_settings_details(self.conflict.their_version)
|
||||
their_layout.addWidget(their_details)
|
||||
|
||||
their_widget.setLayout(their_layout)
|
||||
splitter.addWidget(their_widget)
|
||||
|
||||
layout.addWidget(splitter)
|
||||
|
||||
# Resolution buttons
|
||||
resolution_layout = QHBoxLayout()
|
||||
|
||||
self.button_group = QButtonGroup(self)
|
||||
|
||||
use_ours_btn = QRadioButton("Use Your Version")
|
||||
use_ours_btn.setChecked(True)
|
||||
use_ours_btn.toggled.connect(lambda checked: self._on_resolution_changed("ours") if checked else None)
|
||||
self.button_group.addButton(use_ours_btn)
|
||||
resolution_layout.addWidget(use_ours_btn)
|
||||
|
||||
use_theirs_btn = QRadioButton("Use Other Version")
|
||||
use_theirs_btn.toggled.connect(lambda checked: self._on_resolution_changed("theirs") if checked else None)
|
||||
self.button_group.addButton(use_theirs_btn)
|
||||
resolution_layout.addWidget(use_theirs_btn)
|
||||
|
||||
resolution_layout.addStretch()
|
||||
layout.addLayout(resolution_layout)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def _create_element_details(self, element_data: Dict[str, Any]) -> QTextEdit:
|
||||
"""Create a text widget showing element details"""
|
||||
details = QTextEdit()
|
||||
details.setReadOnly(True)
|
||||
details.setMaximumHeight(150)
|
||||
|
||||
elem_type = element_data.get("type", "unknown")
|
||||
position = element_data.get("position", (0, 0))
|
||||
size = element_data.get("size", (0, 0))
|
||||
deleted = element_data.get("deleted", False)
|
||||
last_modified = element_data.get("last_modified", "Unknown")
|
||||
|
||||
text = f"Type: {elem_type}\n"
|
||||
text += f"Position: ({position[0]:.1f}, {position[1]:.1f})\n"
|
||||
text += f"Size: ({size[0]:.1f} × {size[1]:.1f})\n"
|
||||
text += f"Deleted: {deleted}\n"
|
||||
text += f"Modified: {last_modified[:19] if last_modified else 'Unknown'}\n"
|
||||
|
||||
if elem_type == "image":
|
||||
text += f"Image: {element_data.get('image_path', 'N/A')}\n"
|
||||
elif elem_type == "textbox":
|
||||
text += f"Text: {element_data.get('text_content', '')[:50]}...\n"
|
||||
|
||||
details.setPlainText(text)
|
||||
return details
|
||||
|
||||
def _create_settings_details(self, settings_data: Dict[str, Any]) -> QTextEdit:
|
||||
"""Create a text widget showing settings details"""
|
||||
details = QTextEdit()
|
||||
details.setReadOnly(True)
|
||||
details.setMaximumHeight(150)
|
||||
|
||||
text = ""
|
||||
for key, value in settings_data.items():
|
||||
if key != "last_modified":
|
||||
text += f"{key}: {value}\n"
|
||||
|
||||
last_modified = settings_data.get("last_modified", "Unknown")
|
||||
text += f"\nModified: {last_modified[:19] if last_modified else 'Unknown'}"
|
||||
|
||||
details.setPlainText(text)
|
||||
return details
|
||||
|
||||
def _on_resolution_changed(self, choice: str):
|
||||
"""Emit signal when resolution choice changes"""
|
||||
self.resolution_changed.emit(self.conflict_index, choice)
|
||||
|
||||
def get_resolution(self) -> str:
|
||||
"""Get the current resolution choice"""
|
||||
for button in self.button_group.buttons():
|
||||
if button.isChecked():
|
||||
if "Your" in button.text():
|
||||
return "ours"
|
||||
else:
|
||||
return "theirs"
|
||||
return "ours" # Default
|
||||
|
||||
|
||||
class MergeDialog(QDialog):
|
||||
"""
|
||||
Dialog for visually resolving merge conflicts between two project versions
|
||||
"""
|
||||
|
||||
def __init__(self, our_project_data: Dict[str, Any], their_project_data: Dict[str, Any], parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.our_project_data = our_project_data
|
||||
self.their_project_data = their_project_data
|
||||
self.merge_manager = MergeManager()
|
||||
|
||||
# Detect conflicts
|
||||
self.conflicts = self.merge_manager.detect_conflicts(our_project_data, their_project_data)
|
||||
|
||||
# Resolution choices (conflict_index -> "ours" or "theirs")
|
||||
self.resolutions: Dict[int, str] = {}
|
||||
|
||||
# Initialize default resolutions (all "ours")
|
||||
for i in range(len(self.conflicts)):
|
||||
self.resolutions[i] = "ours"
|
||||
|
||||
self.setWindowTitle("Merge Projects")
|
||||
self.resize(900, 700)
|
||||
|
||||
self._init_ui()
|
||||
|
||||
def _init_ui(self):
|
||||
"""Initialize the user interface"""
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Header
|
||||
header_label = QLabel(
|
||||
f"<h2>Merge Conflicts Detected</h2>"
|
||||
f"<p>Your project: <b>{self.our_project_data.get('name', 'Untitled')}</b> "
|
||||
f"(modified {self.our_project_data.get('last_modified', 'unknown')[:19]})</p>"
|
||||
f"<p>Other project: <b>{self.their_project_data.get('name', 'Untitled')}</b> "
|
||||
f"(modified {self.their_project_data.get('last_modified', 'unknown')[:19]})</p>"
|
||||
f"<p>Found <b>{len(self.conflicts)}</b> conflict(s) requiring resolution.</p>"
|
||||
)
|
||||
header_label.setWordWrap(True)
|
||||
layout.addWidget(header_label)
|
||||
|
||||
# Auto-resolve strategy
|
||||
strategy_layout = QHBoxLayout()
|
||||
strategy_layout.addWidget(QLabel("Auto-resolve all:"))
|
||||
|
||||
self.strategy_combo = QComboBox()
|
||||
self.strategy_combo.addItem("Latest Wins", MergeStrategy.LATEST_WINS)
|
||||
self.strategy_combo.addItem("Always Use Yours", MergeStrategy.OURS)
|
||||
self.strategy_combo.addItem("Always Use Theirs", MergeStrategy.THEIRS)
|
||||
strategy_layout.addWidget(self.strategy_combo)
|
||||
|
||||
auto_resolve_btn = QPushButton("Auto-Resolve All")
|
||||
auto_resolve_btn.clicked.connect(self._auto_resolve)
|
||||
strategy_layout.addWidget(auto_resolve_btn)
|
||||
|
||||
strategy_layout.addStretch()
|
||||
layout.addLayout(strategy_layout)
|
||||
|
||||
# Scroll area for conflicts
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
|
||||
conflicts_widget = QWidget()
|
||||
conflicts_layout = QVBoxLayout()
|
||||
|
||||
# Create conflict widgets
|
||||
self.conflict_widgets: List[ConflictItemWidget] = []
|
||||
for i, conflict in enumerate(self.conflicts):
|
||||
conflict_widget = ConflictItemWidget(i, conflict)
|
||||
conflict_widget.resolution_changed.connect(self._on_resolution_changed)
|
||||
self.conflict_widgets.append(conflict_widget)
|
||||
conflicts_layout.addWidget(conflict_widget)
|
||||
|
||||
conflicts_layout.addStretch()
|
||||
conflicts_widget.setLayout(conflicts_layout)
|
||||
scroll.setWidget(conflicts_widget)
|
||||
|
||||
layout.addWidget(scroll)
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.addStretch()
|
||||
|
||||
cancel_button = QPushButton("Cancel")
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
button_layout.addWidget(cancel_button)
|
||||
|
||||
merge_button = QPushButton("Apply Merge")
|
||||
merge_button.clicked.connect(self.accept)
|
||||
merge_button.setDefault(True)
|
||||
button_layout.addWidget(merge_button)
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def _on_resolution_changed(self, conflict_index: int, choice: str):
|
||||
"""Handle resolution choice change"""
|
||||
self.resolutions[conflict_index] = choice
|
||||
|
||||
def _auto_resolve(self):
|
||||
"""Auto-resolve all conflicts based on selected strategy"""
|
||||
strategy = self.strategy_combo.currentData()
|
||||
auto_resolutions = self.merge_manager.auto_resolve_conflicts(strategy)
|
||||
|
||||
# Update resolution choices
|
||||
self.resolutions.update(auto_resolutions)
|
||||
|
||||
# Update UI to reflect auto-resolutions
|
||||
for i, resolution in auto_resolutions.items():
|
||||
if i < len(self.conflict_widgets):
|
||||
# Find the correct radio button and check it
|
||||
for button in self.conflict_widgets[i].button_group.buttons():
|
||||
if resolution == "ours" and "Your" in button.text():
|
||||
button.setChecked(True)
|
||||
elif resolution == "theirs" and "Other" in button.text():
|
||||
button.setChecked(True)
|
||||
|
||||
def get_merged_project_data(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get the merged project data based on user's conflict resolutions.
|
||||
|
||||
Returns:
|
||||
Merged project data dictionary
|
||||
"""
|
||||
return self.merge_manager.apply_resolutions(self.our_project_data, self.their_project_data, self.resolutions)
|
||||
504
pyPhotoAlbum/merge_manager.py
Normal file
@ -0,0 +1,504 @@
|
||||
"""
|
||||
Merge manager for handling project merge conflicts
|
||||
|
||||
This module provides functionality for:
|
||||
- Detecting when two projects should be merged vs. concatenated
|
||||
- Finding conflicts between two project versions
|
||||
- Resolving conflicts based on user input or automatic strategies
|
||||
"""
|
||||
|
||||
import copy
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass
|
||||
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
|
||||
PAGE_ADDED_BOTH = "page_added_both" # Same page number added in both (rare)
|
||||
|
||||
# Element-level conflicts
|
||||
ELEMENT_MODIFIED_BOTH = "element_modified_both" # Element modified in both versions
|
||||
ELEMENT_DELETED_ONE = "element_deleted_one" # Element deleted in one, modified in other
|
||||
|
||||
# Project-level conflicts
|
||||
SETTINGS_MODIFIED_BOTH = "settings_modified_both" # Project settings changed in both
|
||||
|
||||
|
||||
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
|
||||
MANUAL = "manual" # Require manual resolution
|
||||
|
||||
|
||||
@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)
|
||||
our_version: Any # Our version of the conflicted item
|
||||
their_version: Any # Their version of the conflicted item
|
||||
description: str # Human-readable description
|
||||
|
||||
|
||||
class MergeManager:
|
||||
"""Manages merge operations between two project versions"""
|
||||
|
||||
def __init__(self):
|
||||
self.conflicts: List[ConflictInfo] = []
|
||||
|
||||
def should_merge_projects(self, project_a_data: Dict[str, Any], project_b_data: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Determine if two projects should be merged or concatenated.
|
||||
|
||||
Projects with the same project_id should be merged (conflict resolution).
|
||||
Projects with different project_ids should be concatenated (combine content).
|
||||
|
||||
Args:
|
||||
project_a_data: First project's serialized data
|
||||
project_b_data: Second project's serialized data
|
||||
|
||||
Returns:
|
||||
True if projects should be merged, False if concatenated
|
||||
"""
|
||||
project_a_id = project_a_data.get("project_id")
|
||||
project_b_id = project_b_data.get("project_id")
|
||||
|
||||
# If either project lacks a project_id (v2.0 or earlier), assume different projects
|
||||
if not project_a_id or not project_b_id:
|
||||
print("MergeManager: One or both projects lack project_id, assuming concatenation")
|
||||
return False
|
||||
|
||||
return bool(project_a_id == project_b_id)
|
||||
|
||||
def detect_conflicts(
|
||||
self, our_project_data: Dict[str, Any], their_project_data: Dict[str, Any]
|
||||
) -> List[ConflictInfo]:
|
||||
"""
|
||||
Detect conflicts between two versions of the same project.
|
||||
|
||||
Args:
|
||||
our_project_data: Our version of the project (serialized)
|
||||
their_project_data: Their version of the project (serialized)
|
||||
|
||||
Returns:
|
||||
List of conflicts found
|
||||
"""
|
||||
self.conflicts = []
|
||||
|
||||
# Detect project-level conflicts
|
||||
self._detect_project_settings_conflicts(our_project_data, their_project_data)
|
||||
|
||||
# Detect page-level conflicts
|
||||
self._detect_page_conflicts(our_project_data, their_project_data)
|
||||
|
||||
return self.conflicts
|
||||
|
||||
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",
|
||||
]
|
||||
|
||||
our_modified = our_data.get("last_modified")
|
||||
their_modified = their_data.get("last_modified")
|
||||
|
||||
for key in settings_keys:
|
||||
our_value = our_data.get(key)
|
||||
their_value = their_data.get(key)
|
||||
|
||||
# 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",
|
||||
)
|
||||
)
|
||||
|
||||
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", [])}
|
||||
|
||||
# Check all pages that exist in our version
|
||||
for page_uuid, our_page in our_pages.items():
|
||||
their_page = their_pages.get(page_uuid)
|
||||
|
||||
if their_page is None:
|
||||
# Page exists in ours but not theirs - check if deleted
|
||||
if our_page.get("deleted"):
|
||||
continue # Both deleted, no conflict
|
||||
# We have it, they don't (might have deleted it)
|
||||
# This could be a conflict if we modified it after they deleted it
|
||||
continue
|
||||
|
||||
# Page exists in both - check for modifications
|
||||
self._detect_page_modification_conflicts(page_uuid, our_page, their_page)
|
||||
|
||||
# Check for pages that exist only in their version
|
||||
for page_uuid, their_page in their_pages.items():
|
||||
if page_uuid not in our_pages:
|
||||
# They have a page we don't - this is fine, add it
|
||||
# Unless we deleted it
|
||||
pass
|
||||
|
||||
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")
|
||||
|
||||
# Check if both deleted
|
||||
if our_page.get("deleted") and their_page.get("deleted"):
|
||||
return # No conflict
|
||||
|
||||
# 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",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
# Check page-level properties
|
||||
page_props = ["page_number", "is_cover", "is_double_spread"]
|
||||
page_modified = False
|
||||
for prop in page_props:
|
||||
if our_page.get(prop) != their_page.get(prop):
|
||||
page_modified = True
|
||||
break
|
||||
|
||||
# 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)",
|
||||
)
|
||||
)
|
||||
|
||||
# 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]):
|
||||
"""Detect conflicts in elements within a page."""
|
||||
our_layout = our_page.get("layout", {})
|
||||
their_layout = their_page.get("layout", {})
|
||||
|
||||
our_elements = {elem["uuid"]: elem for elem in our_layout.get("elements", [])}
|
||||
their_elements = {elem["uuid"]: elem for elem in their_layout.get("elements", [])}
|
||||
|
||||
# Check all elements in our version
|
||||
for elem_uuid, our_elem in our_elements.items():
|
||||
their_elem = their_elements.get(elem_uuid)
|
||||
|
||||
if their_elem is None:
|
||||
# Element exists in ours but not theirs
|
||||
if our_elem.get("deleted"):
|
||||
continue # Both deleted, no conflict
|
||||
# We have it, they don't
|
||||
continue
|
||||
|
||||
# Element exists in both - check for modifications
|
||||
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]
|
||||
):
|
||||
"""Detect conflicts in a specific element."""
|
||||
our_modified = our_elem.get("last_modified")
|
||||
their_modified = their_elem.get("last_modified")
|
||||
|
||||
# Check if both deleted
|
||||
if our_elem.get("deleted") and their_elem.get("deleted"):
|
||||
return # No conflict
|
||||
|
||||
# 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",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
# Check element properties
|
||||
elem_props = ["position", "size", "rotation", "z_index"]
|
||||
|
||||
# Add type-specific properties
|
||||
elem_type = our_elem.get("type")
|
||||
if elem_type == "image":
|
||||
elem_props.extend(["image_path", "crop_info", "pil_rotation_90"])
|
||||
elif elem_type == "textbox":
|
||||
elem_props.extend(["text_content", "font_settings", "alignment"])
|
||||
|
||||
# Check if any properties differ
|
||||
props_modified = False
|
||||
for prop in elem_props:
|
||||
if our_elem.get(prop) != their_elem.get(prop):
|
||||
props_modified = True
|
||||
break
|
||||
|
||||
# Without a 3-way merge (base version), we cannot reliably detect if BOTH versions
|
||||
# modified an element vs only ONE version modifying it.
|
||||
#
|
||||
# Strategy: Only flag as conflict when we have strong evidence of concurrent modification:
|
||||
# - Properties differ AND timestamps are identical → suspicious, possible conflict
|
||||
# - Properties differ AND timestamps differ → one version modified it, auto-merge by timestamp
|
||||
#
|
||||
# If timestamps differ, _merge_non_conflicting_changes will handle it by using the newer version.
|
||||
if props_modified and our_modified == their_modified:
|
||||
# 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)",
|
||||
)
|
||||
)
|
||||
|
||||
# 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[int, str]:
|
||||
"""
|
||||
Automatically resolve conflicts based on a strategy.
|
||||
|
||||
Args:
|
||||
strategy: The resolution strategy to use
|
||||
|
||||
Returns:
|
||||
Dictionary mapping conflict index to resolution choice ("ours" or "theirs")
|
||||
"""
|
||||
resolutions = {}
|
||||
|
||||
for i, conflict in enumerate(self.conflicts):
|
||||
if strategy == MergeStrategy.LATEST_WINS:
|
||||
# Compare timestamps
|
||||
our_modified = self._get_timestamp(conflict.our_version)
|
||||
their_modified = self._get_timestamp(conflict.their_version)
|
||||
|
||||
if our_modified and their_modified:
|
||||
resolutions[i] = "ours" if our_modified >= their_modified else "theirs"
|
||||
else:
|
||||
resolutions[i] = "ours" # Default to ours if timestamps missing
|
||||
|
||||
elif strategy == MergeStrategy.OURS:
|
||||
resolutions[i] = "ours"
|
||||
|
||||
elif strategy == MergeStrategy.THEIRS:
|
||||
resolutions[i] = "theirs"
|
||||
|
||||
# MANUAL strategy leaves resolutions empty
|
||||
|
||||
return resolutions
|
||||
|
||||
def _get_timestamp(self, version_data: Any) -> Optional[str]:
|
||||
"""Extract timestamp from version data."""
|
||||
if isinstance(version_data, dict):
|
||||
return version_data.get("last_modified")
|
||||
return None
|
||||
|
||||
def apply_resolutions(
|
||||
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.
|
||||
|
||||
Args:
|
||||
our_project_data: Our version of the project
|
||||
their_project_data: Their version of the project
|
||||
resolutions: Dictionary mapping conflict index to choice ("ours" or "theirs")
|
||||
|
||||
Returns:
|
||||
Merged project data
|
||||
"""
|
||||
# Start with a copy of our project
|
||||
merged_data = copy.deepcopy(our_project_data)
|
||||
|
||||
# Apply resolutions
|
||||
for conflict_idx, choice in resolutions.items():
|
||||
if conflict_idx >= len(self.conflicts):
|
||||
continue
|
||||
|
||||
conflict = self.conflicts[conflict_idx]
|
||||
|
||||
if choice == "theirs":
|
||||
# Apply their version
|
||||
self._apply_their_version(merged_data, conflict)
|
||||
# If choice is "ours", no need to do anything
|
||||
|
||||
# Add pages/elements from their version that we don't have
|
||||
self._merge_non_conflicting_changes(merged_data, their_project_data)
|
||||
|
||||
return merged_data
|
||||
|
||||
def _apply_their_version(self, merged_data: Dict[str, Any], conflict: ConflictInfo):
|
||||
"""Apply their version for a specific conflict."""
|
||||
if conflict.conflict_type == ConflictType.SETTINGS_MODIFIED_BOTH:
|
||||
# Update project setting
|
||||
for key, value in conflict.their_version.items():
|
||||
if key != "last_modified":
|
||||
merged_data[key] = value
|
||||
|
||||
elif conflict.conflict_type in [ConflictType.PAGE_MODIFIED_BOTH, ConflictType.PAGE_DELETED_ONE]:
|
||||
# Replace entire page
|
||||
for i, page in enumerate(merged_data.get("pages", [])):
|
||||
if page.get("uuid") == conflict.page_uuid:
|
||||
merged_data["pages"][i] = conflict.their_version
|
||||
break
|
||||
|
||||
elif conflict.conflict_type in [ConflictType.ELEMENT_MODIFIED_BOTH, ConflictType.ELEMENT_DELETED_ONE]:
|
||||
# Replace element within page
|
||||
for page in merged_data.get("pages", []):
|
||||
if page.get("uuid") == conflict.page_uuid:
|
||||
layout = page.get("layout", {})
|
||||
for i, elem in enumerate(layout.get("elements", [])):
|
||||
if elem.get("uuid") == conflict.element_uuid:
|
||||
layout["elements"][i] = conflict.their_version
|
||||
break
|
||||
break
|
||||
|
||||
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]):
|
||||
"""Add pages that exist only in their version."""
|
||||
our_page_uuids = {page["uuid"] for page in merged_data.get("pages", [])}
|
||||
|
||||
for their_page in their_data.get("pages", []):
|
||||
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]):
|
||||
"""For pages that exist in both versions, merge their elements."""
|
||||
their_pages = {page["uuid"]: page for page in their_data.get("pages", [])}
|
||||
|
||||
for our_page in merged_data.get("pages", []):
|
||||
their_page = their_pages.get(our_page["uuid"])
|
||||
if not their_page:
|
||||
continue
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
def _merge_element(
|
||||
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"]
|
||||
|
||||
# Add new elements that we don't have
|
||||
if elem_uuid not in our_elements:
|
||||
our_page["layout"]["elements"].append(their_elem)
|
||||
return
|
||||
|
||||
# Element exists in both - check if already resolved as conflict
|
||||
if self._is_element_in_conflict(elem_uuid, page_uuid):
|
||||
return
|
||||
|
||||
# No conflict - use the more recently modified version
|
||||
self._merge_by_timestamp(our_page, elem_uuid, their_elem, our_elements[elem_uuid])
|
||||
|
||||
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)
|
||||
|
||||
def _merge_by_timestamp(
|
||||
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")
|
||||
their_modified = their_elem.get("last_modified")
|
||||
|
||||
# Their version is newer
|
||||
if not their_modified or (our_modified and their_modified <= our_modified):
|
||||
return
|
||||
|
||||
# Replace with their newer version
|
||||
for i, elem in enumerate(our_page["layout"]["elements"]):
|
||||
if elem["uuid"] == elem_uuid:
|
||||
our_page["layout"]["elements"][i] = their_elem
|
||||
break
|
||||
|
||||
|
||||
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.
|
||||
|
||||
This combines the pages from both projects into a single project.
|
||||
|
||||
Args:
|
||||
project_a_data: First project data
|
||||
project_b_data: Second project data
|
||||
|
||||
Returns:
|
||||
Combined project data
|
||||
"""
|
||||
# Start with project A as base
|
||||
merged_data = copy.deepcopy(project_a_data)
|
||||
|
||||
# Append all pages from project B
|
||||
merged_data["pages"].extend(copy.deepcopy(project_b_data.get("pages", [])))
|
||||
|
||||
# Update project name to indicate merge
|
||||
merged_data["name"] = f"{project_a_data.get('name', 'Untitled')} + {project_b_data.get('name', 'Untitled')}"
|
||||
|
||||
# Keep project A's ID and settings
|
||||
# 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"
|
||||
)
|
||||
|
||||
return merged_data
|
||||
8
pyPhotoAlbum/mixins/__init__.py
Normal file
@ -0,0 +1,8 @@
|
||||
"""
|
||||
Mixin modules for pyPhotoAlbum
|
||||
"""
|
||||
|
||||
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
|
||||
from pyPhotoAlbum.mixins.dialog_mixin import DialogMixin
|
||||
|
||||
__all__ = ["ApplicationStateMixin", "DialogMixin"]
|
||||
156
pyPhotoAlbum/mixins/asset_drop.py
Normal file
@ -0,0 +1,156 @@
|
||||
"""
|
||||
Asset drop mixin for GLWidget - handles drag-and-drop file operations
|
||||
"""
|
||||
|
||||
from pyPhotoAlbum.models import ImageData, PlaceholderData
|
||||
from pyPhotoAlbum.commands import AddElementCommand
|
||||
|
||||
|
||||
class AssetDropMixin:
|
||||
"""
|
||||
Mixin providing drag-and-drop asset functionality.
|
||||
|
||||
This mixin handles dragging image files into the widget and creating
|
||||
or updating ImageData elements.
|
||||
"""
|
||||
|
||||
IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp"]
|
||||
|
||||
def dragEnterEvent(self, event):
|
||||
"""Handle drag enter events"""
|
||||
if event.mimeData().hasUrls():
|
||||
urls = event.mimeData().urls()
|
||||
for url in urls:
|
||||
file_path = url.toLocalFile()
|
||||
if any(file_path.lower().endswith(ext) for ext in self.IMAGE_EXTENSIONS):
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
event.ignore()
|
||||
|
||||
def dragMoveEvent(self, event):
|
||||
"""Handle drag move events"""
|
||||
if event.mimeData().hasUrls():
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
event.ignore()
|
||||
|
||||
def dropEvent(self, event):
|
||||
"""Handle drop events - delegates to specialized handlers"""
|
||||
image_path = self._extract_image_path(event)
|
||||
if not image_path:
|
||||
event.ignore()
|
||||
return
|
||||
|
||||
x, y = event.position().x(), event.position().y()
|
||||
target_element = self._get_element_at(x, y)
|
||||
|
||||
if target_element and isinstance(target_element, (ImageData, PlaceholderData)):
|
||||
self._handle_drop_on_element(image_path, target_element)
|
||||
else:
|
||||
self._handle_drop_on_empty_space(image_path, x, y)
|
||||
|
||||
event.acceptProposedAction()
|
||||
self.update()
|
||||
|
||||
def _extract_image_path(self, event):
|
||||
"""Extract the first valid image path from drop event"""
|
||||
if not event.mimeData().hasUrls():
|
||||
return None
|
||||
|
||||
for url in event.mimeData().urls():
|
||||
file_path = url.toLocalFile()
|
||||
if any(file_path.lower().endswith(ext) for ext in self.IMAGE_EXTENSIONS):
|
||||
return file_path
|
||||
return None
|
||||
|
||||
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):
|
||||
return
|
||||
|
||||
try:
|
||||
asset_path = main_window.project.asset_manager.import_asset(image_path)
|
||||
|
||||
if isinstance(target_element, PlaceholderData):
|
||||
self._replace_placeholder_with_image(target_element, asset_path, main_window)
|
||||
else:
|
||||
target_element.image_path = asset_path
|
||||
|
||||
print(f"Updated element with image: {asset_path}")
|
||||
except Exception as e:
|
||||
print(f"Error importing dropped image: {e}")
|
||||
|
||||
def _replace_placeholder_with_image(self, placeholder, asset_path, main_window):
|
||||
"""Replace a placeholder element with an ImageData element"""
|
||||
new_image = ImageData(
|
||||
image_path=asset_path,
|
||||
x=placeholder.position[0],
|
||||
y=placeholder.position[1],
|
||||
width=placeholder.size[0],
|
||||
height=placeholder.size[1],
|
||||
z_index=placeholder.z_index,
|
||||
# Inherit styling from placeholder (for templatable styles)
|
||||
style=placeholder.style.copy(),
|
||||
)
|
||||
|
||||
if not main_window.project.pages:
|
||||
return
|
||||
|
||||
for page in main_window.project.pages:
|
||||
if placeholder in page.layout.elements:
|
||||
page.layout.elements.remove(placeholder)
|
||||
page.layout.add_element(new_image)
|
||||
break
|
||||
|
||||
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):
|
||||
return
|
||||
|
||||
target_page, page_index, page_renderer = self._get_page_at(x, y)
|
||||
|
||||
if not (target_page and page_renderer):
|
||||
print("Drop location not on any page")
|
||||
return
|
||||
|
||||
try:
|
||||
# Import asset first, then calculate dimensions from imported asset
|
||||
asset_path = main_window.project.asset_manager.import_asset(image_path)
|
||||
full_asset_path = self.get_asset_full_path(asset_path)
|
||||
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
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error importing dropped image: {e}")
|
||||
|
||||
def _calculate_image_dimensions(self, image_path):
|
||||
"""Calculate scaled image dimensions for new image using centralized utility."""
|
||||
from pyPhotoAlbum.async_backend import get_image_dimensions
|
||||
|
||||
# Use centralized utility (max 300px for UI display)
|
||||
dimensions = get_image_dimensions(image_path, max_size=300)
|
||||
if dimensions:
|
||||
return dimensions
|
||||
|
||||
# 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
|
||||
):
|
||||
"""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)
|
||||
|
||||
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}")
|
||||
68
pyPhotoAlbum/mixins/asset_path.py
Normal file
@ -0,0 +1,68 @@
|
||||
"""
|
||||
Asset path resolution mixin for components that need to resolve asset paths.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class AssetPathMixin:
|
||||
"""
|
||||
Mixin providing asset path resolution functionality.
|
||||
|
||||
Requires access to self.project (typically via ApplicationStateMixin).
|
||||
"""
|
||||
|
||||
def resolve_asset_path(self, asset_path: str) -> Optional[str]:
|
||||
"""
|
||||
Resolve a relative asset path to an absolute path.
|
||||
|
||||
Args:
|
||||
asset_path: Relative path (e.g., "assets/photo.jpg") or absolute path
|
||||
|
||||
Returns:
|
||||
Absolute path if the asset exists, None otherwise
|
||||
"""
|
||||
if not asset_path:
|
||||
return None
|
||||
|
||||
# Handle absolute paths
|
||||
if os.path.isabs(asset_path):
|
||||
if os.path.exists(asset_path):
|
||||
return asset_path
|
||||
return None
|
||||
|
||||
# Resolve relative path using project folder
|
||||
project_folder = self._get_project_folder()
|
||||
if project_folder:
|
||||
full_path = os.path.join(project_folder, asset_path)
|
||||
if os.path.exists(full_path):
|
||||
return full_path
|
||||
|
||||
return None
|
||||
|
||||
def get_asset_full_path(self, relative_path: str) -> Optional[str]:
|
||||
"""
|
||||
Get the full path for a relative asset path (without existence check).
|
||||
|
||||
Args:
|
||||
relative_path: Relative path from project folder
|
||||
|
||||
Returns:
|
||||
Full absolute path, or None if no project folder
|
||||
"""
|
||||
project_folder = self._get_project_folder()
|
||||
if project_folder and relative_path:
|
||||
return os.path.join(project_folder, relative_path)
|
||||
return None
|
||||
|
||||
def _get_project_folder(self) -> Optional[str]:
|
||||
"""
|
||||
Get the current project folder.
|
||||
|
||||
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)
|
||||
return None
|
||||
252
pyPhotoAlbum/mixins/async_loading.py
Normal file
@ -0,0 +1,252 @@
|
||||
"""
|
||||
Async loading mixin for non-blocking image loading and PDF generation.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
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:
|
||||
"""Expected from QWidget"""
|
||||
...
|
||||
|
||||
def window(self) -> "QMainWindow":
|
||||
"""Expected from QWidget"""
|
||||
...
|
||||
"""
|
||||
Mixin to add async loading capabilities to GLWidget.
|
||||
|
||||
Provides non-blocking image loading and PDF generation with
|
||||
progressive updates and shared caching.
|
||||
"""
|
||||
|
||||
def _init_async_loading(self):
|
||||
"""Initialize async loading components."""
|
||||
logger.info("Initializing async loading system...")
|
||||
|
||||
# Create shared image cache (512MB)
|
||||
self.image_cache = ImageCache(max_memory_mb=512)
|
||||
|
||||
# Create async image loader
|
||||
self.async_image_loader = AsyncImageLoader(cache=self.image_cache, max_workers=4)
|
||||
self.async_image_loader.image_loaded.connect(self._on_image_loaded)
|
||||
self.async_image_loader.load_failed.connect(self._on_image_load_failed)
|
||||
self.async_image_loader.start()
|
||||
|
||||
# Create async PDF generator
|
||||
self.async_pdf_generator = AsyncPDFGenerator(image_cache=self.image_cache, max_workers=2)
|
||||
self.async_pdf_generator.progress_updated.connect(self._on_pdf_progress)
|
||||
self.async_pdf_generator.export_complete.connect(self._on_pdf_complete)
|
||||
self.async_pdf_generator.export_failed.connect(self._on_pdf_failed)
|
||||
self.async_pdf_generator.start()
|
||||
|
||||
logger.info("Async loading system initialized")
|
||||
|
||||
def _cleanup_async_loading(self):
|
||||
"""Cleanup async loading components."""
|
||||
logger.info("Cleaning up async loading system...")
|
||||
|
||||
if hasattr(self, "async_image_loader"):
|
||||
self.async_image_loader.stop()
|
||||
|
||||
if hasattr(self, "async_pdf_generator"):
|
||||
self.async_pdf_generator.stop()
|
||||
|
||||
if hasattr(self, "image_cache"):
|
||||
self.image_cache.clear()
|
||||
|
||||
logger.info("Async loading system cleaned up")
|
||||
|
||||
def _on_image_loaded(self, path: Path, image, user_data):
|
||||
"""
|
||||
Handle image loaded callback.
|
||||
|
||||
Args:
|
||||
path: Path to loaded image
|
||||
image: Loaded PIL Image
|
||||
user_data: User data (ImageData element)
|
||||
"""
|
||||
logger.debug(f"Image loaded callback: {path}")
|
||||
|
||||
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
|
||||
self.update()
|
||||
|
||||
def _on_image_load_failed(self, path: Path, error_msg: str, user_data):
|
||||
"""
|
||||
Handle image load failure.
|
||||
|
||||
Args:
|
||||
path: Path that failed to load
|
||||
error_msg: Error message
|
||||
user_data: User data (ImageData element)
|
||||
"""
|
||||
logger.warning(f"Image load failed: {path} - {error_msg}")
|
||||
|
||||
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):
|
||||
"""
|
||||
Handle PDF export progress updates.
|
||||
|
||||
Args:
|
||||
current: Current progress (pages completed)
|
||||
total: Total pages
|
||||
message: Progress message
|
||||
"""
|
||||
logger.debug(f"PDF progress: {current}/{total} - {message}")
|
||||
|
||||
# Update progress dialog if it exists
|
||||
# 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):
|
||||
"""
|
||||
Handle PDF export completion.
|
||||
|
||||
Args:
|
||||
success: Whether export succeeded
|
||||
warnings: List of warning messages
|
||||
"""
|
||||
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:
|
||||
self._pdf_progress_dialog.close()
|
||||
self._pdf_progress_dialog = None
|
||||
|
||||
# Show completion message
|
||||
main_window = self.window()
|
||||
if hasattr(main_window, "show_status"):
|
||||
if success:
|
||||
if warnings:
|
||||
main_window.show_status(f"PDF exported successfully with {len(warnings)} warnings", 5000)
|
||||
else:
|
||||
main_window.show_status("PDF exported successfully", 3000)
|
||||
else:
|
||||
main_window.show_status("PDF export failed", 5000)
|
||||
|
||||
def _on_pdf_failed(self, error_msg: str):
|
||||
"""
|
||||
Handle PDF export failure.
|
||||
|
||||
Args:
|
||||
error_msg: Error message
|
||||
"""
|
||||
logger.error(f"PDF export failed: {error_msg}")
|
||||
|
||||
# Close 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"):
|
||||
main_window.show_status(f"PDF export failed: {error_msg}", 5000)
|
||||
|
||||
def request_image_load(self, image_data, priority: LoadPriority = LoadPriority.NORMAL):
|
||||
"""
|
||||
Request async load for an ImageData element.
|
||||
|
||||
Args:
|
||||
image_data: ImageData element to load
|
||||
priority: Load priority level
|
||||
"""
|
||||
if not hasattr(self, "async_image_loader"):
|
||||
logger.warning("Async image loader not initialized")
|
||||
return
|
||||
|
||||
if not image_data.image_path:
|
||||
return
|
||||
|
||||
# Security: only load images from the assets folder
|
||||
if not image_data.image_path.startswith("assets/"):
|
||||
logger.warning(f"Skipping path not in assets folder (needs healing): {image_data.image_path}")
|
||||
return
|
||||
|
||||
# Use ImageData's path resolution (delegates to project layer)
|
||||
image_full_path = image_data.resolve_image_path()
|
||||
if not image_full_path:
|
||||
logger.warning(f"Image not found (needs healing): {image_data.image_path}")
|
||||
return
|
||||
|
||||
# Calculate target size (max 2048px like original)
|
||||
target_size = (2048, 2048) # Will be downsampled if larger
|
||||
|
||||
# Request load
|
||||
self.async_image_loader.request_load(
|
||||
Path(image_full_path),
|
||||
priority=priority,
|
||||
target_size=target_size,
|
||||
user_data=image_data, # Pass element for callback
|
||||
)
|
||||
|
||||
def export_pdf_async(self, project, output_path: str, export_dpi: int = 300):
|
||||
"""
|
||||
Export PDF asynchronously without blocking UI.
|
||||
|
||||
Args:
|
||||
project: Project to export
|
||||
output_path: Output PDF file path
|
||||
export_dpi: Export DPI (default 300)
|
||||
"""
|
||||
if not hasattr(self, "async_pdf_generator"):
|
||||
logger.warning("Async PDF generator not initialized")
|
||||
return False
|
||||
|
||||
# Create progress dialog
|
||||
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)
|
||||
|
||||
self._pdf_progress_dialog = QProgressDialog("Exporting to PDF...", "Cancel", 0, total_pages, 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)
|
||||
self._pdf_progress_dialog.show()
|
||||
|
||||
# Start async export
|
||||
return self.async_pdf_generator.export_pdf(project, output_path, export_dpi)
|
||||
|
||||
def _on_pdf_cancel(self):
|
||||
"""Handle PDF export cancellation."""
|
||||
logger.info("User requested PDF export cancellation")
|
||||
|
||||
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_pdf_generator"):
|
||||
stats["pdf_generator"] = self.async_pdf_generator.get_stats()
|
||||
|
||||
return stats
|
||||
212
pyPhotoAlbum/mixins/base.py
Normal file
@ -0,0 +1,212 @@
|
||||
"""
|
||||
Base mixin providing shared application state access
|
||||
"""
|
||||
|
||||
from typing import Any, Optional, cast
|
||||
from PyQt6.QtWidgets import QStatusBar, QMessageBox, QWidget
|
||||
|
||||
|
||||
class ApplicationStateMixin:
|
||||
"""
|
||||
Base mixin providing access to shared application state.
|
||||
|
||||
This mixin provides properties and helper methods for accessing
|
||||
core application objects that are shared across all operation mixins.
|
||||
|
||||
Required attributes (must be set by MainWindow):
|
||||
_project: Project instance
|
||||
_gl_widget: GLWidget instance
|
||||
_status_bar: QStatusBar instance
|
||||
_template_manager: TemplateManager instance
|
||||
"""
|
||||
|
||||
@property
|
||||
def project(self):
|
||||
"""Access to current project"""
|
||||
if not hasattr(self, "_project"):
|
||||
raise AttributeError("MainWindow must set _project attribute")
|
||||
return self._project
|
||||
|
||||
@project.setter
|
||||
def project(self, value):
|
||||
"""Set the current project"""
|
||||
self._project = value
|
||||
|
||||
@property
|
||||
def gl_widget(self):
|
||||
"""Access to GL rendering 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"):
|
||||
raise AttributeError("MainWindow must set _status_bar attribute")
|
||||
return cast(QStatusBar, self._status_bar)
|
||||
|
||||
@property
|
||||
def template_manager(self):
|
||||
"""Access to template manager"""
|
||||
if not hasattr(self, "_template_manager"):
|
||||
raise AttributeError("MainWindow must set _template_manager attribute")
|
||||
return self._template_manager
|
||||
|
||||
# Common helper methods
|
||||
|
||||
def _get_most_visible_page_index(self):
|
||||
"""
|
||||
Determine which page is most visible in the current viewport.
|
||||
|
||||
Returns:
|
||||
int: Index of the most visible page
|
||||
"""
|
||||
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
|
||||
viewport_height = self.gl_widget.height()
|
||||
viewport_center_y = viewport_height / 2
|
||||
|
||||
# Find which page's center is closest to viewport center
|
||||
min_distance = float("inf")
|
||||
best_page_index = self.gl_widget.current_page_index
|
||||
|
||||
for renderer, page in self.gl_widget._page_renderers:
|
||||
# Get page center Y position in screen coordinates
|
||||
page_height_mm = page.layout.size[1]
|
||||
page_height_px = page_height_mm * self.project.working_dpi / 25.4
|
||||
page_center_y_offset = renderer.screen_y + (page_height_px * self.gl_widget.zoom_level / 2)
|
||||
|
||||
# Calculate distance from viewport center
|
||||
distance = abs(page_center_y_offset - viewport_center_y)
|
||||
|
||||
if distance < min_distance:
|
||||
min_distance = distance
|
||||
# Find the page index in project.pages
|
||||
try:
|
||||
best_page_index = self.project.pages.index(page)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return best_page_index
|
||||
|
||||
def get_current_page(self):
|
||||
"""
|
||||
Get currently visible page (most visible in viewport).
|
||||
|
||||
Returns:
|
||||
Page instance or None if no page is selected
|
||||
"""
|
||||
if not self.project or not self.project.pages:
|
||||
return None
|
||||
|
||||
index = self._get_most_visible_page_index()
|
||||
if 0 <= index < len(self.project.pages):
|
||||
return self.project.pages[index]
|
||||
|
||||
return None
|
||||
|
||||
def get_current_page_index(self) -> int:
|
||||
"""
|
||||
Get current page index.
|
||||
|
||||
Returns:
|
||||
Current page index, or -1 if no page
|
||||
"""
|
||||
if not self.project or not self.project.pages:
|
||||
return -1
|
||||
return int(self.gl_widget.current_page_index)
|
||||
|
||||
def show_status(self, message: str, timeout: int = 2000):
|
||||
"""
|
||||
Show message in status bar.
|
||||
|
||||
Args:
|
||||
message: Message to display
|
||||
timeout: Display duration in milliseconds
|
||||
"""
|
||||
if self.status_bar:
|
||||
self.status_bar.showMessage(message, timeout)
|
||||
|
||||
def show_error(self, title: str, message: str):
|
||||
"""
|
||||
Show error dialog.
|
||||
|
||||
Args:
|
||||
title: Dialog title
|
||||
message: Error message
|
||||
"""
|
||||
QMessageBox.critical(cast(QWidget, self), title, message)
|
||||
|
||||
def show_warning(self, title: str, message: str):
|
||||
"""
|
||||
Show warning dialog.
|
||||
|
||||
Args:
|
||||
title: Dialog title
|
||||
message: Warning message
|
||||
"""
|
||||
QMessageBox.warning(cast(QWidget, self), title, message)
|
||||
|
||||
def show_info(self, title: str, message: str):
|
||||
"""
|
||||
Show information dialog.
|
||||
|
||||
Args:
|
||||
title: Dialog title
|
||||
message: Information message
|
||||
"""
|
||||
QMessageBox.information(cast(QWidget, self), title, message)
|
||||
|
||||
def require_page(self, show_warning: bool = True) -> bool:
|
||||
"""
|
||||
Check if a page is available and optionally show warning.
|
||||
|
||||
Args:
|
||||
show_warning: Whether to show warning dialog if no page exists
|
||||
|
||||
Returns:
|
||||
True if page exists, False otherwise
|
||||
"""
|
||||
current_page = self.get_current_page()
|
||||
|
||||
if current_page is None:
|
||||
if show_warning:
|
||||
self.show_warning("No Page", "Please create a page first.")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def require_selection(self, min_count: int = 1, show_warning: bool = True) -> bool:
|
||||
"""
|
||||
Check if required number of elements are selected.
|
||||
|
||||
Args:
|
||||
min_count: Minimum number of selected elements required
|
||||
show_warning: Whether to show warning dialog if requirement not met
|
||||
|
||||
Returns:
|
||||
True if requirements met, False otherwise
|
||||
"""
|
||||
selected_count = len(self.gl_widget.selected_elements)
|
||||
|
||||
if selected_count < min_count:
|
||||
if show_warning:
|
||||
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.")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def update_view(self):
|
||||
"""Trigger GL widget update to refresh the view"""
|
||||
if self.gl_widget:
|
||||
self.gl_widget.update()
|
||||
|
||||
# Update scrollbars to reflect new content
|
||||
if hasattr(self, "update_scrollbars"):
|
||||
self.update_scrollbars()
|
||||
66
pyPhotoAlbum/mixins/dialog_mixin.py
Normal file
@ -0,0 +1,66 @@
|
||||
"""
|
||||
Dialog operations mixin for pyPhotoAlbum
|
||||
|
||||
Provides common functionality for creating and managing dialogs.
|
||||
"""
|
||||
|
||||
from typing import Optional, Any, Callable
|
||||
from PyQt6.QtWidgets import QDialog
|
||||
|
||||
|
||||
class DialogMixin:
|
||||
"""
|
||||
Mixin providing dialog creation and management capabilities.
|
||||
|
||||
This mixin separates dialog UI concerns from business logic,
|
||||
making it easier to create, test, and maintain complex dialogs.
|
||||
"""
|
||||
|
||||
def create_dialog(self, dialog_class: type, title: Optional[str] = None, **kwargs) -> Optional[Any]:
|
||||
"""
|
||||
Create and show a dialog, handling the result.
|
||||
|
||||
Args:
|
||||
dialog_class: Dialog class to instantiate
|
||||
title: Optional title override
|
||||
**kwargs: Additional arguments passed to dialog constructor
|
||||
|
||||
Returns:
|
||||
Dialog result if accepted, None if rejected
|
||||
"""
|
||||
# Create dialog instance
|
||||
dialog = dialog_class(parent=self, **kwargs)
|
||||
|
||||
# Set title if provided
|
||||
if title:
|
||||
dialog.setWindowTitle(title)
|
||||
|
||||
# Show dialog and handle result
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
# Check if dialog has a get_values method
|
||||
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:
|
||||
"""
|
||||
Show a dialog and execute callback on acceptance.
|
||||
|
||||
Args:
|
||||
dialog_class: Dialog class to instantiate
|
||||
on_accept: Callback to execute if dialog is accepted.
|
||||
Will receive dialog result as parameter.
|
||||
**kwargs: Additional arguments passed to dialog constructor
|
||||
|
||||
Returns:
|
||||
True if dialog was accepted, False otherwise
|
||||
"""
|
||||
result = self.create_dialog(dialog_class, **kwargs)
|
||||
|
||||
if result is not None and on_accept:
|
||||
on_accept(result)
|
||||
return True
|
||||
|
||||
return result is not None
|
||||
177
pyPhotoAlbum/mixins/element_manipulation.py
Normal file
@ -0,0 +1,177 @@
|
||||
"""
|
||||
Element manipulation mixin for GLWidget - handles element transformations
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Optional, Tuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyPhotoAlbum.models import BaseLayoutElement
|
||||
from PyQt6.QtWidgets import QMainWindow
|
||||
|
||||
|
||||
class ElementManipulationMixin:
|
||||
# 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]]
|
||||
|
||||
def window(self) -> "QMainWindow":
|
||||
"""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
|
||||
self.resize_handle: Optional[str] = None # 'nw', 'ne', 'sw', 'se'
|
||||
self.resize_start_pos: Optional[Tuple[float, float]] = None
|
||||
self.resize_start_size: Optional[Tuple[float, float]] = None
|
||||
|
||||
# Rotation state
|
||||
self.rotation_mode: bool = False # Toggle between move/resize and rotation modes
|
||||
self.rotation_start_angle: Optional[float] = None
|
||||
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}
|
||||
|
||||
def _resize_element(self, dx: float, dy: float):
|
||||
"""
|
||||
Resize the element based on the resize handle.
|
||||
|
||||
Args:
|
||||
dx: Delta X in page-local coordinates
|
||||
dy: Delta Y in page-local coordinates
|
||||
"""
|
||||
if not self.selected_element or not self.resize_handle:
|
||||
return
|
||||
|
||||
if not self.resize_start_pos or not self.resize_start_size:
|
||||
return
|
||||
|
||||
# Get the snapping system from the element's parent page
|
||||
main_window = self.window()
|
||||
if not hasattr(self.selected_element, "_parent_page"):
|
||||
self._resize_element_no_snap(dx, dy)
|
||||
return
|
||||
|
||||
parent_page = self.selected_element._parent_page
|
||||
snap_sys = parent_page.layout.snapping_system
|
||||
|
||||
# Get page size
|
||||
page_size = parent_page.layout.size
|
||||
dpi = main_window.project.working_dpi
|
||||
|
||||
# Apply snapping to resize
|
||||
from pyPhotoAlbum.snapping import SnapResizeParams
|
||||
|
||||
params = SnapResizeParams(
|
||||
position=self.resize_start_pos,
|
||||
size=self.resize_start_size,
|
||||
dx=dx,
|
||||
dy=dy,
|
||||
resize_handle=self.resize_handle,
|
||||
page_size=page_size,
|
||||
dpi=dpi,
|
||||
project=main_window.project,
|
||||
)
|
||||
new_pos, new_size = snap_sys.snap_resize(params)
|
||||
|
||||
# Apply the snapped values
|
||||
self.selected_element.position = new_pos
|
||||
self.selected_element.size = new_size
|
||||
|
||||
# Ensure minimum size
|
||||
min_size = 20
|
||||
w, h = self.selected_element.size
|
||||
if w < min_size or h < min_size:
|
||||
w = max(w, min_size)
|
||||
h = max(h, min_size)
|
||||
self.selected_element.size = (w, h)
|
||||
|
||||
def _resize_element_no_snap(self, dx: float, dy: float):
|
||||
"""
|
||||
Resize element without snapping.
|
||||
|
||||
Args:
|
||||
dx: Delta X in page-local coordinates
|
||||
dy: Delta Y in page-local coordinates
|
||||
"""
|
||||
if not self.resize_start_pos or not self.resize_start_size:
|
||||
return
|
||||
|
||||
start_x, start_y = self.resize_start_pos
|
||||
start_w, start_h = self.resize_start_size
|
||||
|
||||
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":
|
||||
self.selected_element.position = (start_x, start_y + dy)
|
||||
self.selected_element.size = (start_w + dx, start_h - dy)
|
||||
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":
|
||||
self.selected_element.size = (start_w + dx, start_h + dy)
|
||||
|
||||
# Ensure minimum size
|
||||
min_size = 20
|
||||
w, h = self.selected_element.size
|
||||
if w < min_size:
|
||||
self.selected_element.size = (min_size, h)
|
||||
if h < min_size:
|
||||
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
|
||||
):
|
||||
"""
|
||||
Transfer an element from one page to another during drag operation.
|
||||
|
||||
Args:
|
||||
element: The element to transfer
|
||||
source_page: Source page object
|
||||
target_page: Target page object
|
||||
mouse_x: Current mouse X position in screen coordinates
|
||||
mouse_y: Current mouse Y position in screen coordinates
|
||||
target_renderer: PageRenderer for the target page
|
||||
"""
|
||||
# Convert mouse position to target page coordinates
|
||||
new_page_x, new_page_y = target_renderer.screen_to_page(mouse_x, mouse_y)
|
||||
|
||||
# Get element size
|
||||
elem_w, elem_h = element.size
|
||||
|
||||
# Center the element on the mouse position
|
||||
new_x = new_page_x - elem_w / 2
|
||||
new_y = new_page_y - elem_h / 2
|
||||
|
||||
# Remove element from source page
|
||||
if element in source_page.layout.elements:
|
||||
source_page.layout.elements.remove(element)
|
||||
print(f"Removed element from page {source_page.page_number}")
|
||||
|
||||
# Update element position to new page coordinates
|
||||
element.position = (new_x, new_y)
|
||||
|
||||
# Add element to target page
|
||||
target_page.layout.add_element(element)
|
||||
|
||||
# Update element's parent page reference
|
||||
element._parent_page = target_page
|
||||
element._page_renderer = target_renderer
|
||||
|
||||
# Update drag start position and element position for continued dragging
|
||||
self.drag_start_pos = (mouse_x, mouse_y)
|
||||
self.drag_start_element_pos = element.position
|
||||
|
||||
print(f"Transferred element to page {target_page.page_number} at ({new_x:.1f}, {new_y:.1f})")
|
||||
140
pyPhotoAlbum/mixins/element_selection.py
Normal file
@ -0,0 +1,140 @@
|
||||
"""
|
||||
Element selection mixin for GLWidget - handles element selection and hit detection
|
||||
"""
|
||||
|
||||
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":
|
||||
"""Expected from QWidget"""
|
||||
...
|
||||
"""
|
||||
Mixin providing element selection and hit detection functionality.
|
||||
|
||||
This mixin manages which elements are selected and provides methods to
|
||||
detect which element or resize handle is at a given screen position.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Selection state - multi-select support
|
||||
self.selected_elements: Set[BaseLayoutElement] = set()
|
||||
|
||||
@property
|
||||
def selected_element(self) -> Optional[BaseLayoutElement]:
|
||||
"""
|
||||
For backward compatibility - returns first selected element or None.
|
||||
|
||||
Returns:
|
||||
BaseLayoutElement or None: The first selected element, or None if no selection
|
||||
"""
|
||||
return next(iter(self.selected_elements)) if self.selected_elements else None
|
||||
|
||||
@selected_element.setter
|
||||
def selected_element(self, value: Optional[BaseLayoutElement]):
|
||||
"""
|
||||
For backward compatibility - sets single element selection.
|
||||
|
||||
Args:
|
||||
value: Element to select, or None to clear selection
|
||||
"""
|
||||
if value is None:
|
||||
self.selected_elements.clear()
|
||||
else:
|
||||
self.selected_elements = {value}
|
||||
|
||||
def _get_element_at(self, x: float, y: float) -> Optional[BaseLayoutElement]:
|
||||
"""
|
||||
Get the element at the given screen position across all pages.
|
||||
|
||||
Args:
|
||||
x: Screen X coordinate
|
||||
y: Screen Y coordinate
|
||||
|
||||
Returns:
|
||||
BaseLayoutElement or None: The topmost element at the position, or None
|
||||
"""
|
||||
if not hasattr(self, "_page_renderers") or not self._page_renderers:
|
||||
return None
|
||||
|
||||
# Check each page from top to bottom (reverse z-order)
|
||||
for renderer, page in reversed(self._page_renderers):
|
||||
# Convert screen coordinates to page-local coordinates
|
||||
# Do this for all pages, not just those where the click is within bounds
|
||||
# This allows selecting elements that have moved off the page
|
||||
page_x, page_y = renderer.screen_to_page(x, y)
|
||||
|
||||
# Check elements in this page (highest in list = on top, so check in reverse)
|
||||
for element in reversed(page.layout.elements):
|
||||
# Get element bounds
|
||||
ex, ey = element.position
|
||||
ew, eh = element.size
|
||||
|
||||
# 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 # type: ignore[attr-defined]
|
||||
element._parent_page = page # type: ignore[attr-defined]
|
||||
return element
|
||||
|
||||
return None
|
||||
|
||||
def _get_resize_handle_at(self, x: float, y: float) -> Optional[str]:
|
||||
"""
|
||||
Get the resize handle at the given screen position.
|
||||
|
||||
Only checks if there is a single selected element.
|
||||
|
||||
Args:
|
||||
x: Screen X coordinate
|
||||
y: Screen Y coordinate
|
||||
|
||||
Returns:
|
||||
str or None: Handle name ('nw', 'ne', 'sw', 'se') or None
|
||||
"""
|
||||
if not self.selected_element:
|
||||
return None
|
||||
|
||||
main_window = self.window()
|
||||
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"):
|
||||
return None
|
||||
|
||||
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
|
||||
elem_w, elem_h = self.selected_element.size
|
||||
handle_size = 8
|
||||
|
||||
# Convert to screen coordinates using PageRenderer
|
||||
ex, ey = renderer.page_to_screen(elem_x, elem_y)
|
||||
ew = elem_w * renderer.zoom
|
||||
eh = elem_h * renderer.zoom
|
||||
|
||||
# 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),
|
||||
}
|
||||
|
||||
for name, (hx, hy) in handles.items():
|
||||
if hx <= x <= hx + handle_size and hy <= y <= hy + handle_size:
|
||||
return name
|
||||
|
||||
return None
|
||||
87
pyPhotoAlbum/mixins/image_pan.py
Normal file
@ -0,0 +1,87 @@
|
||||
"""
|
||||
Image pan mixin for GLWidget - handles panning images within frames
|
||||
"""
|
||||
|
||||
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.
|
||||
|
||||
This mixin handles Control+drag to pan an image within its frame by
|
||||
adjusting the crop_info property.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Image pan state (for panning image within frame with Control key)
|
||||
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"):
|
||||
"""
|
||||
Handle image panning within a frame during mouse move.
|
||||
|
||||
Args:
|
||||
x: Current mouse X position in screen coordinates
|
||||
y: Current mouse Y position in screen coordinates
|
||||
element: The ImageData element being panned
|
||||
"""
|
||||
if not self.image_pan_mode or not isinstance(element, ImageData):
|
||||
return
|
||||
|
||||
if not self.drag_start_pos:
|
||||
return
|
||||
|
||||
# Calculate mouse movement in screen pixels
|
||||
screen_dx = x - self.drag_start_pos[0]
|
||||
screen_dy = y - self.drag_start_pos[1]
|
||||
|
||||
# Get element size in page-local coordinates
|
||||
elem_w, elem_h = element.size
|
||||
|
||||
# Convert screen movement to normalized crop coordinates
|
||||
# Negative because moving mouse right should pan image left (show more of right side)
|
||||
# Scale by zoom level and element size
|
||||
crop_dx = -screen_dx / (elem_w * self.zoom_level)
|
||||
crop_dy = -screen_dy / (elem_h * self.zoom_level)
|
||||
|
||||
# Get starting crop info
|
||||
start_crop = self.image_pan_start_crop
|
||||
if not start_crop:
|
||||
start_crop = (0, 0, 1, 1)
|
||||
|
||||
# Calculate new crop_info
|
||||
crop_width = start_crop[2] - start_crop[0]
|
||||
crop_height = start_crop[3] - start_crop[1]
|
||||
|
||||
new_x_min = start_crop[0] + crop_dx
|
||||
new_y_min = start_crop[1] + crop_dy
|
||||
new_x_max = new_x_min + crop_width
|
||||
new_y_max = new_y_min + crop_height
|
||||
|
||||
# Clamp to valid range (0-1) to prevent panning beyond image boundaries
|
||||
if new_x_min < 0:
|
||||
new_x_min = 0
|
||||
new_x_max = crop_width
|
||||
if new_x_max > 1:
|
||||
new_x_max = 1
|
||||
new_x_min = 1 - crop_width
|
||||
|
||||
if new_y_min < 0:
|
||||
new_y_min = 0
|
||||
new_y_max = crop_height
|
||||
if new_y_max > 1:
|
||||
new_y_max = 1
|
||||
new_y_min = 1 - crop_height
|
||||
|
||||
# Update element's crop_info
|
||||
element.crop_info = (new_x_min, new_y_min, new_x_max, new_y_max)
|
||||
203
pyPhotoAlbum/mixins/interaction_command_builders.py
Normal file
@ -0,0 +1,203 @@
|
||||
"""
|
||||
Command builders for different interaction types.
|
||||
|
||||
Each builder is responsible for:
|
||||
1. Validating if a command should be created
|
||||
2. Creating the appropriate command object
|
||||
3. Logging the operation
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional, Any
|
||||
from pyPhotoAlbum.models import BaseLayoutElement
|
||||
from .interaction_validators import InteractionChangeDetector
|
||||
|
||||
|
||||
class CommandBuilder(ABC):
|
||||
"""Base class for command builders."""
|
||||
|
||||
def __init__(self, change_detector: Optional[InteractionChangeDetector] = None):
|
||||
self.change_detector = change_detector or InteractionChangeDetector()
|
||||
|
||||
@abstractmethod
|
||||
def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
|
||||
"""
|
||||
Check if a command should be built based on state changes.
|
||||
|
||||
Args:
|
||||
element: The element being modified
|
||||
start_state: Dict containing the initial state
|
||||
**kwargs: Additional context
|
||||
|
||||
Returns:
|
||||
True if a command should be created
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
|
||||
"""
|
||||
Build and return the command object.
|
||||
|
||||
Args:
|
||||
element: The element being modified
|
||||
start_state: Dict containing the initial state
|
||||
**kwargs: Additional context
|
||||
|
||||
Returns:
|
||||
Command object or None
|
||||
"""
|
||||
pass
|
||||
|
||||
def log_command(self, command_type: str, details: str):
|
||||
"""Log command creation for debugging."""
|
||||
print(f"{command_type} command created: {details}")
|
||||
|
||||
|
||||
class MoveCommandBuilder(CommandBuilder):
|
||||
"""Builds MoveElementCommand objects."""
|
||||
|
||||
def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
|
||||
"""Check if position changed significantly."""
|
||||
old_pos = start_state.get("position")
|
||||
if old_pos is None:
|
||||
return False
|
||||
|
||||
new_pos = element.position
|
||||
return self.change_detector.detect_position_change(old_pos, new_pos) is not None
|
||||
|
||||
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
|
||||
"""Build a MoveElementCommand."""
|
||||
old_pos = start_state.get("position")
|
||||
if old_pos is None:
|
||||
return None
|
||||
|
||||
new_pos = element.position
|
||||
change_info = self.change_detector.detect_position_change(old_pos, new_pos)
|
||||
|
||||
if change_info is None:
|
||||
return None
|
||||
|
||||
from pyPhotoAlbum.commands import MoveElementCommand
|
||||
|
||||
command = MoveElementCommand(element, old_pos, new_pos)
|
||||
|
||||
self.log_command("Move", f"{old_pos} → {new_pos}")
|
||||
return command
|
||||
|
||||
|
||||
class ResizeCommandBuilder(CommandBuilder):
|
||||
"""Builds ResizeElementCommand objects."""
|
||||
|
||||
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")
|
||||
|
||||
if old_pos is None or old_size is None:
|
||||
return False
|
||||
|
||||
new_pos = element.position
|
||||
new_size = element.size
|
||||
|
||||
pos_change = self.change_detector.detect_position_change(old_pos, new_pos)
|
||||
size_change = self.change_detector.detect_size_change(old_size, new_size)
|
||||
|
||||
return pos_change is not None or size_change is not None
|
||||
|
||||
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")
|
||||
|
||||
if old_pos is None or old_size is None:
|
||||
return None
|
||||
|
||||
new_pos = element.position
|
||||
new_size = element.size
|
||||
|
||||
if not self.can_build(element, start_state):
|
||||
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}")
|
||||
return command
|
||||
|
||||
|
||||
class RotateCommandBuilder(CommandBuilder):
|
||||
"""Builds RotateElementCommand objects."""
|
||||
|
||||
def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
|
||||
"""Check if rotation changed significantly."""
|
||||
old_rotation = start_state.get("rotation")
|
||||
if old_rotation is None:
|
||||
return False
|
||||
|
||||
new_rotation = element.rotation
|
||||
return self.change_detector.detect_rotation_change(old_rotation, new_rotation) is not None
|
||||
|
||||
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
|
||||
"""Build a RotateElementCommand."""
|
||||
old_rotation = start_state.get("rotation")
|
||||
if old_rotation is None:
|
||||
return None
|
||||
|
||||
new_rotation = element.rotation
|
||||
change_info = self.change_detector.detect_rotation_change(old_rotation, new_rotation)
|
||||
|
||||
if change_info is None:
|
||||
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}°")
|
||||
return command
|
||||
|
||||
|
||||
class ImagePanCommandBuilder(CommandBuilder):
|
||||
"""Builds AdjustImageCropCommand objects for image panning."""
|
||||
|
||||
def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
|
||||
"""Check if crop info changed significantly."""
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
if not isinstance(element, ImageData):
|
||||
return False
|
||||
|
||||
old_crop = start_state.get("crop_info")
|
||||
if old_crop is None:
|
||||
return False
|
||||
|
||||
new_crop = element.crop_info
|
||||
change_detector = InteractionChangeDetector(threshold=0.001)
|
||||
return change_detector.detect_crop_change(old_crop, new_crop) is not None
|
||||
|
||||
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
|
||||
"""Build an AdjustImageCropCommand."""
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
if not isinstance(element, ImageData):
|
||||
return None
|
||||
|
||||
old_crop = start_state.get("crop_info")
|
||||
if old_crop is None:
|
||||
return None
|
||||
|
||||
new_crop = element.crop_info
|
||||
change_detector = InteractionChangeDetector(threshold=0.001)
|
||||
change_info = change_detector.detect_crop_change(old_crop, new_crop)
|
||||
|
||||
if change_info is None:
|
||||
return None
|
||||
|
||||
from pyPhotoAlbum.commands import AdjustImageCropCommand
|
||||
|
||||
command = AdjustImageCropCommand(element, old_crop, new_crop)
|
||||
|
||||
self.log_command("Image pan", f"{old_crop} → {new_crop}")
|
||||
return command
|
||||
148
pyPhotoAlbum/mixins/interaction_command_factory.py
Normal file
@ -0,0 +1,148 @@
|
||||
"""
|
||||
Factory for creating interaction commands based on interaction type.
|
||||
|
||||
This implements the Strategy pattern, allowing different command builders
|
||||
to be registered and used based on the interaction type.
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any
|
||||
from pyPhotoAlbum.models import BaseLayoutElement
|
||||
from .interaction_command_builders import (
|
||||
CommandBuilder,
|
||||
MoveCommandBuilder,
|
||||
ResizeCommandBuilder,
|
||||
RotateCommandBuilder,
|
||||
ImagePanCommandBuilder,
|
||||
)
|
||||
|
||||
|
||||
class InteractionCommandFactory:
|
||||
"""
|
||||
Factory for creating commands from interaction data.
|
||||
|
||||
Uses the Strategy pattern to delegate command creation to
|
||||
specialized builder classes based on interaction type.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize factory with default builders."""
|
||||
self._builders: Dict[str, CommandBuilder] = {}
|
||||
self._register_default_builders()
|
||||
|
||||
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())
|
||||
|
||||
def register_builder(self, interaction_type: str, builder: CommandBuilder):
|
||||
"""
|
||||
Register a command builder for an interaction type.
|
||||
|
||||
Args:
|
||||
interaction_type: The type of interaction (e.g., 'move', 'resize')
|
||||
builder: The CommandBuilder instance to handle this type
|
||||
"""
|
||||
self._builders[interaction_type] = builder
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
interaction_type: Type of interaction ('move', 'resize', etc.)
|
||||
element: The element that was interacted with
|
||||
start_state: Dictionary containing initial state values
|
||||
**kwargs: Additional context for command creation
|
||||
|
||||
Returns:
|
||||
Command object if changes warrant it, None otherwise
|
||||
"""
|
||||
builder = self._builders.get(interaction_type)
|
||||
|
||||
if builder is None:
|
||||
print(f"Warning: No builder registered for interaction type '{interaction_type}'")
|
||||
return None
|
||||
|
||||
if not builder.can_build(element, start_state, **kwargs):
|
||||
return None
|
||||
|
||||
return builder.build(element, start_state, **kwargs)
|
||||
|
||||
def has_builder(self, interaction_type: str) -> bool:
|
||||
"""Check if a builder is registered for the given interaction type."""
|
||||
return interaction_type in self._builders
|
||||
|
||||
def get_supported_types(self) -> list:
|
||||
"""Get list of supported interaction types."""
|
||||
return list(self._builders.keys())
|
||||
|
||||
|
||||
class InteractionState:
|
||||
"""
|
||||
Value object representing the state of an interaction.
|
||||
|
||||
This simplifies passing interaction data around and makes
|
||||
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,
|
||||
):
|
||||
"""
|
||||
Initialize interaction state.
|
||||
|
||||
Args:
|
||||
element: The element being interacted with
|
||||
interaction_type: Type of interaction
|
||||
position: Initial position
|
||||
size: Initial size
|
||||
rotation: Initial rotation
|
||||
crop_info: Initial crop info (for images)
|
||||
"""
|
||||
self.element = element
|
||||
self.interaction_type = interaction_type
|
||||
self.position = position
|
||||
self.size = size
|
||||
self.rotation = rotation
|
||||
self.crop_info = crop_info
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""
|
||||
Convert state to dictionary for command builders.
|
||||
|
||||
Returns:
|
||||
Dict with non-None state values
|
||||
"""
|
||||
state = {}
|
||||
if self.position is not None:
|
||||
state["position"] = self.position
|
||||
if self.size is not None:
|
||||
state["size"] = self.size
|
||||
if self.rotation is not None:
|
||||
state["rotation"] = self.rotation
|
||||
if self.crop_info is not None:
|
||||
state["crop_info"] = self.crop_info
|
||||
return state
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
"""Check if state has required fields for command creation."""
|
||||
return self.element is not None and self.interaction_type is not None
|
||||
|
||||
def clear(self):
|
||||
"""Clear all state values."""
|
||||
self.element = None
|
||||
self.interaction_type = None
|
||||
self.position = None
|
||||
self.size = None
|
||||
self.rotation = None
|
||||
self.crop_info = None
|
||||
116
pyPhotoAlbum/mixins/interaction_undo.py
Normal file
@ -0,0 +1,116 @@
|
||||
"""
|
||||
Mixin for automatic undo/redo handling in interactive mouse operations
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from pyPhotoAlbum.models import BaseLayoutElement
|
||||
from .interaction_command_factory import InteractionCommandFactory, InteractionState
|
||||
|
||||
|
||||
class UndoableInteractionMixin:
|
||||
"""
|
||||
Mixin providing automatic undo/redo for interactive mouse operations.
|
||||
|
||||
This mixin tracks the state of elements before interactive operations
|
||||
(move, resize, rotate) and automatically creates appropriate Command
|
||||
objects when the interaction completes.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Command factory for creating undo/redo commands
|
||||
self._command_factory = InteractionCommandFactory()
|
||||
|
||||
# Interaction state tracking
|
||||
self._interaction_state = InteractionState()
|
||||
|
||||
def _begin_move(self, element: BaseLayoutElement):
|
||||
"""
|
||||
Begin tracking a move operation.
|
||||
|
||||
Args:
|
||||
element: The element being moved
|
||||
"""
|
||||
self._interaction_state.element = element
|
||||
self._interaction_state.interaction_type = "move"
|
||||
self._interaction_state.position = element.position
|
||||
|
||||
def _begin_resize(self, element: BaseLayoutElement):
|
||||
"""
|
||||
Begin tracking a resize operation.
|
||||
|
||||
Args:
|
||||
element: The element being resized
|
||||
"""
|
||||
self._interaction_state.element = element
|
||||
self._interaction_state.interaction_type = "resize"
|
||||
self._interaction_state.position = element.position
|
||||
self._interaction_state.size = element.size
|
||||
|
||||
def _begin_rotate(self, element: BaseLayoutElement):
|
||||
"""
|
||||
Begin tracking a rotate operation.
|
||||
|
||||
Args:
|
||||
element: The element being rotated
|
||||
"""
|
||||
self._interaction_state.element = element
|
||||
self._interaction_state.interaction_type = "rotate"
|
||||
self._interaction_state.rotation = element.rotation
|
||||
|
||||
def _begin_image_pan(self, element):
|
||||
"""
|
||||
Begin tracking an image pan operation.
|
||||
|
||||
Args:
|
||||
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.crop_info = element.crop_info
|
||||
|
||||
def _end_interaction(self):
|
||||
"""
|
||||
End the current interaction and create appropriate undo/redo command.
|
||||
|
||||
This method uses the command factory to create the appropriate
|
||||
Command object based on what changed during the interaction.
|
||||
"""
|
||||
# Validate interaction state
|
||||
if not self._interaction_state.is_valid():
|
||||
self._clear_interaction_state()
|
||||
return
|
||||
|
||||
# Get main window to access project history
|
||||
main_window = self.window()
|
||||
if not hasattr(main_window, "project"):
|
||||
self._clear_interaction_state()
|
||||
return
|
||||
|
||||
# Use factory to create command based on interaction type and changes
|
||||
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(),
|
||||
)
|
||||
|
||||
# Execute the command through history if one was created
|
||||
if command:
|
||||
main_window.project.history.execute(command)
|
||||
|
||||
# Clear interaction state
|
||||
self._clear_interaction_state()
|
||||
|
||||
def _clear_interaction_state(self):
|
||||
"""Clear all interaction tracking state"""
|
||||
self._interaction_state.clear()
|
||||
|
||||
def _cancel_interaction(self):
|
||||
"""Cancel the current interaction without creating a command"""
|
||||
self._clear_interaction_state()
|
||||
149
pyPhotoAlbum/mixins/interaction_validators.py
Normal file
@ -0,0 +1,149 @@
|
||||
"""
|
||||
Decorators and validators for interaction change detection.
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
from typing import Optional, Tuple, Any
|
||||
|
||||
|
||||
def significant_change(threshold: float = 0.1):
|
||||
"""
|
||||
Decorator that validates if a change is significant enough to warrant a command.
|
||||
|
||||
Args:
|
||||
threshold: Minimum change magnitude to be considered significant
|
||||
|
||||
Returns:
|
||||
None if change is insignificant, otherwise returns the command builder result
|
||||
"""
|
||||
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
result = func(*args, **kwargs)
|
||||
if result is None:
|
||||
return None
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
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:
|
||||
"""Check if position changed significantly."""
|
||||
if old_pos is None or new_pos is None:
|
||||
return False
|
||||
|
||||
dx = abs(new_pos[0] - old_pos[0])
|
||||
dy = abs(new_pos[1] - old_pos[1])
|
||||
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:
|
||||
"""Check if size changed significantly."""
|
||||
if old_size is None or new_size is None:
|
||||
return False
|
||||
|
||||
dw = abs(new_size[0] - old_size[0])
|
||||
dh = abs(new_size[1] - old_size[1])
|
||||
return dw > threshold or dh > threshold
|
||||
|
||||
@staticmethod
|
||||
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
|
||||
|
||||
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:
|
||||
"""Check if crop info changed significantly."""
|
||||
if old_crop is None or new_crop is None:
|
||||
return False
|
||||
|
||||
if old_crop == new_crop:
|
||||
return False
|
||||
|
||||
return any(abs(new_crop[i] - old_crop[i]) > threshold for i in range(4))
|
||||
|
||||
|
||||
class InteractionChangeDetector:
|
||||
"""Detects and quantifies changes in element properties."""
|
||||
|
||||
def __init__(self, threshold: float = 0.1):
|
||||
self.threshold = threshold
|
||||
self.validator = ChangeValidator()
|
||||
|
||||
def detect_position_change(self, old_pos: Tuple[float, float], new_pos: Tuple[float, float]) -> Optional[dict]:
|
||||
"""
|
||||
Detect position change and return change info.
|
||||
|
||||
Returns:
|
||||
Dict with change info if significant, None otherwise
|
||||
"""
|
||||
if not self.validator.position_changed(old_pos, new_pos, self.threshold):
|
||||
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],
|
||||
}
|
||||
|
||||
def detect_size_change(self, old_size: Tuple[float, float], new_size: Tuple[float, float]) -> Optional[dict]:
|
||||
"""
|
||||
Detect size change and return change info.
|
||||
|
||||
Returns:
|
||||
Dict with change info if significant, None otherwise
|
||||
"""
|
||||
if not self.validator.size_changed(old_size, new_size, self.threshold):
|
||||
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],
|
||||
}
|
||||
|
||||
def detect_rotation_change(self, old_rotation: float, new_rotation: float) -> Optional[dict]:
|
||||
"""
|
||||
Detect rotation change and return change info.
|
||||
|
||||
Returns:
|
||||
Dict with change info if significant, None otherwise
|
||||
"""
|
||||
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}
|
||||
|
||||
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.
|
||||
|
||||
Returns:
|
||||
Dict with change info if significant, None otherwise
|
||||
"""
|
||||
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))}
|
||||
176
pyPhotoAlbum/mixins/keyboard_navigation.py
Normal file
@ -0,0 +1,176 @@
|
||||
"""
|
||||
Keyboard navigation mixin for GLWidget - handles keyboard-based navigation
|
||||
"""
|
||||
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
|
||||
class KeyboardNavigationMixin:
|
||||
"""
|
||||
Mixin providing keyboard navigation functionality.
|
||||
|
||||
This mixin handles Page Up/Down navigation between pages,
|
||||
arrow key viewport movement, and arrow key element movement.
|
||||
"""
|
||||
|
||||
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:
|
||||
return
|
||||
|
||||
current_index = main_window._get_most_visible_page_index()
|
||||
if current_index < len(main_window.project.pages) - 1:
|
||||
next_page = main_window.project.pages[current_index + 1]
|
||||
self._scroll_to_page(next_page, current_index + 1)
|
||||
|
||||
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:
|
||||
return
|
||||
|
||||
current_index = main_window._get_most_visible_page_index()
|
||||
if current_index > 0:
|
||||
prev_page = main_window.project.pages[current_index - 1]
|
||||
self._scroll_to_page(prev_page, current_index - 1)
|
||||
|
||||
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)
|
||||
|
||||
def _scroll_to_page(self, page, page_index):
|
||||
"""
|
||||
Scroll the viewport to center a specific page.
|
||||
|
||||
Args:
|
||||
page: The page to scroll to
|
||||
page_index: The index of the page in the project
|
||||
"""
|
||||
main_window = self.window()
|
||||
if not hasattr(main_window, "project"):
|
||||
return
|
||||
|
||||
dpi = main_window.project.working_dpi
|
||||
PAGE_MARGIN = 50
|
||||
PAGE_SPACING = 50
|
||||
|
||||
# Calculate the Y offset for this page
|
||||
y_offset = PAGE_MARGIN
|
||||
for i in range(page_index):
|
||||
prev_page = main_window.project.pages[i]
|
||||
prev_height_mm = prev_page.layout.size[1]
|
||||
prev_height_px = prev_height_mm * dpi / 25.4
|
||||
y_offset += prev_height_px * self.zoom_level + PAGE_SPACING
|
||||
|
||||
# Get page height
|
||||
page_height_mm = page.layout.size[1]
|
||||
page_height_px = page_height_mm * dpi / 25.4
|
||||
screen_page_height = page_height_px * self.zoom_level
|
||||
|
||||
# Center the page in the viewport
|
||||
viewport_height = self.height()
|
||||
target_pan_y = viewport_height / 2 - y_offset - screen_page_height / 2
|
||||
|
||||
self.pan_offset[1] = target_pan_y
|
||||
|
||||
# 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()
|
||||
|
||||
def _move_viewport_with_arrow_keys(self, key):
|
||||
"""
|
||||
Move the viewport using arrow keys when no objects are selected.
|
||||
|
||||
Args:
|
||||
key: The Qt key code (Up, Down, Left, Right)
|
||||
"""
|
||||
# Movement amount in pixels
|
||||
move_amount = 50
|
||||
|
||||
if key == Qt.Key.Key_Up:
|
||||
self.pan_offset[1] += move_amount
|
||||
elif key == Qt.Key.Key_Down:
|
||||
self.pan_offset[1] -= move_amount
|
||||
elif key == Qt.Key.Key_Left:
|
||||
self.pan_offset[0] += move_amount
|
||||
elif key == Qt.Key.Key_Right:
|
||||
self.pan_offset[0] -= move_amount
|
||||
|
||||
# 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()
|
||||
|
||||
def _move_selected_elements_with_arrow_keys(self, key):
|
||||
"""
|
||||
Move selected elements using arrow keys.
|
||||
|
||||
Args:
|
||||
key: The Qt key code (Up, Down, Left, Right)
|
||||
"""
|
||||
main_window = self.window()
|
||||
if not hasattr(main_window, "project"):
|
||||
return
|
||||
|
||||
# Movement amount in mm
|
||||
move_amount_mm = 1.0 # 1mm per keypress
|
||||
|
||||
# Calculate movement delta
|
||||
dx, dy = 0, 0
|
||||
if key == Qt.Key.Key_Up:
|
||||
dy = -move_amount_mm
|
||||
elif key == Qt.Key.Key_Down:
|
||||
dy = move_amount_mm
|
||||
elif key == Qt.Key.Key_Left:
|
||||
dx = -move_amount_mm
|
||||
elif key == Qt.Key.Key_Right:
|
||||
dx = move_amount_mm
|
||||
|
||||
# Move all selected elements
|
||||
for element in self.selected_elements:
|
||||
current_x, current_y = element.position
|
||||
new_x = current_x + dx
|
||||
new_y = current_y + dy
|
||||
|
||||
# Apply snapping if element has a 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
|
||||
dpi = main_window.project.working_dpi
|
||||
|
||||
snapped_pos = snap_sys.snap_position(
|
||||
position=(new_x, new_y),
|
||||
size=element.size,
|
||||
page_size=page_size,
|
||||
dpi=dpi,
|
||||
project=main_window.project,
|
||||
)
|
||||
element.position = snapped_pos
|
||||
else:
|
||||
element.position = (new_x, new_y)
|
||||
|
||||
self.update()
|
||||
|
||||
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)
|
||||
374
pyPhotoAlbum/mixins/mouse_interaction.py
Normal file
@ -0,0 +1,374 @@
|
||||
"""
|
||||
Mouse interaction mixin for GLWidget - coordinates all mouse events
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
from PyQt6.QtCore import Qt
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
|
||||
class MouseInteractionMixin:
|
||||
"""
|
||||
Mixin providing mouse event handling and coordination.
|
||||
|
||||
This mixin routes mouse events to appropriate other mixins based on
|
||||
the current interaction state.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Mouse interaction state
|
||||
self.drag_start_pos = None
|
||||
self.drag_start_element_pos = None
|
||||
self.is_dragging = False
|
||||
self.is_panning = False
|
||||
|
||||
def _handle_rotation_start(self, x: float, y: float):
|
||||
"""Start rotation interaction for selected element."""
|
||||
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."""
|
||||
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"""
|
||||
self.setFocus()
|
||||
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
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 not hasattr(self.selected_element, "_page_renderer"):
|
||||
return
|
||||
|
||||
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)
|
||||
|
||||
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."""
|
||||
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:
|
||||
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
|
||||
|
||||
main_window = self.window()
|
||||
snap_sys = page.layout.snapping_system
|
||||
page_size = 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
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
"""Handle mouse move events"""
|
||||
x, y = event.position().x(), event.position().y()
|
||||
|
||||
self._update_page_status(x, y)
|
||||
|
||||
# Canvas panning (middle mouse button)
|
||||
if self.is_panning and self.drag_start_pos:
|
||||
self._handle_canvas_pan(x, y)
|
||||
return
|
||||
|
||||
if not self.is_dragging or not self.drag_start_pos:
|
||||
return
|
||||
|
||||
if not self.selected_element:
|
||||
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)
|
||||
|
||||
self.update()
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
"""Handle mouse release events"""
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self._end_interaction()
|
||||
|
||||
self.is_dragging = False
|
||||
self.drag_start_pos = None
|
||||
self.drag_start_element_pos = None
|
||||
self.resize_handle = None
|
||||
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.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
|
||||
elif event.button() == Qt.MouseButton.MiddleButton:
|
||||
self.is_panning = False
|
||||
self.drag_start_pos = None
|
||||
self.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
|
||||
def mouseDoubleClickEvent(self, event):
|
||||
"""Handle mouse double-click events"""
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
x, y = event.position().x(), event.position().y()
|
||||
element = self._get_element_at(x, y)
|
||||
|
||||
from pyPhotoAlbum.models import TextBoxData
|
||||
|
||||
if isinstance(element, TextBoxData):
|
||||
self._edit_text_element(element)
|
||||
return
|
||||
|
||||
super().mouseDoubleClickEvent(event)
|
||||
|
||||
def wheelEvent(self, event):
|
||||
"""Handle mouse wheel events for scrolling or zooming (with Ctrl)"""
|
||||
delta = event.angleDelta().y()
|
||||
ctrl_pressed = event.modifiers() & Qt.KeyboardModifier.ControlModifier
|
||||
|
||||
if ctrl_pressed:
|
||||
# Ctrl + Wheel: Zoom centered on mouse position
|
||||
mouse_x = event.position().x()
|
||||
mouse_y = event.position().y()
|
||||
|
||||
world_x = (mouse_x - self.pan_offset[0]) / self.zoom_level
|
||||
world_y = (mouse_y - self.pan_offset[1]) / self.zoom_level
|
||||
|
||||
zoom_factor = 1.1 if delta > 0 else 0.9
|
||||
new_zoom = self.zoom_level * zoom_factor
|
||||
|
||||
if 0.1 <= new_zoom <= 5.0:
|
||||
old_zoom = self.zoom_level
|
||||
old_pan_x = self.pan_offset[0]
|
||||
old_pan_y = self.pan_offset[1]
|
||||
|
||||
self.zoom_level = new_zoom
|
||||
|
||||
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 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)
|
||||
|
||||
# Clamp pan offset to content bounds
|
||||
if hasattr(self, "clamp_pan_offset"):
|
||||
self.clamp_pan_offset()
|
||||
|
||||
self.update()
|
||||
|
||||
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()
|
||||
else:
|
||||
# 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[0] += scroll_amount_x
|
||||
self.pan_offset[1] += scroll_amount_y
|
||||
|
||||
# Clamp pan offset to content bounds
|
||||
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] + 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"):
|
||||
main_window.update_scrollbars()
|
||||
|
||||
def _edit_text_element(self, text_element):
|
||||
"""Open dialog to edit text element"""
|
||||
from pyPhotoAlbum.text_edit_dialog import TextEditDialog
|
||||
|
||||
dialog = TextEditDialog(text_element, self)
|
||||
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"]
|
||||
|
||||
self.update()
|
||||
|
||||
print(f"Updated text element: {values['text_content'][:50]}...")
|
||||
31
pyPhotoAlbum/mixins/operations/__init__.py
Normal file
@ -0,0 +1,31 @@
|
||||
"""
|
||||
Operation mixins for pyPhotoAlbum
|
||||
"""
|
||||
|
||||
from pyPhotoAlbum.mixins.operations.file_ops import FileOperationsMixin
|
||||
from pyPhotoAlbum.mixins.operations.edit_ops import EditOperationsMixin
|
||||
from pyPhotoAlbum.mixins.operations.element_ops import ElementOperationsMixin
|
||||
from pyPhotoAlbum.mixins.operations.page_ops import PageOperationsMixin
|
||||
from pyPhotoAlbum.mixins.operations.template_ops import TemplateOperationsMixin
|
||||
from pyPhotoAlbum.mixins.operations.view_ops import ViewOperationsMixin
|
||||
from pyPhotoAlbum.mixins.operations.alignment_ops import AlignmentOperationsMixin
|
||||
from pyPhotoAlbum.mixins.operations.distribution_ops import DistributionOperationsMixin
|
||||
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",
|
||||
"StyleOperationsMixin",
|
||||
]
|
||||
135
pyPhotoAlbum/mixins/operations/alignment_ops.py
Normal file
@ -0,0 +1,135 @@
|
||||
"""
|
||||
Alignment operations mixin for pyPhotoAlbum
|
||||
"""
|
||||
|
||||
from pyPhotoAlbum.decorators import ribbon_action
|
||||
from pyPhotoAlbum.alignment import AlignmentManager
|
||||
from pyPhotoAlbum.commands import AlignElementsCommand, ResizeElementsCommand
|
||||
|
||||
|
||||
class AlignmentOperationsMixin:
|
||||
"""Mixin providing element alignment operations"""
|
||||
|
||||
def _get_selected_elements_list(self):
|
||||
"""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):
|
||||
return
|
||||
|
||||
changes = alignment_func(elements)
|
||||
if changes:
|
||||
cmd = AlignElementsCommand(changes)
|
||||
self.project.history.execute(cmd)
|
||||
self.update_view()
|
||||
self.show_status(status_msg.format(len(elements)), 2000)
|
||||
|
||||
@ribbon_action(
|
||||
label="Align Left",
|
||||
tooltip="Align selected elements to the left",
|
||||
tab="Arrange",
|
||||
group="Align",
|
||||
requires_selection=True,
|
||||
min_selection=2,
|
||||
)
|
||||
def align_left(self):
|
||||
"""Align selected elements to the left"""
|
||||
self._execute_alignment(AlignmentManager.align_left, "Aligned {} elements to left")
|
||||
|
||||
@ribbon_action(
|
||||
label="Align Right",
|
||||
tooltip="Align selected elements to the right",
|
||||
tab="Arrange",
|
||||
group="Align",
|
||||
requires_selection=True,
|
||||
min_selection=2,
|
||||
)
|
||||
def align_right(self):
|
||||
"""Align selected elements to the right"""
|
||||
self._execute_alignment(AlignmentManager.align_right, "Aligned {} elements to right")
|
||||
|
||||
@ribbon_action(
|
||||
label="Align Top",
|
||||
tooltip="Align selected elements to the top",
|
||||
tab="Arrange",
|
||||
group="Align",
|
||||
requires_selection=True,
|
||||
min_selection=2,
|
||||
)
|
||||
def align_top(self):
|
||||
"""Align selected elements to the top"""
|
||||
self._execute_alignment(AlignmentManager.align_top, "Aligned {} elements to top")
|
||||
|
||||
@ribbon_action(
|
||||
label="Align Bottom",
|
||||
tooltip="Align selected elements to the bottom",
|
||||
tab="Arrange",
|
||||
group="Align",
|
||||
requires_selection=True,
|
||||
min_selection=2,
|
||||
)
|
||||
def align_bottom(self):
|
||||
"""Align selected elements to the bottom"""
|
||||
self._execute_alignment(AlignmentManager.align_bottom, "Aligned {} elements to bottom")
|
||||
|
||||
@ribbon_action(
|
||||
label="Align H-Center",
|
||||
tooltip="Align selected elements to horizontal center",
|
||||
tab="Arrange",
|
||||
group="Align",
|
||||
requires_selection=True,
|
||||
min_selection=2,
|
||||
)
|
||||
def align_horizontal_center(self):
|
||||
"""Align selected elements to horizontal center"""
|
||||
self._execute_alignment(AlignmentManager.align_horizontal_center, "Aligned {} elements to horizontal center")
|
||||
|
||||
@ribbon_action(
|
||||
label="Align V-Center",
|
||||
tooltip="Align selected elements to vertical center",
|
||||
tab="Arrange",
|
||||
group="Align",
|
||||
requires_selection=True,
|
||||
min_selection=2,
|
||||
)
|
||||
def align_vertical_center(self):
|
||||
"""Align selected elements to vertical center"""
|
||||
self._execute_alignment(AlignmentManager.align_vertical_center, "Aligned {} elements to vertical center")
|
||||
|
||||
@ribbon_action(
|
||||
label="Maximize Pattern",
|
||||
tooltip="Maximize selected elements using crystal growth algorithm",
|
||||
tab="Arrange",
|
||||
group="Size",
|
||||
requires_selection=True,
|
||||
min_selection=1,
|
||||
)
|
||||
def maximize_pattern(self):
|
||||
"""Maximize selected elements until they are close to borders or each other"""
|
||||
elements = self._get_selected_elements_list()
|
||||
if not self.require_selection(min_count=1):
|
||||
return
|
||||
|
||||
# Get page size from current page
|
||||
page = self.get_current_page()
|
||||
if not page:
|
||||
self.show_warning("No Page", "Please create a page first.")
|
||||
return
|
||||
|
||||
page_size = page.layout.size
|
||||
|
||||
changes = AlignmentManager.maximize_pattern(elements, page_size)
|
||||
if changes:
|
||||
cmd = ResizeElementsCommand(changes)
|
||||
self.project.history.execute(cmd)
|
||||
self.update_view()
|
||||
self.show_status(f"Maximized {len(elements)} element(s) using pattern growth", 2000)
|
||||
82
pyPhotoAlbum/mixins/operations/distribution_ops.py
Normal file
@ -0,0 +1,82 @@
|
||||
"""
|
||||
Distribution operations mixin for pyPhotoAlbum
|
||||
"""
|
||||
|
||||
from pyPhotoAlbum.decorators import ribbon_action
|
||||
from pyPhotoAlbum.alignment import AlignmentManager
|
||||
from pyPhotoAlbum.commands import AlignElementsCommand
|
||||
|
||||
|
||||
class DistributionOperationsMixin:
|
||||
"""Mixin providing element distribution and spacing operations"""
|
||||
|
||||
def _get_selected_elements_list(self):
|
||||
"""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):
|
||||
return
|
||||
|
||||
changes = distribution_func(elements)
|
||||
if changes:
|
||||
cmd = AlignElementsCommand(changes)
|
||||
self.project.history.execute(cmd)
|
||||
self.update_view()
|
||||
self.show_status(status_msg.format(len(elements)), 2000)
|
||||
|
||||
@ribbon_action(
|
||||
label="Distribute H",
|
||||
tooltip="Distribute selected elements evenly horizontally",
|
||||
tab="Arrange",
|
||||
group="Distribute",
|
||||
requires_selection=True,
|
||||
min_selection=3,
|
||||
)
|
||||
def distribute_horizontally(self):
|
||||
"""Distribute selected elements evenly horizontally"""
|
||||
self._execute_distribution(AlignmentManager.distribute_horizontally, "Distributed {} elements horizontally")
|
||||
|
||||
@ribbon_action(
|
||||
label="Distribute V",
|
||||
tooltip="Distribute selected elements evenly vertically",
|
||||
tab="Arrange",
|
||||
group="Distribute",
|
||||
requires_selection=True,
|
||||
min_selection=3,
|
||||
)
|
||||
def distribute_vertically(self):
|
||||
"""Distribute selected elements evenly vertically"""
|
||||
self._execute_distribution(AlignmentManager.distribute_vertically, "Distributed {} elements vertically")
|
||||
|
||||
@ribbon_action(
|
||||
label="Space H",
|
||||
tooltip="Space selected elements equally horizontally",
|
||||
tab="Arrange",
|
||||
group="Distribute",
|
||||
requires_selection=True,
|
||||
min_selection=3,
|
||||
)
|
||||
def space_horizontally(self):
|
||||
"""Space selected elements equally horizontally"""
|
||||
self._execute_distribution(AlignmentManager.space_horizontally, "Spaced {} elements horizontally")
|
||||
|
||||
@ribbon_action(
|
||||
label="Space V",
|
||||
tooltip="Space selected elements equally vertically",
|
||||
tab="Arrange",
|
||||
group="Distribute",
|
||||
requires_selection=True,
|
||||
min_selection=3,
|
||||
)
|
||||
def space_vertically(self):
|
||||
"""Space selected elements equally vertically"""
|
||||
self._execute_distribution(AlignmentManager.space_vertically, "Spaced {} elements vertically")
|
||||
144
pyPhotoAlbum/mixins/operations/edit_ops.py
Normal file
@ -0,0 +1,144 @@
|
||||
"""
|
||||
Edit operations mixin for pyPhotoAlbum
|
||||
"""
|
||||
|
||||
from pyPhotoAlbum.decorators import ribbon_action
|
||||
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")
|
||||
def undo(self):
|
||||
"""Undo last action"""
|
||||
if self.project.history.undo():
|
||||
self.update_view()
|
||||
self.show_status("Undo successful", 2000)
|
||||
print("Undo successful")
|
||||
else:
|
||||
self.show_status("Nothing to undo", 2000)
|
||||
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"
|
||||
)
|
||||
def redo(self):
|
||||
"""Redo last action"""
|
||||
if self.project.history.redo():
|
||||
self.update_view()
|
||||
self.show_status("Redo successful", 2000)
|
||||
print("Redo successful")
|
||||
else:
|
||||
self.show_status("Nothing to redo", 2000)
|
||||
print("Nothing to redo")
|
||||
|
||||
@ribbon_action(
|
||||
label="Delete",
|
||||
tooltip="Delete selected element (Delete key)",
|
||||
tab="Home",
|
||||
group="Edit",
|
||||
shortcut="Delete",
|
||||
requires_selection=True,
|
||||
)
|
||||
def delete_selected_element(self):
|
||||
"""Delete the currently selected element"""
|
||||
if not self.require_selection(min_count=1):
|
||||
return
|
||||
|
||||
current_page = self.get_current_page()
|
||||
if not current_page:
|
||||
return
|
||||
|
||||
# Delete the first selected element (for backward compatibility)
|
||||
# In the future, we could delete all selected elements
|
||||
selected_element = next(iter(self.gl_widget.selected_elements))
|
||||
|
||||
try:
|
||||
cmd = DeleteElementCommand(current_page.layout, selected_element, asset_manager=self.project.asset_manager)
|
||||
self.project.history.execute(cmd)
|
||||
|
||||
# Clear selection
|
||||
self.gl_widget.selected_elements.clear()
|
||||
|
||||
# Update display
|
||||
self.update_view()
|
||||
|
||||
self.show_status("Element deleted (Ctrl+Z to undo)", 2000)
|
||||
print("Deleted selected element")
|
||||
|
||||
except Exception as e:
|
||||
self.show_error("Error", f"Failed to delete element: {str(e)}")
|
||||
print(f"Error deleting element: {e}")
|
||||
|
||||
@ribbon_action(
|
||||
label="Rotate Left",
|
||||
tooltip="Rotate selected element 90° counter-clockwise",
|
||||
tab="Arrange",
|
||||
group="Transform",
|
||||
requires_selection=True,
|
||||
)
|
||||
def rotate_left(self):
|
||||
"""Rotate selected element 90 degrees counter-clockwise"""
|
||||
if not self.require_selection(min_count=1):
|
||||
return
|
||||
|
||||
selected_element = next(iter(self.gl_widget.selected_elements))
|
||||
old_rotation = selected_element.rotation
|
||||
new_rotation = (old_rotation - 90) % 360
|
||||
|
||||
cmd = RotateElementCommand(selected_element, old_rotation, new_rotation)
|
||||
self.project.history.execute(cmd)
|
||||
|
||||
self.update_view()
|
||||
self.show_status(f"Rotated left (Ctrl+Z to undo)", 2000)
|
||||
print(f"Rotated element left: {old_rotation}° → {new_rotation}°")
|
||||
|
||||
@ribbon_action(
|
||||
label="Rotate Right",
|
||||
tooltip="Rotate selected element 90° clockwise",
|
||||
tab="Arrange",
|
||||
group="Transform",
|
||||
requires_selection=True,
|
||||
)
|
||||
def rotate_right(self):
|
||||
"""Rotate selected element 90 degrees clockwise"""
|
||||
if not self.require_selection(min_count=1):
|
||||
return
|
||||
|
||||
selected_element = next(iter(self.gl_widget.selected_elements))
|
||||
old_rotation = selected_element.rotation
|
||||
new_rotation = (old_rotation + 90) % 360
|
||||
|
||||
cmd = RotateElementCommand(selected_element, old_rotation, new_rotation)
|
||||
self.project.history.execute(cmd)
|
||||
|
||||
self.update_view()
|
||||
self.show_status(f"Rotated right (Ctrl+Z to undo)", 2000)
|
||||
print(f"Rotated element right: {old_rotation}° → {new_rotation}°")
|
||||
|
||||
@ribbon_action(
|
||||
label="Reset Rotation",
|
||||
tooltip="Reset selected element rotation to 0°",
|
||||
tab="Arrange",
|
||||
group="Transform",
|
||||
requires_selection=True,
|
||||
)
|
||||
def reset_rotation(self):
|
||||
"""Reset selected element rotation to 0 degrees"""
|
||||
if not self.require_selection(min_count=1):
|
||||
return
|
||||
|
||||
selected_element = next(iter(self.gl_widget.selected_elements))
|
||||
old_rotation = selected_element.rotation
|
||||
|
||||
if old_rotation == 0:
|
||||
self.show_status("Element already at 0°", 2000)
|
||||
return
|
||||
|
||||
cmd = RotateElementCommand(selected_element, old_rotation, 0)
|
||||
self.project.history.execute(cmd)
|
||||
|
||||
self.update_view()
|
||||
self.show_status("Reset rotation to 0° (Ctrl+Z to undo)", 2000)
|
||||
print(f"Reset element rotation: {old_rotation}° → 0°")
|
||||
133
pyPhotoAlbum/mixins/operations/element_ops.py
Normal file
@ -0,0 +1,133 @@
|
||||
"""
|
||||
Element operations mixin for pyPhotoAlbum
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import QFileDialog
|
||||
from pyPhotoAlbum.decorators import ribbon_action
|
||||
from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
|
||||
from pyPhotoAlbum.commands import AddElementCommand
|
||||
from pyPhotoAlbum.async_backend import get_image_dimensions
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
def add_image(self):
|
||||
"""Add an image to the current page"""
|
||||
if not self.require_page():
|
||||
return
|
||||
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Select Image", "", "Image Files (*.jpg *.jpeg *.png *.gif *.bmp *.tiff *.webp);;All Files (*)"
|
||||
)
|
||||
|
||||
if not file_path:
|
||||
return
|
||||
|
||||
current_page = self.get_current_page()
|
||||
if not current_page:
|
||||
return
|
||||
|
||||
try:
|
||||
# Import asset to project
|
||||
asset_path = self.project.asset_manager.import_asset(file_path)
|
||||
|
||||
# Get dimensions using centralized utility (max 300px for UI display)
|
||||
full_asset_path = self.get_asset_full_path(asset_path)
|
||||
dimensions = get_image_dimensions(full_asset_path, max_size=300)
|
||||
if dimensions:
|
||||
img_width, img_height = dimensions
|
||||
else:
|
||||
# Fallback dimensions if image cannot be read
|
||||
img_width, img_height = 200, 150
|
||||
|
||||
# Create image element at center of page
|
||||
page_width_mm = current_page.layout.size[0]
|
||||
page_height_mm = current_page.layout.size[1]
|
||||
|
||||
# Center position
|
||||
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)
|
||||
|
||||
# Add element using command pattern for undo/redo
|
||||
cmd = AddElementCommand(current_page.layout, new_image, asset_manager=self.project.asset_manager)
|
||||
self.project.history.execute(cmd)
|
||||
|
||||
self.update_view()
|
||||
self.show_status("Added image (Ctrl+Z to undo)", 2000)
|
||||
print(f"Added image to page {self.get_current_page_index() + 1}: {asset_path}")
|
||||
|
||||
except Exception as e:
|
||||
self.show_error("Error", f"Failed to add image: {str(e)}")
|
||||
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
|
||||
)
|
||||
def add_text(self):
|
||||
"""Add text to the current page"""
|
||||
if not self.require_page():
|
||||
return
|
||||
|
||||
current_page = self.get_current_page()
|
||||
if not current_page:
|
||||
return
|
||||
|
||||
# Create text box element at center of page
|
||||
page_width_mm = current_page.layout.size[0]
|
||||
page_height_mm = current_page.layout.size[1]
|
||||
|
||||
text_width = 200
|
||||
text_height = 50
|
||||
|
||||
# Center position
|
||||
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)
|
||||
|
||||
current_page.layout.add_element(new_text)
|
||||
self.update_view()
|
||||
|
||||
print(f"Added text box to page {self.get_current_page_index() + 1}")
|
||||
|
||||
@ribbon_action(
|
||||
label="Placeholder",
|
||||
tooltip="Add a placeholder to the current page",
|
||||
tab="Insert",
|
||||
group="Media",
|
||||
requires_page=True,
|
||||
)
|
||||
def add_placeholder(self):
|
||||
"""Add a placeholder to the current page"""
|
||||
if not self.require_page():
|
||||
return
|
||||
|
||||
current_page = self.get_current_page()
|
||||
if not current_page:
|
||||
return
|
||||
|
||||
# Create placeholder element at center of page
|
||||
page_width_mm = current_page.layout.size[0]
|
||||
page_height_mm = current_page.layout.size[1]
|
||||
|
||||
placeholder_width = 200
|
||||
placeholder_height = 150
|
||||
|
||||
# Center position
|
||||
x = (page_width_mm - placeholder_width) / 2
|
||||
y = (page_height_mm - placeholder_height) / 2
|
||||
|
||||
new_placeholder = PlaceholderData(
|
||||
placeholder_type="image", x=x, y=y, width=placeholder_width, height=placeholder_height
|
||||
)
|
||||
|
||||
current_page.layout.add_element(new_placeholder)
|
||||
self.update_view()
|
||||
|
||||
print(f"Added placeholder to page {self.get_current_page_index() + 1}")
|
||||
836
pyPhotoAlbum/mixins/operations/file_ops.py
Normal file
@ -0,0 +1,836 @@
|
||||
"""
|
||||
File operations mixin for pyPhotoAlbum
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Optional, cast
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
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, 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 FileOperationsMixin:
|
||||
"""Mixin providing file-related operations"""
|
||||
|
||||
# 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
|
||||
dialog = QDialog(self)
|
||||
dialog.setWindowTitle("New Project Setup")
|
||||
dialog.setMinimumWidth(450)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Project name group
|
||||
name_group = QGroupBox("Project Name")
|
||||
name_layout = QVBoxLayout()
|
||||
name_input = QLineEdit()
|
||||
name_input.setText("New Project")
|
||||
name_input.selectAll()
|
||||
name_layout.addWidget(name_input)
|
||||
name_group.setLayout(name_layout)
|
||||
layout.addWidget(name_group)
|
||||
|
||||
# Default page size group
|
||||
size_group = QGroupBox("Default Page Size")
|
||||
size_layout = QVBoxLayout()
|
||||
|
||||
info_label = QLabel("This will be the default size for all new pages in this project.")
|
||||
info_label.setWordWrap(True)
|
||||
info_label.setStyleSheet("font-size: 9pt; color: gray;")
|
||||
size_layout.addWidget(info_label)
|
||||
|
||||
# Width
|
||||
width_layout = QHBoxLayout()
|
||||
width_layout.addWidget(QLabel("Width:"))
|
||||
width_spinbox = QDoubleSpinBox()
|
||||
width_spinbox.setRange(10, 1000)
|
||||
width_spinbox.setValue(140) # Default 14cm
|
||||
width_spinbox.setSuffix(" mm")
|
||||
width_layout.addWidget(width_spinbox)
|
||||
size_layout.addLayout(width_layout)
|
||||
|
||||
# Height
|
||||
height_layout = QHBoxLayout()
|
||||
height_layout.addWidget(QLabel("Height:"))
|
||||
height_spinbox = QDoubleSpinBox()
|
||||
height_spinbox.setRange(10, 1000)
|
||||
height_spinbox.setValue(140) # Default 14cm
|
||||
height_spinbox.setSuffix(" mm")
|
||||
height_layout.addWidget(height_spinbox)
|
||||
size_layout.addLayout(height_layout)
|
||||
|
||||
# Add common size presets
|
||||
presets_layout = QHBoxLayout()
|
||||
presets_layout.addWidget(QLabel("Presets:"))
|
||||
|
||||
def set_preset(w, h):
|
||||
width_spinbox.setValue(w)
|
||||
height_spinbox.setValue(h)
|
||||
|
||||
preset_a4 = QPushButton("A4 (210×297)")
|
||||
preset_a4.clicked.connect(lambda: set_preset(210, 297))
|
||||
presets_layout.addWidget(preset_a4)
|
||||
|
||||
preset_a5 = QPushButton("A5 (148×210)")
|
||||
preset_a5.clicked.connect(lambda: set_preset(148, 210))
|
||||
presets_layout.addWidget(preset_a5)
|
||||
|
||||
preset_square = QPushButton("Square (200×200)")
|
||||
preset_square.clicked.connect(lambda: set_preset(200, 200))
|
||||
presets_layout.addWidget(preset_square)
|
||||
|
||||
presets_layout.addStretch()
|
||||
size_layout.addLayout(presets_layout)
|
||||
|
||||
size_group.setLayout(size_layout)
|
||||
layout.addWidget(size_group)
|
||||
|
||||
# DPI settings group
|
||||
dpi_group = QGroupBox("DPI Settings")
|
||||
dpi_layout = QVBoxLayout()
|
||||
|
||||
# Working DPI
|
||||
working_dpi_layout = QHBoxLayout()
|
||||
working_dpi_layout.addWidget(QLabel("Working DPI:"))
|
||||
working_dpi_spinbox = QSpinBox()
|
||||
working_dpi_spinbox.setRange(72, 1200)
|
||||
working_dpi_spinbox.setValue(300)
|
||||
working_dpi_layout.addWidget(working_dpi_spinbox)
|
||||
dpi_layout.addLayout(working_dpi_layout)
|
||||
|
||||
# Export DPI
|
||||
export_dpi_layout = QHBoxLayout()
|
||||
export_dpi_layout.addWidget(QLabel("Export DPI:"))
|
||||
export_dpi_spinbox = QSpinBox()
|
||||
export_dpi_spinbox.setRange(72, 1200)
|
||||
export_dpi_spinbox.setValue(300)
|
||||
export_dpi_layout.addWidget(export_dpi_spinbox)
|
||||
dpi_layout.addLayout(export_dpi_layout)
|
||||
|
||||
dpi_group.setLayout(dpi_layout)
|
||||
layout.addWidget(dpi_group)
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
cancel_btn.clicked.connect(dialog.reject)
|
||||
create_btn = QPushButton("Create Project")
|
||||
create_btn.clicked.connect(dialog.accept)
|
||||
create_btn.setDefault(True)
|
||||
|
||||
button_layout.addStretch()
|
||||
button_layout.addWidget(cancel_btn)
|
||||
button_layout.addWidget(create_btn)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
dialog.setLayout(layout)
|
||||
|
||||
# Show dialog
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
# Get values
|
||||
project_name = name_input.text().strip() or "New Project"
|
||||
width_mm = width_spinbox.value()
|
||||
height_mm = height_spinbox.value()
|
||||
working_dpi = working_dpi_spinbox.value()
|
||||
export_dpi = export_dpi_spinbox.value()
|
||||
|
||||
# Cleanup old project if it exists
|
||||
if hasattr(self, "project") and self.project:
|
||||
self.project.cleanup()
|
||||
|
||||
# Create project with custom settings
|
||||
self.project = Project(project_name)
|
||||
self.project.page_size_mm = (width_mm, height_mm)
|
||||
self.project.working_dpi = working_dpi
|
||||
self.project.export_dpi = export_dpi
|
||||
|
||||
# Set asset resolution context
|
||||
set_asset_resolution_context(self.project.folder_path)
|
||||
|
||||
# Update view
|
||||
self.update_view()
|
||||
|
||||
self.show_status(f"New project created: {project_name} ({width_mm}×{height_mm} mm)")
|
||||
print(f"New project created: {project_name}, default page size: {width_mm}×{height_mm} mm")
|
||||
else:
|
||||
# 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")
|
||||
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 (*)"
|
||||
)
|
||||
|
||||
if file_path:
|
||||
print(f"Opening project: {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("Opening project...")
|
||||
|
||||
# Create and configure async loader
|
||||
self._project_loader = AsyncProjectLoader(file_path)
|
||||
self._opening_file_path = file_path # Store for later
|
||||
|
||||
# Connect signals
|
||||
self._project_loader.progress_updated.connect(self._on_load_progress)
|
||||
self._project_loader.load_complete.connect(self._on_load_complete)
|
||||
self._project_loader.load_failed.connect(self._on_load_failed)
|
||||
|
||||
# Start async loading
|
||||
self._project_loader.start()
|
||||
|
||||
def _on_load_progress(self, current: int, total: int, message: str):
|
||||
"""Handle loading progress updates"""
|
||||
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:
|
||||
self.project.cleanup()
|
||||
|
||||
# Set new project
|
||||
self.project = project
|
||||
|
||||
# Set file path and mark as clean
|
||||
if hasattr(self, "_opening_file_path"):
|
||||
self.project.file_path = 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"):
|
||||
self._loading_widget.hide_loading()
|
||||
|
||||
# Update view (this will trigger progressive image loading)
|
||||
self.update_view()
|
||||
|
||||
# Check for missing assets and inform user
|
||||
missing_assets = self._check_missing_assets()
|
||||
if missing_assets:
|
||||
self._show_missing_assets_warning(missing_assets)
|
||||
self.show_status(f"Project opened: {project.name} ({len(missing_assets)} missing images)")
|
||||
else:
|
||||
self.show_status(f"Project opened: {project.name}")
|
||||
print(f"Successfully loaded project: {project.name}")
|
||||
|
||||
def _on_load_failed(self, error_msg: str):
|
||||
"""Handle project load failure"""
|
||||
# Hide loading widget
|
||||
if hasattr(self, "_loading_widget"):
|
||||
self._loading_widget.hide_loading()
|
||||
|
||||
error_msg = f"Failed to open project: {error_msg}"
|
||||
self.show_status(error_msg)
|
||||
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 asynchronously with progress feedback"""
|
||||
# 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
|
||||
|
||||
if not file_path:
|
||||
file_path, _ = QFileDialog.getSaveFileName(
|
||||
self, "Save Project", "", "pyPhotoAlbum Projects (*.ppz);;All Files (*)"
|
||||
)
|
||||
|
||||
if file_path:
|
||||
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...")
|
||||
|
||||
# Define callbacks for async save
|
||||
def on_progress(progress: int, message: str):
|
||||
"""Update progress display"""
|
||||
if hasattr(self, "_loading_widget"):
|
||||
self._loading_widget.set_progress(progress, 100)
|
||||
self._loading_widget.set_status(message)
|
||||
|
||||
def on_complete(success: bool, error: str):
|
||||
"""Handle save completion"""
|
||||
# Hide loading widget
|
||||
if hasattr(self, "_loading_widget"):
|
||||
self._loading_widget.hide_loading()
|
||||
|
||||
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}")
|
||||
else:
|
||||
error_msg = f"Failed to save project: {error}"
|
||||
self.show_status(error_msg)
|
||||
self.show_error("Save Failed", error_msg)
|
||||
print(error_msg)
|
||||
|
||||
# Start async save
|
||||
save_to_zip_async(
|
||||
self.project,
|
||||
file_path,
|
||||
on_complete=on_complete,
|
||||
on_progress=on_progress
|
||||
)
|
||||
|
||||
# Show immediate feedback
|
||||
self.show_status("Saving project in background...", 2000)
|
||||
|
||||
@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)
|
||||
dialog.exec()
|
||||
|
||||
# Update the view to reflect any changes
|
||||
self.update_view()
|
||||
|
||||
def _check_missing_assets(self) -> list:
|
||||
"""Check for missing assets in the project - returns list of missing paths"""
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
missing = []
|
||||
for page in self.project.pages:
|
||||
for element in page.layout.elements:
|
||||
if isinstance(element, ImageData) and element.image_path:
|
||||
# Absolute paths need healing
|
||||
if os.path.isabs(element.image_path):
|
||||
missing.append(element.image_path)
|
||||
# Paths not in assets/ need healing
|
||||
elif not element.image_path.startswith("assets/"):
|
||||
missing.append(element.image_path)
|
||||
else:
|
||||
# Check if file exists in assets using mixin
|
||||
if not self.resolve_asset_path(element.image_path):
|
||||
missing.append(element.image_path)
|
||||
return list(set(missing)) # Remove duplicates
|
||||
|
||||
def _show_missing_assets_warning(self, missing_assets: list):
|
||||
"""Show a warning about missing assets"""
|
||||
from PyQt6.QtWidgets import QMessageBox
|
||||
|
||||
# Build message with list of missing images
|
||||
if len(missing_assets) <= 5:
|
||||
asset_list = "\n".join(f" • {path}" for path in missing_assets)
|
||||
else:
|
||||
asset_list = "\n".join(f" • {path}" for path in missing_assets[:5])
|
||||
asset_list += f"\n ... and {len(missing_assets) - 5} more"
|
||||
|
||||
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.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Open)
|
||||
msg.button(QMessageBox.StandardButton.Open).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)])
|
||||
def project_settings(self):
|
||||
"""Configure project-wide settings including default page size"""
|
||||
# Create dialog
|
||||
dialog = QDialog(self)
|
||||
dialog.setWindowTitle("Project Settings")
|
||||
dialog.setMinimumWidth(500)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Page size group
|
||||
size_group = QGroupBox("Default Page Size")
|
||||
size_layout = QVBoxLayout()
|
||||
|
||||
# Width
|
||||
width_layout = QHBoxLayout()
|
||||
width_layout.addWidget(QLabel("Width:"))
|
||||
width_spinbox = QDoubleSpinBox()
|
||||
width_spinbox.setRange(10, 1000)
|
||||
width_spinbox.setValue(self.project.page_size_mm[0])
|
||||
width_spinbox.setSuffix(" mm")
|
||||
width_layout.addWidget(width_spinbox)
|
||||
size_layout.addLayout(width_layout)
|
||||
|
||||
# Height
|
||||
height_layout = QHBoxLayout()
|
||||
height_layout.addWidget(QLabel("Height:"))
|
||||
height_spinbox = QDoubleSpinBox()
|
||||
height_spinbox.setRange(10, 1000)
|
||||
height_spinbox.setValue(self.project.page_size_mm[1])
|
||||
height_spinbox.setSuffix(" mm")
|
||||
height_layout.addWidget(height_spinbox)
|
||||
size_layout.addLayout(height_layout)
|
||||
|
||||
size_group.setLayout(size_layout)
|
||||
layout.addWidget(size_group)
|
||||
|
||||
# DPI settings group
|
||||
dpi_group = QGroupBox("DPI Settings")
|
||||
dpi_layout = QVBoxLayout()
|
||||
|
||||
# Working DPI
|
||||
working_dpi_layout = QHBoxLayout()
|
||||
working_dpi_layout.addWidget(QLabel("Working DPI:"))
|
||||
working_dpi_spinbox = QSpinBox()
|
||||
working_dpi_spinbox.setRange(72, 1200)
|
||||
working_dpi_spinbox.setValue(self.project.working_dpi)
|
||||
working_dpi_layout.addWidget(working_dpi_spinbox)
|
||||
dpi_layout.addLayout(working_dpi_layout)
|
||||
|
||||
# Export DPI
|
||||
export_dpi_layout = QHBoxLayout()
|
||||
export_dpi_layout.addWidget(QLabel("Export DPI:"))
|
||||
export_dpi_spinbox = QSpinBox()
|
||||
export_dpi_spinbox.setRange(72, 1200)
|
||||
export_dpi_spinbox.setValue(self.project.export_dpi)
|
||||
export_dpi_layout.addWidget(export_dpi_spinbox)
|
||||
dpi_layout.addLayout(export_dpi_layout)
|
||||
|
||||
dpi_group.setLayout(dpi_layout)
|
||||
layout.addWidget(dpi_group)
|
||||
|
||||
# Content scaling options (only if pages exist and size is changing)
|
||||
scaling_group = None
|
||||
scaling_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)
|
||||
|
||||
scaling_buttons = QButtonGroup()
|
||||
|
||||
proportional_radio = QRadioButton("Resize proportionally (fit to smallest axis)")
|
||||
proportional_radio.setToolTip("Scale content uniformly to fit the new page size")
|
||||
scaling_buttons.addButton(proportional_radio, 0)
|
||||
scaling_layout.addWidget(proportional_radio)
|
||||
|
||||
stretch_radio = QRadioButton("Resize on both axes (stretch)")
|
||||
stretch_radio.setToolTip("Scale width and height independently")
|
||||
scaling_buttons.addButton(stretch_radio, 1)
|
||||
scaling_layout.addWidget(stretch_radio)
|
||||
|
||||
reposition_radio = QRadioButton("Keep content size, reposition to center")
|
||||
reposition_radio.setToolTip("Maintain element sizes but center them on new page")
|
||||
scaling_buttons.addButton(reposition_radio, 2)
|
||||
scaling_layout.addWidget(reposition_radio)
|
||||
|
||||
none_radio = QRadioButton("Don't adjust content (page size only)")
|
||||
none_radio.setToolTip("Only change page size, leave content as-is")
|
||||
none_radio.setChecked(True) # Default
|
||||
scaling_buttons.addButton(none_radio, 3)
|
||||
scaling_layout.addWidget(none_radio)
|
||||
|
||||
scaling_group.setLayout(scaling_layout)
|
||||
layout.addWidget(scaling_group)
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
cancel_btn.clicked.connect(dialog.reject)
|
||||
ok_btn = QPushButton("OK")
|
||||
ok_btn.clicked.connect(dialog.accept)
|
||||
ok_btn.setDefault(True)
|
||||
|
||||
button_layout.addStretch()
|
||||
button_layout.addWidget(cancel_btn)
|
||||
button_layout.addWidget(ok_btn)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
dialog.setLayout(layout)
|
||||
|
||||
# Show dialog
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
# Get new values
|
||||
new_width = width_spinbox.value()
|
||||
new_height = height_spinbox.value()
|
||||
new_working_dpi = working_dpi_spinbox.value()
|
||||
new_export_dpi = export_dpi_spinbox.value()
|
||||
|
||||
# Determine scaling mode
|
||||
scaling_mode = "none"
|
||||
if scaling_buttons:
|
||||
selected_id = scaling_buttons.checkedId()
|
||||
modes = {0: "proportional", 1: "stretch", 2: "reposition", 3: "none"}
|
||||
scaling_mode = modes.get(selected_id, "none")
|
||||
|
||||
# Apply settings
|
||||
old_size = self.project.page_size_mm
|
||||
self.project.page_size_mm = (new_width, new_height)
|
||||
self.project.working_dpi = new_working_dpi
|
||||
self.project.export_dpi = new_export_dpi
|
||||
|
||||
# Update existing pages (exclude manually sized ones)
|
||||
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.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):
|
||||
"""
|
||||
Apply new page size to all non-manually-sized 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'
|
||||
"""
|
||||
old_width, old_height = old_size
|
||||
new_width, new_height = new_size
|
||||
|
||||
width_ratio = new_width / old_width if old_width > 0 else 1.0
|
||||
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:
|
||||
continue
|
||||
|
||||
# Update page size
|
||||
old_page_width, old_page_height = page.layout.size
|
||||
|
||||
# For double spreads, maintain the 2x multiplier
|
||||
if page.is_double_spread:
|
||||
page.layout.size = (new_width * 2, new_height)
|
||||
else:
|
||||
page.layout.size = (new_width, new_height)
|
||||
|
||||
# Apply content scaling based on mode
|
||||
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":
|
||||
# Scale independently on each axis
|
||||
self._scale_page_elements(page, width_ratio, height_ratio)
|
||||
elif scaling_mode == "reposition":
|
||||
# Keep size, center content
|
||||
self._reposition_page_elements(page, old_size, new_size)
|
||||
# 'none' - do nothing to elements
|
||||
|
||||
def _scale_page_elements(self, page, x_scale, y_scale):
|
||||
"""
|
||||
Scale all elements on a page
|
||||
|
||||
Args:
|
||||
page: Page object
|
||||
x_scale: Horizontal scale factor
|
||||
y_scale: Vertical scale factor
|
||||
"""
|
||||
for element in page.layout.elements:
|
||||
# Scale position
|
||||
x, y = element.position
|
||||
element.position = (x * x_scale, y * y_scale)
|
||||
|
||||
# Scale size
|
||||
width, height = element.size
|
||||
element.size = (width * x_scale, height * y_scale)
|
||||
|
||||
def _reposition_page_elements(self, page, old_size, new_size):
|
||||
"""
|
||||
Reposition elements to center them on the new page size
|
||||
|
||||
Args:
|
||||
page: Page object
|
||||
old_size: Old page size (width, height) in mm
|
||||
new_size: New page size (width, height) in mm
|
||||
"""
|
||||
old_width, old_height = old_size
|
||||
new_width, new_height = new_size
|
||||
|
||||
x_offset = (new_width - old_width) / 2.0
|
||||
y_offset = (new_height - old_height) / 2.0
|
||||
|
||||
for element in page.layout.elements:
|
||||
x, y = element.position
|
||||
element.position = (x + x_offset, y + y_offset)
|
||||
|
||||
@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
|
||||
if not self.project or not self.project.pages:
|
||||
self.show_status("No pages to export")
|
||||
return
|
||||
|
||||
# Show file save dialog
|
||||
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"
|
||||
|
||||
# Use async PDF export (non-blocking, UI stays responsive)
|
||||
success = self.gl_widget.export_pdf_async(self.project, file_path, export_dpi=300)
|
||||
if success:
|
||||
self.show_status("PDF export started...", 2000)
|
||||
else:
|
||||
self.show_status("PDF export failed to start", 3000)
|
||||
|
||||
@ribbon_action(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)
|
||||
dialog.setWindowTitle("About pyPhotoAlbum")
|
||||
dialog.setMinimumWidth(600)
|
||||
dialog.setMinimumHeight(400)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Application info
|
||||
app_info = QLabel("<h2>pyPhotoAlbum</h2>")
|
||||
app_info.setWordWrap(True)
|
||||
layout.addWidget(app_info)
|
||||
|
||||
description = QLabel(
|
||||
"A photo album layout and design application with advanced "
|
||||
"page composition features and PDF export capabilities."
|
||||
)
|
||||
description.setWordWrap(True)
|
||||
layout.addWidget(description)
|
||||
|
||||
# Version information
|
||||
version_text = QTextEdit()
|
||||
version_text.setReadOnly(True)
|
||||
version_text.setPlainText(format_version_info())
|
||||
layout.addWidget(version_text)
|
||||
|
||||
# Close button
|
||||
close_button = QPushButton("Close")
|
||||
close_button.clicked.connect(dialog.accept)
|
||||
layout.addWidget(close_button)
|
||||
|
||||
dialog.setLayout(layout)
|
||||
dialog.exec()
|
||||
178
pyPhotoAlbum/mixins/operations/merge_ops.py
Normal file
@ -0,0 +1,178 @@
|
||||
"""
|
||||
Merge operations mixin for pyPhotoAlbum
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import QFileDialog, QMessageBox
|
||||
from pyPhotoAlbum.decorators import ribbon_action
|
||||
from pyPhotoAlbum.merge_manager import MergeManager, concatenate_projects
|
||||
from pyPhotoAlbum.merge_dialog import MergeDialog
|
||||
from pyPhotoAlbum.project_serializer import load_from_zip, save_to_zip
|
||||
from pyPhotoAlbum.models import set_asset_resolution_context
|
||||
from pyPhotoAlbum.project import Project
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
|
||||
class MergeOperationsMixin:
|
||||
"""Mixin providing project merge operations"""
|
||||
|
||||
@ribbon_action(
|
||||
label="Merge Projects",
|
||||
tooltip="Merge another project file with the current project",
|
||||
tab="Home",
|
||||
group="File",
|
||||
)
|
||||
def merge_projects(self):
|
||||
"""
|
||||
Merge another project with the current project.
|
||||
|
||||
If the projects have the same project_id, conflicts will be resolved.
|
||||
If they have different project_ids, they will be concatenated.
|
||||
"""
|
||||
# Check if current project has changes
|
||||
if self.project.is_dirty():
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Unsaved Changes",
|
||||
"You have unsaved changes in the current project. Save before merging?",
|
||||
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"):
|
||||
self.save_project()
|
||||
|
||||
# Select file to merge
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Select Project to Merge", "", "Photo Album Projects (*.ppz);;All Files (*)"
|
||||
)
|
||||
|
||||
if not file_path:
|
||||
return
|
||||
|
||||
try:
|
||||
# Disable autosave during merge
|
||||
if hasattr(self, "_autosave_timer"):
|
||||
self._autosave_timer.stop()
|
||||
|
||||
# Load the other project
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Load project data
|
||||
other_project = load_from_zip(file_path, temp_dir)
|
||||
|
||||
# Serialize both projects for comparison
|
||||
our_data = self.project.serialize()
|
||||
their_data = other_project.serialize()
|
||||
|
||||
# Check if projects should be merged or concatenated
|
||||
merge_manager = MergeManager()
|
||||
should_merge = merge_manager.should_merge_projects(our_data, their_data)
|
||||
|
||||
if should_merge:
|
||||
# Same project - merge with conflict resolution
|
||||
self._perform_merge_with_conflicts(our_data, their_data)
|
||||
else:
|
||||
# Different projects - concatenate
|
||||
self._perform_concatenation(our_data, their_data)
|
||||
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Merge Error", f"Failed to merge projects:\n{str(e)}")
|
||||
finally:
|
||||
# Re-enable autosave
|
||||
if hasattr(self, "_autosave_timer"):
|
||||
self._autosave_timer.start()
|
||||
|
||||
def _perform_merge_with_conflicts(self, our_data, their_data):
|
||||
"""Perform merge with conflict resolution UI"""
|
||||
# Detect conflicts
|
||||
merge_manager = MergeManager()
|
||||
conflicts = merge_manager.detect_conflicts(our_data, their_data)
|
||||
|
||||
if not conflicts:
|
||||
# No conflicts - auto-merge
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"No Conflicts",
|
||||
"No conflicts detected. Merge projects automatically?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
)
|
||||
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
# Auto-merge non-conflicting changes
|
||||
merged_data = merge_manager.apply_resolutions(our_data, their_data, {})
|
||||
else:
|
||||
# Show merge dialog for conflict resolution
|
||||
dialog = MergeDialog(our_data, their_data, self)
|
||||
|
||||
if dialog.exec() != QMessageBox.DialogCode.Accepted:
|
||||
QMessageBox.information(self, "Merge Cancelled", "Merge operation cancelled.")
|
||||
return
|
||||
|
||||
# Get merged data from dialog
|
||||
merged_data = dialog.get_merged_project_data()
|
||||
|
||||
# Apply merged data to current project
|
||||
self._apply_merged_data(merged_data)
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Merge Complete",
|
||||
f"Projects merged successfully.\n"
|
||||
f"Total pages: {len(merged_data.get('pages', []))}\n"
|
||||
f"Resolved conflicts: {len(conflicts)}",
|
||||
)
|
||||
|
||||
def _perform_concatenation(self, our_data, their_data):
|
||||
"""Concatenate two different projects"""
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Different Projects",
|
||||
f"These are different projects:\n"
|
||||
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,
|
||||
)
|
||||
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
# Concatenate projects
|
||||
merged_data = concatenate_projects(our_data, their_data)
|
||||
|
||||
# Apply merged data
|
||||
self._apply_merged_data(merged_data)
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Concatenation Complete",
|
||||
f"Projects concatenated successfully.\n" f"Total pages: {len(merged_data.get('pages', []))}",
|
||||
)
|
||||
|
||||
def _apply_merged_data(self, merged_data):
|
||||
"""Apply merged project data to current project"""
|
||||
# Create new project from merged data
|
||||
new_project = Project()
|
||||
new_project.deserialize(merged_data)
|
||||
|
||||
# Replace current project
|
||||
self._project = new_project
|
||||
|
||||
# Update asset resolution context
|
||||
set_asset_resolution_context(new_project.folder_path)
|
||||
|
||||
# Mark as dirty (has unsaved changes from merge)
|
||||
new_project.mark_dirty()
|
||||
|
||||
# Update UI
|
||||
if hasattr(self, "gl_widget"):
|
||||
self.gl_widget.set_project(new_project)
|
||||
self.gl_widget.update()
|
||||
|
||||
if hasattr(self, "status_bar"):
|
||||
self.status_bar.showMessage("Merge completed successfully", 3000)
|
||||
248
pyPhotoAlbum/mixins/operations/page_ops.py
Normal file
@ -0,0 +1,248 @@
|
||||
"""
|
||||
Page operations mixin for pyPhotoAlbum
|
||||
"""
|
||||
|
||||
from pyPhotoAlbum.decorators import ribbon_action, dialog_action
|
||||
from pyPhotoAlbum.dialogs import PageSetupDialog
|
||||
from pyPhotoAlbum.project import Page
|
||||
from pyPhotoAlbum.page_layout import PageLayout
|
||||
|
||||
|
||||
class PageOperationsMixin:
|
||||
"""Mixin providing page management operations"""
|
||||
|
||||
# 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")
|
||||
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
|
||||
current_page_index = self._get_most_visible_page_index()
|
||||
|
||||
# Ensure index is valid, default to end if not
|
||||
if current_page_index < 0 or current_page_index >= len(self.project.pages):
|
||||
insert_index = len(self.project.pages)
|
||||
else:
|
||||
# Insert after the current page
|
||||
insert_index = current_page_index + 1
|
||||
|
||||
# Create layout with project default size
|
||||
width_mm, height_mm = self.project.page_size_mm
|
||||
new_layout = PageLayout(width=width_mm, height=height_mm)
|
||||
|
||||
# Calculate proper page number for the new page
|
||||
# The page_number represents the logical page number in the book
|
||||
if insert_index == 0:
|
||||
# Inserting at the beginning
|
||||
new_page_number = 1
|
||||
elif insert_index >= len(self.project.pages):
|
||||
# Inserting at the end - calculate based on last page
|
||||
if self.project.pages:
|
||||
last_page = self.project.pages[-1]
|
||||
# Add the count of pages the last page represents
|
||||
new_page_number = last_page.page_number + last_page.get_page_count()
|
||||
else:
|
||||
new_page_number = 1
|
||||
else:
|
||||
# Inserting in the middle - take the page number of the page that will come after
|
||||
new_page_number = self.project.pages[insert_index].page_number
|
||||
|
||||
new_page = Page(layout=new_layout, page_number=new_page_number)
|
||||
# New pages are not manually sized - they use project defaults
|
||||
new_page.manually_sized = False
|
||||
|
||||
# Insert the page at the calculated position
|
||||
self.project.add_page(new_page, index=insert_index)
|
||||
|
||||
# Renumber all pages to ensure consistent numbering
|
||||
# Page numbers represent logical page numbers in the book
|
||||
current_page_num = 1
|
||||
for page in self.project.pages:
|
||||
page.page_number = current_page_num
|
||||
current_page_num += page.get_page_count()
|
||||
|
||||
self.update_view()
|
||||
|
||||
# Get display name for status message
|
||||
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")
|
||||
@dialog_action(dialog_class=PageSetupDialog, requires_pages=True)
|
||||
def page_setup(self, values):
|
||||
"""
|
||||
Apply page setup configuration.
|
||||
|
||||
This method contains only business logic. UI presentation
|
||||
is handled by PageSetupDialog and the dialog_action decorator.
|
||||
|
||||
Args:
|
||||
values: Dictionary of values from the dialog
|
||||
"""
|
||||
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"]
|
||||
|
||||
# Handle cover designation (only for first page)
|
||||
if selected_index == 0:
|
||||
was_cover = selected_page.is_cover
|
||||
is_cover = values["is_cover"]
|
||||
|
||||
if was_cover != is_cover:
|
||||
selected_page.is_cover = is_cover
|
||||
self.project.has_cover = is_cover
|
||||
|
||||
if is_cover:
|
||||
# Calculate and set cover dimensions
|
||||
self.project.update_cover_dimensions()
|
||||
print(f"Page 1 designated as cover")
|
||||
else:
|
||||
# Restore normal page size
|
||||
selected_page.layout.size = self.project.page_size_mm
|
||||
print(f"Cover removed from page 1")
|
||||
|
||||
# Get new values
|
||||
width_mm = values["width_mm"]
|
||||
height_mm = values["height_mm"]
|
||||
|
||||
# Don't allow manual size changes for covers
|
||||
if not selected_page.is_cover:
|
||||
# Check if size actually changed
|
||||
# For double spreads, compare with base width
|
||||
if selected_page.is_double_spread:
|
||||
old_base_width = (
|
||||
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
|
||||
|
||||
if size_changed:
|
||||
# Update double spread
|
||||
selected_page.layout.base_width = width_mm
|
||||
selected_page.layout.size = (width_mm * 2, height_mm)
|
||||
selected_page.manually_sized = True
|
||||
print(
|
||||
f"{self.project.get_page_display_name(selected_page)} "
|
||||
f"(double spread) updated to {width_mm}×{height_mm} mm per page"
|
||||
)
|
||||
else:
|
||||
old_size = selected_page.layout.size
|
||||
size_changed = old_size != (width_mm, height_mm)
|
||||
|
||||
if size_changed:
|
||||
# Update single page
|
||||
selected_page.layout.size = (width_mm, height_mm)
|
||||
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"
|
||||
)
|
||||
|
||||
# Update DPI settings
|
||||
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"]:
|
||||
self.project.page_size_mm = (width_mm, height_mm)
|
||||
print(f"Project default page size set to {width_mm}×{height_mm} mm")
|
||||
|
||||
self.update_view()
|
||||
|
||||
# Build status message
|
||||
page_name = self.project.get_page_display_name(selected_page)
|
||||
if selected_page.is_cover:
|
||||
status_msg = f"{page_name} updated"
|
||||
else:
|
||||
status_msg = f"{page_name} size: {width_mm}×{height_mm} mm"
|
||||
if values["set_as_default"]:
|
||||
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"
|
||||
)
|
||||
def toggle_double_spread(self):
|
||||
"""Toggle double spread for the current page"""
|
||||
if not self.project.pages:
|
||||
return
|
||||
|
||||
# Try to get the most visible page in viewport, fallback to current_page_index
|
||||
page_index = self._get_most_visible_page_index()
|
||||
|
||||
# Ensure index is valid
|
||||
if page_index < 0 or page_index >= len(self.project.pages):
|
||||
page_index = 0
|
||||
|
||||
current_page = self.project.pages[page_index]
|
||||
|
||||
# Toggle the state
|
||||
is_double = not current_page.is_double_spread
|
||||
current_page.is_double_spread = is_double
|
||||
|
||||
# Mark as manually sized when toggling spread
|
||||
current_page.manually_sized = True
|
||||
|
||||
# Update the page layout width
|
||||
current_width = current_page.layout.size[0]
|
||||
current_height = current_page.layout.size[1]
|
||||
|
||||
# Get base width (might already be doubled)
|
||||
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
|
||||
base_width = current_width / 2 if current_page.layout.is_facing_page else current_width
|
||||
|
||||
# Set new width based on double spread state
|
||||
new_width = base_width * 2 if is_double else base_width
|
||||
current_page.layout.base_width = base_width
|
||||
current_page.layout.is_facing_page = is_double
|
||||
current_page.layout.size = (new_width, current_height)
|
||||
|
||||
# Update display
|
||||
self.update_view()
|
||||
|
||||
status = "enabled" if is_double else "disabled"
|
||||
page_name = self.project.get_page_display_name(current_page)
|
||||
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")
|
||||
def remove_page(self):
|
||||
"""Remove the currently selected page"""
|
||||
if len(self.project.pages) <= 1:
|
||||
self.show_warning("Cannot Remove", "Must have at least one page")
|
||||
print("Cannot remove page - must have at least one page")
|
||||
return
|
||||
|
||||
# Get the most visible page in viewport
|
||||
page_index = self._get_most_visible_page_index()
|
||||
|
||||
# Ensure index is valid
|
||||
if page_index < 0 or page_index >= len(self.project.pages):
|
||||
page_index = len(self.project.pages) - 1
|
||||
|
||||
page_to_remove = self.project.pages[page_index]
|
||||
page_name = self.project.get_page_display_name(page_to_remove)
|
||||
|
||||
# Remove the selected page
|
||||
self.project.remove_page(page_to_remove)
|
||||
|
||||
# Renumber remaining pages to ensure consistent numbering
|
||||
# Page numbers represent logical page numbers in the book
|
||||
current_page_num = 1
|
||||
for page in self.project.pages:
|
||||
page.page_number = current_page_num
|
||||
current_page_num += page.get_page_count()
|
||||
|
||||
# Update display
|
||||
self.update_view()
|
||||
|
||||
print(f"Removed {page_name}, now have {len(self.project.pages)} pages")
|
||||
177
pyPhotoAlbum/mixins/operations/size_ops.py
Normal file
@ -0,0 +1,177 @@
|
||||
"""
|
||||
Size operations mixin for pyPhotoAlbum
|
||||
"""
|
||||
|
||||
from pyPhotoAlbum.decorators import ribbon_action
|
||||
from pyPhotoAlbum.alignment import AlignmentManager
|
||||
from pyPhotoAlbum.commands import ResizeElementsCommand
|
||||
|
||||
|
||||
class SizeOperationsMixin:
|
||||
"""Mixin providing element sizing operations"""
|
||||
|
||||
def _get_selected_elements_list(self):
|
||||
"""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):
|
||||
return
|
||||
|
||||
changes = resize_func(elements)
|
||||
if changes:
|
||||
cmd = ResizeElementsCommand(changes)
|
||||
self.project.history.execute(cmd)
|
||||
self.update_view()
|
||||
self.show_status(status_msg.format(len(elements)), 2000)
|
||||
|
||||
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):
|
||||
return
|
||||
|
||||
page = self.get_current_page()
|
||||
if not page:
|
||||
self.show_warning("No Page", "Please create a page first.")
|
||||
return
|
||||
|
||||
element = next(iter(self.gl_widget.selected_elements))
|
||||
change = fit_func(element, page)
|
||||
|
||||
if change:
|
||||
cmd = ResizeElementsCommand([change])
|
||||
self.project.history.execute(cmd)
|
||||
self.update_view()
|
||||
self.show_status(status_msg, 2000)
|
||||
|
||||
@ribbon_action(
|
||||
label="Same Size",
|
||||
tooltip="Make all selected elements the same size",
|
||||
tab="Arrange",
|
||||
group="Size",
|
||||
requires_selection=True,
|
||||
min_selection=2,
|
||||
)
|
||||
def make_same_size(self):
|
||||
"""Make all selected elements the same size"""
|
||||
self._execute_resize(AlignmentManager.make_same_size, "Resized {} elements to same size")
|
||||
|
||||
@ribbon_action(
|
||||
label="Same Width",
|
||||
tooltip="Make all selected elements the same width",
|
||||
tab="Arrange",
|
||||
group="Size",
|
||||
requires_selection=True,
|
||||
min_selection=2,
|
||||
)
|
||||
def make_same_width(self):
|
||||
"""Make all selected elements the same width"""
|
||||
self._execute_resize(AlignmentManager.make_same_width, "Resized {} elements to same width")
|
||||
|
||||
@ribbon_action(
|
||||
label="Same Height",
|
||||
tooltip="Make all selected elements the same height",
|
||||
tab="Arrange",
|
||||
group="Size",
|
||||
requires_selection=True,
|
||||
min_selection=2,
|
||||
)
|
||||
def make_same_height(self):
|
||||
"""Make all selected elements the same height"""
|
||||
self._execute_resize(AlignmentManager.make_same_height, "Resized {} elements to same height")
|
||||
|
||||
@ribbon_action(
|
||||
label="Fit Width",
|
||||
tooltip="Fit selected element to page width",
|
||||
tab="Arrange",
|
||||
group="Size",
|
||||
requires_selection=True,
|
||||
min_selection=1,
|
||||
)
|
||||
def fit_to_width(self):
|
||||
"""Fit selected element to page width"""
|
||||
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",
|
||||
tooltip="Fit selected element to page height",
|
||||
tab="Arrange",
|
||||
group="Size",
|
||||
requires_selection=True,
|
||||
min_selection=1,
|
||||
)
|
||||
def fit_to_height(self):
|
||||
"""Fit selected element to page height"""
|
||||
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",
|
||||
tooltip="Fit selected element to page dimensions",
|
||||
tab="Arrange",
|
||||
group="Size",
|
||||
requires_selection=True,
|
||||
min_selection=1,
|
||||
)
|
||||
def fit_to_page(self):
|
||||
"""Fit selected element to page dimensions"""
|
||||
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",
|
||||
tooltip="Expand selected image to fill available space until it reaches page edges or other elements",
|
||||
tab="Arrange",
|
||||
group="Size",
|
||||
requires_selection=True,
|
||||
min_selection=1,
|
||||
)
|
||||
def expand_image(self):
|
||||
"""Expand selected image to fill available space"""
|
||||
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))
|
||||
|
||||
# Get other elements on the same page (excluding the selected one)
|
||||
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)
|
||||
|
||||
# Expand to bounds
|
||||
page_width, page_height = page.layout.size
|
||||
change = AlignmentManager.expand_to_bounds(element, (page_width, page_height), other_elements, min_gap)
|
||||
|
||||
if change:
|
||||
cmd = ResizeElementsCommand([change])
|
||||
self.project.history.execute(cmd)
|
||||
self.update_view()
|
||||
self.show_status(f"Expanded image with {min_gap}mm gap", 2000)
|
||||
372
pyPhotoAlbum/mixins/operations/style_ops.py
Normal file
@ -0,0 +1,372 @@
|
||||
"""
|
||||
Style operations mixin for pyPhotoAlbum
|
||||
|
||||
Provides ribbon actions for applying visual styles to images:
|
||||
- Rounded corners
|
||||
- Borders
|
||||
- Drop shadows
|
||||
- (Future) Decorative frames
|
||||
"""
|
||||
|
||||
from pyPhotoAlbum.decorators import ribbon_action
|
||||
from pyPhotoAlbum.models import ImageData, ImageStyle
|
||||
|
||||
|
||||
class StyleOperationsMixin:
|
||||
"""Mixin providing element styling operations"""
|
||||
|
||||
def _get_selected_images(self):
|
||||
"""Get list of selected ImageData elements"""
|
||||
if not self.gl_widget.selected_elements:
|
||||
return []
|
||||
return [e for e in self.gl_widget.selected_elements if isinstance(e, ImageData)]
|
||||
|
||||
def _apply_style_change(self, style_updater, description: str):
|
||||
"""
|
||||
Apply a style change to selected images with undo support.
|
||||
|
||||
Args:
|
||||
style_updater: Function that takes an ImageStyle and modifies it
|
||||
description: Description for undo history
|
||||
"""
|
||||
images = self._get_selected_images()
|
||||
if not images:
|
||||
self.show_status("No images selected", 2000)
|
||||
return
|
||||
|
||||
# Store old styles for undo
|
||||
old_styles = [(img, img.style.copy()) for img in images]
|
||||
|
||||
# Create undo command
|
||||
from pyPhotoAlbum.commands import Command
|
||||
|
||||
class StyleChangeCommand(Command):
|
||||
def __init__(self, old_styles, new_style_updater, desc):
|
||||
self.old_styles = old_styles
|
||||
self.new_style_updater = new_style_updater
|
||||
self.description = desc
|
||||
|
||||
def _invalidate_texture(self, img):
|
||||
"""Invalidate the image texture so it will be regenerated."""
|
||||
# Clear the style hash to force regeneration check
|
||||
if hasattr(img, "_texture_style_hash"):
|
||||
delattr(img, "_texture_style_hash")
|
||||
# Clear async load state so it will reload
|
||||
img._async_load_requested = False
|
||||
# Delete texture if it exists (will be recreated on next render)
|
||||
if hasattr(img, "_texture_id") and img._texture_id:
|
||||
from pyPhotoAlbum.gl_imports import glDeleteTextures
|
||||
try:
|
||||
glDeleteTextures([img._texture_id])
|
||||
except Exception:
|
||||
pass # GL context might not be available
|
||||
delattr(img, "_texture_id")
|
||||
|
||||
def execute(self):
|
||||
for img, _ in self.old_styles:
|
||||
self.new_style_updater(img.style)
|
||||
self._invalidate_texture(img)
|
||||
|
||||
def undo(self):
|
||||
for img, old_style in self.old_styles:
|
||||
img.style = old_style.copy()
|
||||
self._invalidate_texture(img)
|
||||
|
||||
def redo(self):
|
||||
self.execute()
|
||||
|
||||
def serialize(self):
|
||||
# Style changes are not serialized (session-only undo)
|
||||
return {"type": "style_change", "description": self.description}
|
||||
|
||||
@staticmethod
|
||||
def deserialize(data, project):
|
||||
# Style changes cannot be deserialized (session-only)
|
||||
return None
|
||||
|
||||
cmd = StyleChangeCommand(old_styles, style_updater, description)
|
||||
self.project.history.execute(cmd)
|
||||
|
||||
self.update_view()
|
||||
self.show_status(f"{description} applied to {len(images)} image(s)", 2000)
|
||||
|
||||
# =========================================================================
|
||||
# Corner Radius
|
||||
# =========================================================================
|
||||
|
||||
@ribbon_action(
|
||||
label="Round Corners",
|
||||
tooltip="Set corner radius for selected images",
|
||||
tab="Style",
|
||||
group="Corners",
|
||||
requires_selection=True,
|
||||
)
|
||||
def show_corner_radius_dialog(self):
|
||||
"""Show dialog to set corner radius"""
|
||||
images = self._get_selected_images()
|
||||
if not images:
|
||||
self.show_status("No images selected", 2000)
|
||||
return
|
||||
|
||||
from pyPhotoAlbum.dialogs.style_dialogs import CornerRadiusDialog
|
||||
|
||||
# Get current radius from first selected image
|
||||
current_radius = images[0].style.corner_radius
|
||||
|
||||
dialog = CornerRadiusDialog(self, current_radius)
|
||||
if dialog.exec():
|
||||
new_radius = dialog.get_value()
|
||||
self._apply_style_change(
|
||||
lambda style: setattr(style, "corner_radius", new_radius),
|
||||
f"Set corner radius to {new_radius}%",
|
||||
)
|
||||
|
||||
@ribbon_action(
|
||||
label="No Corners",
|
||||
tooltip="Remove rounded corners from selected images",
|
||||
tab="Style",
|
||||
group="Corners",
|
||||
requires_selection=True,
|
||||
)
|
||||
def remove_corner_radius(self):
|
||||
"""Remove corner radius (set to 0)"""
|
||||
self._apply_style_change(
|
||||
lambda style: setattr(style, "corner_radius", 0.0),
|
||||
"Remove corner radius",
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Borders
|
||||
# =========================================================================
|
||||
|
||||
@ribbon_action(
|
||||
label="Border...",
|
||||
tooltip="Set border for selected images",
|
||||
tab="Style",
|
||||
group="Border",
|
||||
requires_selection=True,
|
||||
)
|
||||
def show_border_dialog(self):
|
||||
"""Show dialog to configure border"""
|
||||
images = self._get_selected_images()
|
||||
if not images:
|
||||
self.show_status("No images selected", 2000)
|
||||
return
|
||||
|
||||
from pyPhotoAlbum.dialogs.style_dialogs import BorderDialog
|
||||
|
||||
# Get current border from first selected image
|
||||
current_style = images[0].style
|
||||
|
||||
dialog = BorderDialog(self, current_style.border_width, current_style.border_color)
|
||||
if dialog.exec():
|
||||
width, color = dialog.get_values()
|
||||
|
||||
def update_border(style):
|
||||
style.border_width = width
|
||||
style.border_color = color
|
||||
|
||||
self._apply_style_change(update_border, f"Set border ({width}mm)")
|
||||
|
||||
@ribbon_action(
|
||||
label="No Border",
|
||||
tooltip="Remove border from selected images",
|
||||
tab="Style",
|
||||
group="Border",
|
||||
requires_selection=True,
|
||||
)
|
||||
def remove_border(self):
|
||||
"""Remove border (set width to 0)"""
|
||||
self._apply_style_change(
|
||||
lambda style: setattr(style, "border_width", 0.0),
|
||||
"Remove border",
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Shadows
|
||||
# =========================================================================
|
||||
|
||||
@ribbon_action(
|
||||
label="Shadow...",
|
||||
tooltip="Configure drop shadow for selected images",
|
||||
tab="Style",
|
||||
group="Effects",
|
||||
requires_selection=True,
|
||||
)
|
||||
def show_shadow_dialog(self):
|
||||
"""Show dialog to configure drop shadow"""
|
||||
images = self._get_selected_images()
|
||||
if not images:
|
||||
self.show_status("No images selected", 2000)
|
||||
return
|
||||
|
||||
from pyPhotoAlbum.dialogs.style_dialogs import ShadowDialog
|
||||
|
||||
# Get current shadow settings from first selected image
|
||||
current_style = images[0].style
|
||||
|
||||
dialog = ShadowDialog(
|
||||
self,
|
||||
current_style.shadow_enabled,
|
||||
current_style.shadow_offset,
|
||||
current_style.shadow_blur,
|
||||
current_style.shadow_color,
|
||||
)
|
||||
if dialog.exec():
|
||||
enabled, offset, blur, color = dialog.get_values()
|
||||
|
||||
def update_shadow(style):
|
||||
style.shadow_enabled = enabled
|
||||
style.shadow_offset = offset
|
||||
style.shadow_blur = blur
|
||||
style.shadow_color = color
|
||||
|
||||
self._apply_style_change(update_shadow, "Configure shadow")
|
||||
|
||||
@ribbon_action(
|
||||
label="Toggle Shadow",
|
||||
tooltip="Toggle drop shadow on/off for selected images",
|
||||
tab="Style",
|
||||
group="Effects",
|
||||
requires_selection=True,
|
||||
)
|
||||
def toggle_shadow(self):
|
||||
"""Toggle shadow enabled/disabled"""
|
||||
images = self._get_selected_images()
|
||||
if not images:
|
||||
self.show_status("No images selected", 2000)
|
||||
return
|
||||
|
||||
# Toggle based on first selected image
|
||||
new_state = not images[0].style.shadow_enabled
|
||||
|
||||
self._apply_style_change(
|
||||
lambda style: setattr(style, "shadow_enabled", new_state),
|
||||
"Enable shadow" if new_state else "Disable shadow",
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Style Presets
|
||||
# =========================================================================
|
||||
|
||||
@ribbon_action(
|
||||
label="Polaroid",
|
||||
tooltip="Apply Polaroid-style frame (white border, shadow)",
|
||||
tab="Style",
|
||||
group="Presets",
|
||||
requires_selection=True,
|
||||
)
|
||||
def apply_polaroid_style(self):
|
||||
"""Apply Polaroid-style preset"""
|
||||
|
||||
def apply_preset(style):
|
||||
style.corner_radius = 0.0
|
||||
style.border_width = 3.0 # 3mm white border
|
||||
style.border_color = (255, 255, 255)
|
||||
style.shadow_enabled = True
|
||||
style.shadow_offset = (2.0, 2.0)
|
||||
style.shadow_blur = 4.0
|
||||
style.shadow_color = (0, 0, 0, 100)
|
||||
|
||||
self._apply_style_change(apply_preset, "Apply Polaroid style")
|
||||
|
||||
@ribbon_action(
|
||||
label="Rounded",
|
||||
tooltip="Apply rounded photo style",
|
||||
tab="Style",
|
||||
group="Presets",
|
||||
requires_selection=True,
|
||||
)
|
||||
def apply_rounded_style(self):
|
||||
"""Apply rounded corners preset"""
|
||||
|
||||
def apply_preset(style):
|
||||
style.corner_radius = 10.0 # 10% rounded
|
||||
style.border_width = 0.0
|
||||
style.shadow_enabled = True
|
||||
style.shadow_offset = (1.5, 1.5)
|
||||
style.shadow_blur = 3.0
|
||||
style.shadow_color = (0, 0, 0, 80)
|
||||
|
||||
self._apply_style_change(apply_preset, "Apply rounded style")
|
||||
|
||||
@ribbon_action(
|
||||
label="Clear Style",
|
||||
tooltip="Remove all styling from selected images",
|
||||
tab="Style",
|
||||
group="Presets",
|
||||
requires_selection=True,
|
||||
)
|
||||
def clear_style(self):
|
||||
"""Remove all styling (reset to defaults)"""
|
||||
|
||||
def clear_all(style):
|
||||
style.corner_radius = 0.0
|
||||
style.border_width = 0.0
|
||||
style.border_color = (0, 0, 0)
|
||||
style.shadow_enabled = False
|
||||
style.shadow_offset = (2.0, 2.0)
|
||||
style.shadow_blur = 3.0
|
||||
style.shadow_color = (0, 0, 0, 128)
|
||||
style.frame_style = None
|
||||
style.frame_color = (0, 0, 0)
|
||||
style.frame_corners = (True, True, True, True)
|
||||
|
||||
self._apply_style_change(clear_all, "Clear style")
|
||||
|
||||
# =========================================================================
|
||||
# Decorative Frames
|
||||
# =========================================================================
|
||||
|
||||
@ribbon_action(
|
||||
label="Frame...",
|
||||
tooltip="Add decorative frame to selected images",
|
||||
tab="Style",
|
||||
group="Frame",
|
||||
requires_selection=True,
|
||||
)
|
||||
def show_frame_picker(self):
|
||||
"""Show dialog to select decorative frame"""
|
||||
images = self._get_selected_images()
|
||||
if not images:
|
||||
self.show_status("No images selected", 2000)
|
||||
return
|
||||
|
||||
from pyPhotoAlbum.dialogs.frame_picker_dialog import FramePickerDialog
|
||||
|
||||
# Get current frame settings from first selected image
|
||||
current_style = images[0].style
|
||||
|
||||
dialog = FramePickerDialog(
|
||||
self,
|
||||
current_frame=current_style.frame_style,
|
||||
current_color=current_style.frame_color,
|
||||
current_corners=current_style.frame_corners,
|
||||
)
|
||||
if dialog.exec():
|
||||
frame_name, color, corners = dialog.get_values()
|
||||
|
||||
def update_frame(style):
|
||||
style.frame_style = frame_name
|
||||
style.frame_color = color
|
||||
style.frame_corners = corners
|
||||
|
||||
desc = f"Apply frame '{frame_name}'" if frame_name else "Remove frame"
|
||||
self._apply_style_change(update_frame, desc)
|
||||
|
||||
@ribbon_action(
|
||||
label="Remove Frame",
|
||||
tooltip="Remove decorative frame from selected images",
|
||||
tab="Style",
|
||||
group="Frame",
|
||||
requires_selection=True,
|
||||
)
|
||||
def remove_frame(self):
|
||||
"""Remove decorative frame"""
|
||||
|
||||
def clear_frame(style):
|
||||
style.frame_style = None
|
||||
style.frame_color = (0, 0, 0)
|
||||
style.frame_corners = (True, True, True, True)
|
||||
|
||||
self._apply_style_change(clear_frame, "Remove frame")
|
||||
325
pyPhotoAlbum/mixins/operations/template_ops.py
Normal file
@ -0,0 +1,325 @@
|
||||
"""
|
||||
Template operations mixin for pyPhotoAlbum
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QInputDialog,
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QLabel,
|
||||
QComboBox,
|
||||
QRadioButton,
|
||||
QButtonGroup,
|
||||
QPushButton,
|
||||
QHBoxLayout,
|
||||
QDoubleSpinBox,
|
||||
)
|
||||
from pyPhotoAlbum.decorators import ribbon_action, undoable_operation
|
||||
|
||||
|
||||
class TemplateOperationsMixin:
|
||||
"""Mixin providing template-related operations"""
|
||||
|
||||
@ribbon_action(
|
||||
label="Save as Template",
|
||||
tooltip="Save current page as a reusable template",
|
||||
tab="Layout",
|
||||
group="Templates",
|
||||
requires_page=True,
|
||||
)
|
||||
def save_page_as_template(self):
|
||||
"""Save current page as a template"""
|
||||
current_page = self.get_current_page()
|
||||
if not current_page:
|
||||
return
|
||||
|
||||
# Check if page has any elements
|
||||
if not current_page.layout.elements:
|
||||
self.show_warning("Empty Page", "Cannot save an empty page as a template.")
|
||||
return
|
||||
|
||||
# Ask for template name
|
||||
name, ok = QInputDialog.getText(
|
||||
self,
|
||||
"Save Template",
|
||||
"Enter template name:",
|
||||
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):")
|
||||
|
||||
if not ok:
|
||||
description = ""
|
||||
|
||||
try:
|
||||
# Create template from page
|
||||
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.")
|
||||
|
||||
print(f"Saved template: {name}")
|
||||
|
||||
except Exception as e:
|
||||
self.show_error("Error", f"Failed to save template: {str(e)}")
|
||||
print(f"Error saving template: {e}")
|
||||
|
||||
@ribbon_action(
|
||||
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"""
|
||||
# Get available templates
|
||||
templates = self.template_manager.list_templates()
|
||||
|
||||
if not templates:
|
||||
self.show_info(
|
||||
"No Templates", "No templates available. Create a template first by using 'Save as Template'."
|
||||
)
|
||||
return
|
||||
|
||||
# Create dialog for template selection and options
|
||||
dialog = QDialog(self)
|
||||
dialog.setWindowTitle("New Page from Template")
|
||||
dialog.setMinimumWidth(400)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Template selection
|
||||
layout.addWidget(QLabel("Select Template:"))
|
||||
template_combo = QComboBox()
|
||||
template_combo.addItems(templates)
|
||||
layout.addWidget(template_combo)
|
||||
|
||||
layout.addSpacing(10)
|
||||
|
||||
# Margin/Spacing percentage
|
||||
layout.addWidget(QLabel("Margin/Spacing:"))
|
||||
margin_layout = QHBoxLayout()
|
||||
margin_spinbox = QDoubleSpinBox()
|
||||
margin_spinbox.setRange(0.0, 10.0)
|
||||
margin_spinbox.setValue(2.5)
|
||||
margin_spinbox.setSuffix("%")
|
||||
margin_spinbox.setDecimals(1)
|
||||
margin_spinbox.setSingleStep(0.5)
|
||||
margin_spinbox.setToolTip("Percentage of page size to use for margins and spacing")
|
||||
margin_layout.addWidget(margin_spinbox)
|
||||
margin_layout.addStretch()
|
||||
layout.addLayout(margin_layout)
|
||||
|
||||
layout.addSpacing(10)
|
||||
|
||||
# Scaling selection
|
||||
layout.addWidget(QLabel("Scaling:"))
|
||||
scale_group = QButtonGroup(dialog)
|
||||
|
||||
proportional_radio = QRadioButton("Proportional (maintain aspect ratio)")
|
||||
scale_group.addButton(proportional_radio, 0)
|
||||
layout.addWidget(proportional_radio)
|
||||
|
||||
stretch_radio = QRadioButton("Stretch to fit")
|
||||
stretch_radio.setChecked(True)
|
||||
scale_group.addButton(stretch_radio, 1)
|
||||
layout.addWidget(stretch_radio)
|
||||
|
||||
center_radio = QRadioButton("Center (no scaling)")
|
||||
scale_group.addButton(center_radio, 2)
|
||||
layout.addWidget(center_radio)
|
||||
|
||||
layout.addSpacing(20)
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
cancel_btn.clicked.connect(dialog.reject)
|
||||
create_btn = QPushButton("Create")
|
||||
create_btn.clicked.connect(dialog.accept)
|
||||
create_btn.setDefault(True)
|
||||
|
||||
button_layout.addStretch()
|
||||
button_layout.addWidget(cancel_btn)
|
||||
button_layout.addWidget(create_btn)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
dialog.setLayout(layout)
|
||||
|
||||
# Show dialog
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
|
||||
# Get selections
|
||||
template_name = template_combo.currentText()
|
||||
scale_id = scale_group.checkedId()
|
||||
margin_percent = margin_spinbox.value()
|
||||
scale_mode = ["proportional", "stretch", "center"][scale_id]
|
||||
|
||||
try:
|
||||
# Load template
|
||||
template = self.template_manager.load_template(template_name)
|
||||
|
||||
# Create new page from template
|
||||
new_page_number = len(self.project.pages) + 1
|
||||
new_page = self.template_manager.create_page_from_template(
|
||||
template,
|
||||
page_number=new_page_number,
|
||||
target_size_mm=self.project.page_size_mm,
|
||||
scale_mode=scale_mode,
|
||||
margin_percent=margin_percent,
|
||||
)
|
||||
|
||||
# Add to project
|
||||
self.project.add_page(new_page)
|
||||
|
||||
# Switch to new page
|
||||
self.gl_widget.current_page_index = len(self.project.pages) - 1
|
||||
self.update_view()
|
||||
|
||||
self.show_status(f"Created page {new_page_number} from template '{template_name}'", 3000)
|
||||
print(f"Created page from template: {template_name} with scale_mode={scale_mode}, margin={margin_percent}%")
|
||||
|
||||
except Exception as e:
|
||||
self.show_error("Error", f"Failed to create page from template: {str(e)}")
|
||||
print(f"Error creating page from template: {e}")
|
||||
|
||||
@ribbon_action(
|
||||
label="Apply Template",
|
||||
tooltip="Apply a template layout to current page",
|
||||
tab="Layout",
|
||||
group="Templates",
|
||||
requires_page=True,
|
||||
)
|
||||
@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()
|
||||
if not current_page:
|
||||
return
|
||||
|
||||
# Get available templates
|
||||
templates = self.template_manager.list_templates()
|
||||
|
||||
if not templates:
|
||||
self.show_info(
|
||||
"No Templates", "No templates available. Create a template first by using 'Save as Template'."
|
||||
)
|
||||
return
|
||||
|
||||
# Create dialog for template application options
|
||||
dialog = QDialog(self)
|
||||
dialog.setWindowTitle("Apply Template")
|
||||
dialog.setMinimumWidth(400)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Template selection
|
||||
layout.addWidget(QLabel("Select Template:"))
|
||||
template_combo = QComboBox()
|
||||
template_combo.addItems(templates)
|
||||
layout.addWidget(template_combo)
|
||||
|
||||
layout.addSpacing(10)
|
||||
|
||||
# Mode selection
|
||||
layout.addWidget(QLabel("Mode:"))
|
||||
mode_group = QButtonGroup(dialog)
|
||||
|
||||
replace_radio = QRadioButton("Replace with placeholders")
|
||||
replace_radio.setChecked(True)
|
||||
replace_radio.setToolTip("Clear page and add template placeholders")
|
||||
mode_group.addButton(replace_radio, 0)
|
||||
layout.addWidget(replace_radio)
|
||||
|
||||
reflow_radio = QRadioButton("Reflow existing content")
|
||||
reflow_radio.setToolTip("Keep existing images and reposition to template slots")
|
||||
mode_group.addButton(reflow_radio, 1)
|
||||
layout.addWidget(reflow_radio)
|
||||
|
||||
layout.addSpacing(10)
|
||||
|
||||
# Margin/Spacing percentage
|
||||
layout.addWidget(QLabel("Margin/Spacing:"))
|
||||
margin_layout = QHBoxLayout()
|
||||
margin_spinbox = QDoubleSpinBox()
|
||||
margin_spinbox.setRange(0.0, 10.0)
|
||||
margin_spinbox.setValue(2.5)
|
||||
margin_spinbox.setSuffix("%")
|
||||
margin_spinbox.setDecimals(1)
|
||||
margin_spinbox.setSingleStep(0.5)
|
||||
margin_spinbox.setToolTip("Percentage of page size to use for margins and spacing")
|
||||
margin_layout.addWidget(margin_spinbox)
|
||||
margin_layout.addStretch()
|
||||
layout.addLayout(margin_layout)
|
||||
|
||||
layout.addSpacing(10)
|
||||
|
||||
# Scaling selection
|
||||
layout.addWidget(QLabel("Scaling:"))
|
||||
scale_group = QButtonGroup(dialog)
|
||||
|
||||
proportional_radio = QRadioButton("Proportional (maintain aspect ratio)")
|
||||
scale_group.addButton(proportional_radio, 0)
|
||||
layout.addWidget(proportional_radio)
|
||||
|
||||
stretch_radio = QRadioButton("Stretch to fit")
|
||||
stretch_radio.setChecked(True)
|
||||
scale_group.addButton(stretch_radio, 1)
|
||||
layout.addWidget(stretch_radio)
|
||||
|
||||
center_radio = QRadioButton("Center (no scaling)")
|
||||
scale_group.addButton(center_radio, 2)
|
||||
layout.addWidget(center_radio)
|
||||
|
||||
layout.addSpacing(20)
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
cancel_btn.clicked.connect(dialog.reject)
|
||||
apply_btn = QPushButton("Apply")
|
||||
apply_btn.clicked.connect(dialog.accept)
|
||||
apply_btn.setDefault(True)
|
||||
|
||||
button_layout.addStretch()
|
||||
button_layout.addWidget(cancel_btn)
|
||||
button_layout.addWidget(apply_btn)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
dialog.setLayout(layout)
|
||||
|
||||
# Show dialog
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
|
||||
# Get selections
|
||||
template_name = template_combo.currentText()
|
||||
mode_id = mode_group.checkedId()
|
||||
scale_id = scale_group.checkedId()
|
||||
margin_percent = margin_spinbox.value()
|
||||
|
||||
mode = "replace" if mode_id == 0 else "reflow"
|
||||
scale_mode = ["proportional", "stretch", "center"][scale_id]
|
||||
|
||||
try:
|
||||
# Load template
|
||||
template = self.template_manager.load_template(template_name)
|
||||
|
||||
# Apply template to page
|
||||
self.template_manager.apply_template_to_page(
|
||||
template, current_page, mode=mode, scale_mode=scale_mode, margin_percent=margin_percent
|
||||
)
|
||||
|
||||
# Update display
|
||||
self.update_view()
|
||||
|
||||
self.show_status(f"Applied template '{template_name}' to current page", 3000)
|
||||
print(f"Applied template '{template_name}' with mode={mode}, scale_mode={scale_mode}")
|
||||
|
||||
except Exception as e:
|
||||
self.show_error("Error", f"Failed to apply template: {str(e)}")
|
||||
print(f"Error applying template: {e}")
|
||||
265
pyPhotoAlbum/mixins/operations/view_ops.py
Normal file
@ -0,0 +1,265 @@
|
||||
"""
|
||||
View operations mixin for pyPhotoAlbum
|
||||
"""
|
||||
|
||||
from pyPhotoAlbum.decorators import ribbon_action
|
||||
|
||||
|
||||
class ViewOperationsMixin:
|
||||
"""Mixin providing view-related operations"""
|
||||
|
||||
@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
|
||||
if self.gl_widget.zoom_level > 5.0:
|
||||
self.gl_widget.zoom_level = 5.0
|
||||
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+-")
|
||||
def zoom_out(self):
|
||||
"""Zoom out"""
|
||||
self.gl_widget.zoom_level /= 1.2
|
||||
if self.gl_widget.zoom_level < 0.1:
|
||||
self.gl_widget.zoom_level = 0.1
|
||||
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")
|
||||
def zoom_fit(self):
|
||||
"""Fit page to window"""
|
||||
if not self.project.pages:
|
||||
return
|
||||
|
||||
current_page = self.project.pages[self.gl_widget.current_page_index]
|
||||
page_width_mm = current_page.layout.size[0]
|
||||
page_height_mm = current_page.layout.size[1]
|
||||
|
||||
# Convert to pixels
|
||||
dpi = self.project.working_dpi
|
||||
page_width_px = page_width_mm * dpi / 25.4
|
||||
page_height_px = page_height_mm * dpi / 25.4
|
||||
|
||||
# Get widget size
|
||||
widget_width = self.gl_widget.width() - 100 # Margins
|
||||
widget_height = self.gl_widget.height() - 100
|
||||
|
||||
# Calculate zoom to fit
|
||||
zoom_w = widget_width / page_width_px
|
||||
zoom_h = widget_height / page_height_px
|
||||
|
||||
self.gl_widget.zoom_level = min(zoom_w, zoom_h)
|
||||
self.gl_widget.zoom_level = max(0.1, min(5.0, self.gl_widget.zoom_level))
|
||||
self.update_view()
|
||||
self.show_status(f"Zoom: {int(self.gl_widget.zoom_level * 100)}%", 2000)
|
||||
|
||||
@ribbon_action(
|
||||
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"""
|
||||
if not self.project:
|
||||
return
|
||||
|
||||
self.project.snap_to_grid = not self.project.snap_to_grid
|
||||
|
||||
status = "enabled" if self.project.snap_to_grid else "disabled"
|
||||
self.update_view()
|
||||
self.show_status(f"Grid snapping {status}", 2000)
|
||||
print(f"Grid snapping {status}")
|
||||
|
||||
@ribbon_action(
|
||||
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"""
|
||||
if not self.project:
|
||||
return
|
||||
|
||||
self.project.snap_to_edges = not self.project.snap_to_edges
|
||||
|
||||
status = "enabled" if self.project.snap_to_edges else "disabled"
|
||||
self.update_view()
|
||||
self.show_status(f"Edge snapping {status}", 2000)
|
||||
print(f"Edge snapping {status}")
|
||||
|
||||
@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:
|
||||
return
|
||||
|
||||
self.project.snap_to_guides = not self.project.snap_to_guides
|
||||
|
||||
status = "enabled" if self.project.snap_to_guides else "disabled"
|
||||
self.update_view()
|
||||
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="Insert", group="Snapping")
|
||||
def toggle_show_grid(self):
|
||||
"""Toggle grid visibility"""
|
||||
if not self.project:
|
||||
return
|
||||
|
||||
self.project.show_grid = not self.project.show_grid
|
||||
|
||||
status = "visible" if self.project.show_grid else "hidden"
|
||||
self.update_view()
|
||||
self.show_status(f"Grid {status}", 2000)
|
||||
print(f"Grid {status}")
|
||||
|
||||
@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:
|
||||
return
|
||||
|
||||
self.project.show_snap_lines = not self.project.show_snap_lines
|
||||
|
||||
status = "visible" if self.project.show_snap_lines else "hidden"
|
||||
self.update_view()
|
||||
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")
|
||||
def add_horizontal_guide(self):
|
||||
"""Add a horizontal guide at page center"""
|
||||
current_page = self.get_current_page()
|
||||
if not current_page:
|
||||
return
|
||||
|
||||
# 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")
|
||||
|
||||
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")
|
||||
def add_vertical_guide(self):
|
||||
"""Add a vertical guide at page center"""
|
||||
current_page = self.get_current_page()
|
||||
if not current_page:
|
||||
return
|
||||
|
||||
# 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")
|
||||
|
||||
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")
|
||||
def clear_guides(self):
|
||||
"""Clear all guides from current page"""
|
||||
current_page = self.get_current_page()
|
||||
if not current_page:
|
||||
return
|
||||
|
||||
guide_count = len(current_page.layout.snapping_system.guides)
|
||||
current_page.layout.snapping_system.clear_guides()
|
||||
|
||||
self.update_view()
|
||||
self.show_status(f"Cleared {guide_count} guides", 2000)
|
||||
print(f"Cleared {guide_count} guides")
|
||||
|
||||
@ribbon_action(
|
||||
label="Image Browser",
|
||||
tooltip="Show/hide the image browser panel",
|
||||
tab="View",
|
||||
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"""
|
||||
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QDoubleSpinBox, QPushButton
|
||||
|
||||
if not self.project:
|
||||
return
|
||||
|
||||
# Create dialog
|
||||
dialog = QDialog(self)
|
||||
dialog.setWindowTitle("Grid Settings")
|
||||
dialog.setMinimumWidth(300)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Grid size setting
|
||||
size_layout = QHBoxLayout()
|
||||
size_layout.addWidget(QLabel("Grid Size:"))
|
||||
|
||||
size_spinbox = QDoubleSpinBox()
|
||||
size_spinbox.setRange(1.0, 100.0)
|
||||
size_spinbox.setValue(self.project.grid_size_mm)
|
||||
size_spinbox.setSuffix(" mm")
|
||||
size_spinbox.setDecimals(1)
|
||||
size_spinbox.setSingleStep(1.0)
|
||||
size_layout.addWidget(size_spinbox)
|
||||
|
||||
layout.addLayout(size_layout)
|
||||
|
||||
# Snap threshold setting
|
||||
threshold_layout = QHBoxLayout()
|
||||
threshold_layout.addWidget(QLabel("Snap Threshold:"))
|
||||
|
||||
threshold_spinbox = QDoubleSpinBox()
|
||||
threshold_spinbox.setRange(0.5, 20.0)
|
||||
threshold_spinbox.setValue(self.project.snap_threshold_mm)
|
||||
threshold_spinbox.setSuffix(" mm")
|
||||
threshold_spinbox.setDecimals(1)
|
||||
threshold_spinbox.setSingleStep(0.5)
|
||||
threshold_layout.addWidget(threshold_spinbox)
|
||||
|
||||
layout.addLayout(threshold_layout)
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
cancel_btn.clicked.connect(dialog.reject)
|
||||
ok_btn = QPushButton("OK")
|
||||
ok_btn.clicked.connect(dialog.accept)
|
||||
ok_btn.setDefault(True)
|
||||
|
||||
button_layout.addStretch()
|
||||
button_layout.addWidget(cancel_btn)
|
||||
button_layout.addWidget(ok_btn)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
dialog.setLayout(layout)
|
||||
|
||||
# Show dialog and apply if accepted
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
new_grid_size = size_spinbox.value()
|
||||
new_threshold = threshold_spinbox.value()
|
||||
|
||||
self.project.grid_size_mm = new_grid_size
|
||||
self.project.snap_threshold_mm = new_threshold
|
||||
|
||||
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")
|
||||
199
pyPhotoAlbum/mixins/operations/zorder_ops.py
Normal file
@ -0,0 +1,199 @@
|
||||
"""
|
||||
Z-order operations mixin for pyPhotoAlbum
|
||||
"""
|
||||
|
||||
from pyPhotoAlbum.decorators import ribbon_action
|
||||
from pyPhotoAlbum.commands import ChangeZOrderCommand
|
||||
|
||||
|
||||
class ZOrderOperationsMixin:
|
||||
"""Mixin providing z-order/layer control operations"""
|
||||
|
||||
@ribbon_action(
|
||||
label="Bring to Front",
|
||||
tooltip="Bring selected element to front",
|
||||
tab="Arrange",
|
||||
group="Order",
|
||||
shortcut="Ctrl+Shift+]",
|
||||
requires_selection=True,
|
||||
)
|
||||
def bring_to_front(self):
|
||||
"""Bring selected element to front (end of list)"""
|
||||
if not self.gl_widget.selected_element:
|
||||
return
|
||||
|
||||
current_page = self.get_current_page()
|
||||
if not current_page:
|
||||
return
|
||||
|
||||
element = self.gl_widget.selected_element
|
||||
elements = current_page.layout.elements
|
||||
|
||||
if element not in elements:
|
||||
return
|
||||
|
||||
old_index = elements.index(element)
|
||||
new_index = len(elements) - 1
|
||||
|
||||
if old_index == new_index:
|
||||
self.show_status("Element is already at front", 2000)
|
||||
return
|
||||
|
||||
# Create and execute command
|
||||
cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index)
|
||||
self.project.history.execute(cmd)
|
||||
|
||||
self.update_view()
|
||||
self.show_status("Brought element to front (Ctrl+Z to undo)", 2000)
|
||||
print(f"Brought element to front: {old_index} → {new_index}")
|
||||
|
||||
@ribbon_action(
|
||||
label="Send to Back",
|
||||
tooltip="Send selected element to back",
|
||||
tab="Arrange",
|
||||
group="Order",
|
||||
shortcut="Ctrl+Shift+[",
|
||||
requires_selection=True,
|
||||
)
|
||||
def send_to_back(self):
|
||||
"""Send selected element to back (start of list)"""
|
||||
if not self.gl_widget.selected_element:
|
||||
return
|
||||
|
||||
current_page = self.get_current_page()
|
||||
if not current_page:
|
||||
return
|
||||
|
||||
element = self.gl_widget.selected_element
|
||||
elements = current_page.layout.elements
|
||||
|
||||
if element not in elements:
|
||||
return
|
||||
|
||||
old_index = elements.index(element)
|
||||
new_index = 0
|
||||
|
||||
if old_index == new_index:
|
||||
self.show_status("Element is already at back", 2000)
|
||||
return
|
||||
|
||||
# Create and execute command
|
||||
cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index)
|
||||
self.project.history.execute(cmd)
|
||||
|
||||
self.update_view()
|
||||
self.show_status("Sent element to back (Ctrl+Z to undo)", 2000)
|
||||
print(f"Sent element to back: {old_index} → {new_index}")
|
||||
|
||||
@ribbon_action(
|
||||
label="Bring Forward",
|
||||
tooltip="Bring selected element forward one layer",
|
||||
tab="Arrange",
|
||||
group="Order",
|
||||
shortcut="Ctrl+]",
|
||||
requires_selection=True,
|
||||
)
|
||||
def bring_forward(self):
|
||||
"""Move selected element forward one position in list"""
|
||||
if not self.gl_widget.selected_element:
|
||||
return
|
||||
|
||||
current_page = self.get_current_page()
|
||||
if not current_page:
|
||||
return
|
||||
|
||||
element = self.gl_widget.selected_element
|
||||
elements = current_page.layout.elements
|
||||
|
||||
if element not in elements:
|
||||
return
|
||||
|
||||
old_index = elements.index(element)
|
||||
new_index = old_index + 1
|
||||
|
||||
if new_index >= len(elements):
|
||||
self.show_status("Element is already at front", 2000)
|
||||
return
|
||||
|
||||
# Create and execute command
|
||||
cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index)
|
||||
self.project.history.execute(cmd)
|
||||
|
||||
self.update_view()
|
||||
self.show_status("Brought element forward (Ctrl+Z to undo)", 2000)
|
||||
print(f"Brought element forward: {old_index} → {new_index}")
|
||||
|
||||
@ribbon_action(
|
||||
label="Send Backward",
|
||||
tooltip="Send selected element backward one layer",
|
||||
tab="Arrange",
|
||||
group="Order",
|
||||
shortcut="Ctrl+[",
|
||||
requires_selection=True,
|
||||
)
|
||||
def send_backward(self):
|
||||
"""Move selected element backward one position in list"""
|
||||
if not self.gl_widget.selected_element:
|
||||
return
|
||||
|
||||
current_page = self.get_current_page()
|
||||
if not current_page:
|
||||
return
|
||||
|
||||
element = self.gl_widget.selected_element
|
||||
elements = current_page.layout.elements
|
||||
|
||||
if element not in elements:
|
||||
return
|
||||
|
||||
old_index = elements.index(element)
|
||||
new_index = old_index - 1
|
||||
|
||||
if new_index < 0:
|
||||
self.show_status("Element is already at back", 2000)
|
||||
return
|
||||
|
||||
# Create and execute command
|
||||
cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index)
|
||||
self.project.history.execute(cmd)
|
||||
|
||||
self.update_view()
|
||||
self.show_status("Sent element backward (Ctrl+Z to undo)", 2000)
|
||||
print(f"Sent element backward: {old_index} → {new_index}")
|
||||
|
||||
@ribbon_action(
|
||||
label="Swap Order",
|
||||
tooltip="Swap z-order of two selected elements",
|
||||
tab="Arrange",
|
||||
group="Order",
|
||||
shortcut="Ctrl+Shift+X",
|
||||
requires_selection=True,
|
||||
min_selection=2,
|
||||
)
|
||||
def swap_order(self):
|
||||
"""Swap the z-order of two selected elements"""
|
||||
if len(self.gl_widget.selected_elements) != 2:
|
||||
self.show_status("Please select exactly 2 elements to swap", 2000)
|
||||
return
|
||||
|
||||
current_page = self.get_current_page()
|
||||
if not current_page:
|
||||
return
|
||||
|
||||
elements = current_page.layout.elements
|
||||
selected = list(self.gl_widget.selected_elements)
|
||||
|
||||
# Get indices of both elements
|
||||
try:
|
||||
index1 = elements.index(selected[0])
|
||||
index2 = elements.index(selected[1])
|
||||
except ValueError:
|
||||
self.show_status("Selected elements not found on current page", 2000)
|
||||
return
|
||||
|
||||
# Swap them in the list
|
||||
elements[index1], elements[index2] = elements[index2], elements[index1]
|
||||
|
||||
self.update_view()
|
||||
self.show_status(f"Swapped z-order of elements", 2000)
|
||||
print(f"Swapped elements at indices {index1} and {index2}")
|
||||
268
pyPhotoAlbum/mixins/page_navigation.py
Normal file
@ -0,0 +1,268 @@
|
||||
"""
|
||||
Page navigation mixin for GLWidget - handles page detection and ghost pages
|
||||
"""
|
||||
|
||||
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:
|
||||
"""Expected from QWidget"""
|
||||
...
|
||||
|
||||
def window(self) -> "QMainWindow":
|
||||
"""Expected from QWidget"""
|
||||
...
|
||||
"""
|
||||
Mixin providing page navigation and ghost page functionality.
|
||||
|
||||
This mixin handles page detection from screen coordinates, calculating
|
||||
page positions with ghost pages, and managing ghost page interactions.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Current page tracking for operations that need to know which page to work on
|
||||
self.current_page_index: int = 0
|
||||
|
||||
# Store page renderers for later use (mouse interaction, text overlays, etc.)
|
||||
self._page_renderers: List = []
|
||||
|
||||
def _get_page_at(self, x: float, y: float):
|
||||
"""
|
||||
Get the page at the given screen coordinates.
|
||||
|
||||
Args:
|
||||
x: Screen X coordinate
|
||||
y: Screen Y coordinate
|
||||
|
||||
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:
|
||||
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:
|
||||
return None, -1, None
|
||||
|
||||
# Check each page to find which one contains the coordinates
|
||||
for renderer, page in self._page_renderers:
|
||||
if renderer.is_point_in_page(x, y):
|
||||
# Find the page index in the project's pages list
|
||||
page_index = main_window.project.pages.index(page)
|
||||
return page, page_index, renderer
|
||||
|
||||
return None, -1, None
|
||||
|
||||
def _get_page_positions(self):
|
||||
"""
|
||||
Calculate page positions including ghost pages.
|
||||
|
||||
Returns:
|
||||
List of tuples (page_type, page_or_ghost_data, y_offset)
|
||||
"""
|
||||
# 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 = project.working_dpi
|
||||
|
||||
# Use project's page_spacing_mm setting (default is 10mm = 1cm)
|
||||
# Convert to pixels at working DPI
|
||||
spacing_mm = project.page_spacing_mm
|
||||
spacing_px = spacing_mm * dpi / 25.4
|
||||
|
||||
# Start with a small top margin (5mm)
|
||||
top_margin_mm = 5.0
|
||||
top_margin_px = top_margin_mm * dpi / 25.4
|
||||
|
||||
result = []
|
||||
current_y = top_margin_px # Initial top offset in pixels (not screen pixels)
|
||||
|
||||
# First, render cover if it exists
|
||||
for page in project.pages:
|
||||
if page.is_cover:
|
||||
result.append(("page", page, current_y))
|
||||
|
||||
# Calculate cover height in pixels
|
||||
page_height_mm = page.layout.size[1]
|
||||
page_height_px = page_height_mm * dpi / 25.4
|
||||
|
||||
# Move to next position (add height + spacing)
|
||||
current_y += page_height_px + spacing_px
|
||||
break # Only one cover allowed
|
||||
|
||||
# Get page layout with ghosts from project (this excludes cover)
|
||||
layout_with_ghosts = project.calculate_page_layout_with_ghosts()
|
||||
|
||||
for page_type, page_obj, logical_pos in layout_with_ghosts:
|
||||
if page_type == "page":
|
||||
# Regular page (single or double spread)
|
||||
result.append((page_type, page_obj, current_y))
|
||||
|
||||
# Calculate page height in pixels
|
||||
# For double spreads, layout.size already contains the doubled width
|
||||
page_height_mm = page_obj.layout.size[1]
|
||||
page_height_px = page_height_mm * dpi / 25.4
|
||||
|
||||
# Move to next position (add height + spacing)
|
||||
current_y += page_height_px + spacing_px
|
||||
|
||||
elif page_type == "ghost":
|
||||
# Ghost page - use default page size
|
||||
page_size_mm = project.page_size_mm
|
||||
from pyPhotoAlbum.models import GhostPageData
|
||||
|
||||
# Create ghost page data with correct size
|
||||
ghost = GhostPageData(page_size=page_size_mm)
|
||||
result.append((page_type, ghost, current_y))
|
||||
|
||||
# Calculate ghost page height
|
||||
page_height_px = page_size_mm[1] * dpi / 25.4
|
||||
|
||||
# Move to next position (add height + spacing)
|
||||
current_y += page_height_px + spacing_px
|
||||
|
||||
return result
|
||||
|
||||
def _check_ghost_page_click(self, x: float, y: float) -> bool:
|
||||
"""
|
||||
Check if click is on a ghost page (entire page is clickable) and handle it.
|
||||
|
||||
Args:
|
||||
x: Screen X coordinate
|
||||
y: Screen Y coordinate
|
||||
|
||||
Returns:
|
||||
bool: True if a ghost page was clicked and a new page was created
|
||||
"""
|
||||
if not hasattr(self, "_page_renderers"):
|
||||
return False
|
||||
|
||||
main_window = self.window()
|
||||
if not hasattr(main_window, "project"):
|
||||
return False
|
||||
|
||||
# Get page positions which includes ghosts
|
||||
page_positions = self._get_page_positions()
|
||||
|
||||
# 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":
|
||||
continue
|
||||
|
||||
ghost = page_or_ghost
|
||||
dpi = main_window.project.working_dpi
|
||||
|
||||
# Calculate ghost page renderer
|
||||
ghost_width_mm, ghost_height_mm = ghost.page_size
|
||||
screen_x = 50 + self.pan_offset[0]
|
||||
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,
|
||||
)
|
||||
|
||||
# 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")
|
||||
|
||||
print(f"Ghost page clicked at index {insert_index} - inserting new page in place")
|
||||
|
||||
# Create a new page and insert it directly into the pages list
|
||||
from pyPhotoAlbum.project import Page
|
||||
from pyPhotoAlbum.page_layout import PageLayout
|
||||
|
||||
# Create new page with next page number
|
||||
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]
|
||||
),
|
||||
page_number=new_page_number,
|
||||
)
|
||||
|
||||
# Insert the page at the correct position
|
||||
main_window.project.pages.insert(insert_index, new_page)
|
||||
|
||||
# Renumber all pages after this one
|
||||
for i, page in enumerate(main_window.project.pages):
|
||||
page.page_number = i + 1
|
||||
|
||||
print(f"Inserted page at index {insert_index}, renumbered pages")
|
||||
self.update()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _update_page_status(self, x: float, y: float):
|
||||
"""
|
||||
Update status bar with current page and total page count.
|
||||
|
||||
Args:
|
||||
x: Screen X coordinate
|
||||
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:
|
||||
return
|
||||
|
||||
if not hasattr(self, "_page_renderers") or not self._page_renderers:
|
||||
return
|
||||
|
||||
# Get total page count (accounting for double spreads = 2 pages each)
|
||||
total_pages = sum(page.get_page_count() for page in main_window.project.pages)
|
||||
|
||||
# Find which page mouse is over
|
||||
current_page_info = None
|
||||
|
||||
for renderer, page in self._page_renderers:
|
||||
# Check if mouse is within this page bounds
|
||||
if renderer.is_point_in_page(x, y):
|
||||
# For facing page spreads, determine left or right
|
||||
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":
|
||||
current_page_info = f"Page {page_nums[0]}"
|
||||
else:
|
||||
current_page_info = f"Page {page_nums[1]}"
|
||||
else:
|
||||
current_page_info = f"Page {page.page_number}"
|
||||
break
|
||||
|
||||
# Update 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)}%"
|
||||
)
|
||||
else:
|
||||
main_window.status_bar.showMessage(f"Total pages: {total_pages} | Zoom: {int(self.zoom_level * 100)}%")
|
||||
328
pyPhotoAlbum/mixins/rendering.py
Normal file
@ -0,0 +1,328 @@
|
||||
"""
|
||||
Rendering mixin for GLWidget - handles OpenGL rendering
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
from pyPhotoAlbum.gl_imports import *
|
||||
from PyQt6.QtGui import QPainter, QFont, QColor, QPen
|
||||
from PyQt6.QtCore import Qt, QRectF
|
||||
from pyPhotoAlbum.models import TextBoxData
|
||||
|
||||
|
||||
class RenderingMixin:
|
||||
"""
|
||||
Mixin providing OpenGL rendering functionality.
|
||||
|
||||
This mixin handles rendering pages, elements, selection handles,
|
||||
and text overlays.
|
||||
"""
|
||||
|
||||
def paintGL(self):
|
||||
"""Main rendering function - renders all pages vertically"""
|
||||
from pyPhotoAlbum.page_renderer import PageRenderer
|
||||
|
||||
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
|
||||
glLoadIdentity()
|
||||
|
||||
# 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
|
||||
if not self.initial_zoom_set:
|
||||
self.zoom_level = self._calculate_fit_to_screen_zoom()
|
||||
self.pan_offset = self._calculate_center_pan_offset(self.zoom_level)
|
||||
self.initial_zoom_set = True
|
||||
|
||||
# Update scrollbars now that we have content bounds
|
||||
if hasattr(self, '_main_window') and hasattr(self._main_window, "update_scrollbars"):
|
||||
self._main_window.update_scrollbars()
|
||||
|
||||
dpi = project.working_dpi
|
||||
|
||||
# Calculate page positions with ghosts
|
||||
page_positions = self._get_page_positions()
|
||||
|
||||
# Store page renderers for later use
|
||||
self._page_renderers = []
|
||||
|
||||
# Left margin for page rendering
|
||||
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":
|
||||
page = page_or_ghost
|
||||
page_width_mm, page_height_mm = page.layout.size
|
||||
|
||||
screen_x = PAGE_MARGIN + self.pan_offset[0]
|
||||
screen_y = (y_offset * self.zoom_level) + self.pan_offset[1]
|
||||
|
||||
renderer = PageRenderer(
|
||||
page_width_mm=page_width_mm,
|
||||
page_height_mm=page_height_mm,
|
||||
screen_x=screen_x,
|
||||
screen_y=screen_y,
|
||||
dpi=dpi,
|
||||
zoom=self.zoom_level,
|
||||
)
|
||||
|
||||
self._page_renderers.append((renderer, page))
|
||||
|
||||
renderer.begin_render()
|
||||
# Pass widget reference for async loading
|
||||
page.layout._parent_widget = self
|
||||
page.layout.render(dpi=dpi, project=project)
|
||||
renderer.end_render()
|
||||
pages_rendered += 1
|
||||
|
||||
elif page_type == "ghost":
|
||||
ghost = page_or_ghost
|
||||
ghost_width_mm, ghost_height_mm = ghost.page_size
|
||||
|
||||
screen_x = PAGE_MARGIN + self.pan_offset[0]
|
||||
screen_y = (y_offset * self.zoom_level) + self.pan_offset[1]
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
self._render_ghost_page(ghost, renderer)
|
||||
|
||||
# Update PageRenderer references for selected elements
|
||||
for element in self.selected_elements:
|
||||
if hasattr(element, "_parent_page"):
|
||||
for renderer, page in self._page_renderers:
|
||||
if page is element._parent_page:
|
||||
element._page_renderer = renderer
|
||||
break
|
||||
|
||||
# Draw selection handles for all selected elements
|
||||
for element in self.selected_elements:
|
||||
self._draw_selection_handles(element)
|
||||
|
||||
# Render text overlays using QPainter
|
||||
# Qt will handle OpenGL/QPainter coordination automatically
|
||||
self._render_text_overlays()
|
||||
|
||||
def _draw_selection_handles(self, element):
|
||||
"""Draw selection handles around the given element"""
|
||||
if not element:
|
||||
return
|
||||
|
||||
main_window = self.window()
|
||||
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
|
||||
return
|
||||
|
||||
if not hasattr(element, "_page_renderer"):
|
||||
return
|
||||
|
||||
renderer = element._page_renderer
|
||||
|
||||
elem_x, elem_y = element.position
|
||||
elem_w, elem_h = element.size
|
||||
handle_size = 8
|
||||
|
||||
x, y = renderer.page_to_screen(elem_x, elem_y)
|
||||
w = elem_w * renderer.zoom
|
||||
h = elem_h * renderer.zoom
|
||||
|
||||
center_x = x + w / 2
|
||||
center_y = y + h / 2
|
||||
|
||||
# No rotation transformation needed - images are already rotated at PIL level
|
||||
|
||||
if self.rotation_mode:
|
||||
glColor3f(1.0, 0.5, 0.0)
|
||||
else:
|
||||
glColor3f(0.0, 0.5, 1.0)
|
||||
|
||||
glLineWidth(2.0)
|
||||
glBegin(GL_LINE_LOOP)
|
||||
glVertex2f(x, y)
|
||||
glVertex2f(x + w, y)
|
||||
glVertex2f(x + w, y + h)
|
||||
glVertex2f(x, y + h)
|
||||
glEnd()
|
||||
glLineWidth(1.0)
|
||||
|
||||
if self.rotation_mode:
|
||||
handle_radius = 6
|
||||
handles = [(x, y), (x + w, y), (x, y + h), (x + w, y + h)]
|
||||
|
||||
glColor3f(1.0, 0.5, 0.0)
|
||||
glBegin(GL_TRIANGLE_FAN)
|
||||
glVertex2f(center_x, center_y)
|
||||
for angle in range(0, 361, 10):
|
||||
rad = math.radians(angle)
|
||||
hx = center_x + 3 * math.cos(rad)
|
||||
hy = center_y + 3 * math.sin(rad)
|
||||
glVertex2f(hx, hy)
|
||||
glEnd()
|
||||
|
||||
for hx, hy in handles:
|
||||
glColor3f(1.0, 1.0, 1.0)
|
||||
glBegin(GL_TRIANGLE_FAN)
|
||||
glVertex2f(hx, hy)
|
||||
for angle in range(0, 361, 30):
|
||||
rad = math.radians(angle)
|
||||
px = hx + handle_radius * math.cos(rad)
|
||||
py = hy + handle_radius * math.sin(rad)
|
||||
glVertex2f(px, py)
|
||||
glEnd()
|
||||
|
||||
glColor3f(1.0, 0.5, 0.0)
|
||||
glBegin(GL_LINE_LOOP)
|
||||
for angle in range(0, 361, 30):
|
||||
rad = math.radians(angle)
|
||||
px = hx + handle_radius * math.cos(rad)
|
||||
py = hy + handle_radius * math.sin(rad)
|
||||
glVertex2f(px, py)
|
||||
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),
|
||||
]
|
||||
|
||||
glColor3f(1.0, 1.0, 1.0)
|
||||
for hx, hy in handles:
|
||||
glBegin(GL_QUADS)
|
||||
glVertex2f(hx, hy)
|
||||
glVertex2f(hx + handle_size, hy)
|
||||
glVertex2f(hx + handle_size, hy + handle_size)
|
||||
glVertex2f(hx, hy + handle_size)
|
||||
glEnd()
|
||||
|
||||
glColor3f(0.0, 0.5, 1.0)
|
||||
for hx, hy in handles:
|
||||
glBegin(GL_LINE_LOOP)
|
||||
glVertex2f(hx, hy)
|
||||
glVertex2f(hx + handle_size, hy)
|
||||
glVertex2f(hx + handle_size, hy + handle_size)
|
||||
glVertex2f(hx, hy + handle_size)
|
||||
glEnd()
|
||||
|
||||
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:
|
||||
return
|
||||
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setRenderHint(QPainter.RenderHint.TextAntialiasing)
|
||||
|
||||
try:
|
||||
for renderer, page in self._page_renderers:
|
||||
text_elements = [elem for elem in page.layout.elements if isinstance(elem, TextBoxData)]
|
||||
|
||||
for element in text_elements:
|
||||
if not element.text_content:
|
||||
continue
|
||||
|
||||
x, y = element.position
|
||||
w, h = element.size
|
||||
|
||||
screen_x, screen_y = renderer.page_to_screen(x, y)
|
||||
screen_w = w * renderer.zoom
|
||||
screen_h = h * 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))
|
||||
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.translate(w / 2, h / 2)
|
||||
painter.rotate(element.rotation)
|
||||
painter.translate(-w / 2, -h / 2)
|
||||
|
||||
rect = QRectF(0, 0, w, h)
|
||||
|
||||
alignment = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop
|
||||
if element.alignment == "center":
|
||||
alignment = Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop
|
||||
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)
|
||||
|
||||
painter.restore()
|
||||
|
||||
finally:
|
||||
painter.end()
|
||||
|
||||
def _render_ghost_page(self, ghost_data, renderer):
|
||||
"""Render a ghost page using PageRenderer"""
|
||||
renderer.begin_render()
|
||||
ghost_data.render()
|
||||
renderer.end_render()
|
||||
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setRenderHint(QPainter.RenderHint.TextAntialiasing)
|
||||
|
||||
try:
|
||||
px, py, pw, ph = ghost_data.get_page_rect()
|
||||
|
||||
screen_x, screen_y = renderer.page_to_screen(px, py)
|
||||
|
||||
# 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))
|
||||
|
||||
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()
|
||||
289
pyPhotoAlbum/mixins/viewport.py
Normal file
@ -0,0 +1,289 @@
|
||||
"""
|
||||
Viewport mixin for GLWidget - handles zoom and pan
|
||||
"""
|
||||
|
||||
from pyPhotoAlbum.gl_imports import *
|
||||
|
||||
|
||||
class ViewportMixin:
|
||||
"""
|
||||
Mixin providing viewport zoom and pan functionality.
|
||||
|
||||
This mixin manages the zoom level and pan offset for the OpenGL canvas,
|
||||
including fit-to-screen calculations and OpenGL initialization.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Zoom and pan state
|
||||
self.zoom_level = 1.0
|
||||
self.pan_offset = [0, 0]
|
||||
self.initial_zoom_set = False # Track if we've set initial fit-to-screen zoom
|
||||
|
||||
# Track previous viewport size to detect scrollbar-induced resizes
|
||||
self._last_viewport_size = (0, 0)
|
||||
|
||||
def initializeGL(self):
|
||||
"""Initialize OpenGL resources"""
|
||||
glClearColor(1.0, 1.0, 1.0, 1.0)
|
||||
glEnable(GL_DEPTH_TEST)
|
||||
|
||||
def resizeGL(self, w, h):
|
||||
"""Handle window resizing"""
|
||||
glViewport(0, 0, w, h)
|
||||
glMatrixMode(GL_PROJECTION)
|
||||
glLoadIdentity()
|
||||
glOrtho(0, w, h, 0, -1, 1)
|
||||
glMatrixMode(GL_MODELVIEW)
|
||||
|
||||
# Detect if this is a small resize (likely scrollbar visibility change)
|
||||
# Scrollbars are typically 14-20 pixels wide
|
||||
last_w, last_h = self._last_viewport_size
|
||||
width_change = abs(w - last_w)
|
||||
height_change = abs(h - last_h)
|
||||
is_small_resize = width_change <= 20 and height_change <= 20
|
||||
is_first_resize = last_w == 0 and last_h == 0
|
||||
|
||||
# Recalculate centering if we have a project loaded
|
||||
# Recenter on:
|
||||
# 1. First resize (initial setup)
|
||||
# 2. Large resizes (window resize, NOT scrollbar changes)
|
||||
# Don't recenter on small resizes (scrollbar visibility changes during zoom)
|
||||
if self.initial_zoom_set and (is_first_resize or not is_small_resize):
|
||||
# Maintain current zoom level, just recenter
|
||||
self.pan_offset = self._calculate_center_pan_offset(self.zoom_level)
|
||||
|
||||
# Update tracked viewport size
|
||||
self._last_viewport_size = (w, h)
|
||||
|
||||
self.update()
|
||||
|
||||
# Update scrollbars when viewport size changes
|
||||
main_window = self.window()
|
||||
if hasattr(main_window, "update_scrollbars"):
|
||||
main_window.update_scrollbars()
|
||||
|
||||
def _calculate_fit_to_screen_zoom(self):
|
||||
"""
|
||||
Calculate zoom level to fit first page to screen.
|
||||
|
||||
Returns:
|
||||
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:
|
||||
return 1.0
|
||||
|
||||
window_width = self.width()
|
||||
window_height = self.height()
|
||||
|
||||
# Get first page dimensions in mm
|
||||
first_page = main_window.project.pages[0]
|
||||
page_width_mm, page_height_mm = first_page.layout.size
|
||||
|
||||
# Convert to pixels
|
||||
dpi = main_window.project.working_dpi
|
||||
page_width_px = page_width_mm * dpi / 25.4
|
||||
page_height_px = page_height_mm * dpi / 25.4
|
||||
|
||||
# Calculate zoom to fit with margins
|
||||
margin = 100 # pixels
|
||||
zoom_w = (window_width - margin * 2) / page_width_px
|
||||
zoom_h = (window_height - margin * 2) / page_height_px
|
||||
|
||||
# Use the smaller zoom to ensure entire page fits
|
||||
return min(zoom_w, zoom_h, 1.0) # Don't zoom in beyond 100%
|
||||
|
||||
def _calculate_center_pan_offset(self, zoom_level):
|
||||
"""
|
||||
Calculate pan offset to center the first page in the viewport.
|
||||
|
||||
Args:
|
||||
zoom_level: The current zoom level to use for calculations
|
||||
|
||||
Returns:
|
||||
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:
|
||||
return [0, 0]
|
||||
|
||||
window_width = self.width()
|
||||
window_height = self.height()
|
||||
|
||||
# Get first page dimensions in mm
|
||||
first_page = main_window.project.pages[0]
|
||||
page_width_mm, page_height_mm = first_page.layout.size
|
||||
|
||||
# Convert to pixels
|
||||
dpi = main_window.project.working_dpi
|
||||
page_width_px = page_width_mm * dpi / 25.4
|
||||
page_height_px = page_height_mm * dpi / 25.4
|
||||
|
||||
# Apply zoom to get screen dimensions
|
||||
screen_page_width = page_width_px * zoom_level
|
||||
screen_page_height = page_height_px * zoom_level
|
||||
|
||||
# Calculate offsets to center the page
|
||||
# PAGE_MARGIN from rendering.py is 50
|
||||
PAGE_MARGIN = 50
|
||||
x_offset = (window_width - screen_page_width) / 2 - PAGE_MARGIN
|
||||
y_offset = (window_height - screen_page_height) / 2
|
||||
|
||||
return [x_offset, y_offset]
|
||||
|
||||
def get_content_bounds(self):
|
||||
"""
|
||||
Calculate the total bounds of all content (pages).
|
||||
|
||||
Returns:
|
||||
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}
|
||||
|
||||
dpi = main_window.project.working_dpi
|
||||
PAGE_MARGIN = 50
|
||||
PAGE_SPACING = 50
|
||||
|
||||
# Calculate total dimensions
|
||||
total_height = PAGE_MARGIN
|
||||
max_width = 0
|
||||
|
||||
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
|
||||
|
||||
total_height += screen_page_height + PAGE_SPACING
|
||||
max_width = max(max_width, screen_page_width)
|
||||
|
||||
total_width = max_width + PAGE_MARGIN * 2
|
||||
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,
|
||||
}
|
||||
|
||||
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 = 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.
|
||||
|
||||
Pan offset semantics:
|
||||
- Positive pan_offset = content moved right/down (viewing top-left)
|
||||
- Negative pan_offset = content moved left/up (viewing bottom-right)
|
||||
|
||||
For horizontal clamping, we use a centerline-based approach that interpolates
|
||||
between page centers based on vertical scroll position. This prevents jumps
|
||||
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:
|
||||
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
|
||||
centerlines = self._build_page_centerlines(main_window.project.pages, dpi)
|
||||
if not centerlines:
|
||||
return
|
||||
|
||||
# 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)
|
||||
|
||||
# Horizontal clamping
|
||||
self._clamp_horizontal_pan(viewport_width, target_x, target_width)
|
||||
1002
pyPhotoAlbum/models.py
Normal file
331
pyPhotoAlbum/page_layout.py
Normal file
@ -0,0 +1,331 @@
|
||||
"""
|
||||
Page layout and template system for pyPhotoAlbum
|
||||
"""
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
class PageLayout:
|
||||
"""Class to manage page layout and templates"""
|
||||
|
||||
def __init__(self, width: float = 210, height: float = 297, is_facing_page: bool = False):
|
||||
"""
|
||||
Initialize page layout.
|
||||
|
||||
Args:
|
||||
width: Width in mm (doubled automatically if is_facing_page=True)
|
||||
height: Height in mm
|
||||
is_facing_page: If True, width is doubled for facing page spread
|
||||
"""
|
||||
self.base_width = width # Store the base single-page width
|
||||
self.is_facing_page = is_facing_page
|
||||
self.size = (width * 2 if is_facing_page else width, height)
|
||||
self.elements: List[BaseLayoutElement] = []
|
||||
self.grid_layout: Optional[GridLayout] = None
|
||||
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"""
|
||||
if element not in self.elements:
|
||||
self.elements.append(element)
|
||||
|
||||
def remove_element(self, element: BaseLayoutElement):
|
||||
"""Remove a layout element from the page"""
|
||||
if element in self.elements:
|
||||
self.elements.remove(element)
|
||||
|
||||
def set_grid_layout(self, grid: "GridLayout"):
|
||||
"""Set a grid layout for the page"""
|
||||
self.grid_layout = grid
|
||||
|
||||
def render(self, dpi: int = 300, project=None):
|
||||
"""
|
||||
Render all elements on the page in page-local coordinates.
|
||||
|
||||
Note: This method assumes OpenGL transformations have already been set up
|
||||
by PageRenderer.begin_render(). All coordinates here are in page-local space.
|
||||
|
||||
Args:
|
||||
dpi: Working DPI for converting mm to pixels
|
||||
project: Optional project instance for global snapping settings
|
||||
"""
|
||||
# Disable depth testing for 2D rendering
|
||||
glDisable(GL_DEPTH_TEST)
|
||||
|
||||
# Convert size from mm to pixels based on DPI
|
||||
width_px = self.size[0] * dpi / 25.4
|
||||
height_px = self.size[1] * dpi / 25.4
|
||||
|
||||
# All rendering is at page origin (0, 0) in page-local coordinates
|
||||
page_x = 0
|
||||
page_y = 0
|
||||
|
||||
# Draw drop shadow FIRST (behind everything)
|
||||
shadow_offset = 5
|
||||
glColor3f(0.5, 0.5, 0.5)
|
||||
glBegin(GL_QUADS)
|
||||
glVertex2f(page_x + shadow_offset, page_y + height_px)
|
||||
glVertex2f(page_x + width_px + shadow_offset, page_y + height_px)
|
||||
glVertex2f(page_x + width_px + shadow_offset, page_y + height_px + shadow_offset)
|
||||
glVertex2f(page_x + shadow_offset, page_y + height_px + shadow_offset)
|
||||
glEnd()
|
||||
|
||||
glBegin(GL_QUADS)
|
||||
glVertex2f(page_x + width_px, page_y + shadow_offset)
|
||||
glVertex2f(page_x + width_px + shadow_offset, page_y + shadow_offset)
|
||||
glVertex2f(page_x + width_px + shadow_offset, page_y + height_px)
|
||||
glVertex2f(page_x + width_px, page_y + height_px)
|
||||
glEnd()
|
||||
|
||||
# Draw page background (slightly off-white to distinguish from canvas)
|
||||
glColor3f(0.98, 0.98, 0.98)
|
||||
glBegin(GL_QUADS)
|
||||
glVertex2f(page_x, page_y)
|
||||
glVertex2f(page_x + width_px, page_y)
|
||||
glVertex2f(page_x + width_px, page_y + height_px)
|
||||
glVertex2f(page_x, page_y + height_px)
|
||||
glEnd()
|
||||
|
||||
# Render elements in list order (list position = z-order)
|
||||
# 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"):
|
||||
# Try to get async loader from a parent widget
|
||||
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
|
||||
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)
|
||||
element._async_load_requested = True
|
||||
element._async_loading = True
|
||||
|
||||
element.render()
|
||||
|
||||
# Draw page border LAST (on top of everything)
|
||||
glColor3f(0.7, 0.7, 0.7)
|
||||
glLineWidth(2.0)
|
||||
glBegin(GL_LINE_LOOP)
|
||||
glVertex2f(page_x, page_y)
|
||||
glVertex2f(page_x + width_px, page_y)
|
||||
glVertex2f(page_x + width_px, page_y + height_px)
|
||||
glVertex2f(page_x, page_y + height_px)
|
||||
glEnd()
|
||||
glLineWidth(1.0)
|
||||
|
||||
# Draw center line for facing pages
|
||||
if self.is_facing_page:
|
||||
center_x = page_x + (width_px / 2)
|
||||
glColor3f(0.5, 0.5, 0.5) # Gray line
|
||||
glLineWidth(1.5)
|
||||
glBegin(GL_LINES)
|
||||
glVertex2f(center_x, page_y)
|
||||
glVertex2f(center_x, page_y + height_px)
|
||||
glEnd()
|
||||
glLineWidth(1.0)
|
||||
|
||||
# Always render snap lines (grid shows when show_grid is on, guides show when show_snap_lines is on)
|
||||
self._render_snap_lines(dpi, page_x, page_y, project)
|
||||
|
||||
# Re-enable depth testing
|
||||
glEnable(GL_DEPTH_TEST)
|
||||
|
||||
def _render_snap_lines(self, dpi: int, page_x: float, page_y: float, project=None):
|
||||
"""Render snap lines (grid, edges, guides)"""
|
||||
# Use project settings if available, otherwise fall back to local snapping_system
|
||||
if project:
|
||||
# Use project-level global settings
|
||||
snap_to_grid = project.snap_to_grid
|
||||
snap_to_edges = project.snap_to_edges
|
||||
snap_to_guides = project.snap_to_guides
|
||||
grid_size_mm = project.grid_size_mm
|
||||
snap_threshold_mm = project.snap_threshold_mm
|
||||
show_grid = project.show_grid
|
||||
show_snap_lines = project.show_snap_lines
|
||||
else:
|
||||
# Fall back to per-page settings (backward compatibility)
|
||||
snap_to_grid = self.snapping_system.snap_to_grid
|
||||
snap_to_edges = self.snapping_system.snap_to_edges
|
||||
snap_to_guides = self.snapping_system.snap_to_guides
|
||||
grid_size_mm = self.snapping_system.grid_size_mm
|
||||
snap_threshold_mm = self.snapping_system.snap_threshold_mm
|
||||
show_grid = snap_to_grid # Old behavior: grid only shows when snapping
|
||||
show_snap_lines = self.show_snap_lines
|
||||
|
||||
# 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
|
||||
temp_snap_sys.snap_to_edges = snap_to_edges
|
||||
temp_snap_sys.snap_to_guides = snap_to_guides
|
||||
temp_snap_sys.guides = self.snapping_system.guides # Use page-specific guides
|
||||
|
||||
snap_lines = temp_snap_sys.get_snap_lines(self.size, dpi)
|
||||
|
||||
# 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"]:
|
||||
glBegin(GL_LINES)
|
||||
if orientation == "vertical":
|
||||
glVertex2f(page_x + position, page_y)
|
||||
glVertex2f(page_x + position, page_y + self.size[1] * dpi / 25.4)
|
||||
else: # horizontal
|
||||
glVertex2f(page_x, page_y + position)
|
||||
glVertex2f(page_x + self.size[0] * dpi / 25.4, page_y + position)
|
||||
glEnd()
|
||||
|
||||
# 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"]:
|
||||
glBegin(GL_LINES)
|
||||
if orientation == "vertical":
|
||||
glVertex2f(page_x + position, page_y)
|
||||
glVertex2f(page_x + position, page_y + self.size[1] * dpi / 25.4)
|
||||
else: # horizontal
|
||||
glVertex2f(page_x, page_y + position)
|
||||
glVertex2f(page_x + self.size[0] * dpi / 25.4, page_y + position)
|
||||
glEnd()
|
||||
|
||||
glLineWidth(1.0)
|
||||
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
"""Serialize page layout to dictionary"""
|
||||
return {
|
||||
"size": self.size,
|
||||
"base_width": self.base_width,
|
||||
"is_facing_page": self.is_facing_page,
|
||||
"background_color": self.background_color,
|
||||
"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,
|
||||
}
|
||||
|
||||
def deserialize(self, data: Dict[str, Any]):
|
||||
"""Deserialize from dictionary"""
|
||||
self.size = tuple(data.get("size", (210, 297)))
|
||||
self.base_width = data.get("base_width", self.size[0])
|
||||
self.is_facing_page = data.get("is_facing_page", False)
|
||||
self.background_color = tuple(data.get("background_color", (1.0, 1.0, 1.0)))
|
||||
self.elements = []
|
||||
|
||||
# Deserialize elements and sort by z_index to establish list order
|
||||
# This ensures backward compatibility with projects that used z_index
|
||||
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":
|
||||
elem = PlaceholderData()
|
||||
elif elem_type == "textbox":
|
||||
elem = TextBoxData()
|
||||
else:
|
||||
continue
|
||||
|
||||
elem.deserialize(elem_data)
|
||||
elem_list.append(elem)
|
||||
|
||||
# Sort by z_index to establish proper list order (lower z_index = earlier in list = behind)
|
||||
elem_list.sort(key=lambda e: e.z_index)
|
||||
self.elements = elem_list
|
||||
|
||||
# Deserialize grid layout
|
||||
grid_data = data.get("grid_layout")
|
||||
if grid_data:
|
||||
self.grid_layout = GridLayout()
|
||||
self.grid_layout.deserialize(grid_data)
|
||||
|
||||
# Deserialize snapping system
|
||||
snap_data = data.get("snapping_system")
|
||||
if snap_data:
|
||||
self.snapping_system.deserialize(snap_data)
|
||||
|
||||
self.show_snap_lines = data.get("show_snap_lines", True)
|
||||
|
||||
|
||||
class GridLayout:
|
||||
"""Class to manage grid layouts"""
|
||||
|
||||
def __init__(self, rows: int = 1, columns: int = 1, spacing: float = 10.0):
|
||||
self.rows = rows
|
||||
self.columns = columns
|
||||
self.spacing = spacing
|
||||
self.merged_cells: List[Tuple[int, int]] = [] # List of (row, col) for merged cells
|
||||
|
||||
def merge_cells(self, row: int, col: int):
|
||||
"""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]:
|
||||
"""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
|
||||
|
||||
x = self.spacing + (col * (cell_width + self.spacing))
|
||||
y = self.spacing + (row * (cell_height + self.spacing))
|
||||
|
||||
return (x, y)
|
||||
|
||||
def get_cell_size(self, page_width: float = 800, page_height: float = 600) -> Tuple[float, float]:
|
||||
"""Get the size 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
|
||||
|
||||
return (cell_width, cell_height)
|
||||
|
||||
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}
|
||||
|
||||
def deserialize(self, data: Dict[str, Any]):
|
||||
"""Deserialize from dictionary"""
|
||||
self.rows = data.get("rows", 1)
|
||||
self.columns = data.get("columns", 1)
|
||||
self.spacing = data.get("spacing", 10.0)
|
||||
self.merged_cells = data.get("merged_cells", [])
|
||||
156
pyPhotoAlbum/page_renderer.py
Normal file
@ -0,0 +1,156 @@
|
||||
"""
|
||||
Page renderer helper for pyPhotoAlbum
|
||||
|
||||
This module provides a unified coordinate system for rendering pages and their elements.
|
||||
All coordinate transformations are centralized here to ensure consistency.
|
||||
|
||||
Coordinate Systems:
|
||||
- Page-local: Coordinates in millimeters relative to the page's top-left corner
|
||||
- Pixel: Coordinates in pixels at working DPI
|
||||
- Screen: Coordinates on screen after applying zoom and pan
|
||||
"""
|
||||
|
||||
from typing import Tuple, Optional
|
||||
from pyPhotoAlbum.gl_imports import glPushMatrix, glPopMatrix, glScalef, glTranslatef
|
||||
|
||||
|
||||
class PageRenderer:
|
||||
"""
|
||||
Handles rendering and coordinate transformations for a single page.
|
||||
|
||||
This class encapsulates all coordinate transformations needed to render
|
||||
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
|
||||
):
|
||||
"""
|
||||
Initialize a page renderer.
|
||||
|
||||
Args:
|
||||
page_width_mm: Page width in millimeters
|
||||
page_height_mm: Page height in millimeters
|
||||
screen_x: X position on screen where page should be rendered
|
||||
screen_y: Y position on screen where page should be rendered
|
||||
dpi: Working DPI for converting mm to pixels
|
||||
zoom: Current zoom level
|
||||
"""
|
||||
self.page_width_mm = page_width_mm
|
||||
self.page_height_mm = page_height_mm
|
||||
self.screen_x = screen_x
|
||||
self.screen_y = screen_y
|
||||
self.dpi = dpi
|
||||
self.zoom = zoom
|
||||
|
||||
# Calculate page dimensions in pixels
|
||||
self.page_width_px = page_width_mm * dpi / 25.4
|
||||
self.page_height_px = page_height_mm * dpi / 25.4
|
||||
|
||||
# Calculate screen dimensions (with zoom applied)
|
||||
self.screen_width = self.page_width_px * zoom
|
||||
self.screen_height = self.page_height_px * zoom
|
||||
|
||||
def page_to_screen(self, page_x: float, page_y: float) -> Tuple[float, float]:
|
||||
"""
|
||||
Convert page-local coordinates (in pixels) to screen coordinates.
|
||||
|
||||
Args:
|
||||
page_x: X coordinate in page-local space (pixels)
|
||||
page_y: Y coordinate in page-local space (pixels)
|
||||
|
||||
Returns:
|
||||
Tuple of (screen_x, screen_y)
|
||||
"""
|
||||
screen_x = self.screen_x + page_x * self.zoom
|
||||
screen_y = self.screen_y + page_y * self.zoom
|
||||
return (screen_x, screen_y)
|
||||
|
||||
def screen_to_page(self, screen_x: float, screen_y: float) -> Tuple[float, float]:
|
||||
"""
|
||||
Convert screen coordinates to page-local coordinates (in pixels).
|
||||
|
||||
Args:
|
||||
screen_x: X coordinate in screen space
|
||||
screen_y: Y coordinate in screen space
|
||||
|
||||
Returns:
|
||||
Tuple of (page_x, page_y) in pixels, or None if outside page bounds
|
||||
"""
|
||||
page_x = (screen_x - self.screen_x) / self.zoom
|
||||
page_y = (screen_y - self.screen_y) / self.zoom
|
||||
return (page_x, page_y)
|
||||
|
||||
def is_point_in_page(self, screen_x: float, screen_y: float) -> bool:
|
||||
"""
|
||||
Check if a screen coordinate is within the page bounds.
|
||||
|
||||
Args:
|
||||
screen_x: X coordinate in screen space
|
||||
screen_y: Y coordinate in screen space
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
def get_sub_page_at(self, screen_x: float, is_facing_page: bool) -> Optional[str]:
|
||||
"""
|
||||
For facing page spreads, determine if mouse is on left or right page.
|
||||
|
||||
Args:
|
||||
screen_x: X coordinate in screen space
|
||||
is_facing_page: Whether this is a facing page spread
|
||||
|
||||
Returns:
|
||||
'left' or 'right' for facing pages, None for single pages
|
||||
"""
|
||||
if not is_facing_page:
|
||||
return None
|
||||
|
||||
# Calculate the center line of the spread
|
||||
center_x = self.screen_x + self.screen_width / 2
|
||||
|
||||
if screen_x < center_x:
|
||||
return "left"
|
||||
else:
|
||||
return "right"
|
||||
|
||||
def begin_render(self):
|
||||
"""
|
||||
Set up OpenGL transformations for rendering this page.
|
||||
Call this before rendering page content.
|
||||
"""
|
||||
glPushMatrix()
|
||||
# Apply zoom
|
||||
glScalef(self.zoom, self.zoom, 1.0)
|
||||
# Translate to page position (in zoomed coordinates)
|
||||
glTranslatef(self.screen_x / self.zoom, self.screen_y / self.zoom, 0.0)
|
||||
|
||||
def end_render(self):
|
||||
"""
|
||||
Clean up OpenGL transformations after rendering this page.
|
||||
Call this after rendering page content.
|
||||
"""
|
||||
glPopMatrix()
|
||||
|
||||
def get_page_bounds_screen(self) -> Tuple[float, float, float, float]:
|
||||
"""
|
||||
Get the page bounds in screen coordinates.
|
||||
|
||||
Returns:
|
||||
Tuple of (x, y, width, height) in screen space
|
||||
"""
|
||||
return (self.screen_x, self.screen_y, self.screen_width, self.screen_height)
|
||||
|
||||
def get_page_bounds_page(self) -> Tuple[float, float, float, float]:
|
||||
"""
|
||||
Get the page bounds in page-local coordinates.
|
||||
|
||||
Returns:
|
||||
Tuple of (x, y, width, height) in page-local space (pixels)
|
||||
"""
|
||||
return (0, 0, self.page_width_px, self.page_height_px)
|
||||
1117
pyPhotoAlbum/pdf_exporter.py
Normal file
497
pyPhotoAlbum/project.py
Normal file
@ -0,0 +1,497 @@
|
||||
"""
|
||||
Project and page management for pyPhotoAlbum
|
||||
"""
|
||||
|
||||
import os
|
||||
import math
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
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"""
|
||||
|
||||
def __init__(self, layout: Optional[PageLayout] = None, page_number: int = 1, is_double_spread: bool = False):
|
||||
"""
|
||||
Initialize a page.
|
||||
|
||||
Args:
|
||||
layout: PageLayout instance (created automatically if None)
|
||||
page_number: The page number (for spreads, this is the left page number)
|
||||
is_double_spread: If True, this is a facing page spread (2x width)
|
||||
"""
|
||||
self.page_number = page_number
|
||||
self.is_cover = False
|
||||
self.is_double_spread = is_double_spread
|
||||
self.manually_sized = False # Track if user manually changed page size
|
||||
|
||||
# UUID for merge conflict resolution (v3.0+)
|
||||
self.uuid = str(uuid.uuid4())
|
||||
|
||||
# Timestamps for merge conflict resolution (v3.0+)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
self.created = now
|
||||
self.last_modified = now
|
||||
|
||||
# Deletion tracking for merge (v3.0+)
|
||||
self.deleted = False
|
||||
self.deleted_at: Optional[str] = None
|
||||
|
||||
# Create layout with appropriate width
|
||||
if layout is None:
|
||||
self.layout = PageLayout(is_facing_page=is_double_spread)
|
||||
else:
|
||||
self.layout = layout
|
||||
# Ensure layout matches the is_double_spread setting
|
||||
if is_double_spread != self.layout.is_facing_page:
|
||||
# Need to update the layout for the new facing page state
|
||||
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"):
|
||||
base_width = self.layout.base_width
|
||||
else:
|
||||
# If base_width not set, assume current width is correct
|
||||
# and derive base_width from current state
|
||||
base_width = self.layout.size[0] / 2 if not is_double_spread else self.layout.size[0]
|
||||
self.layout.base_width = base_width
|
||||
|
||||
# Set the new width based on facing page state
|
||||
self.layout.size = (base_width * 2 if is_double_spread else base_width, height)
|
||||
|
||||
def get_page_numbers(self) -> List[int]:
|
||||
"""
|
||||
Get the page numbers this page represents.
|
||||
|
||||
Returns:
|
||||
List of page numbers (2 numbers for spreads, 1 for single pages)
|
||||
"""
|
||||
if self.is_double_spread:
|
||||
return [self.page_number, self.page_number + 1]
|
||||
else:
|
||||
return [self.page_number]
|
||||
|
||||
def get_page_count(self) -> int:
|
||||
"""
|
||||
Get the number of physical pages this represents.
|
||||
|
||||
Returns:
|
||||
2 for spreads, 1 for single pages
|
||||
"""
|
||||
return 2 if self.is_double_spread else 1
|
||||
|
||||
def mark_modified(self):
|
||||
"""Update the last_modified timestamp to now."""
|
||||
self.last_modified = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def mark_deleted(self):
|
||||
"""Mark this page as deleted."""
|
||||
self.deleted = True
|
||||
self.deleted_at = datetime.now(timezone.utc).isoformat()
|
||||
self.mark_modified()
|
||||
|
||||
def render(self):
|
||||
"""Render the entire page"""
|
||||
print(f"Rendering page {self.page_number}")
|
||||
self.layout.render()
|
||||
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
"""Serialize page to dictionary"""
|
||||
return {
|
||||
"page_number": self.page_number,
|
||||
"is_cover": self.is_cover,
|
||||
"is_double_spread": self.is_double_spread,
|
||||
"manually_sized": self.manually_sized,
|
||||
"layout": self.layout.serialize(),
|
||||
# v3.0+ fields
|
||||
"uuid": self.uuid,
|
||||
"created": self.created,
|
||||
"last_modified": self.last_modified,
|
||||
"deleted": self.deleted,
|
||||
"deleted_at": self.deleted_at,
|
||||
}
|
||||
|
||||
def deserialize(self, data: Dict[str, Any]):
|
||||
"""Deserialize from dictionary"""
|
||||
self.page_number = data.get("page_number", 1)
|
||||
self.is_cover = data.get("is_cover", False)
|
||||
self.is_double_spread = data.get("is_double_spread", False)
|
||||
self.manually_sized = data.get("manually_sized", False)
|
||||
|
||||
# v3.0+ fields (with backwards compatibility)
|
||||
self.uuid = data.get("uuid", str(uuid.uuid4()))
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
self.created = data.get("created", now)
|
||||
self.last_modified = data.get("last_modified", now)
|
||||
self.deleted = data.get("deleted", False)
|
||||
self.deleted_at = data.get("deleted_at", None)
|
||||
|
||||
layout_data = data.get("layout", {})
|
||||
self.layout = PageLayout()
|
||||
self.layout.deserialize(layout_data)
|
||||
|
||||
|
||||
class Project:
|
||||
"""Class representing the entire photo album project"""
|
||||
|
||||
def __init__(self, name: str = "Untitled Project", folder_path: Optional[str] = None):
|
||||
self.name = name
|
||||
self.folder_path = folder_path or os.path.join("./projects", name.replace(" ", "_"))
|
||||
self.pages: List[Page] = []
|
||||
self.default_min_distance = 10.0 # Default minimum distance between images
|
||||
self.cover_size = (800, 600) # Default cover size in pixels
|
||||
self.page_size = (800, 600) # Default page size in pixels
|
||||
self.page_size_mm = (140, 140) # Default page size in mm (14cm x 14cm square)
|
||||
self.working_dpi = 300 # Default working DPI
|
||||
self.export_dpi = 300 # Default export DPI
|
||||
self.page_spacing_mm = 10.0 # Default spacing between pages (1cm)
|
||||
|
||||
# Project ID for merge detection (v3.0+)
|
||||
# Projects with same ID should be merged, different IDs should be concatenated
|
||||
self.project_id = str(uuid.uuid4())
|
||||
|
||||
# Timestamps for project-level changes (v3.0+)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
self.created = now
|
||||
self.last_modified = now
|
||||
|
||||
# Cover configuration
|
||||
self.has_cover = False # Whether project has a cover
|
||||
self.paper_thickness_mm = 0.2 # Paper thickness for spine calculation (default 0.2mm)
|
||||
self.cover_bleed_mm = 0.0 # Bleed margin for cover (default 0mm)
|
||||
self.binding_type = "saddle_stitch" # Binding type for spine calculation
|
||||
|
||||
# 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: Optional[TemporaryDirectory[str]] = None
|
||||
|
||||
# Global snapping settings (apply to all pages)
|
||||
self.snap_to_grid = False
|
||||
self.snap_to_edges = True
|
||||
self.snap_to_guides = True
|
||||
self.grid_size_mm = 10.0
|
||||
self.snap_threshold_mm = 5.0
|
||||
self.show_grid = False # Show grid lines independently of snap_to_grid
|
||||
self.show_snap_lines = True # Show snap lines (guides) during dragging
|
||||
|
||||
# Initialize asset manager
|
||||
self.asset_manager = AssetManager(self.folder_path)
|
||||
|
||||
# Initialize command history with asset manager and project reference
|
||||
self.history = CommandHistory(max_history=100, asset_manager=self.asset_manager, project=self)
|
||||
|
||||
# Track unsaved changes
|
||||
self._dirty = False
|
||||
self.file_path = None # Path to the saved .ppz file
|
||||
|
||||
def mark_dirty(self):
|
||||
"""Mark the project as having unsaved changes."""
|
||||
self._dirty = True
|
||||
self.mark_modified()
|
||||
|
||||
def mark_clean(self):
|
||||
"""Mark the project as saved (no unsaved changes)."""
|
||||
self._dirty = False
|
||||
|
||||
def is_dirty(self) -> bool:
|
||||
"""Check if the project has unsaved changes."""
|
||||
return self._dirty
|
||||
|
||||
def mark_modified(self):
|
||||
"""Update the last_modified timestamp to now."""
|
||||
self.last_modified = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def add_page(self, page: Page, index: Optional[int] = None):
|
||||
"""
|
||||
Add a page to the project.
|
||||
|
||||
Args:
|
||||
page: The page to add
|
||||
index: Optional index to insert at. If None, appends to end.
|
||||
"""
|
||||
if index is None:
|
||||
self.pages.append(page)
|
||||
else:
|
||||
self.pages.insert(index, page)
|
||||
# Update cover dimensions if we have a cover
|
||||
if self.has_cover and self.pages:
|
||||
self.update_cover_dimensions()
|
||||
self.mark_dirty()
|
||||
|
||||
def remove_page(self, page: Page):
|
||||
"""Remove a page from the project"""
|
||||
self.pages.remove(page)
|
||||
# Update cover dimensions if we have a cover
|
||||
if self.has_cover and self.pages:
|
||||
self.update_cover_dimensions()
|
||||
self.mark_dirty()
|
||||
|
||||
def calculate_spine_width(self) -> float:
|
||||
"""
|
||||
Calculate spine width based on page count and paper thickness.
|
||||
|
||||
For saddle stitch binding:
|
||||
- Each sheet = 4 pages (2 pages per side when folded)
|
||||
- Spine width = (Number of sheets × Paper thickness × 2)
|
||||
|
||||
Returns:
|
||||
Spine width in mm
|
||||
"""
|
||||
if not self.has_cover:
|
||||
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)
|
||||
|
||||
if self.binding_type == "saddle_stitch":
|
||||
# Calculate number of sheets (each sheet = 4 pages)
|
||||
sheets = math.ceil(content_page_count / 4)
|
||||
# Spine width = sheets × paper thickness × 2 (folded)
|
||||
spine_width = sheets * self.paper_thickness_mm * 2
|
||||
return spine_width
|
||||
|
||||
return 0.0
|
||||
|
||||
def update_cover_dimensions(self):
|
||||
"""
|
||||
Update cover page dimensions based on current page count and settings.
|
||||
Calculates: Front width + Spine width + Back width + Bleed margins
|
||||
"""
|
||||
if not self.has_cover or not self.pages:
|
||||
return
|
||||
|
||||
# Find cover page (should be first page)
|
||||
cover_page = None
|
||||
for page in self.pages:
|
||||
if page.is_cover:
|
||||
cover_page = page
|
||||
break
|
||||
|
||||
if not cover_page:
|
||||
return
|
||||
|
||||
# Get standard page dimensions
|
||||
page_width_mm, page_height_mm = self.page_size_mm
|
||||
|
||||
# Calculate spine width
|
||||
spine_width = self.calculate_spine_width()
|
||||
|
||||
# Calculate cover dimensions
|
||||
# Cover = Front + Spine + Back + Bleed on all sides
|
||||
cover_width = (page_width_mm * 2) + spine_width + (self.cover_bleed_mm * 2)
|
||||
cover_height = page_height_mm + (self.cover_bleed_mm * 2)
|
||||
|
||||
# Update cover page layout
|
||||
cover_page.layout.size = (cover_width, cover_height)
|
||||
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})"
|
||||
)
|
||||
|
||||
def get_page_display_name(self, page: Page) -> str:
|
||||
"""
|
||||
Get display name for a page.
|
||||
|
||||
Args:
|
||||
page: The page to get the display name for
|
||||
|
||||
Returns:
|
||||
Display name like "Cover", "Page 1", "Pages 1-2", etc.
|
||||
"""
|
||||
if page.is_cover:
|
||||
return "Cover"
|
||||
|
||||
# Calculate adjusted page number (excluding cover from count)
|
||||
adjusted_num = page.page_number
|
||||
if self.has_cover:
|
||||
# Subtract 1 to account for cover
|
||||
adjusted_num = page.page_number - 1
|
||||
|
||||
if page.is_double_spread:
|
||||
return f"Pages {adjusted_num}-{adjusted_num + 1}"
|
||||
else:
|
||||
return f"Page {adjusted_num}"
|
||||
|
||||
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.
|
||||
|
||||
Returns:
|
||||
List of tuples (page_type, page_or_ghost, logical_position)
|
||||
where page_type is 'page' or 'ghost',
|
||||
page_or_ghost is the Page object or None for ghost,
|
||||
logical_position is the position in the album (1=right, 2=left, etc.)
|
||||
"""
|
||||
from pyPhotoAlbum.models import GhostPageData
|
||||
|
||||
layout = []
|
||||
current_position = 1 # Start at position 1 (right page)
|
||||
|
||||
for page in self.pages:
|
||||
# Skip cover in spread calculations
|
||||
if page.is_cover:
|
||||
# Cover is rendered separately, doesn't participate in spreads
|
||||
continue
|
||||
# Check if we need a ghost page for alignment
|
||||
# Ghost pages are needed when a single page would appear on the left
|
||||
# but should be on the right (odd positions)
|
||||
if not page.is_double_spread and current_position % 2 == 0:
|
||||
# Current position is even (left page), but we have a single page
|
||||
# This is fine - single page goes on left
|
||||
pass
|
||||
elif not page.is_double_spread and current_position % 2 == 1:
|
||||
# Current position is odd (right page), single page is fine
|
||||
pass
|
||||
|
||||
# Actually, let me reconsider the logic:
|
||||
# In a photobook:
|
||||
# - Position 1 is the right page (when opened, first content page)
|
||||
# - Position 2 is the left page of the next spread
|
||||
# - Position 3 is the right page of the next spread
|
||||
# - etc.
|
||||
#
|
||||
# Double spreads occupy TWO positions (both left and right of a spread)
|
||||
# They must start on an even position (left side) so they span across both pages
|
||||
|
||||
# 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))
|
||||
current_position += 1
|
||||
|
||||
# Add the actual page
|
||||
layout.append(("page", page, current_position))
|
||||
|
||||
# Update position based on page type
|
||||
if page.is_double_spread:
|
||||
current_position += 2 # Double spread takes 2 positions
|
||||
else:
|
||||
current_position += 1 # Single page takes 1 position
|
||||
|
||||
return layout
|
||||
|
||||
def render_all_pages(self):
|
||||
"""Render all pages in the project"""
|
||||
for page in self.pages:
|
||||
page.render()
|
||||
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
"""Serialize entire project to dictionary"""
|
||||
return {
|
||||
"name": self.name,
|
||||
"folder_path": self.folder_path,
|
||||
"default_min_distance": self.default_min_distance,
|
||||
"cover_size": self.cover_size,
|
||||
"page_size": self.page_size,
|
||||
"page_size_mm": self.page_size_mm,
|
||||
"working_dpi": self.working_dpi,
|
||||
"export_dpi": self.export_dpi,
|
||||
"page_spacing_mm": self.page_spacing_mm,
|
||||
"has_cover": self.has_cover,
|
||||
"paper_thickness_mm": self.paper_thickness_mm,
|
||||
"cover_bleed_mm": self.cover_bleed_mm,
|
||||
"binding_type": self.binding_type,
|
||||
"embedded_templates": self.embedded_templates,
|
||||
"snap_to_grid": self.snap_to_grid,
|
||||
"snap_to_edges": self.snap_to_edges,
|
||||
"snap_to_guides": self.snap_to_guides,
|
||||
"grid_size_mm": self.grid_size_mm,
|
||||
"snap_threshold_mm": self.snap_threshold_mm,
|
||||
"show_grid": self.show_grid,
|
||||
"show_snap_lines": self.show_snap_lines,
|
||||
"pages": [page.serialize() for page in self.pages],
|
||||
"history": self.history.serialize(),
|
||||
"asset_manager": self.asset_manager.serialize(),
|
||||
# v3.0+ fields
|
||||
"project_id": self.project_id,
|
||||
"created": self.created,
|
||||
"last_modified": self.last_modified,
|
||||
}
|
||||
|
||||
def deserialize(self, data: Dict[str, Any]):
|
||||
"""Deserialize from dictionary"""
|
||||
self.name = data.get("name", "Untitled Project")
|
||||
self.folder_path = data.get("folder_path", os.path.join("./projects", self.name.replace(" ", "_")))
|
||||
self.default_min_distance = data.get("default_min_distance", 10.0)
|
||||
self.cover_size = tuple(data.get("cover_size", (800, 600)))
|
||||
self.page_size = tuple(data.get("page_size", (800, 600)))
|
||||
self.page_size_mm = tuple(data.get("page_size_mm", (210, 297)))
|
||||
self.working_dpi = data.get("working_dpi", 300)
|
||||
self.export_dpi = data.get("export_dpi", 300)
|
||||
self.page_spacing_mm = data.get("page_spacing_mm", 10.0)
|
||||
self.has_cover = data.get("has_cover", False)
|
||||
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")
|
||||
|
||||
# Deserialize embedded templates
|
||||
self.embedded_templates = data.get("embedded_templates", {})
|
||||
|
||||
# Deserialize global snapping settings
|
||||
self.snap_to_grid = data.get("snap_to_grid", False)
|
||||
self.snap_to_edges = data.get("snap_to_edges", True)
|
||||
self.snap_to_guides = data.get("snap_to_guides", True)
|
||||
self.grid_size_mm = data.get("grid_size_mm", 10.0)
|
||||
self.snap_threshold_mm = data.get("snap_threshold_mm", 5.0)
|
||||
self.show_grid = data.get("show_grid", False)
|
||||
self.show_snap_lines = data.get("show_snap_lines", True)
|
||||
|
||||
# v3.0+ fields (with backwards compatibility)
|
||||
self.project_id = data.get("project_id", str(uuid.uuid4()))
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
self.created = data.get("created", now)
|
||||
self.last_modified = data.get("last_modified", now)
|
||||
|
||||
self.pages = []
|
||||
|
||||
# Deserialize asset manager
|
||||
self.asset_manager = AssetManager(self.folder_path)
|
||||
asset_data = data.get("asset_manager")
|
||||
if asset_data:
|
||||
self.asset_manager.deserialize(asset_data)
|
||||
|
||||
# Deserialize pages
|
||||
for page_data in data.get("pages", []):
|
||||
page = Page()
|
||||
page.deserialize(page_data)
|
||||
self.pages.append(page)
|
||||
|
||||
# Deserialize command history with asset manager and project reference
|
||||
history_data = data.get("history")
|
||||
if history_data:
|
||||
self.history = CommandHistory(max_history=100, asset_manager=self.asset_manager, project=self)
|
||||
self.history.deserialize(history_data, self)
|
||||
else:
|
||||
self.history = CommandHistory(max_history=100, asset_manager=self.asset_manager, project=self)
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleanup project resources, including temporary directories.
|
||||
Should be called when the project is closed or no longer needed.
|
||||
"""
|
||||
if self._temp_dir is not None:
|
||||
try:
|
||||
# Let TemporaryDirectory clean itself up
|
||||
temp_path = self._temp_dir.name
|
||||
self._temp_dir.cleanup()
|
||||
self._temp_dir = None
|
||||
print(f"Cleaned up temporary project directory: {temp_path}")
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to cleanup temporary directory: {e}")
|
||||
|
||||
def __del__(self):
|
||||
"""Destructor to ensure cleanup happens when project is deleted."""
|
||||
self.cleanup()
|
||||
439
pyPhotoAlbum/project_serializer.py
Normal file
@ -0,0 +1,439 @@
|
||||
"""
|
||||
Project serialization to/from ZIP files for pyPhotoAlbum
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import zipfile
|
||||
import shutil
|
||||
import tempfile
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
# Legacy constant for backward compatibility
|
||||
SERIALIZATION_VERSION = CURRENT_DATA_VERSION
|
||||
|
||||
|
||||
def _import_external_images(project: Project):
|
||||
"""
|
||||
Find and import any images that have external (absolute or non-assets) paths.
|
||||
This ensures all images are in the assets folder before saving.
|
||||
|
||||
Args:
|
||||
project: The Project instance to check
|
||||
"""
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
imported_count = 0
|
||||
|
||||
for page in project.pages:
|
||||
for element in page.layout.elements:
|
||||
if isinstance(element, ImageData) and element.image_path:
|
||||
# Check if this is an external path (absolute or not in assets/)
|
||||
is_external = False
|
||||
|
||||
if os.path.isabs(element.image_path):
|
||||
# Absolute path - definitely external
|
||||
is_external = True
|
||||
external_path = element.image_path
|
||||
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)
|
||||
if os.path.exists(full_path) and not full_path.startswith(project.asset_manager.assets_folder):
|
||||
is_external = True
|
||||
external_path = full_path
|
||||
else:
|
||||
# Path doesn't exist - skip it (will be caught as missing asset)
|
||||
continue
|
||||
else:
|
||||
# Already in assets/ folder
|
||||
continue
|
||||
|
||||
# Import the external image
|
||||
if is_external and os.path.exists(external_path):
|
||||
try:
|
||||
new_asset_path = project.asset_manager.import_asset(external_path)
|
||||
element.image_path = new_asset_path
|
||||
imported_count += 1
|
||||
print(f"Auto-imported external image: {external_path} → {new_asset_path}")
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to import external image {external_path}: {e}")
|
||||
|
||||
if imported_count > 0:
|
||||
print(f"Auto-imported {imported_count} external image(s) to assets folder")
|
||||
|
||||
|
||||
def _normalize_asset_paths(project: Project, project_folder: str):
|
||||
"""
|
||||
Normalize asset paths in a loaded project to be relative to the project folder.
|
||||
This fixes legacy projects that may have absolute paths or paths relative to old locations.
|
||||
|
||||
Args:
|
||||
project: The Project instance to normalize
|
||||
project_folder: The current project folder path
|
||||
"""
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
normalized_count = 0
|
||||
|
||||
for page in project.pages:
|
||||
for element in page.layout.elements:
|
||||
if isinstance(element, ImageData) and element.image_path:
|
||||
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/"):
|
||||
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 len(parts) == 2:
|
||||
new_path = os.path.join("assets", parts[1])
|
||||
element.image_path = new_path
|
||||
normalized_count += 1
|
||||
print(f"Normalized path: {original_path} -> {new_path}")
|
||||
continue
|
||||
|
||||
# Pattern 2: Absolute path - try to make it relative if it's in the extraction folder
|
||||
if os.path.isabs(original_path):
|
||||
try:
|
||||
new_path = os.path.relpath(original_path, project_folder)
|
||||
element.image_path = new_path
|
||||
normalized_count += 1
|
||||
print(f"Normalized absolute path: {original_path} -> {new_path}")
|
||||
except ValueError:
|
||||
# Can't make relative (different drives on Windows, etc.)
|
||||
pass
|
||||
|
||||
if normalized_count > 0:
|
||||
print(f"Normalized {normalized_count} asset paths")
|
||||
|
||||
|
||||
def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Save a project to a ZIP file, including all assets.
|
||||
|
||||
Args:
|
||||
project: The Project instance to save
|
||||
zip_path: Path where the ZIP file should be created
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, error_message: Optional[str])
|
||||
"""
|
||||
try:
|
||||
# Ensure .ppz extension
|
||||
if not zip_path.lower().endswith(".ppz"):
|
||||
zip_path += ".ppz"
|
||||
|
||||
# Check for and import any external images before saving
|
||||
_import_external_images(project)
|
||||
|
||||
# Serialize project to dictionary
|
||||
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
|
||||
|
||||
# Create ZIP file
|
||||
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)
|
||||
|
||||
# Add all files from the assets folder
|
||||
assets_folder = project.asset_manager.assets_folder
|
||||
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)
|
||||
# Store with relative path from project folder
|
||||
arcname = os.path.relpath(file_path, project.folder_path)
|
||||
zipf.write(file_path, arcname)
|
||||
|
||||
print(f"Project saved to {zip_path}")
|
||||
return True, None
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error saving project: {str(e)}"
|
||||
print(error_msg)
|
||||
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)
|
||||
"""
|
||||
def _background_save():
|
||||
"""Background thread function to create the ZIP file."""
|
||||
temp_dir = None
|
||||
try:
|
||||
# Report progress: Starting
|
||||
if on_progress:
|
||||
on_progress(0, "Preparing to save...")
|
||||
|
||||
# Ensure .ppz extension
|
||||
final_zip_path = zip_path
|
||||
if not final_zip_path.lower().endswith(".ppz"):
|
||||
final_zip_path += ".ppz"
|
||||
|
||||
# Check for and import any external images before saving
|
||||
if on_progress:
|
||||
on_progress(5, "Checking for external images...")
|
||||
_import_external_images(project)
|
||||
|
||||
# Serialize project to dictionary
|
||||
if on_progress:
|
||||
on_progress(10, "Serializing project data...")
|
||||
project_data = project.serialize()
|
||||
|
||||
# Add version information
|
||||
project_data["serialization_version"] = SERIALIZATION_VERSION
|
||||
project_data["data_version"] = CURRENT_DATA_VERSION
|
||||
|
||||
# Create a temporary directory for staging
|
||||
if on_progress:
|
||||
on_progress(15, "Creating temporary staging area...")
|
||||
temp_dir = tempfile.mkdtemp(prefix="pyPhotoAlbum_save_")
|
||||
|
||||
# Write project.json to temp directory
|
||||
if on_progress:
|
||||
on_progress(20, "Writing project metadata...")
|
||||
temp_project_json = os.path.join(temp_dir, "project.json")
|
||||
with open(temp_project_json, "w") as f:
|
||||
json.dump(project_data, f, indent=2, sort_keys=True)
|
||||
|
||||
# Create temp ZIP file (not final location - for atomic write)
|
||||
temp_zip_path = os.path.join(temp_dir, "project.ppz")
|
||||
|
||||
# Count assets for progress reporting
|
||||
assets_folder = project.asset_manager.assets_folder
|
||||
total_files = 1 # project.json
|
||||
asset_files = []
|
||||
if os.path.exists(assets_folder):
|
||||
for root, dirs, files in os.walk(assets_folder):
|
||||
for file in files:
|
||||
asset_files.append((root, file))
|
||||
total_files += 1
|
||||
|
||||
if on_progress:
|
||||
on_progress(25, f"Creating ZIP archive ({total_files} files)...")
|
||||
|
||||
# Create ZIP file in temp location
|
||||
with zipfile.ZipFile(temp_zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
|
||||
# Write project.json
|
||||
zipf.write(temp_project_json, "project.json")
|
||||
|
||||
# Add all assets with progress reporting
|
||||
if asset_files:
|
||||
# Progress from 25% to 90% for assets
|
||||
progress_range = 90 - 25
|
||||
for idx, (root, file) in enumerate(asset_files):
|
||||
file_path = os.path.join(root, file)
|
||||
arcname = os.path.relpath(file_path, project.folder_path)
|
||||
zipf.write(file_path, arcname)
|
||||
|
||||
# Report progress every 10 files or at end
|
||||
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)})"
|
||||
)
|
||||
|
||||
# Atomic move: move temp ZIP to final location
|
||||
if on_progress:
|
||||
on_progress(95, "Finalizing save...")
|
||||
|
||||
# Ensure parent directory exists
|
||||
os.makedirs(os.path.dirname(os.path.abspath(final_zip_path)), exist_ok=True)
|
||||
|
||||
# Remove old file if it exists
|
||||
if os.path.exists(final_zip_path):
|
||||
os.remove(final_zip_path)
|
||||
|
||||
# Move temp ZIP to final location (atomic on same filesystem)
|
||||
shutil.move(temp_zip_path, final_zip_path)
|
||||
|
||||
if on_progress:
|
||||
on_progress(100, "Save complete!")
|
||||
|
||||
print(f"Project saved to {final_zip_path}")
|
||||
|
||||
# Call completion callback with success
|
||||
if on_complete:
|
||||
on_complete(True, None)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error saving project: {str(e)}"
|
||||
print(error_msg)
|
||||
|
||||
# Call completion callback with error
|
||||
if on_complete:
|
||||
on_complete(False, error_msg)
|
||||
|
||||
finally:
|
||||
# Clean up temp directory
|
||||
if temp_dir and os.path.exists(temp_dir):
|
||||
try:
|
||||
shutil.rmtree(temp_dir)
|
||||
except Exception:
|
||||
pass # Ignore cleanup errors
|
||||
|
||||
# Start background thread
|
||||
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.
|
||||
|
||||
Args:
|
||||
zip_path: Path to the ZIP file to load
|
||||
extract_to: Optional directory to extract to. If None, uses a temporary
|
||||
directory that will be cleaned up when the project is closed.
|
||||
|
||||
Returns:
|
||||
Project instance (raises exception on error)
|
||||
"""
|
||||
if not os.path.exists(zip_path):
|
||||
raise FileNotFoundError(f"ZIP file not found: {zip_path}")
|
||||
|
||||
# Track if we created a temp directory
|
||||
temp_dir_obj = None
|
||||
|
||||
# Determine extraction directory
|
||||
if extract_to is None:
|
||||
# Create a temporary directory using TemporaryDirectory
|
||||
# This will be attached to the Project and auto-cleaned on deletion
|
||||
zip_basename = os.path.splitext(os.path.basename(zip_path))[0]
|
||||
temp_dir_obj = tempfile.TemporaryDirectory(prefix=f"pyPhotoAlbum_{zip_basename}_")
|
||||
extract_to = temp_dir_obj.name
|
||||
else:
|
||||
# Create extraction directory if it doesn't exist
|
||||
os.makedirs(extract_to, exist_ok=True)
|
||||
|
||||
# Extract ZIP contents
|
||||
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")
|
||||
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:
|
||||
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"))
|
||||
|
||||
# Check if version is compatible
|
||||
is_compatible, error_msg = check_version_compatibility(file_version, zip_path)
|
||||
if not is_compatible:
|
||||
raise ValueError(error_msg)
|
||||
|
||||
# Apply migrations if needed
|
||||
if VersionCompatibility.needs_migration(file_version):
|
||||
print(f"Migrating project from version {file_version} to {CURRENT_DATA_VERSION}...")
|
||||
project_data = DataMigration.migrate(project_data, file_version, CURRENT_DATA_VERSION)
|
||||
print(f"Migration completed successfully")
|
||||
elif file_version != CURRENT_DATA_VERSION:
|
||||
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 = Project(name=project_name, folder_path=extract_to)
|
||||
|
||||
# Deserialize project data
|
||||
project.deserialize(project_data)
|
||||
|
||||
# Update folder path to extraction location
|
||||
project.folder_path = extract_to
|
||||
project.asset_manager.project_folder = extract_to
|
||||
project.asset_manager.assets_folder = os.path.join(extract_to, "assets")
|
||||
|
||||
# Attach temporary directory to project (if we created one)
|
||||
# The TemporaryDirectory will auto-cleanup when the project is deleted
|
||||
if temp_dir_obj is not None:
|
||||
project._temp_dir = temp_dir_obj
|
||||
print(f"Project loaded to temporary directory: {extract_to}")
|
||||
|
||||
# Normalize asset paths in all ImageData elements
|
||||
# This fixes old projects that have absolute or wrong relative paths
|
||||
_normalize_asset_paths(project, extract_to)
|
||||
|
||||
# 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}")
|
||||
return project
|
||||
|
||||
|
||||
def get_project_info(zip_path: str) -> Optional[dict]:
|
||||
"""
|
||||
Get basic information about a project without fully loading it.
|
||||
|
||||
Args:
|
||||
zip_path: Path to the ZIP file
|
||||
|
||||
Returns:
|
||||
Dictionary with project info, or None if error
|
||||
"""
|
||||
try:
|
||||
with zipfile.ZipFile(zip_path, "r") as zipf:
|
||||
# Read project.json
|
||||
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),
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error reading project info: {e}")
|
||||
return None
|
||||
6
pyPhotoAlbum/requirements.txt
Normal file
@ -0,0 +1,6 @@
|
||||
PyQt6>=6.0.0
|
||||
PyOpenGL>=3.1.0
|
||||
numpy>=1.20.0
|
||||
Pillow>=8.0.0
|
||||
reportlab>=3.5.0
|
||||
lxml>=4.6.0
|
||||
232
pyPhotoAlbum/ribbon_builder.py
Normal file
@ -0,0 +1,232 @@
|
||||
"""
|
||||
Ribbon configuration builder for pyPhotoAlbum
|
||||
|
||||
This module scans classes for methods decorated with @ribbon_action
|
||||
and automatically builds the ribbon configuration structure.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Any, Type
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
def build_ribbon_config(window_class: Type) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract decorated methods and build ribbon configuration.
|
||||
|
||||
This function scans all methods in the window class and its mixins
|
||||
for methods decorated with @ribbon_action, then builds a nested
|
||||
configuration structure suitable for the RibbonWidget.
|
||||
|
||||
Args:
|
||||
window_class: The MainWindow class with decorated methods
|
||||
|
||||
Returns:
|
||||
Dictionary containing the ribbon configuration with structure:
|
||||
{
|
||||
"TabName": {
|
||||
"groups": [
|
||||
{
|
||||
"name": "GroupName",
|
||||
"actions": [
|
||||
{
|
||||
"label": "Button Label",
|
||||
"action": "method_name",
|
||||
"tooltip": "Tooltip text",
|
||||
...
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
"""
|
||||
# Structure to collect actions by tab and group
|
||||
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):
|
||||
try:
|
||||
attr = getattr(window_class, attr_name)
|
||||
|
||||
# Check if this attribute has ribbon action metadata
|
||||
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"]
|
||||
|
||||
# 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"),
|
||||
}
|
||||
)
|
||||
except (AttributeError, TypeError):
|
||||
# Skip attributes that can't be inspected
|
||||
continue
|
||||
|
||||
# Convert to the expected ribbon config format
|
||||
ribbon_config = {}
|
||||
|
||||
# Define tab order (tabs will appear in this order)
|
||||
tab_order = ["Home", "Insert", "Layout", "Arrange", "Style", "View"]
|
||||
|
||||
# Add tabs in the defined order, then add any remaining tabs
|
||||
all_tabs = list(tabs.keys())
|
||||
ordered_tabs = [t for t in tab_order if t in all_tabs]
|
||||
ordered_tabs.extend([t for t in all_tabs if t not in tab_order])
|
||||
|
||||
for tab_name in ordered_tabs:
|
||||
groups_dict = tabs[tab_name]
|
||||
|
||||
# Convert groups dictionary to list format
|
||||
groups_list = []
|
||||
|
||||
# Define group order per tab (if needed)
|
||||
group_orders = {
|
||||
"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
|
||||
if tab_name in group_orders:
|
||||
group_order = group_orders[tab_name]
|
||||
# Add any groups not in the defined order
|
||||
all_groups = list(groups_dict.keys())
|
||||
group_order.extend([g for g in all_groups if g not in group_order])
|
||||
else:
|
||||
group_order = sorted(groups_dict.keys())
|
||||
|
||||
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})
|
||||
|
||||
ribbon_config[tab_name] = {"groups": groups_list}
|
||||
|
||||
return ribbon_config
|
||||
|
||||
|
||||
def get_keyboard_shortcuts(window_class: Type) -> Dict[str, str]:
|
||||
"""
|
||||
Extract keyboard shortcuts from decorated methods.
|
||||
|
||||
Args:
|
||||
window_class: The MainWindow class with decorated methods
|
||||
|
||||
Returns:
|
||||
Dictionary mapping shortcut strings to method names
|
||||
Example: {'Ctrl+N': 'new_project', 'Ctrl+S': 'save_project'}
|
||||
"""
|
||||
shortcuts = {}
|
||||
|
||||
for attr_name in dir(window_class):
|
||||
try:
|
||||
attr = getattr(window_class, attr_name)
|
||||
|
||||
if hasattr(attr, "_ribbon_action"):
|
||||
action_data = attr._ribbon_action
|
||||
shortcut = action_data.get("shortcut")
|
||||
|
||||
if shortcut:
|
||||
shortcuts[shortcut] = action_data["action"]
|
||||
except (AttributeError, TypeError):
|
||||
continue
|
||||
|
||||
return shortcuts
|
||||
|
||||
|
||||
def validate_ribbon_config(config: Dict[str, Any]) -> List[str]:
|
||||
"""
|
||||
Validate the ribbon configuration structure.
|
||||
|
||||
Args:
|
||||
config: The ribbon configuration dictionary
|
||||
|
||||
Returns:
|
||||
List of validation error messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
if not isinstance(config, dict):
|
||||
errors.append("Config must be a dictionary")
|
||||
return errors
|
||||
|
||||
for tab_name, tab_data in config.items():
|
||||
if not isinstance(tab_data, dict):
|
||||
errors.append(f"Tab '{tab_name}' data must be a dictionary")
|
||||
continue
|
||||
|
||||
if "groups" not in tab_data:
|
||||
errors.append(f"Tab '{tab_name}' missing 'groups' key")
|
||||
continue
|
||||
|
||||
groups = tab_data["groups"]
|
||||
if not isinstance(groups, list):
|
||||
errors.append(f"Tab '{tab_name}' groups must be a list")
|
||||
continue
|
||||
|
||||
for i, group in enumerate(groups):
|
||||
if not isinstance(group, dict):
|
||||
errors.append(f"Tab '{tab_name}' group {i} must be a dictionary")
|
||||
continue
|
||||
|
||||
if "name" not in group:
|
||||
errors.append(f"Tab '{tab_name}' group {i} missing 'name'")
|
||||
|
||||
if "actions" not in group:
|
||||
errors.append(f"Tab '{tab_name}' group {i} missing 'actions'")
|
||||
continue
|
||||
|
||||
actions = group["actions"]
|
||||
if not isinstance(actions, list):
|
||||
errors.append(f"Tab '{tab_name}' group {i} actions must be a list")
|
||||
continue
|
||||
|
||||
for j, action in enumerate(actions):
|
||||
if not isinstance(action, dict):
|
||||
errors.append(f"Tab '{tab_name}' group {i} action {j} must be a dictionary")
|
||||
continue
|
||||
|
||||
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}'")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def print_ribbon_summary(config: Dict[str, Any]):
|
||||
"""
|
||||
Print a summary of the ribbon configuration.
|
||||
|
||||
Args:
|
||||
config: The ribbon configuration dictionary
|
||||
"""
|
||||
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"])
|
||||
|
||||
print(f"Total Tabs: {total_tabs}")
|
||||
print(f"Total Groups: {total_groups}")
|
||||
print(f"Total Actions: {total_actions}\n")
|
||||
|
||||
for tab_name, tab_data in config.items():
|
||||
print(f"📑 {tab_name}")
|
||||
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 ""
|
||||
print(f" • {action['label']}{shortcut}")
|
||||
print()
|
||||
122
pyPhotoAlbum/ribbon_widget.py
Normal file
@ -0,0 +1,122 @@
|
||||
"""
|
||||
Ribbon widget for pyPhotoAlbum
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import QWidget, QTabWidget, QHBoxLayout, QVBoxLayout, QPushButton, QLabel, QFrame, QGridLayout
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
|
||||
class RibbonWidget(QWidget):
|
||||
"""A ribbon-style toolbar using QTabWidget"""
|
||||
|
||||
def __init__(self, main_window, ribbon_config=None, buttons_per_row=4, parent=None):
|
||||
super().__init__(parent)
|
||||
self.main_window = main_window
|
||||
self.buttons_per_row = buttons_per_row # Default to 4 buttons per row
|
||||
|
||||
# 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
|
||||
|
||||
# Main layout
|
||||
main_layout = QVBoxLayout()
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.setSpacing(0)
|
||||
self.setLayout(main_layout)
|
||||
|
||||
# Create tab widget
|
||||
self.tab_widget = QTabWidget()
|
||||
self.tab_widget.setDocumentMode(True)
|
||||
main_layout.addWidget(self.tab_widget)
|
||||
|
||||
# Build ribbon from config
|
||||
self._build_ribbon()
|
||||
|
||||
def _build_ribbon(self):
|
||||
"""Build the ribbon UI from configuration"""
|
||||
for tab_name, tab_config in self.ribbon_config.items():
|
||||
tab_widget = self._create_tab(tab_config)
|
||||
self.tab_widget.addTab(tab_widget, tab_name)
|
||||
|
||||
def _create_tab(self, tab_config):
|
||||
"""Create a tab widget with groups and actions"""
|
||||
tab_widget = QWidget()
|
||||
tab_layout = QHBoxLayout()
|
||||
tab_layout.setContentsMargins(5, 5, 5, 5)
|
||||
tab_layout.setSpacing(10)
|
||||
tab_widget.setLayout(tab_layout)
|
||||
|
||||
# Create groups
|
||||
for group_config in tab_config.get("groups", []):
|
||||
group_widget = self._create_group(group_config)
|
||||
tab_layout.addWidget(group_widget)
|
||||
|
||||
# Add stretch to push groups to the left
|
||||
tab_layout.addStretch()
|
||||
|
||||
return tab_widget
|
||||
|
||||
def _create_group(self, group_config):
|
||||
"""Create a group of actions"""
|
||||
group_widget = QFrame()
|
||||
group_layout = QVBoxLayout()
|
||||
group_layout.setContentsMargins(5, 5, 5, 5)
|
||||
group_layout.setSpacing(5)
|
||||
group_widget.setLayout(group_layout)
|
||||
|
||||
# Create actions grid layout
|
||||
actions_layout = QGridLayout()
|
||||
actions_layout.setSpacing(5)
|
||||
|
||||
# Get buttons per row from group config or use default
|
||||
buttons_per_row = group_config.get("buttons_per_row", self.buttons_per_row)
|
||||
|
||||
# Add buttons to grid
|
||||
actions = group_config.get("actions", [])
|
||||
for i, action_config in enumerate(actions):
|
||||
button = self._create_action_button(action_config)
|
||||
row = i // buttons_per_row
|
||||
col = i % buttons_per_row
|
||||
actions_layout.addWidget(button, row, col)
|
||||
|
||||
group_layout.addLayout(actions_layout)
|
||||
|
||||
# Add group label
|
||||
group_label = QLabel(group_config.get("name", ""))
|
||||
group_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
group_label.setStyleSheet("font-size: 10px; color: gray;")
|
||||
group_layout.addWidget(group_label)
|
||||
|
||||
# Add separator frame
|
||||
group_widget.setFrameShape(QFrame.Shape.Box)
|
||||
group_widget.setFrameShadow(QFrame.Shadow.Sunken)
|
||||
group_widget.setLineWidth(1)
|
||||
|
||||
return group_widget
|
||||
|
||||
def _create_action_button(self, action_config):
|
||||
"""Create a button for an action"""
|
||||
button = QPushButton(action_config.get("label", ""))
|
||||
button.setToolTip(action_config.get("tooltip", ""))
|
||||
button.setMinimumSize(60, 40)
|
||||
|
||||
# Connect to action
|
||||
action_name = action_config.get("action")
|
||||
if 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
|
||||
|
||||
def _execute_action(self, action_name):
|
||||
"""Execute an action by calling the corresponding method on main window"""
|
||||
if hasattr(self.main_window, action_name):
|
||||
method = getattr(self.main_window, action_name)
|
||||
if callable(method):
|
||||
method()
|
||||
else:
|
||||
print(f"Warning: Action '{action_name}' not implemented in main window")
|
||||
441
pyPhotoAlbum/snapping.py
Normal file
@ -0,0 +1,441 @@
|
||||
"""
|
||||
Snapping system for pyPhotoAlbum
|
||||
Provides grid snapping, edge snapping, and custom guide snapping
|
||||
"""
|
||||
|
||||
import math
|
||||
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}
|
||||
|
||||
@staticmethod
|
||||
def deserialize(data: dict) -> "Guide":
|
||||
"""Deserialize guide from dictionary"""
|
||||
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
|
||||
dy: float
|
||||
resize_handle: str
|
||||
page_size: Tuple[float, float]
|
||||
dpi: int = 300
|
||||
project: Optional[Any] = None
|
||||
|
||||
|
||||
class SnappingSystem:
|
||||
"""Manages snapping behavior for layout elements"""
|
||||
|
||||
def __init__(self, snap_threshold_mm: float = 5.0):
|
||||
"""
|
||||
Initialize snapping system
|
||||
|
||||
Args:
|
||||
snap_threshold_mm: Distance in mm within which snapping occurs
|
||||
"""
|
||||
self.snap_threshold_mm = snap_threshold_mm
|
||||
self.grid_size_mm = 10.0 # Grid spacing in mm
|
||||
self.snap_to_grid = False
|
||||
self.snap_to_edges = True
|
||||
self.snap_to_guides = True
|
||||
self.guides: List[Guide] = []
|
||||
|
||||
def add_guide(self, position: float, orientation: str):
|
||||
"""Add a new guide"""
|
||||
guide = Guide(position=position, orientation=orientation)
|
||||
self.guides.append(guide)
|
||||
return guide
|
||||
|
||||
def remove_guide(self, guide: Guide):
|
||||
"""Remove a guide"""
|
||||
if guide in self.guides:
|
||||
self.guides.remove(guide)
|
||||
|
||||
def clear_guides(self):
|
||||
"""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]:
|
||||
"""
|
||||
Apply snapping to a position using combined distance threshold
|
||||
|
||||
Args:
|
||||
position: Current position (x, y) in pixels
|
||||
size: Element size (width, height) in pixels
|
||||
page_size: Page size (width, height) in mm
|
||||
dpi: DPI for conversion
|
||||
project: Optional project for global snapping settings
|
||||
|
||||
Returns:
|
||||
Snapped position (x, y) in pixels
|
||||
"""
|
||||
x, y = position
|
||||
width, height = size
|
||||
page_width_mm, page_height_mm = page_size
|
||||
|
||||
# Use project settings if available, otherwise use local settings
|
||||
if project:
|
||||
snap_to_grid = project.snap_to_grid
|
||||
snap_to_edges = project.snap_to_edges
|
||||
snap_to_guides = project.snap_to_guides
|
||||
grid_size_mm = project.grid_size_mm
|
||||
snap_threshold_mm = project.snap_threshold_mm
|
||||
else:
|
||||
snap_to_grid = self.snap_to_grid
|
||||
snap_to_edges = self.snap_to_edges
|
||||
snap_to_guides = self.snap_to_guides
|
||||
grid_size_mm = self.grid_size_mm
|
||||
snap_threshold_mm = self.snap_threshold_mm
|
||||
|
||||
# Convert threshold from mm to pixels
|
||||
snap_threshold_px = snap_threshold_mm * dpi / 25.4
|
||||
|
||||
# Collect all potential snap points for both edges of the element
|
||||
snap_points = []
|
||||
|
||||
# 1. Page edge snap points
|
||||
if snap_to_edges:
|
||||
page_width_px = page_width_mm * dpi / 25.4
|
||||
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
|
||||
]
|
||||
)
|
||||
|
||||
# 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
|
||||
]
|
||||
)
|
||||
|
||||
# 2. Grid snap points
|
||||
if snap_to_grid:
|
||||
grid_size_px = grid_size_mm * dpi / 25.4
|
||||
page_width_px = page_width_mm * dpi / 25.4
|
||||
page_height_px = page_height_mm * dpi / 25.4
|
||||
|
||||
# Calculate grid intersection points within range
|
||||
x_start = max(0, int((x - snap_threshold_px) / grid_size_px)) * grid_size_px
|
||||
x_end = min(page_width_px, int((x + snap_threshold_px) / grid_size_px + 1) * grid_size_px)
|
||||
y_start = max(0, int((y - snap_threshold_px) / grid_size_px)) * grid_size_px
|
||||
y_end = min(page_height_px, int((y + snap_threshold_px) / grid_size_px + 1) * grid_size_px)
|
||||
|
||||
grid_x = x_start
|
||||
while grid_x <= x_end:
|
||||
grid_y = y_start
|
||||
while grid_y <= y_end:
|
||||
snap_points.append((grid_x, grid_y))
|
||||
# Also snap element's far edge to grid
|
||||
if grid_x >= width:
|
||||
snap_points.append((grid_x - width, grid_y))
|
||||
if grid_y >= height:
|
||||
snap_points.append((grid_x, grid_y - height))
|
||||
grid_y += grid_size_px
|
||||
grid_x += grid_size_px
|
||||
|
||||
# 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"]
|
||||
|
||||
# Guide intersections (when both vertical and horizontal guides exist)
|
||||
for vg in vertical_guides:
|
||||
for hg in horizontal_guides:
|
||||
snap_points.append((vg, hg))
|
||||
# Also snap element's far edge to intersections
|
||||
snap_points.append((vg - width, hg))
|
||||
snap_points.append((vg, hg - height))
|
||||
snap_points.append((vg - width, hg - height))
|
||||
|
||||
# Find the nearest snap point using Euclidean distance
|
||||
best_snap_point = None
|
||||
best_distance = snap_threshold_px
|
||||
|
||||
for snap_x, snap_y in snap_points:
|
||||
distance = math.sqrt((x - snap_x) ** 2 + (y - snap_y) ** 2)
|
||||
if distance < best_distance:
|
||||
best_snap_point = (snap_x, snap_y)
|
||||
best_distance = distance
|
||||
|
||||
# Return snapped position or original position
|
||||
if best_snap_point:
|
||||
return best_snap_point
|
||||
else:
|
||||
return (x, y)
|
||||
|
||||
def snap_resize(self, params: SnapResizeParams) -> Tuple[Tuple[float, float], Tuple[float, float]]:
|
||||
"""
|
||||
Apply snapping during resize operations
|
||||
|
||||
Args:
|
||||
params: SnapResizeParams containing all resize parameters
|
||||
|
||||
Returns:
|
||||
Tuple of (snapped_position, snapped_size) in pixels
|
||||
"""
|
||||
x, y = params.position
|
||||
width, height = params.size
|
||||
page_width_mm, page_height_mm = params.page_size
|
||||
|
||||
# Use project settings if available, otherwise use local settings
|
||||
if params.project:
|
||||
snap_threshold_mm = params.project.snap_threshold_mm
|
||||
else:
|
||||
snap_threshold_mm = self.snap_threshold_mm
|
||||
|
||||
# Convert threshold from mm to pixels
|
||||
snap_threshold_px = snap_threshold_mm * params.dpi / 25.4
|
||||
|
||||
# Calculate new position and size based on resize handle
|
||||
new_x, new_y = x, y
|
||||
new_width, new_height = width, height
|
||||
|
||||
# Apply resize based on handle
|
||||
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"]:
|
||||
# Bottom edge moving
|
||||
new_height = height + params.dy
|
||||
|
||||
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"]:
|
||||
# Right edge moving
|
||||
new_width = width + params.dx
|
||||
|
||||
# Now apply snapping to the edges that are being moved
|
||||
# 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"]:
|
||||
# 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
|
||||
)
|
||||
if snapped_left is not None:
|
||||
# Adjust width to compensate for position change
|
||||
width_adjustment = new_x - snapped_left
|
||||
new_x = snapped_left
|
||||
new_width += width_adjustment
|
||||
|
||||
# Snap right edge (for ne, e, se handles)
|
||||
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
|
||||
)
|
||||
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"]:
|
||||
# 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
|
||||
)
|
||||
if snapped_top is not None:
|
||||
# Adjust height to compensate for position change
|
||||
height_adjustment = new_y - snapped_top
|
||||
new_y = snapped_top
|
||||
new_height += height_adjustment
|
||||
|
||||
# Snap bottom edge (for sw, s, se handles)
|
||||
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
|
||||
)
|
||||
if snapped_bottom is not None:
|
||||
new_height = snapped_bottom - new_y
|
||||
|
||||
# Ensure minimum size
|
||||
min_size = 10 # Minimum 10 pixels
|
||||
new_width = max(new_width, min_size)
|
||||
new_height = max(new_height, min_size)
|
||||
|
||||
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]:
|
||||
"""
|
||||
Snap an edge position to available targets (grid, edges, guides)
|
||||
|
||||
Args:
|
||||
edge_position: Current edge position 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
|
||||
project: Optional project for global snapping settings
|
||||
|
||||
Returns:
|
||||
Snapped edge position in pixels, or None if no snap
|
||||
"""
|
||||
# Use project settings if available, otherwise use local settings
|
||||
if project:
|
||||
snap_to_grid = project.snap_to_grid
|
||||
snap_to_edges = project.snap_to_edges
|
||||
snap_to_guides = project.snap_to_guides
|
||||
grid_size_mm = project.grid_size_mm
|
||||
else:
|
||||
snap_to_grid = self.snap_to_grid
|
||||
snap_to_edges = self.snap_to_edges
|
||||
snap_to_guides = self.snap_to_guides
|
||||
grid_size_mm = self.grid_size_mm
|
||||
|
||||
snap_candidates: List[Tuple[float, float]] = []
|
||||
|
||||
# 1. Page edge snapping
|
||||
if snap_to_edges:
|
||||
# Snap to start edge (0)
|
||||
snap_candidates.append((0.0, abs(edge_position - 0)))
|
||||
|
||||
# Snap to end edge
|
||||
page_size_px = page_size_mm * dpi / 25.4
|
||||
snap_candidates.append((page_size_px, abs(edge_position - page_size_px)))
|
||||
|
||||
# 2. Grid snapping
|
||||
if snap_to_grid:
|
||||
grid_size_px = grid_size_mm * dpi / 25.4
|
||||
|
||||
# Snap to nearest grid line
|
||||
nearest_grid = round(edge_position / grid_size_px) * grid_size_px
|
||||
snap_candidates.append((nearest_grid, abs(edge_position - nearest_grid)))
|
||||
|
||||
# 3. Guide snapping
|
||||
if snap_to_guides:
|
||||
for guide in self.guides:
|
||||
if guide.orientation == orientation:
|
||||
guide_pos_px = guide.position * dpi / 25.4
|
||||
snap_candidates.append((guide_pos_px, abs(edge_position - 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
|
||||
|
||||
def get_snap_lines(self, page_size: Tuple[float, float], dpi: int = 300) -> dict:
|
||||
"""
|
||||
Get all snap lines for visualization
|
||||
|
||||
Args:
|
||||
page_size: Page size (width, height) in mm
|
||||
dpi: DPI for conversion
|
||||
|
||||
Returns:
|
||||
Dictionary with 'grid', 'edges', and 'guides' lists
|
||||
"""
|
||||
page_width_mm, page_height_mm = page_size
|
||||
page_width_px = page_width_mm * dpi / 25.4
|
||||
page_height_px = page_height_mm * dpi / 25.4
|
||||
|
||||
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: float = 0
|
||||
while x <= page_width_px:
|
||||
result["grid"].append(("vertical", x))
|
||||
x += grid_size_px
|
||||
|
||||
# Horizontal grid lines
|
||||
y: float = 0
|
||||
while y <= page_height_px:
|
||||
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)]
|
||||
)
|
||||
|
||||
# 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))
|
||||
|
||||
return result
|
||||
|
||||
def serialize(self) -> dict:
|
||||
"""Serialize snapping system to dictionary"""
|
||||
return {
|
||||
"snap_threshold_mm": self.snap_threshold_mm,
|
||||
"grid_size_mm": self.grid_size_mm,
|
||||
"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],
|
||||
}
|
||||
|
||||
def deserialize(self, data: dict):
|
||||
"""Deserialize from dictionary"""
|
||||
self.snap_threshold_mm = data.get("snap_threshold_mm", 5.0)
|
||||
self.grid_size_mm = data.get("grid_size_mm", 10.0)
|
||||
self.snap_to_grid = data.get("snap_to_grid", False)
|
||||
self.snap_to_edges = data.get("snap_to_edges", True)
|
||||
self.snap_to_guides = data.get("snap_to_guides", True)
|
||||
|
||||
self.guides = []
|
||||
for guide_data in data.get("guides", []):
|
||||
self.guides.append(Guide.deserialize(guide_data))
|
||||
488
pyPhotoAlbum/template_manager.py
Normal file
@ -0,0 +1,488 @@
|
||||
"""
|
||||
Template management system for pyPhotoAlbum
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Tuple, Optional
|
||||
from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData
|
||||
from pyPhotoAlbum.page_layout import PageLayout
|
||||
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)
|
||||
):
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.page_size_mm = page_size_mm
|
||||
self.elements: List[BaseLayoutElement] = []
|
||||
|
||||
def add_element(self, element: BaseLayoutElement):
|
||||
"""Add an element to the template"""
|
||||
self.elements.append(element)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize template to dictionary"""
|
||||
return {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"page_size_mm": self.page_size_mm,
|
||||
"elements": [elem.serialize() for elem in self.elements],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
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))),
|
||||
)
|
||||
|
||||
# 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":
|
||||
elem = TextBoxData()
|
||||
else:
|
||||
continue # Skip image elements in templates
|
||||
|
||||
elem.deserialize(elem_data)
|
||||
template.add_element(elem)
|
||||
|
||||
return template
|
||||
|
||||
def save_to_file(self, file_path: str):
|
||||
"""Save template to JSON file"""
|
||||
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":
|
||||
"""Load template from JSON file"""
|
||||
with open(file_path, "r") as f:
|
||||
data = json.load(f)
|
||||
return cls.from_dict(data)
|
||||
|
||||
|
||||
class TemplateManager:
|
||||
"""Manager for template operations"""
|
||||
|
||||
def __init__(self, project=None):
|
||||
self.templates_dir = self._get_templates_directory()
|
||||
self._ensure_templates_directory()
|
||||
self.project = project # Optional project for embedded templates
|
||||
|
||||
def _get_templates_directory(self) -> Path:
|
||||
"""Get the templates directory path"""
|
||||
# User templates directory
|
||||
home = Path.home()
|
||||
templates_dir = home / ".pyphotoalbum" / "templates"
|
||||
return templates_dir
|
||||
|
||||
def _get_builtin_templates_directory(self) -> Path:
|
||||
"""Get the built-in templates directory path"""
|
||||
# Built-in templates in the application directory
|
||||
app_dir = Path(__file__).parent
|
||||
return app_dir / "templates"
|
||||
|
||||
def _ensure_templates_directory(self):
|
||||
"""Create templates directory if it doesn't exist"""
|
||||
self.templates_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Also ensure built-in templates directory exists
|
||||
builtin_dir = self._get_builtin_templates_directory()
|
||||
builtin_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def list_templates(self) -> List[str]:
|
||||
"""List all available template names (embedded + user + built-in)"""
|
||||
templates = []
|
||||
|
||||
# List embedded templates (priority)
|
||||
if self.project and self.project.embedded_templates:
|
||||
for template_name in self.project.embedded_templates.keys():
|
||||
templates.append(f"[Embedded] {template_name}")
|
||||
|
||||
# List user templates
|
||||
if self.templates_dir.exists():
|
||||
for file in self.templates_dir.glob("*.json"):
|
||||
templates.append(file.stem)
|
||||
|
||||
# List built-in templates
|
||||
builtin_dir = self._get_builtin_templates_directory()
|
||||
if builtin_dir.exists():
|
||||
for file in builtin_dir.glob("*.json"):
|
||||
template_name = f"[Built-in] {file.stem}"
|
||||
templates.append(template_name)
|
||||
|
||||
return sorted(templates)
|
||||
|
||||
def load_template(self, name: str) -> Template:
|
||||
"""
|
||||
Load a template by name with priority: embedded > user > built-in.
|
||||
|
||||
Args:
|
||||
name: Template name (may include prefix like '[Embedded]' or '[Built-in]')
|
||||
|
||||
Returns:
|
||||
Template instance
|
||||
"""
|
||||
# Check if it's an embedded template (priority)
|
||||
if name.startswith("[Embedded] "):
|
||||
actual_name = name.replace("[Embedded] ", "")
|
||||
if self.project and actual_name in self.project.embedded_templates:
|
||||
template_data = self.project.embedded_templates[actual_name]
|
||||
return Template.from_dict(template_data)
|
||||
raise FileNotFoundError(f"Embedded template '{actual_name}' not found")
|
||||
|
||||
# Check embedded templates even without prefix (for backward compatibility)
|
||||
if self.project and name in self.project.embedded_templates:
|
||||
template_data = self.project.embedded_templates[name]
|
||||
return Template.from_dict(template_data)
|
||||
|
||||
# Check if it's a built-in template
|
||||
if name.startswith("[Built-in] "):
|
||||
actual_name = name.replace("[Built-in] ", "")
|
||||
template_path = self._get_builtin_templates_directory() / f"{actual_name}.json"
|
||||
else:
|
||||
# User template
|
||||
template_path = self.templates_dir / f"{name}.json"
|
||||
|
||||
if not template_path.exists():
|
||||
raise FileNotFoundError(f"Template '{name}' not found")
|
||||
|
||||
return Template.load_from_file(str(template_path))
|
||||
|
||||
def save_template(self, template: Template, embed_in_project: bool = False):
|
||||
"""
|
||||
Save a template to filesystem or embed in project.
|
||||
|
||||
Args:
|
||||
template: Template to save
|
||||
embed_in_project: If True, embed in project instead of saving to filesystem
|
||||
"""
|
||||
if embed_in_project and self.project:
|
||||
# Embed in project
|
||||
self.project.embedded_templates[template.name] = template.to_dict()
|
||||
print(f"Embedded template '{template.name}' in project")
|
||||
else:
|
||||
# Save to filesystem
|
||||
template_path = self.templates_dir / f"{template.name}.json"
|
||||
template.save_to_file(str(template_path))
|
||||
|
||||
def delete_template(self, name: str):
|
||||
"""Delete a template (embedded or user templates only)"""
|
||||
if name.startswith("[Built-in] "):
|
||||
raise PermissionError("Cannot delete built-in templates")
|
||||
|
||||
# Check if it's an embedded template
|
||||
if name.startswith("[Embedded] "):
|
||||
actual_name = name.replace("[Embedded] ", "")
|
||||
if self.project and actual_name in self.project.embedded_templates:
|
||||
del self.project.embedded_templates[actual_name]
|
||||
print(f"Removed embedded template '{actual_name}'")
|
||||
return
|
||||
raise FileNotFoundError(f"Embedded template '{actual_name}' not found")
|
||||
|
||||
# User template from filesystem
|
||||
template_path = self.templates_dir / f"{name}.json"
|
||||
if template_path.exists():
|
||||
template_path.unlink()
|
||||
|
||||
def embed_template(self, template: Template):
|
||||
"""
|
||||
Embed a template in the project.
|
||||
|
||||
Args:
|
||||
template: Template to embed
|
||||
"""
|
||||
if not self.project:
|
||||
raise RuntimeError("No project associated with this TemplateManager")
|
||||
|
||||
self.project.embedded_templates[template.name] = template.to_dict()
|
||||
print(f"Embedded template '{template.name}' in project")
|
||||
|
||||
def create_template_from_page(self, page: Page, name: str, description: str = "") -> Template:
|
||||
"""
|
||||
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)
|
||||
|
||||
# Convert elements
|
||||
for element in page.layout.elements:
|
||||
if isinstance(element, ImageData):
|
||||
# Convert image to placeholder
|
||||
placeholder = PlaceholderData(
|
||||
placeholder_type="image",
|
||||
x=element.position[0],
|
||||
y=element.position[1],
|
||||
width=element.size[0],
|
||||
height=element.size[1],
|
||||
rotation=element.rotation,
|
||||
z_index=element.z_index,
|
||||
)
|
||||
template.add_element(placeholder)
|
||||
elif isinstance(element, TextBoxData):
|
||||
# Keep text boxes as-is
|
||||
text_box = TextBoxData(
|
||||
text_content=element.text_content,
|
||||
font_settings=element.font_settings,
|
||||
alignment=element.alignment,
|
||||
x=element.position[0],
|
||||
y=element.position[1],
|
||||
width=element.size[0],
|
||||
height=element.size[1],
|
||||
rotation=element.rotation,
|
||||
z_index=element.z_index,
|
||||
)
|
||||
template.add_element(text_box)
|
||||
elif isinstance(element, PlaceholderData):
|
||||
# Keep placeholders as-is
|
||||
placeholder = PlaceholderData(
|
||||
placeholder_type=element.placeholder_type,
|
||||
default_content=element.default_content,
|
||||
x=element.position[0],
|
||||
y=element.position[1],
|
||||
width=element.size[0],
|
||||
height=element.size[1],
|
||||
rotation=element.rotation,
|
||||
z_index=element.z_index,
|
||||
)
|
||||
template.add_element(placeholder)
|
||||
|
||||
return template
|
||||
|
||||
def scale_template_elements(
|
||||
self,
|
||||
elements: List[BaseLayoutElement],
|
||||
from_size: Tuple[float, float],
|
||||
to_size: Tuple[float, float],
|
||||
scale_mode: str = "proportional",
|
||||
margin_percent: float = 0.0,
|
||||
) -> List[BaseLayoutElement]:
|
||||
"""
|
||||
Scale template elements to fit target page size with adjustable margins.
|
||||
|
||||
Args:
|
||||
elements: List of elements to scale
|
||||
from_size: Original template size (width, height) in mm
|
||||
to_size: Target page size (width, height) in mm
|
||||
scale_mode: "proportional", "stretch", or "center"
|
||||
margin_percent: Percentage of page size to use for margins (0-10%)
|
||||
|
||||
Returns:
|
||||
List of scaled elements
|
||||
"""
|
||||
from_width, from_height = from_size
|
||||
to_width, to_height = to_size
|
||||
|
||||
# Calculate target margins from percentage
|
||||
margin_x = to_width * (margin_percent / 100.0)
|
||||
margin_y = to_height * (margin_percent / 100.0)
|
||||
|
||||
# Available content area after margins
|
||||
content_width = to_width - (2 * margin_x)
|
||||
content_height = to_height - (2 * margin_y)
|
||||
|
||||
# Calculate scale factors based on mode
|
||||
if scale_mode == "stretch":
|
||||
# Stretch to fill content area independently in each dimension
|
||||
scale_x = content_width / from_width
|
||||
scale_y = content_height / from_height
|
||||
offset_x = margin_x
|
||||
offset_y = margin_y
|
||||
elif scale_mode == "proportional":
|
||||
# Maintain aspect ratio - scale uniformly to fit content area
|
||||
scale = min(content_width / from_width, content_height / from_height)
|
||||
scale_x = scale
|
||||
scale_y = scale
|
||||
# Center the scaled content within the page
|
||||
scaled_width = from_width * scale
|
||||
scaled_height = from_height * scale
|
||||
offset_x = (to_width - scaled_width) / 2
|
||||
offset_y = (to_height - scaled_height) / 2
|
||||
else: # "center"
|
||||
# No scaling, just center on page
|
||||
scale_x = 1.0
|
||||
scale_y = 1.0
|
||||
offset_x = (to_width - from_width) / 2
|
||||
offset_y = (to_height - from_height) / 2
|
||||
|
||||
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
|
||||
)
|
||||
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,
|
||||
)
|
||||
else:
|
||||
continue # Skip other types
|
||||
|
||||
# Scale position and size (still in mm)
|
||||
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.rotation = element.rotation
|
||||
new_elem.z_index = element.z_index
|
||||
|
||||
scaled_elements.append(new_elem)
|
||||
|
||||
# Convert all elements from mm to pixels (DPI conversion)
|
||||
# The rest of the application uses pixels, not mm
|
||||
dpi = 300 # Default DPI (should match project working_dpi if available)
|
||||
if self.project:
|
||||
dpi = self.project.working_dpi
|
||||
|
||||
mm_to_px = dpi / 25.4
|
||||
|
||||
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)
|
||||
# Convert size from mm to pixels
|
||||
elem.size = (elem.size[0] * mm_to_px, elem.size[1] * mm_to_px)
|
||||
|
||||
return scaled_elements
|
||||
|
||||
def apply_template_to_page(
|
||||
self,
|
||||
template: Template,
|
||||
page: Page,
|
||||
mode: str = "replace",
|
||||
scale_mode: str = "proportional",
|
||||
margin_percent: float = 2.5,
|
||||
auto_embed: bool = True,
|
||||
):
|
||||
"""
|
||||
Apply template to an existing page with adjustable margins.
|
||||
|
||||
Args:
|
||||
template: Template to apply
|
||||
page: Target page
|
||||
mode: "replace" to clear page and add placeholders,
|
||||
"reflow" to keep existing content and reposition
|
||||
scale_mode: "proportional", "stretch", or "center"
|
||||
margin_percent: Percentage of page size to use for margins (0-10%)
|
||||
auto_embed: If True, automatically embed template in project
|
||||
"""
|
||||
# Auto-embed template if requested and not already embedded
|
||||
if auto_embed and self.project:
|
||||
if template.name not in self.project.embedded_templates:
|
||||
self.embed_template(template)
|
||||
|
||||
if mode == "replace":
|
||||
# Clear existing elements
|
||||
page.layout.elements.clear()
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
# Add scaled elements to page
|
||||
for element in scaled_elements:
|
||||
page.layout.add_element(element)
|
||||
|
||||
elif mode == "reflow":
|
||||
# Keep existing content but reposition to template slots
|
||||
existing_images = [e for e in page.layout.elements if isinstance(e, ImageData)]
|
||||
existing_text = [e for e in page.layout.elements if isinstance(e, TextBoxData)]
|
||||
|
||||
# Get template placeholders (scaled)
|
||||
scaled_elements = self.scale_template_elements(
|
||||
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)]
|
||||
template_text = [e for e in scaled_elements if isinstance(e, TextBoxData)]
|
||||
|
||||
# Clear page
|
||||
page.layout.elements.clear()
|
||||
|
||||
# Reflow images into placeholder slots
|
||||
for i, placeholder in enumerate(template_placeholders):
|
||||
if i < len(existing_images):
|
||||
# Use existing image, update position/size
|
||||
img = existing_images[i]
|
||||
img.position = placeholder.position
|
||||
img.size = placeholder.size
|
||||
img.z_index = placeholder.z_index
|
||||
page.layout.add_element(img)
|
||||
else:
|
||||
# Add placeholder if no more images
|
||||
page.layout.add_element(placeholder)
|
||||
|
||||
# Add remaining images (if any) at their original positions
|
||||
for img in existing_images[len(template_placeholders) :]:
|
||||
page.layout.add_element(img)
|
||||
|
||||
# Add template text boxes
|
||||
for text_elem in template_text:
|
||||
page.layout.add_element(text_elem)
|
||||
|
||||
def create_page_from_template(
|
||||
self,
|
||||
template: Template,
|
||||
page_number: int = 1,
|
||||
target_size_mm: Optional[Tuple[float, float]] = None,
|
||||
scale_mode: str = "proportional",
|
||||
margin_percent: float = 2.5,
|
||||
auto_embed: bool = True,
|
||||
) -> Page:
|
||||
"""
|
||||
Create a new page from a template.
|
||||
|
||||
Args:
|
||||
template: Template to use
|
||||
page_number: Page number for the new page
|
||||
target_size_mm: Target page size (if different from template)
|
||||
scale_mode: Scaling mode if target_size_mm is provided
|
||||
margin_percent: Percentage of page size to use for margins (0-10%)
|
||||
auto_embed: If True, automatically embed template in project
|
||||
|
||||
Returns:
|
||||
New Page instance with template layout
|
||||
"""
|
||||
# Auto-embed template if requested and not already embedded
|
||||
if auto_embed and self.project:
|
||||
if template.name not in self.project.embedded_templates:
|
||||
self.embed_template(template)
|
||||
|
||||
# Determine page size
|
||||
if target_size_mm is None:
|
||||
page_size = template.page_size_mm
|
||||
elements = [e for e in template.elements] # Copy elements as-is
|
||||
else:
|
||||
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
|
||||
)
|
||||
|
||||
# Create new page layout
|
||||
layout = PageLayout(width=page_size[0], height=page_size[1])
|
||||
|
||||
# Add elements
|
||||
for element in elements:
|
||||
layout.add_element(element)
|
||||
|
||||
# Create and return page
|
||||
page = Page(layout=layout, page_number=page_number)
|
||||
return page
|
||||
70
pyPhotoAlbum/templates/Featured_Grid.json
Normal file
@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "Featured_Grid",
|
||||
"description": "1 large featured image on top with 3 smaller images below, with 5mm spacing and borders",
|
||||
"page_size_mm": [
|
||||
200,
|
||||
200
|
||||
],
|
||||
"elements": [
|
||||
{
|
||||
"type": "placeholder",
|
||||
"position": [
|
||||
5,
|
||||
5
|
||||
],
|
||||
"size": [
|
||||
190,
|
||||
125
|
||||
],
|
||||
"rotation": 0,
|
||||
"z_index": 0,
|
||||
"placeholder_type": "image",
|
||||
"default_content": ""
|
||||
},
|
||||
{
|
||||
"type": "placeholder",
|
||||
"position": [
|
||||
5,
|
||||
135
|
||||
],
|
||||
"size": [
|
||||
60,
|
||||
60
|
||||
],
|
||||
"rotation": 0,
|
||||
"z_index": 0,
|
||||
"placeholder_type": "image",
|
||||
"default_content": ""
|
||||
},
|
||||
{
|
||||
"type": "placeholder",
|
||||
"position": [
|
||||
70,
|
||||
135
|
||||
],
|
||||
"size": [
|
||||
60,
|
||||
60
|
||||
],
|
||||
"rotation": 0,
|
||||
"z_index": 0,
|
||||
"placeholder_type": "image",
|
||||
"default_content": ""
|
||||
},
|
||||
{
|
||||
"type": "placeholder",
|
||||
"position": [
|
||||
135,
|
||||
135
|
||||
],
|
||||
"size": [
|
||||
60,
|
||||
60
|
||||
],
|
||||
"rotation": 0,
|
||||
"z_index": 0,
|
||||
"placeholder_type": "image",
|
||||
"default_content": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
55
pyPhotoAlbum/templates/Grid_1x3.json
Normal file
@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "Grid_1x3",
|
||||
"description": "1x3 vertical grid layout with 5mm spacing and borders",
|
||||
"page_size_mm": [
|
||||
200,
|
||||
200
|
||||
],
|
||||
"elements": [
|
||||
{
|
||||
"type": "placeholder",
|
||||
"position": [
|
||||
5,
|
||||
5
|
||||
],
|
||||
"size": [
|
||||
190,
|
||||
60
|
||||
],
|
||||
"rotation": 0,
|
||||
"z_index": 0,
|
||||
"placeholder_type": "image",
|
||||
"default_content": ""
|
||||
},
|
||||
{
|
||||
"type": "placeholder",
|
||||
"position": [
|
||||
5,
|
||||
70
|
||||
],
|
||||
"size": [
|
||||
190,
|
||||
60
|
||||
],
|
||||
"rotation": 0,
|
||||
"z_index": 0,
|
||||
"placeholder_type": "image",
|
||||
"default_content": ""
|
||||
},
|
||||
{
|
||||
"type": "placeholder",
|
||||
"position": [
|
||||
5,
|
||||
135
|
||||
],
|
||||
"size": [
|
||||
190,
|
||||
60
|
||||
],
|
||||
"rotation": 0,
|
||||
"z_index": 0,
|
||||
"placeholder_type": "image",
|
||||
"default_content": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
70
pyPhotoAlbum/templates/Grid_2x2.json
Normal file
@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "Grid_2x2",
|
||||
"description": "2x2 grid layout with 5mm spacing between placeholders and 5mm borders",
|
||||
"page_size_mm": [
|
||||
200,
|
||||
200
|
||||
],
|
||||
"elements": [
|
||||
{
|
||||
"type": "placeholder",
|
||||
"position": [
|
||||
5,
|
||||
5
|
||||
],
|
||||
"size": [
|
||||
92.5,
|
||||
92.5
|
||||
],
|
||||
"rotation": 0,
|
||||
"z_index": 0,
|
||||
"placeholder_type": "image",
|
||||
"default_content": ""
|
||||
},
|
||||
{
|
||||
"type": "placeholder",
|
||||
"position": [
|
||||
102.5,
|
||||
5
|
||||
],
|
||||
"size": [
|
||||
92.5,
|
||||
92.5
|
||||
],
|
||||
"rotation": 0,
|
||||
"z_index": 0,
|
||||
"placeholder_type": "image",
|
||||
"default_content": ""
|
||||
},
|
||||
{
|
||||
"type": "placeholder",
|
||||
"position": [
|
||||
5,
|
||||
102.5
|
||||
],
|
||||
"size": [
|
||||
92.5,
|
||||
92.5
|
||||
],
|
||||
"rotation": 0,
|
||||
"z_index": 0,
|
||||
"placeholder_type": "image",
|
||||
"default_content": ""
|
||||
},
|
||||
{
|
||||
"type": "placeholder",
|
||||
"position": [
|
||||
102.5,
|
||||
102.5
|
||||
],
|
||||
"size": [
|
||||
92.5,
|
||||
92.5
|
||||
],
|
||||
"rotation": 0,
|
||||
"z_index": 0,
|
||||
"placeholder_type": "image",
|
||||
"default_content": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
55
pyPhotoAlbum/templates/Grid_3x1.json
Normal file
@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "Grid_3x1",
|
||||
"description": "3x1 horizontal grid layout with 5mm spacing and borders",
|
||||
"page_size_mm": [
|
||||
200,
|
||||
200
|
||||
],
|
||||
"elements": [
|
||||
{
|
||||
"type": "placeholder",
|
||||
"position": [
|
||||
5,
|
||||
5
|
||||
],
|
||||
"size": [
|
||||
60,
|
||||
190
|
||||
],
|
||||
"rotation": 0,
|
||||
"z_index": 0,
|
||||
"placeholder_type": "image",
|
||||
"default_content": ""
|
||||
},
|
||||
{
|
||||
"type": "placeholder",
|
||||
"position": [
|
||||
70,
|
||||
5
|
||||
],
|
||||
"size": [
|
||||
60,
|
||||
190
|
||||
],
|
||||
"rotation": 0,
|
||||
"z_index": 0,
|
||||
"placeholder_type": "image",
|
||||
"default_content": ""
|
||||
},
|
||||
{
|
||||
"type": "placeholder",
|
||||
"position": [
|
||||
135,
|
||||
5
|
||||
],
|
||||
"size": [
|
||||
60,
|
||||
190
|
||||
],
|
||||
"rotation": 0,
|
||||
"z_index": 0,
|
||||
"placeholder_type": "image",
|
||||
"default_content": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
145
pyPhotoAlbum/templates/Grid_3x3.json
Normal file
@ -0,0 +1,145 @@
|
||||
{
|
||||
"name": "Grid_3x3",
|
||||
"description": "3x3 grid layout with 5mm spacing and borders",
|
||||
"page_size_mm": [
|
||||
200,
|
||||
200
|
||||
],
|
||||
"elements": [
|
||||
{
|
||||
"type": "placeholder",
|
||||
"position": [
|
||||
5,
|
||||
5
|
||||
],
|
||||
"size": [
|
||||
60,
|
||||
60
|
||||
],
|
||||
"rotation": 0,
|
||||
"z_index": 0,
|
||||
"placeholder_type": "image",
|
||||
"default_content": ""
|
||||
},
|
||||
{
|
||||
"type": "placeholder",
|
||||
"position": [
|
||||
70,
|
||||
5
|
||||
],
|
||||
"size": [
|
||||
60,
|
||||
60
|
||||
],
|
||||
"rotation": 0,
|
||||
"z_index": 0,
|
||||
"placeholder_type": "image",
|
||||
"default_content": ""
|
||||
},
|
||||
{
|
||||
"type": "placeholder",
|
||||
"position": [
|
||||
135,
|
||||
5
|
||||
],
|
||||
"size": [
|
||||
60,
|
||||
60
|
||||
],
|
||||
"rotation": 0,
|
||||
"z_index": 0,
|
||||
"placeholder_type": "image",
|
||||
"default_content": ""
|
||||
},
|
||||
{
|
||||
"type": "placeholder",
|
||||
"position": [
|
||||
5,
|
||||
70
|
||||
],
|
||||
"size": [
|
||||
60,
|
||||
60
|
||||
],
|
||||
"rotation": 0,
|
||||
"z_index": 0,
|
||||
"placeholder_type": "image",
|
||||
"default_content": ""
|
||||
},
|
||||
{
|
||||
"type": "placeholder",
|
||||
"position": [
|
||||
70,
|
||||
70
|
||||
],
|
||||
"size": [
|
||||
60,
|
||||
60
|
||||
],
|
||||
"rotation": 0,
|
||||
"z_index": 0,
|
||||
"placeholder_type": "image",
|
||||
"default_content": ""
|
||||
},
|
||||
{
|
||||
"type": "placeholder",
|
||||
"position": [
|
||||
135,
|
||||
70
|
||||
],
|
||||
"size": [
|
||||
60,
|
||||
60
|
||||
],
|
||||
"rotation": 0,
|
||||
"z_index": 0,
|
||||
"placeholder_type": "image",
|
||||
"default_content": ""
|
||||
},
|
||||
{
|
||||
"type": "placeholder",
|
||||
"position": [
|
||||
5,
|
||||
135
|
||||
],
|
||||
"size": [
|
||||
60,
|
||||
60
|
||||
],
|
||||
"rotation": 0,
|
||||
"z_index": 0,
|
||||
"placeholder_type": "image",
|
||||
"default_content": ""
|
||||
},
|
||||
{
|
||||
"type": "placeholder",
|
||||
"position": [
|
||||
70,
|
||||
135
|
||||
],
|
||||
"size": [
|
||||
60,
|
||||
60
|
||||
],
|
||||
"rotation": 0,
|
||||
"z_index": 0,
|
||||
"placeholder_type": "image",
|
||||
"default_content": ""
|
||||
},
|
||||
{
|
||||
"type": "placeholder",
|
||||
"position": [
|
||||
135,
|
||||
135
|
||||
],
|
||||
"size": [
|
||||
60,
|
||||
60
|
||||
],
|
||||
"rotation": 0,
|
||||
"z_index": 0,
|
||||
"placeholder_type": "image",
|
||||
"default_content": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
85
pyPhotoAlbum/templates/Large_Plus_Four.json
Normal file
@ -0,0 +1,85 @@
|
||||
{
|
||||
"name": "Large_Plus_Four",
|
||||
"description": "1 large image on left with 4 smaller images stacked on right, with 5mm spacing and borders",
|
||||
"page_size_mm": [
|
||||
200,
|
||||
200
|
||||
],
|
||||
"elements": [
|
||||
{
|
||||
"type": "placeholder",
|
||||
"position": [
|
||||
5,
|
||||
5
|
||||
],
|
||||
"size": [
|
||||
125,
|
||||
190
|
||||
],
|
||||
"rotation": 0,
|
||||
"z_index": 0,
|
||||
"placeholder_type": "image",
|
||||
"default_content": ""
|
||||
},
|
||||
{
|
||||
"type": "placeholder",
|
||||
"position": [
|
||||
135,
|
||||
5
|
||||
],
|
||||
"size": [
|
||||
60,
|
||||
44.375
|
||||
],
|
||||
"rotation": 0,
|
||||
"z_index": 0,
|
||||
"placeholder_type": "image",
|
||||
"default_content": ""
|
||||
},
|
||||
{
|
||||
"type": "placeholder",
|
||||
"position": [
|
||||
135,
|
||||
54.375
|
||||
],
|
||||
"size": [
|
||||
60,
|
||||
44.375
|
||||
],
|
||||
"rotation": 0,
|
||||
"z_index": 0,
|
||||
"placeholder_type": "image",
|
||||
"default_content": ""
|
||||
},
|
||||
{
|
||||
"type": "placeholder",
|
||||
"position": [
|
||||
135,
|
||||
103.75
|
||||
],
|
||||
"size": [
|
||||
60,
|
||||
44.375
|
||||
],
|
||||
"rotation": 0,
|
||||
"z_index": 0,
|
||||
"placeholder_type": "image",
|
||||
"default_content": ""
|
||||
},
|
||||
{
|
||||
"type": "placeholder",
|
||||
"position": [
|
||||
135,
|
||||
153.125
|
||||
],
|
||||
"size": [
|
||||
60,
|
||||
41.875
|
||||
],
|
||||
"rotation": 0,
|
||||
"z_index": 0,
|
||||
"placeholder_type": "image",
|
||||
"default_content": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
49
pyPhotoAlbum/templates/Single_Large.json
Normal file
@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "Single_Large",
|
||||
"description": "Single large image placeholder with title text",
|
||||
"page_size_mm": [
|
||||
200,
|
||||
200
|
||||
],
|
||||
"elements": [
|
||||
{
|
||||
"type": "textbox",
|
||||
"position": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"size": [
|
||||
200,
|
||||
20
|
||||
],
|
||||
"rotation": 0,
|
||||
"z_index": 1,
|
||||
"text_content": "Title",
|
||||
"font_settings": {
|
||||
"family": "Arial",
|
||||
"size": 24,
|
||||
"color": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"alignment": "center"
|
||||
},
|
||||
{
|
||||
"type": "placeholder",
|
||||
"position": [
|
||||
0,
|
||||
20
|
||||
],
|
||||
"size": [
|
||||
200,
|
||||
180
|
||||
],
|
||||
"rotation": 0,
|
||||
"z_index": 0,
|
||||
"placeholder_type": "image",
|
||||
"default_content": ""
|
||||
}
|
||||
]
|
||||
}
|
||||