Compare commits

...
50 Commits
Author SHA1 Message Date
dtourolle 15d8ca8c05 Fix ci more
Lint / lint (push) Successful in 35s
Tests / test (push) Successful in 52s
2026-06-20 14:54:22 +02:00
dtourolle 1fe44e7d8a More CI fixes and mypy fixes
Lint / lint (push) Successful in 20s
Tests / test (push) Successful in 10s
Python CI / test (push) Failing after 1m43s
2026-04-09 23:07:59 +02:00
dtourolle fd0758dd6c use x86 for CI
Lint / lint (push) Successful in 20s
Python CI / test (push) Failing after 1m45s
Tests / test (3.11) (push) Successful in 1m2s
Tests / test (3.12) (push) Failing after 58s
Tests / test (3.13) (push) Failing after 45s
Tests / test (3.14) (push) Failing after 45s
2026-04-09 22:52:16 +02:00
dtourolle 5763fa629e More fixes for CI
Lint / lint (push) Successful in 20s
Python CI / test (push) Failing after 1m42s
Tests / test (3.12) (push) Failing after 6s
Tests / test (3.13) (push) Failing after 6s
Tests / test (3.14) (push) Failing after 8s
Tests / test (3.11) (push) Failing after 37s
2026-04-09 22:39:15 +02:00
dtourolle 3092388226 Use docker image for CI
Lint / lint (push) Successful in 22s
Tests / test (3.11) (push) Failing after 15s
Tests / test (3.12) (push) Failing after 3s
Tests / test (3.13) (push) Failing after 4s
Tests / test (3.14) (push) Failing after 4s
Python CI / test (push) Failing after 1m42s
2026-04-09 22:35:31 +02:00
dtourolle cdd5215eb4 CI fix
Lint / lint (push) Successful in 25s
Tests / test (3.11) (push) Successful in 31s
Tests / test (3.12) (push) Successful in 29s
Python CI / test (push) Failing after 1m40s
Tests / test (3.13) (push) Successful in 28s
Tests / test (3.14) (push) Has been cancelled
2026-04-09 22:32:21 +02:00
dtourolle e112437372 Faster pdf export and test support
Lint / lint (push) Successful in 21s
Tests / test (3.11) (push) Successful in 19s
Tests / test (3.12) (push) Successful in 16s
Tests / test (3.13) (push) Successful in 16s
Tests / test (3.14) (push) Successful in 17s
Python CI / test (push) Failing after 1m44s
2026-04-09 22:30:36 +02:00
dtourolle f96200c799 Many improvements and stability fixes
Lint / lint (push) Successful in 1m47s
Tests / test (3.11) (push) Successful in 57s
Tests / test (3.12) (push) Successful in 55s
Tests / test (3.13) (push) Successful in 56s
Tests / test (3.14) (push) Successful in 1m3s
Python CI / test (push) Failing after 6m1s
2026-04-09 21:39:20 +02:00
dtourolle f0aa005d8c refactor to reduce complexity of nesting
Python CI / test (push) Successful in 1m41s
Lint / lint (push) Successful in 1m29s
Tests / test (3.11) (push) Successful in 1m46s
Tests / test (3.12) (push) Successful in 1m50s
Tests / test (3.13) (push) Successful in 1m43s
Tests / test (3.14) (push) Successful in 1m26s
2026-01-01 18:42:58 +01:00
dtourolle 293b568772 simplified deserialisation
Python CI / test (push) Successful in 1m40s
Lint / lint (push) Successful in 1m29s
Tests / test (3.11) (push) Successful in 1m48s
Tests / test (3.12) (push) Successful in 1m51s
Tests / test (3.13) (push) Successful in 1m46s
Tests / test (3.14) (push) Successful in 1m29s
2026-01-01 14:17:16 +01:00
dtourolle 3a2d8b05db fixed export issue
Python CI / test (push) Successful in 1m39s
Lint / lint (push) Successful in 1m30s
Tests / test (3.11) (push) Successful in 1m47s
Tests / test (3.12) (push) Successful in 1m51s
Tests / test (3.13) (push) Successful in 1m47s
Tests / test (3.14) (push) Successful in 1m27s
2026-01-01 13:50:17 +01:00
dtourolle 54cc78783a Added styling
Python CI / test (push) Successful in 1m44s
Lint / lint (push) Successful in 1m29s
Tests / test (3.11) (push) Successful in 1m49s
Tests / test (3.12) (push) Successful in 1m52s
Tests / test (3.13) (push) Successful in 1m45s
Tests / test (3.14) (push) Successful in 1m28s
Improved pdf generation speed
2026-01-01 13:37:14 +01:00
dtourolle cf27d9ebee fixed bug in font rendering when zooming
Python CI / test (push) Successful in 1m32s
Lint / lint (push) Successful in 1m18s
Tests / test (3.11) (push) Successful in 1m47s
Tests / test (3.12) (push) Successful in 1m48s
Tests / test (3.13) (push) Successful in 1m42s
Tests / test (3.14) (push) Successful in 1m18s
2025-12-31 13:19:36 +01:00
dtourolle 6a791b1397 Use md5 to only store unique content
Python CI / test (push) Successful in 3m12s
Lint / lint (push) Successful in 1m38s
Tests / test (3.11) (push) Successful in 2m26s
Tests / test (3.12) (push) Successful in 3m13s
Tests / test (3.13) (push) Successful in 3m9s
Tests / test (3.14) (push) Successful in 1m20s
2025-12-31 12:46:01 +01:00
dtourolle 254a95d83c fix tests
Python CI / test (push) Successful in 1m24s
Lint / lint (push) Successful in 1m13s
Tests / test (3.11) (push) Successful in 1m37s
Tests / test (3.12) (push) Successful in 1m41s
Tests / test (3.13) (push) Successful in 1m34s
Tests / test (3.14) (push) Successful in 1m12s
2025-12-13 17:33:12 +01:00
dtourolle c1ee894e7b Sort library tab, background saving
Python CI / test (push) Successful in 1m26s
Lint / lint (push) Successful in 1m13s
Tests / test (3.11) (push) Successful in 1m40s
Tests / test (3.12) (push) Successful in 1m46s
Tests / test (3.13) (push) Successful in 1m36s
Tests / test (3.14) (push) Successful in 1m13s
2025-12-13 16:53:43 +01:00
dtourolle 8f9f387848 Added gallery pane to easily add images on smaller screen devices
Python CI / test (push) Successful in 1m31s
Lint / lint (push) Successful in 1m10s
Tests / test (3.11) (push) Successful in 1m42s
Tests / test (3.12) (push) Successful in 1m43s
Tests / test (3.13) (push) Successful in 1m36s
Tests / test (3.14) (push) Successful in 1m14s
2025-12-13 15:30:37 +01:00
dtourolle c66724c190 Add debian installer and launcher
Python CI / test (push) Successful in 1m30s
Lint / lint (push) Successful in 1m8s
Tests / test (3.11) (push) Successful in 1m41s
Tests / test (3.12) (push) Successful in 1m42s
Tests / test (3.13) (push) Successful in 1m36s
Tests / test (3.14) (push) Successful in 1m17s
2025-12-07 18:14:19 +01:00
dtourolle a7f32e73b9 pan with two finger scroll
Python CI / test (push) Successful in 1m31s
Lint / lint (push) Successful in 1m8s
Tests / test (3.11) (push) Successful in 1m42s
Tests / test (3.12) (push) Successful in 1m43s
Tests / test (3.13) (push) Successful in 1m37s
Tests / test (3.14) (push) Successful in 1m16s
2025-12-02 20:57:37 +01:00
dtourolle 8f1e906884 justified test and two finger scrolling
Python CI / test (push) Successful in 1m27s
Lint / lint (push) Successful in 1m6s
Tests / test (3.11) (push) Successful in 1m42s
Tests / test (3.12) (push) Successful in 1m43s
Tests / test (3.13) (push) Successful in 1m37s
Tests / test (3.14) (push) Successful in 1m16s
2025-12-01 20:40:11 +01:00
dtourolle 80d7d291f3 Re-organised ribbons. Fixed bug in rendering.
Python CI / test (push) Successful in 1m31s
Lint / lint (push) Successful in 1m7s
Tests / test (3.11) (push) Successful in 1m43s
Tests / test (3.12) (push) Successful in 1m44s
Tests / test (3.13) (push) Successful in 1m38s
Tests / test (3.14) (push) Successful in 1m17s
2025-12-01 20:19:43 +01:00
dtourolle 2a087f0c9d update readme
Lint / lint (push) Successful in 1m7s
2025-11-28 20:41:38 +01:00
dtourolle b18a780a33 increase test coverage
Python CI / test (push) Successful in 1m28s
Lint / lint (push) Successful in 1m4s
Tests / test (3.11) (push) Successful in 1m41s
Tests / test (3.12) (push) Successful in 1m42s
Tests / test (3.13) (push) Successful in 1m35s
Tests / test (3.14) (push) Successful in 1m15s
2025-11-28 19:54:41 +01:00
dtourolle 558b7be01a additional test coverage and mypy fixes
Python CI / test (push) Successful in 1m18s
Lint / lint (push) Successful in 1m5s
Tests / test (3.11) (push) Successful in 1m29s
Tests / test (3.12) (push) Successful in 1m33s
Tests / test (3.13) (push) Successful in 1m26s
Tests / test (3.14) (push) Successful in 1m14s
2025-11-27 23:20:43 +01:00
dtourolle f6ed11b0bc black formatting
Python CI / test (push) Successful in 1m20s
Lint / lint (push) Successful in 1m4s
Tests / test (3.11) (push) Successful in 1m27s
Tests / test (3.12) (push) Successful in 2m25s
Tests / test (3.13) (push) Successful in 2m52s
Tests / test (3.14) (push) Successful in 1m9s
2025-11-27 23:07:16 +01:00
dtourolle 3b2301b547 removed sudo instal from ci
Python CI / test (push) Successful in 1m18s
Lint / lint (push) Successful in 1m31s
Tests / test (3.11) (push) Successful in 1m25s
Tests / test (3.13) (push) Has been cancelled
Tests / test (3.14) (push) Has been cancelled
Tests / test (3.12) (push) Has been cancelled
2025-11-27 23:02:06 +01:00
dtourolle d911f00c93 added badges to main repo
Python CI / test (push) Successful in 1m17s
Lint / lint (push) Successful in 1m31s
Tests / test (3.12) (push) Has been cancelled
Tests / test (3.13) (push) Has been cancelled
Tests / test (3.14) (push) Has been cancelled
Tests / test (3.11) (push) Has been cancelled
2025-11-27 22:07:58 +01:00
dtourolle 40e709a29a bump python version 2025-11-27 22:03:06 +01:00
dtourolle 35b740496d convert to RGBA if not RBGA
Python CI / test (push) Successful in 1m16s
Lint / lint (push) Successful in 1m31s
Tests / test (3.10) (push) Successful in 1m9s
Tests / test (3.11) (push) Successful in 1m9s
Tests / test (3.9) (push) Has been cancelled
2025-11-27 22:02:44 +01:00
dtourolle fae9e5bd2b Additional refactoring
Python CI / test (push) Successful in 1m17s
Lint / lint (push) Successful in 1m32s
Tests / test (3.10) (push) Successful in 1m10s
Tests / test (3.9) (push) Has been cancelled
Tests / test (3.11) (push) Has been cancelled
2025-11-27 21:57:57 +01:00
dtourolle d7786ede80 centralised image loading logic
Python CI / test (push) Successful in 1m17s
Lint / lint (push) Successful in 1m32s
Tests / test (3.10) (push) Successful in 1m9s
Tests / test (3.11) (push) Successful in 1m8s
Tests / test (3.9) (push) Successful in 1m7s
2025-11-27 21:33:53 +01:00
dtourolle 45268cdfe4 update reaquirements. removed redundant loading logic
Python CI / test (push) Successful in 1m26s
Lint / lint (push) Successful in 1m37s
Tests / test (3.10) (push) Successful in 1m17s
Tests / test (3.11) (push) Successful in 1m14s
Tests / test (3.9) (push) Successful in 1m11s
2025-11-27 20:06:01 +01:00
dtourolle e8d9eaca51 added additonal logging around file loading
Python CI / test (push) Successful in 4m36s
Lint / lint (push) Successful in 2m11s
Tests / test (3.10) (push) Failing after 3m22s
Tests / test (3.11) (push) Failing after 1m37s
Tests / test (3.9) (push) Failing after 2m20s
2025-11-27 19:55:26 +01:00
dtourolle fc672bcdff only make textures when GL context exists
Lint / lint (push) Has been cancelled
Python CI / test (push) Has been cancelled
Tests / test (3.10) (push) Has been cancelled
Tests / test (3.11) (push) Has been cancelled
Tests / test (3.9) (push) Has been cancelled
2025-11-27 13:40:43 +01:00
dtourolle 73c504abb7 fixed bug in pdf export
Python CI / test (push) Has been cancelled
Lint / lint (push) Has been cancelled
Tests / test (3.10) (push) Has been cancelled
Tests / test (3.11) (push) Has been cancelled
Tests / test (3.9) (push) Has been cancelled
fixed bug in asset loading
2025-11-26 22:59:42 +01:00
dtourolle 56ea7d5ea9 remove debug statement
Python CI / test (push) Successful in 1m33s
Lint / lint (push) Successful in 1m37s
Tests / test (3.10) (push) Successful in 1m11s
Tests / test (3.11) (push) Successful in 1m10s
Tests / test (3.9) (push) Successful in 1m8s
2025-11-23 21:33:39 +01:00
dtourolle e52cb7334a remove debug statement 2025-11-23 21:33:15 +01:00
dtourolle 2fc99a3292 Fixed bug where zooming could cause viewport to moce to page 1 2025-11-23 21:32:58 +01:00
dtourolle 6755549dfd Refactor to allow indepth testing
Python CI / test (push) Successful in 1m37s
Lint / lint (push) Successful in 1m32s
Tests / test (3.10) (push) Successful in 1m11s
Tests / test (3.11) (push) Successful in 1m11s
Tests / test (3.9) (push) Successful in 1m8s
2025-11-23 11:05:46 +01:00
dtourolle 0d698a83b4 large change to allow project merging
Python CI / test (push) Successful in 55s
Lint / lint (push) Successful in 1m31s
Tests / test (3.10) (push) Failing after 44s
Tests / test (3.11) (push) Failing after 42s
Tests / test (3.9) (push) Failing after 42s
2025-11-23 00:33:42 +01:00
dtourolle e93a789524 Added autosave
Fixed unusual bug where scrolling while draging content results in desyned movements
2025-11-22 22:29:05 +01:00
dtourolle 3bfe2fa654 Fix installer
Python CI / test (push) Successful in 1m19s
Lint / lint (push) Successful in 1m24s
Tests / test (3.10) (push) Failing after 1m2s
Tests / test (3.11) (push) Failing after 58s
Tests / test (3.9) (push) Failing after 59s
Added navigation tools
2025-11-22 08:50:36 +01:00
dtourolle ca2b3545ee update page selection
Python CI / test (push) Successful in 1m24s
Lint / lint (push) Successful in 1m22s
Tests / test (3.10) (push) Successful in 1m2s
Tests / test (3.11) (push) Successful in 1m3s
Tests / test (3.9) (push) Successful in 59s
2025-11-22 08:22:01 +01:00
dtourolle 353b0c4aff Fix tests
Python CI / test (push) Successful in 1m23s
Lint / lint (push) Successful in 1m21s
Tests / test (3.10) (push) Successful in 1m2s
Tests / test (3.11) (push) Successful in 1m2s
Tests / test (3.9) (push) Successful in 58s
2025-11-22 00:14:33 +01:00
dtourolle eca6d43e6a Improved snapping. Fixed bug in content embedding.
Python CI / test (push) Successful in 1m23s
Lint / lint (push) Successful in 1m21s
Tests / test (3.10) (push) Failing after 1m9s
Tests / test (3.11) (push) Failing after 1m3s
Tests / test (3.9) (push) Failing after 1m4s
2025-11-22 00:06:07 +01:00
dtourolle e972fb864e More bug fixes and usability changes
Python CI / test (push) Successful in 1m22s
Lint / lint (push) Successful in 1m20s
Tests / test (3.10) (push) Successful in 1m4s
Tests / test (3.11) (push) Successful in 1m5s
Tests / test (3.9) (push) Successful in 58s
2025-11-21 23:06:06 +01:00
dtourolle 5de3384c35 Many improvements and fixes
Python CI / test (push) Successful in 1m19s
Lint / lint (push) Successful in 1m21s
Tests / test (3.10) (push) Failing after 1m2s
Tests / test (3.11) (push) Failing after 57s
Tests / test (3.9) (push) Failing after 59s
2025-11-21 22:35:47 +01:00
dtourolle d868328e9d Simplified rotation to data level transpose
Python CI / test (push) Successful in 1m16s
Lint / lint (push) Successful in 1m21s
Tests / test (3.10) (push) Failing after 1m2s
Tests / test (3.11) (push) Failing after 57s
Tests / test (3.9) (push) Failing after 59s
2025-11-21 21:36:52 +01:00
dtourolle 950aa6bee9 Async loading and use of temp file
Python CI / test (push) Successful in 1m21s
Lint / lint (push) Successful in 1m48s
Tests / test (3.10) (push) Failing after 2m16s
Tests / test (3.11) (push) Failing after 1m30s
Tests / test (3.9) (push) Failing after 2m13s
2025-11-21 21:13:37 +01:00
dtourolle 1b0e6f9ed1 fixed bug with images being displayed squashed when dimensions exceeded render size 2025-11-11 17:13:12 +01:00
168 changed files with 41148 additions and 7761 deletions
+35
View File
@@ -0,0 +1,35 @@
# Virtual environments
venv/
.venv/
pyPhotoAlbum/venv/
# Python cache
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
dist/
eggs/
*.egg-info/
*.egg
# IDE
.idea/
.vscode/
*.swp
*.swo
# Git
.git/
# Test/coverage
.coverage
htmlcov/
.pytest_cache/
.tox/
# OS files
.DS_Store
Thumbs.db
-183
View File
@@ -1,183 +0,0 @@
name: Python CI
on:
push:
branches: [ main, master, develop ]
paths-ignore:
- 'coverage*.svg'
- 'README.md'
pull_request:
branches: [ main, master, develop ]
jobs:
test:
runs-on: self-hosted
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
# Install package in development mode with dev dependencies
pip install -e ".[dev]"
# Install additional test packages
pip install coverage-badge interrogate
- name: Download initial failed badges
run: |
echo "Downloading initial failed badges..."
# Create cov_info directory first
mkdir -p cov_info
# Download failed badges as defaults
curl -o cov_info/coverage.svg "https://img.shields.io/badge/coverage-failed-red.svg"
curl -o cov_info/coverage-docs.svg "https://img.shields.io/badge/docs-failed-red.svg"
echo "Initial failed badges created:"
ls -la cov_info/coverage*.svg
- name: Run tests with pytest
id: pytest
continue-on-error: true
run: |
# Run tests with coverage
# Check if xvfb-run is available, use it if present
if command -v xvfb-run &> /dev/null; then
echo "Using xvfb-run for headless Qt testing"
xvfb-run -a python -m pytest tests/ -v --cov=pyPhotoAlbum --cov-report=term-missing --cov-report=json --cov-report=html --cov-report=xml
else
echo "xvfb-run not found, running with QT_QPA_PLATFORM=offscreen only"
echo "For better Qt support, install: sudo apt-get install xvfb"
python -m pytest tests/ -v --cov=pyPhotoAlbum --cov-report=term-missing --cov-report=json --cov-report=html --cov-report=xml
fi
env:
QT_QPA_PLATFORM: offscreen
- name: Check documentation coverage
id: docs
continue-on-error: true
run: |
# Generate documentation coverage report
interrogate -v --ignore-init-method --ignore-init-module --ignore-magic --ignore-private --ignore-property-decorators --ignore-semiprivate --fail-under=80 pyPhotoAlbum/
- name: Lint with flake8
run: |
# Stop the build if there are Python syntax errors or undefined names
flake8 pyPhotoAlbum --count --select=E9,F63,F7,F82 --show-source --statistics
# Exit-zero treats all errors as warnings
flake8 pyPhotoAlbum --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Create coverage info directory
if: always()
run: |
mkdir -p cov_info
echo "Created cov_info directory for coverage data"
- name: Update test coverage badge on success
if: steps.pytest.outcome == 'success' && always()
run: |
echo "Tests passed! Generating successful coverage badge..."
if [ -f coverage.json ]; then
coverage-badge -o cov_info/coverage.svg -f
echo "✅ Test coverage badge updated with actual results"
else
echo "⚠️ No coverage.json found, keeping failed badge"
fi
- name: Update docs coverage badge on success
if: steps.docs.outcome == 'success' && always()
run: |
echo "Docs check passed! Generating successful docs badge..."
# Remove existing badge first to avoid overwrite error
rm -f cov_info/coverage-docs.svg
interrogate --generate-badge cov_info/coverage-docs.svg pyPhotoAlbum/
echo "✅ Docs coverage badge updated with actual results"
- name: Generate coverage reports
if: steps.pytest.outcome == 'success'
run: |
# Generate coverage summary for README
python -c "
import json
import os
# Read coverage data
if os.path.exists('coverage.json'):
with open('coverage.json', 'r') as f:
coverage_data = json.load(f)
total_coverage = round(coverage_data['totals']['percent_covered'], 1)
# Create coverage summary file in cov_info directory
with open('cov_info/coverage-summary.txt', 'w') as f:
f.write(f'{total_coverage}%')
print(f'Test Coverage: {total_coverage}%')
covered_lines = coverage_data['totals']['covered_lines']
total_lines = coverage_data['totals']['num_statements']
print(f'Lines Covered: {covered_lines}/{total_lines}')
else:
print('No coverage data found')
"
# Copy other coverage files to cov_info
if [ -f coverage.json ]; then cp coverage.json cov_info/; fi
if [ -f coverage.xml ]; then cp coverage.xml cov_info/; fi
if [ -d htmlcov ]; then cp -r htmlcov cov_info/; fi
- name: Final badge status
if: always()
run: |
echo "=== FINAL BADGE STATUS ==="
echo "Test outcome: ${{ steps.pytest.outcome }}"
echo "Docs outcome: ${{ steps.docs.outcome }}"
if [ -f cov_info/coverage.svg ]; then
echo "✅ Test coverage badge: $(ls -lh cov_info/coverage.svg)"
else
echo "❌ Test coverage badge: MISSING"
fi
if [ -f cov_info/coverage-docs.svg ]; then
echo "✅ Docs coverage badge: $(ls -lh cov_info/coverage-docs.svg)"
else
echo "❌ Docs coverage badge: MISSING"
fi
echo "Coverage info directory contents:"
ls -la cov_info/ 2>/dev/null || echo "No cov_info directory found"
- name: Upload coverage artifacts
uses: actions/upload-artifact@v3
with:
name: coverage-reports
path: |
cov_info/
- name: Commit badges to badges branch
if: github.ref == 'refs/heads/master'
run: |
git config --local user.email "action@gitea.local"
git config --local user.name "Gitea Action"
# Set the remote URL to use the token
git remote set-url origin https://${{ secrets.PUSH_TOKEN }}@gitea.tourolle.paris/dtourolle/pyPhotoAlbum.git
# Create a new orphan branch for badges (this discards any existing badges branch)
git checkout --orphan badges
# Remove all files except cov_info
find . -maxdepth 1 -not -name '.git' -not -name 'cov_info' -exec rm -rf {} + 2>/dev/null || true
# Add only the coverage info directory
git add -f cov_info/
# Always commit (force overwrite)
echo "Force updating badges branch with new coverage data..."
git commit -m "Update coverage badges [skip ci]"
git push -f origin badges
+7 -15
View File
@@ -4,34 +4,26 @@ on: [push, pull_request]
jobs: jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/pyphotoalbum-ci:latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 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 - name: Run flake8
run: | run: |
# Stop the build if there are Python syntax errors or undefined names # Stop the build if there are Python syntax errors or undefined names
flake8 pyPhotoAlbum --count --select=E9,F63,F7,F82 --show-source --statistics flake8 pyPhotoAlbum --count --select=E9,F63,F7,F82 --show-source --statistics
# Exit-zero treats all errors as warnings # Exit-zero treats all errors as warnings
flake8 pyPhotoAlbum --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics flake8 pyPhotoAlbum --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Check formatting with black - name: Check formatting with black
run: | run: |
black --check pyPhotoAlbum black --check pyPhotoAlbum
continue-on-error: true continue-on-error: true
- name: Type check with mypy - name: Type check with mypy
run: | run: |
mypy pyPhotoAlbum --ignore-missing-imports mypy pyPhotoAlbum --ignore-missing-imports
+93 -22
View File
@@ -1,36 +1,107 @@
name: Tests name: Tests
on: [push, pull_request] on:
push:
branches: [main, master, develop]
paths-ignore:
- 'coverage*.svg'
- 'README.md'
pull_request:
branches: [main, master, develop]
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: linux/amd64
strategy: container:
matrix: image: gitea.tourolle.paris/dtourolle/pyphotoalbum-ci:latest
python-version: ['3.9', '3.10', '3.11']
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }} - name: Install project
uses: actions/setup-python@v4 run: pip3 install -e . --no-deps --break-system-packages
with:
python-version: ${{ matrix.python-version }} - name: Download initial failed badges
- name: Install Python dependencies
run: | run: |
python -m pip install --upgrade pip mkdir -p cov_info
pip install -e ".[dev]" 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 - name: Run tests with coverage
id: pytest
continue-on-error: true
run: | run: |
xvfb-run -a pytest --cov=pyPhotoAlbum --cov-report=xml --cov-report=term-missing pytest --cov=pyPhotoAlbum --cov-report=xml --cov-report=json --cov-report=html --cov-report=term-missing
env: env:
QT_QPA_PLATFORM: offscreen QT_QPA_PLATFORM: offscreen
- name: Upload coverage reports - name: Check documentation coverage
if: matrix.python-version == '3.11' id: docs
uses: codecov/codecov-action@v3 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: |
python3 -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: with:
file: ./coverage.xml name: coverage-reports
fail_ci_if_error: false 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"
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
-70
View File
@@ -1,70 +0,0 @@
# Coverage Badges Integration
This document explains how to integrate the coverage badges generated by the CI workflow into your README.
## How It Works
The Python CI workflow automatically:
1. Runs tests with coverage reporting
2. Checks documentation coverage with interrogate
3. Generates coverage badges
4. Commits badges to a separate `badges` branch
## Using the Badges in README
Once the workflow has run successfully on the `master` branch, you can add the following badges to your README.md:
### Test Coverage Badge
```markdown
![Coverage Badge](https://gitea.tourolle.paris/dtourolle/pyPhotoAlbum/raw/branch/badges/cov_info/coverage.svg)
```
### Documentation Coverage Badge
```markdown
![Docs Badge](https://gitea.tourolle.paris/dtourolle/pyPhotoAlbum/raw/branch/badges/cov_info/coverage-docs.svg)
```
## Example README Section
```markdown
# pyPhotoAlbum
![Coverage Badge](https://gitea.tourolle.paris/dtourolle/pyPhotoAlbum/raw/branch/badges/cov_info/coverage.svg)
![Docs Badge](https://gitea.tourolle.paris/dtourolle/pyPhotoAlbum/raw/branch/badges/cov_info/coverage-docs.svg)
A Python application for designing photo albums and exporting them to PDF.
```
## Workflow Details
- **Workflow File**: `.gitea/workflows/ci.yml`
- **Trigger**: Pushes to `main`, `master`, or `develop` branches
- **Runner**: Self-hosted
- **Badge Branch**: `badges` (automatically created/updated)
- **Badge Location**: `cov_info/` directory in badges branch
## Requirements
The workflow requires a `PUSH_TOKEN` secret to be configured in your Gitea repository settings. This token allows the workflow to push to the badges branch.
### Setting Up the PUSH_TOKEN
1. Go to your Gitea profile settings
2. Navigate to Applications → Generate New Token
3. Give it a descriptive name (e.g., "CI Badges Token")
4. Select the `repository` scope
5. Generate the token
6. Go to your repository → Settings → Secrets
7. Add a new secret named `PUSH_TOKEN` with the token value
## Coverage Reports
In addition to badges, the workflow also generates:
- `coverage.json` - Machine-readable coverage data
- `coverage.xml` - XML format coverage report
- `htmlcov/` - HTML coverage report
- `coverage-summary.txt` - Simple text summary of coverage percentage
All these files are available as artifacts after each workflow run and are stored in the `badges` branch under `cov_info/`.
+67
View File
@@ -0,0 +1,67 @@
# CI test image for pyPhotoAlbum
# Build: docker build -f Dockerfile.ci -t gitea.tourolle.paris/dtourolle/pyphotoalbum-ci:latest .
# Push: docker push gitea.tourolle.paris/dtourolle/pyphotoalbum-ci:latest
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y \
--no-install-recommends \
# Python
python3 \
python3-pip \
# PyQt6 / OpenGL runtime deps
libgl1 \
libglib2.0-0 \
libegl1 \
libfontconfig1 \
libfreetype6 \
libdbus-1-3 \
# XCB / display (needed even with offscreen platform)
libx11-6 \
libx11-xcb1 \
libxcb1 \
libxcb-cursor0 \
libxcb-glx0 \
libxcb-icccm4 \
libxcb-image0 \
libxcb-keysyms1 \
libxcb-randr0 \
libxcb-render0 \
libxcb-render-util0 \
libxcb-shape0 \
libxcb-shm0 \
libxcb-sync1 \
libxcb-xfixes0 \
libxcb-xinerama0 \
libxcb-xkb1 \
libxkbcommon0 \
libxkbcommon-x11-0 \
# Misc tools used in workflows
curl \
git \
nodejs \
&& rm -rf /var/lib/apt/lists/*
# Install all Python dependencies so CI runs don't need to download anything
RUN pip3 install --break-system-packages --no-cache-dir \
# Runtime deps
PyQt6 \
PyOpenGL \
numpy \
Pillow \
reportlab \
lxml \
pypdf \
# Dev/test deps
pytest \
pytest-qt \
pytest-cov \
pytest-mock \
pdfplumber \
flake8 \
black \
mypy \
coverage-badge \
interrogate \
setuptools
+63
View File
@@ -0,0 +1,63 @@
# Dockerfile for testing pyPhotoAlbum installation on Debian
FROM debian:bookworm
# Avoid interactive prompts
ENV DEBIAN_FRONTEND=noninteractive
# Install system dependencies (same as install-debian.sh)
RUN apt-get update && apt-get install -y \
sudo \
curl \
python3 \
python3-venv \
python3-pip \
libgl1-mesa-dev \
libglu1-mesa-dev \
libxcb-xinerama0 \
libxcb-cursor0 \
libxkbcommon0 \
libdbus-1-3 \
libegl1 \
libfontconfig1 \
libfreetype6 \
libx11-6 \
libx11-xcb1 \
libxcb1 \
libxcb-glx0 \
libxcb-icccm4 \
libxcb-image0 \
libxcb-keysyms1 \
libxcb-randr0 \
libxcb-render0 \
libxcb-render-util0 \
libxcb-shape0 \
libxcb-shm0 \
libxcb-sync1 \
libxcb-xfixes0 \
libxcb-xkb1 \
libxkbcommon-x11-0 \
libglib2.0-0 \
libgtk-3-0 \
&& rm -rf /var/lib/apt/lists/*
# Create a non-root user for testing
RUN useradd -m -s /bin/bash testuser && \
echo "testuser ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
# Copy project files
WORKDIR /home/testuser/pyPhotoAlbum
COPY . .
# Fix ownership
RUN chown -R testuser:testuser /home/testuser/pyPhotoAlbum
# Switch to test user
USER testuser
# Create venv and install
RUN python3 -m venv venv && \
./venv/bin/pip install --upgrade pip && \
./venv/bin/pip install -e .
# Default command - run the app
CMD ["./launch-pyphotoalbum.sh"]
+541
View 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.
+34 -616
View File
@@ -1,6 +1,10 @@
# pyPhotoAlbum # pyPhotoAlbum
A Python-based desktop application for designing photo albums with an intuitive interface and professional PDF export capabilities. ![Test Coverage](https://gitea.tourolle.paris/dtourolle/pyPhotoAlbum/raw/branch/badges/cov_info/coverage.svg)
![Documentation Coverage](https://gitea.tourolle.paris/dtourolle/pyPhotoAlbum/raw/branch/badges/cov_info/coverage-docs.svg)
![License](https://img.shields.io/badge/license-MIT-blue.svg)
A desktop application for designing and creating professional photo albums with an intuitive drag-and-drop interface and high-quality PDF export.
## Overview ## Overview
@@ -31,19 +35,8 @@ cd pyPhotoAlbum
./install.sh ./install.sh
``` ```
**For GNOME users:** See [GNOME_QUICKSTART.md](GNOME_QUICKSTART.md) for desktop integration.
**For detailed instructions:** See [INSTALLATION.md](INSTALLATION.md) **For detailed instructions:** See [INSTALLATION.md](INSTALLATION.md)
### Requirements
- Python 3.9 or higher
- PyQt6
- PyOpenGL
- Pillow
- ReportLab
- lxml
### Distribution Packages ### Distribution Packages
**Fedora (RPM):** **Fedora (RPM):**
@@ -59,25 +52,12 @@ makepkg -si
See [INSTALLATION.md](INSTALLATION.md) for complete instructions. See [INSTALLATION.md](INSTALLATION.md) for complete instructions.
### Install for Development
```bash
# Clone repository
git clone https://gitea.tourolle.paris/dtourolle/pyPhotoAlbum.git
cd pyPhotoAlbum
# Create virtual environment
python -m venv venv
source venv/bin/activate
# Install with development dependencies
pip install -e ".[dev]"
```
## Quick Start ## Quick Start
### Running the Application ### Running the Application
After installation, launch pyPhotoAlbum:
```bash ```bash
pyphotoalbum pyphotoalbum
``` ```
@@ -88,600 +68,44 @@ Or run directly from source:
python pyPhotoAlbum/main.py python pyPhotoAlbum/main.py
``` ```
### Basic Usage Example ### Basic Workflow
```python 1. **Create a New Project** - Choose your page size (A4, Letter, etc.) and DPI
from pyPhotoAlbum.project import Project, Page 2. **Add Pages** - Start with blank pages or use templates
from pyPhotoAlbum.page_layout import PageLayout 3. **Add Images** - Drag and drop images from your file browser onto pages
from pyPhotoAlbum.models import ImageData 4. **Arrange & Edit** - Move, resize, rotate, and crop images to your liking
5. **Save Your Work** - Projects are saved as .ppz files (ZIP archives)
6. **Export to PDF** - Generate high-quality PDFs ready for printing
# Create a new project ## Using Templates
project = Project(name="My Photo Album")
project.page_size_mm = (210, 297) # A4 size
project.working_dpi = 300
# Create a page with an image pyPhotoAlbum includes a template system to help you quickly create consistent layouts:
layout = PageLayout(width=210, height=297)
image = ImageData(
image_path="photos/vacation.jpg",
x=10.0,
y=10.0,
width=190.0,
height=140.0
)
layout.add_element(image)
# Add page to project - **Built-in Templates**: Grid layouts, single large image, and more
page = Page(layout=layout, page_number=1) - **Custom Templates**: Save your favorite layouts as templates
project.add_page(page) - **Flexible Application**: Apply templates to new or existing pages
# Save project ## Architecture Highlights
from pyPhotoAlbum.project_serializer import save_to_zip
success, error = save_to_zip(project, "my_album.ppz")
```
## Architecture pyPhotoAlbum is built with clean, maintainable design patterns:
### GLWidget Mixin Architecture ### Mixin-Based Composition
The main OpenGL widget uses a **mixin-based architecture** for maintainability and testability. The monolithic 1,368-line `gl_widget.py` has been refactored into 9 focused mixins averaging 89 lines each: The main OpenGL widget is composed of **12 specialized mixins** instead of one monolithic class:
- Each mixin handles a single responsibility (viewport, rendering, selection, etc.)
- Average ~90 lines per mixin for maintainability
- Easy to test in isolation with comprehensive unit tests
- Clean separation of concerns throughout the codebase
```python ### Declarative UI with Decorators
class GLWidget(
ViewportMixin, # Zoom & pan state
RenderingMixin, # OpenGL rendering
AssetDropMixin, # Drag-and-drop
PageNavigationMixin, # Page detection
ImagePanMixin, # Image cropping
ElementManipulationMixin, # Resize & rotate
ElementSelectionMixin, # Hit detection
MouseInteractionMixin, # Event routing
UndoableInteractionMixin, # Undo/redo
QOpenGLWidget # Qt base class
):
"""Clean orchestration with minimal boilerplate"""
```
**Benefits:** The ribbon interface is **auto-generated from decorator metadata**:
- Each mixin has a single, clear responsibility - `@ribbon_action` - Automatically creates ribbon buttons from method metadata
- 89 comprehensive unit tests with 69-97% coverage per mixin - `@undoable_operation` - Automatically captures state for undo/redo
- Easy to test in isolation with mock dependencies - `@dialog_action` - Separates dialog presentation from business logic
- Clear separation of concerns - No manual UI wiring required - just add decorators to your methods
- Maintainable codebase (average 89 lines per mixin)
See [REFACTORING_COMPLETE.md](REFACTORING_COMPLETE.md) for details on the refactoring process. This approach keeps UI concerns separate from business logic and makes the codebase easier to maintain and extend.
### Core Components
#### Models (`models.py`)
Base classes for layout elements:
```python
# Image element with crop support
image = ImageData(
image_path="photo.jpg",
x=10, y=20,
width=200, height=150,
rotation=0,
z_index=0,
crop_info=(0, 0, 1, 1) # (x_min, y_min, x_max, y_max)
)
# Text box element
textbox = TextBoxData(
text_content="My Caption",
font_settings={"family": "Arial", "size": 14, "color": (0, 0, 0)},
alignment="center",
x=10, y=180,
width=200, height=30
)
# Placeholder for templates
placeholder = PlaceholderData(
placeholder_type="image",
x=10, y=10,
width=100, height=100
)
```
#### Project Structure (`project.py`)
```python
# Project contains multiple pages
project = Project(name="Album", folder_path="/path/to/project")
# Each page has a layout with elements
page = Page(layout=PageLayout(), page_number=1)
page.layout.add_element(image)
project.add_page(page)
```
#### Asset Management (`asset_manager.py`)
Automatic asset handling with reference counting:
```python
# Import an image into the project
asset_path = project.asset_manager.import_asset("photo.jpg")
# Returns: "assets/photo_001.jpg" (relative path)
# Assets are automatically copied to project folder
# Reference counting tracks usage across pages
# Cleanup happens automatically when elements are deleted
```
#### Command System (`commands.py`)
Undo/redo support for all operations:
```python
# Commands are automatically created for operations
from pyPhotoAlbum.commands import AddElementCommand, MoveElementCommand
# Add element (undoable)
cmd = AddElementCommand(page.layout, image, project.asset_manager)
project.history.execute(cmd)
# Move element (undoable)
cmd = MoveElementCommand(image, old_pos=(10, 10), new_pos=(20, 20))
project.history.execute(cmd)
# Undo/redo
project.history.undo()
project.history.redo()
```
### Layout System
#### PageLayout (`page_layout.py`)
Manages elements on a page:
```python
layout = PageLayout(width=210, height=297) # A4 in mm
# Add multiple elements
layout.add_element(image1)
layout.add_element(image2)
layout.add_element(textbox)
# Elements are rendered in z_index order
# Serialize/deserialize for saving
data = layout.serialize()
layout2 = PageLayout()
layout2.deserialize(data)
```
#### Alignment Tools (`alignment.py`)
Precise element positioning:
```python
from pyPhotoAlbum.alignment import AlignmentManager
# Align multiple elements to the left
changes = AlignmentManager.align_left(selected_elements)
for element, new_position in changes:
element.position = new_position
# Distribute elements evenly
changes = AlignmentManager.distribute_horizontally(selected_elements)
# Make elements the same size
changes = AlignmentManager.make_same_size(selected_elements)
for element, new_position, new_size in changes:
element.position = new_position
element.size = new_size
```
### Template System
#### Creating Templates
```python
from pyPhotoAlbum.template_manager import TemplateManager, Template
manager = TemplateManager()
# Create template from existing page
template = manager.create_template_from_page(
page=current_page,
name="My Grid Layout",
description="2x2 photo grid"
)
# Save template
manager.save_template(template)
```
#### Using Templates
```python
# List available templates
templates = manager.list_templates()
# Returns: ["Grid_2x2", "Single_Large", "My Grid Layout", ...]
# Create new page from template
new_page = manager.create_page_from_template(
template_name="Grid_2x2",
target_page_size=(210, 297),
page_number=5
)
# Apply template to existing page
manager.apply_template_to_page(
template=template,
target_page=existing_page,
mode="replace", # or "reflow"
scaling="proportional" # or "stretch" or "center"
)
```
### Project Serialization
#### Save/Load Projects
Projects are saved as ZIP archives (.ppz) containing:
- `project.json` - Project metadata and structure
- `assets/` - All referenced images
```python
from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip
# Save project
success, error = save_to_zip(project, "album.ppz")
if not success:
print(f"Error saving: {error}")
# Load project
loaded_project, error = load_from_zip("album.ppz")
if loaded_project:
print(f"Loaded: {loaded_project.name}")
else:
print(f"Error loading: {error}")
# Get project info without loading
from pyPhotoAlbum.project_serializer import get_project_info
info = get_project_info("album.ppz")
print(f"Name: {info['name']}, Pages: {info['page_count']}")
```
#### Project Structure
```python
{
"name": "My Album",
"serialization_version": "1.0",
"page_size_mm": [210, 297],
"working_dpi": 300,
"export_dpi": 300,
"pages": [
{
"page_number": 1,
"is_double_spread": false,
"layout": {
"width": 210,
"height": 297,
"elements": [...]
}
}
]
}
```
### PDF Export
```python
from pyPhotoAlbum.pdf_exporter import PDFExporter
# Create exporter
exporter = PDFExporter(project, export_dpi=300)
# Export with progress callback
def progress_callback(current, total):
print(f"Exporting page {current}/{total}")
success, errors = exporter.export(
output_path="album.pdf",
progress_callback=progress_callback
)
if success:
print("PDF exported successfully")
else:
print(f"Errors: {errors}")
```
## Testing
### Run Tests
```bash
# Run all tests
pytest
# Run with coverage
pytest --cov=pyPhotoAlbum --cov-report=html
# Run specific test file
pytest tests/test_models.py
# Run with verbose output
pytest -v
```
### Test Structure
```
tests/
├── __init__.py
├── conftest.py # Shared fixtures
├── test_models.py # Model serialization tests
├── test_project.py # Project and page tests
├── test_project_serialization.py # Save/load tests
├── test_page_renderer.py # Rendering tests
├── test_pdf_export.py # PDF export tests
├── test_gl_widget_fixtures.py # Shared GL widget test fixtures
├── test_viewport_mixin.py # Viewport mixin tests
├── test_element_selection_mixin.py # Selection mixin tests
├── test_element_manipulation_mixin.py # Manipulation mixin tests
├── test_image_pan_mixin.py # Image pan mixin tests
├── test_page_navigation_mixin.py # Page navigation mixin tests
└── test_asset_drop_mixin.py # Asset drop mixin tests
```
### Example Test Cases
From `tests/test_models.py`:
```python
def test_image_serialization():
"""Test ImageData serialization"""
img = ImageData(
image_path="test.jpg",
x=15.0, y=25.0,
width=180.0, height=120.0,
rotation=30.0,
z_index=3
)
# Serialize
data = img.serialize()
assert data["type"] == "image"
assert data["position"] == (15.0, 25.0)
# Deserialize
img2 = ImageData()
img2.deserialize(data)
assert img2.position == img.position
```
From `tests/test_project_serialization.py`:
```python
def test_save_and_load_project(tmp_path):
"""Test complete save/load cycle"""
# Create project with pages
project = Project(name="Test")
page = Page(layout=PageLayout(), page_number=1)
project.add_page(page)
# Save
zip_path = tmp_path / "project.ppz"
success, error = save_to_zip(project, str(zip_path))
assert success is True
# Load
loaded, error = load_from_zip(str(zip_path))
assert loaded.name == "Test"
assert len(loaded.pages) == 1
```
## Project Structure
```
pyPhotoAlbum/
├── __init__.py
├── main.py # Application entry point
├── models.py # Data models (ImageData, TextBoxData, etc.)
├── project.py # Project and Page classes
├── page_layout.py # Page layout management
├── page_renderer.py # OpenGL rendering
├── gl_widget.py # Main OpenGL widget (mixin orchestration)
├── project_serializer.py # Save/load functionality
├── asset_manager.py # Asset handling
├── commands.py # Undo/redo system
├── template_manager.py # Template system
├── pdf_exporter.py # PDF export
├── alignment.py # Alignment tools
├── snapping.py # Snapping system
├── decorators.py # UI decorators
├── ribbon_widget.py # Ribbon interface
├── ribbon_builder.py # Ribbon configuration
├── mixins/ # Mixin architecture
│ ├── __init__.py
│ ├── base.py # Base mixin class
│ ├── viewport.py # Zoom and pan management
│ ├── rendering.py # OpenGL rendering pipeline
│ ├── asset_drop.py # Drag-and-drop functionality
│ ├── page_navigation.py # Page detection and ghost pages
│ ├── image_pan.py # Image cropping within frames
│ ├── element_manipulation.py # Resize and rotate
│ ├── element_selection.py # Hit detection and selection
│ ├── mouse_interaction.py # Mouse event coordination
│ ├── interaction_undo.py # Undo/redo integration
│ └── operations/ # Operation mixins
│ ├── element_ops.py
│ ├── page_ops.py
│ ├── file_ops.py
│ ├── view_ops.py
│ ├── edit_ops.py
│ ├── template_ops.py
│ ├── alignment_ops.py
│ ├── distribution_ops.py
│ └── size_ops.py
└── templates/ # Built-in templates
├── Grid_2x2.json
└── Single_Large.json
tests/ # Unit tests (312 tests, 29% coverage)
examples/ # Usage examples
```
## Development
### Code Style
The project uses:
- **Black** for code formatting (line length: 120)
- **Flake8** for linting
- **MyPy** for type checking
```bash
# Format code
black pyPhotoAlbum tests
# Run linter
flake8 pyPhotoAlbum tests
# Type checking
mypy pyPhotoAlbum
```
### Continuous Integration
GitHub Actions / Gitea Actions workflows:
- Run tests on Python 3.9, 3.10, 3.11
- Check code quality with linters
- Generate coverage reports
### Contributing
1. Fork the repository
2. Create a feature branch
3. Write tests for new features
4. Ensure all tests pass
5. Submit a pull request
## Usage Examples
See the `examples/` directory for complete working examples:
- `basic_usage.py` - Creating projects and adding images
- `template_example.py` - Working with templates
- `generate_screenshots.py` - Creating documentation screenshots
Run examples:
```bash
cd examples
python basic_usage.py
```
## API Reference
### Key Classes
#### BaseLayoutElement (Abstract)
Base class for all layout elements.
**Methods:**
- `render()` - Render element using OpenGL
- `serialize() -> Dict` - Convert to dictionary
- `deserialize(data: Dict)` - Load from dictionary
**Attributes:**
- `position: Tuple[float, float]` - (x, y) in mm
- `size: Tuple[float, float]` - (width, height) in mm
- `rotation: float` - Rotation angle in degrees
- `z_index: int` - Layer order
#### ImageData
Image element with crop support.
**Constructor:**
```python
ImageData(
image_path: str = "",
crop_info: Tuple = (0, 0, 1, 1),
x: float = 0,
y: float = 0,
width: float = 100,
height: float = 100,
rotation: float = 0,
z_index: int = 0
)
```
#### TextBoxData
Text element with formatting.
**Constructor:**
```python
TextBoxData(
text_content: str = "",
font_settings: Dict = None,
alignment: str = "left",
x: float = 0,
y: float = 0,
width: float = 100,
height: float = 100
)
```
**Font Settings:**
```python
{
"family": "Arial",
"size": 12,
"color": (0, 0, 0) # RGB tuple
}
```
#### Project
Main project container.
**Methods:**
- `add_page(page: Page)` - Add page to project
- `remove_page(page: Page)` - Remove page
- `serialize() -> Dict` - Save to dictionary
- `deserialize(data: Dict)` - Load from dictionary
**Attributes:**
- `name: str` - Project name
- `pages: List[Page]` - List of pages
- `page_size_mm: Tuple[float, float]` - Page dimensions
- `working_dpi: int` - Display DPI
- `export_dpi: int` - Export DPI
- `asset_manager: AssetManager` - Asset handler
- `history: CommandHistory` - Undo/redo history
#### Page
Single page in project.
**Constructor:**
```python
Page(
layout: PageLayout = None,
page_number: int = 1,
is_double_spread: bool = False
)
```
#### PageLayout
Element container for a page.
**Methods:**
- `add_element(element: BaseLayoutElement)` - Add element
- `remove_element(element: BaseLayoutElement)` - Remove element
- `render(dpi: int)` - Render all elements
**Attributes:**
- `elements: List[BaseLayoutElement]` - Page elements
- `width: float` - Page width in mm
- `height: float` - Page height in mm
## Keyboard Shortcuts ## Keyboard Shortcuts
@@ -700,12 +124,6 @@ Element container for a page.
This project is licensed under the MIT License. This project is licensed under the MIT License.
## Links
- Documentation: [Link to docs]
- Issue Tracker: [Link to issues]
- Changelog: [Link to changelog]
## Acknowledgments ## Acknowledgments
Built with: Built with:
-293
View File
@@ -1,293 +0,0 @@
# GLWidget Refactoring - COMPLETE! ✅
## Summary
Successfully refactored [gl_widget.py](pyPhotoAlbum/gl_widget.py) from **1,368 lines** into a clean **mixin-based architecture** with **9 focused mixins** totaling **~800 lines** of well-tested, maintainable code.
## Results
### Test Coverage
-**449 tests passing** (was 223 originally)
- **+226 new tests** added for mixins, commands, undo, and operations
- **0 failures** - complete backwards compatibility maintained
- Overall project coverage: **50%** (up from 6%) 🎉
### Code Metrics
**Before:**
- 1,368 lines in one monolithic file
- 27 methods
- 25+ state variables
- 13 conflated responsibilities
**After:**
- 85 lines in [gl_widget.py](pyPhotoAlbum/gl_widget.py:1-85) (orchestration only)
- ~800 lines total across 9 focused mixins
- Each mixin averages 89 lines
- Clear separation of concerns
### Extracted Mixins
| Mixin | Lines | Tests | Coverage | Purpose |
|-------|-------|-------|----------|---------|
| [ViewportMixin](pyPhotoAlbum/mixins/viewport.py:1-32) | 32 | 11 | 75% | Zoom and pan management |
| [ElementSelectionMixin](pyPhotoAlbum/mixins/element_selection.py:1-78) | 78 | 21 | 69% | Element hit detection & selection |
| [ElementManipulationMixin](pyPhotoAlbum/mixins/element_manipulation.py:1-71) | 71 | 18 | 97% | Resize, rotate, transfer |
| [ImagePanMixin](pyPhotoAlbum/mixins/image_pan.py:1-39) | 39 | 12 | 95% | Image cropping within frames |
| [PageNavigationMixin](pyPhotoAlbum/mixins/page_navigation.py:1-103) | 103 | 16 | 86% | Page detection & ghost pages |
| [AssetDropMixin](pyPhotoAlbum/mixins/asset_drop.py:1-74) | 74 | 11 | 81% | Drag-and-drop file handling |
| [MouseInteractionMixin](pyPhotoAlbum/mixins/mouse_interaction.py:1-189) | 189 | 18 | 65% | Mouse event coordination |
| [RenderingMixin](pyPhotoAlbum/mixins/rendering.py:1-194) | 194 | - | - | OpenGL rendering pipeline |
| [UndoableInteractionMixin](pyPhotoAlbum/mixins/interaction_undo.py:1-104) | 104 | 22 | 100% | Undo/redo integration |
**Total:** 884 lines extracted, 147 tests added
## Architecture
### New GLWidget Structure
```python
class GLWidget(
ViewportMixin, # Zoom & pan state
RenderingMixin, # OpenGL rendering
AssetDropMixin, # Drag-and-drop
PageNavigationMixin, # Page detection
ImagePanMixin, # Image cropping
ElementManipulationMixin, # Resize & rotate
ElementSelectionMixin, # Hit detection
MouseInteractionMixin, # Event routing
UndoableInteractionMixin, # Undo/redo
QOpenGLWidget # Qt base class
):
"""Clean orchestration with minimal boilerplate"""
```
### Method Resolution Order (MRO)
The mixin order is carefully designed:
1. **ViewportMixin** - Provides fundamental state (zoom, pan)
2. **RenderingMixin** - Uses viewport for rendering
3. **AssetDropMixin** - Depends on page navigation
4. **PageNavigationMixin** - Provides page detection
5. **ImagePanMixin** - Needs viewport and selection
6. **ElementManipulationMixin** - Needs selection
7. **ElementSelectionMixin** - Core element operations
8. **MouseInteractionMixin** - Coordinates all above
9. **UndoableInteractionMixin** - Adds undo to interactions
## Benefits Achieved
### 1. **Maintainability**
- Each mixin has a single, clear responsibility
- Average mixin size: 89 lines (easy to understand)
- Self-contained functionality with minimal coupling
### 2. **Testability**
- 89 new unit tests for previously untested code
- Mixins can be tested in isolation
- Mock dependencies easily
- High coverage (69-97% per mixin)
### 3. **Reusability**
- Mixins can be composed in different ways
- Easy to add new functionality by creating new mixins
- Pattern established for future refactoring
### 4. **Backwards Compatibility**
- All 223 original tests still pass
- No breaking changes to public API
- `selected_element` property maintained for compatibility
- Zero regressions
### 5. **Code Quality**
- Type hints added throughout
- Comprehensive docstrings
- Clear naming conventions
- Consistent patterns
## Files Changed
### Created
- [pyPhotoAlbum/mixins/viewport.py](pyPhotoAlbum/mixins/viewport.py)
- [pyPhotoAlbum/mixins/element_selection.py](pyPhotoAlbum/mixins/element_selection.py)
- [pyPhotoAlbum/mixins/element_manipulation.py](pyPhotoAlbum/mixins/element_manipulation.py)
- [pyPhotoAlbum/mixins/image_pan.py](pyPhotoAlbum/mixins/image_pan.py)
- [pyPhotoAlbum/mixins/page_navigation.py](pyPhotoAlbum/mixins/page_navigation.py)
- [pyPhotoAlbum/mixins/asset_drop.py](pyPhotoAlbum/mixins/asset_drop.py)
- [pyPhotoAlbum/mixins/mouse_interaction.py](pyPhotoAlbum/mixins/mouse_interaction.py)
- [pyPhotoAlbum/mixins/rendering.py](pyPhotoAlbum/mixins/rendering.py)
- [tests/test_viewport_mixin.py](tests/test_viewport_mixin.py)
- [tests/test_element_selection_mixin.py](tests/test_element_selection_mixin.py)
- [tests/test_element_manipulation_mixin.py](tests/test_element_manipulation_mixin.py)
- [tests/test_image_pan_mixin.py](tests/test_image_pan_mixin.py)
- [tests/test_page_navigation_mixin.py](tests/test_page_navigation_mixin.py)
- [tests/test_asset_drop_mixin.py](tests/test_asset_drop_mixin.py)
- [tests/test_mouse_interaction_mixin.py](tests/test_mouse_interaction_mixin.py)
- [tests/test_gl_widget_integration.py](tests/test_gl_widget_integration.py)
- [tests/test_gl_widget_fixtures.py](tests/test_gl_widget_fixtures.py)
- [tests/test_commands.py](tests/test_commands.py) - 39 tests for command pattern (Phase 2)
### Modified
- [pyPhotoAlbum/gl_widget.py](pyPhotoAlbum/gl_widget.py) - Reduced from 1,368 → 85 lines
- [pyPhotoAlbum/commands.py](pyPhotoAlbum/commands.py) - Coverage improved from 26% → 59%
### Archived
- [pyPhotoAlbum/gl_widget_old.py](pyPhotoAlbum/gl_widget_old.py) - Original backup
## Bug Fixes During Refactoring
1. **None project checks** - Added null safety in ViewportMixin, ElementSelectionMixin, PageNavigationMixin, AssetDropMixin
2. **Floating point precision** - Fixed tolerance issues in image pan tests
3. **Mock decorator paths** - Corrected @patch paths in page navigation tests
## Testing Strategy
Each mixin follows this proven pattern:
1. **Initialization tests** - Verify default state
2. **Functionality tests** - Test core methods
3. **Edge case tests** - Null checks, boundary conditions
4. **Integration tests** - Verify mixin interactions
Example coverage breakdown:
- **ElementManipulationMixin**: 97% (2 of 71 lines uncovered)
- **ImagePanMixin**: 95% (2 of 39 lines uncovered)
- **PageNavigationMixin**: 86% (14 of 103 lines uncovered)
- **AssetDropMixin**: 81% (14 of 74 lines uncovered)
- **MouseInteractionMixin**: 65% (67 of 189 lines uncovered)
## Phase 2: Command Pattern Testing (Medium Effort)
After completing the GLWidget refactoring, we continued with Phase 2 to improve test coverage for the command pattern implementation.
### Command Tests Added
- **39 comprehensive tests** for [commands.py](pyPhotoAlbum/commands.py)
- Coverage improved from **26% → 59%** (+33 percentage points)
- Tests cover all command types:
- `_normalize_asset_path` helper (4 tests)
- `AddElementCommand` (5 tests)
- `DeleteElementCommand` (3 tests)
- `MoveElementCommand` (3 tests)
- `ResizeElementCommand` (3 tests)
- `RotateElementCommand` (3 tests)
- `AdjustImageCropCommand` (2 tests)
- `AlignElementsCommand` (2 tests)
- `ResizeElementsCommand` (2 tests)
- `ChangeZOrderCommand` (2 tests)
- `StateChangeCommand` (3 tests)
- `CommandHistory` (7 tests)
### Key Test Patterns
Each command follows this test structure:
1. **Execute tests** - Verify command execution changes state correctly
2. **Undo tests** - Verify undo restores previous state
3. **Redo tests** - Verify redo re-applies changes
4. **Serialization tests** - Verify command can be serialized/deserialized
5. **Asset management tests** - Verify reference counting for image assets
6. **History management tests** - Verify undo/redo stack behavior
### Coverage Improvements by File
- [pyPhotoAlbum/commands.py](pyPhotoAlbum/commands.py): 26% → 59% (+33%)
- Overall project: 38% → 40% (+2%)
## Phase 3: InteractionUndo Testing (High Value)
After completing Phase 2, we continued with Phase 3 to achieve 100% coverage for the undo/redo interaction tracking system.
### InteractionUndo Tests Added
- **22 comprehensive tests** for [interaction_undo.py](pyPhotoAlbum/mixins/interaction_undo.py)
- Coverage improved from **42% → 100%** (+58 percentage points)
- Tests cover all interaction types:
- Initialization (1 test)
- Begin Move (2 tests)
- Begin Resize (1 test)
- Begin Rotate (1 test)
- Begin Image Pan (2 tests)
- End Interaction (9 tests - all command types)
- Clear State (2 tests)
- Cancel Interaction (1 test)
- Edge Cases (3 tests)
### Key Test Patterns
Each interaction follows this test structure:
1. **Begin tests** - Verify state capture (position, size, rotation, crop)
2. **End tests** - Verify command creation and execution
3. **Significance tests** - Verify tiny changes don't create commands
4. **Error handling tests** - Verify graceful handling of edge cases
### Coverage Improvements by File
- [pyPhotoAlbum/mixins/interaction_undo.py](pyPhotoAlbum/mixins/interaction_undo.py): 42% → 100% (+58%)
- Overall project: 40% → 41% (+1%)
## Phase 4: Operations Mixins Testing (Easy Wins)
After completing Phase 3, we continued with Phase 4 to test operations mixins that were all at 0% coverage.
### Operations Mixin Tests Added
- **40 comprehensive tests** for 3 operations mixins
- Coverage improvements:
- `zorder_ops.py`: 0% → 92% (+92%, 17 tests)
- `alignment_ops.py`: 0% → 93% (+93%, 12 tests)
- `element_ops.py`: 0% → 96% (+96%, 11 tests)
### Key Operations Tested
**Z-Order Operations (17 tests):**
- Bring to Front / Send to Back
- Bring Forward / Send Backward
- Swap Order
- Command pattern integration
- Edge cases (no selection, already at position, etc.)
**Alignment Operations (12 tests):**
- Align Left / Right / Top / Bottom
- Align Horizontal Center / Vertical Center
- Command pattern integration
- Minimum selection checks (requires 2+ elements)
**Element Operations (11 tests):**
- Add Image (with asset management)
- Add Text Box
- Add Placeholder
- Image scaling for large images
- File dialog integration
- Error handling
### Coverage Improvements by File
- [pyPhotoAlbum/mixins/operations/zorder_ops.py](pyPhotoAlbum/mixins/operations/zorder_ops.py): 0% → 92% (+92%)
- [pyPhotoAlbum/mixins/operations/alignment_ops.py](pyPhotoAlbum/mixins/operations/alignment_ops.py): 0% → 93% (+93%)
- [pyPhotoAlbum/mixins/operations/element_ops.py](pyPhotoAlbum/mixins/operations/element_ops.py): 0% → 96% (+96%)
- **Overall project: 41% → 50%** (+9%) 🎉
## Next Steps (Optional)
While the refactoring is complete and Phases 2-4 are done, future improvements could include:
1. **Phase 5: Remaining operations mixins** - 7 files at 5-26% coverage (distribution, size, edit, view, template, page, file)
2. **Add tests for RenderingMixin** - Visual testing is challenging but possible (currently at 5%)
3. **Improve MouseInteractionMixin coverage** - Currently at 65%, could add tests for rotation and resize modes
4. **Improve ElementSelectionMixin coverage** - Currently at 69%, could add complex selection tests
5. **Performance profiling** - Ensure mixin overhead is negligible
6. **Documentation** - Add architecture diagrams and mixin interaction guide
## Conclusion
The refactoring successfully achieved all goals:
✅ Broke up monolithic 1,368-line file
✅ Created maintainable mixin architecture
✅ Added 226 comprehensive tests across 4 phases
✅ Maintained 100% backwards compatibility
✅ Established pattern for future refactoring
✅ Improved overall code quality
**Increased test coverage from 6% to 50%** - major milestone! 🎉
The codebase is now significantly more maintainable, testable, and extensible.
---
**Completed:** 2025-11-11
**Time invested:** ~40 hours
**Lines refactored:** 1,368 → 85 + (9 mixins × ~89 avg lines)
**Tests added:** 226 (125 for mixins, 39 for commands, 22 for undo, 40 for operations)
**Tests passing:** 449/449 ✅
**Coverage:** 6% → 50% (+44%)
+205
View 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.
+265
View File
@@ -0,0 +1,265 @@
#!/bin/bash
# Debian/Ubuntu installation script for pyPhotoAlbum
# Creates a virtual environment and installs all dependencies
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
print_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
print_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
print_step() {
echo -e "${BLUE}[STEP]${NC} $1"
}
# Get the directory where this script is located
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VENV_DIR="$SCRIPT_DIR/venv"
INSTALL_DIR="$HOME/.local"
BIN_DIR="$INSTALL_DIR/bin"
echo "========================================"
echo " pyPhotoAlbum Debian Installation "
echo "========================================"
echo ""
# Check if running on Debian/Ubuntu
if [ -f /etc/os-release ]; then
. /etc/os-release
if [[ "$ID" != "debian" && "$ID" != "ubuntu" && "$ID_LIKE" != *"debian"* && "$ID_LIKE" != *"ubuntu"* ]]; then
print_warn "This script is designed for Debian/Ubuntu-based systems."
print_warn "Detected: $PRETTY_NAME"
read -p "Continue anyway? [y/N]: " continue_choice
if [[ ! "$continue_choice" =~ ^[Yy]$ ]]; then
exit 1
fi
else
print_info "Detected: $PRETTY_NAME"
fi
fi
# Check for required files
if [ ! -f "$SCRIPT_DIR/pyproject.toml" ]; then
print_error "pyproject.toml not found. Please run this script from the project root."
exit 1
fi
# Step 1: Install system dependencies
print_step "Installing system dependencies..."
echo ""
# Check if we need sudo
if [ "$(id -u)" -ne 0 ]; then
SUDO="sudo"
else
SUDO=""
fi
$SUDO apt update
# Install Python and venv support
print_info "Installing Python and venv support..."
$SUDO apt install -y python3 python3-venv python3-pip
# Install system libraries required for PyQt6 and OpenGL
print_info "Installing Qt6 and OpenGL libraries..."
$SUDO apt install -y \
libgl1-mesa-dev \
libglu1-mesa-dev \
libxcb-xinerama0 \
libxcb-cursor0 \
libxkbcommon0 \
libdbus-1-3 \
libegl1 \
libfontconfig1 \
libfreetype6 \
libx11-6 \
libx11-xcb1 \
libxcb1 \
libxcb-glx0 \
libxcb-icccm4 \
libxcb-image0 \
libxcb-keysyms1 \
libxcb-randr0 \
libxcb-render0 \
libxcb-render-util0 \
libxcb-shape0 \
libxcb-shm0 \
libxcb-sync1 \
libxcb-xfixes0 \
libxcb-xkb1 \
libxkbcommon-x11-0 \
libglib2.0-0 \
libgtk-3-0 || print_warn "Some packages may not be available, continuing..."
echo ""
# Step 2: Create virtual environment
print_step "Creating virtual environment..."
echo ""
if [ -d "$VENV_DIR" ]; then
print_warn "Virtual environment already exists at $VENV_DIR"
read -p "Remove and recreate? [y/N]: " recreate_choice
if [[ "$recreate_choice" =~ ^[Yy]$ ]]; then
print_info "Removing existing virtual environment..."
rm -rf "$VENV_DIR"
else
print_info "Using existing virtual environment..."
fi
fi
if [ ! -d "$VENV_DIR" ]; then
print_info "Creating virtual environment at $VENV_DIR..."
python3 -m venv "$VENV_DIR"
fi
# Activate virtual environment
source "$VENV_DIR/bin/activate"
# Upgrade pip
print_info "Upgrading pip..."
pip install --upgrade pip
echo ""
# Step 3: Install Python dependencies
print_step "Installing Python dependencies..."
echo ""
print_info "Installing pyPhotoAlbum and its dependencies..."
pip install -e "$SCRIPT_DIR"
echo ""
# Step 4: Create launcher script
print_step "Creating launcher script..."
echo ""
mkdir -p "$BIN_DIR"
cat > "$BIN_DIR/pyphotoalbum" << EOF
#!/bin/bash
# pyPhotoAlbum launcher script
# Activates the virtual environment and runs the application
SCRIPT_DIR="$SCRIPT_DIR"
VENV_DIR="$VENV_DIR"
# Activate venv and run
source "\$VENV_DIR/bin/activate"
exec python "\$SCRIPT_DIR/pyPhotoAlbum/main.py" "\$@"
EOF
chmod +x "$BIN_DIR/pyphotoalbum"
print_info "Launcher script created at $BIN_DIR/pyphotoalbum"
echo ""
# Step 5: Install desktop integration
print_step "Installing desktop integration..."
echo ""
DESKTOP_DIR="$HOME/.local/share/applications"
ICON_DIR="$HOME/.local/share/icons/hicolor"
mkdir -p "$DESKTOP_DIR"
mkdir -p "$ICON_DIR/256x256/apps"
# Create desktop file with correct path
cat > "$DESKTOP_DIR/pyphotoalbum.desktop" << EOF
[Desktop Entry]
Type=Application
Name=pyPhotoAlbum
GenericName=Photo Album Designer
Comment=Design photo albums and export them to PDF
Exec=$BIN_DIR/pyphotoalbum %F
Icon=pyphotoalbum
Terminal=false
Categories=Graphics;Photography;Qt;
Keywords=photo;album;pdf;design;layout;
MimeType=application/x-pyphotoalbum-project;
StartupNotify=true
StartupWMClass=pyPhotoAlbum
Actions=NewProject;
[Desktop Action NewProject]
Name=New Project
Exec=$BIN_DIR/pyphotoalbum --new
EOF
print_info "Desktop file created at $DESKTOP_DIR/pyphotoalbum.desktop"
# Copy icon
if [ -f "$SCRIPT_DIR/pyPhotoAlbum/icons/icon.png" ]; then
cp "$SCRIPT_DIR/pyPhotoAlbum/icons/icon.png" "$ICON_DIR/256x256/apps/pyphotoalbum.png"
print_info "Icon installed"
# Generate additional icon sizes if ImageMagick is available
if command -v convert &> /dev/null || command -v magick &> /dev/null; then
for size in 48 64 128; do
mkdir -p "$ICON_DIR/${size}x${size}/apps"
if command -v magick &> /dev/null; then
magick "$SCRIPT_DIR/pyPhotoAlbum/icons/icon.png" -resize ${size}x${size} "$ICON_DIR/${size}x${size}/apps/pyphotoalbum.png" 2>/dev/null
else
convert "$SCRIPT_DIR/pyPhotoAlbum/icons/icon.png" -resize ${size}x${size} "$ICON_DIR/${size}x${size}/apps/pyphotoalbum.png" 2>/dev/null
fi
done
print_info "Additional icon sizes generated"
fi
fi
# Update desktop database
if command -v update-desktop-database &> /dev/null; then
update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true
fi
# Update icon cache
if command -v gtk-update-icon-cache &> /dev/null; then
gtk-update-icon-cache -f "$ICON_DIR" 2>/dev/null || true
fi
echo ""
# Deactivate venv
deactivate
# Final message
echo "========================================"
echo -e "${GREEN} Installation complete!${NC}"
echo "========================================"
echo ""
echo "You can now run pyPhotoAlbum by:"
echo " 1) Running 'pyphotoalbum' in the terminal"
echo " 2) Finding 'pyPhotoAlbum' in your application menu"
echo ""
# Check if ~/.local/bin is in PATH
if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
print_warn "~/.local/bin is not in your PATH"
echo ""
echo "Add this to your ~/.bashrc or ~/.profile:"
echo " export PATH=\"\$HOME/.local/bin:\$PATH\""
echo ""
echo "Then run: source ~/.bashrc"
fi
echo ""
echo "To run directly from source directory:"
echo " $SCRIPT_DIR/launch-pyphotoalbum.sh"
echo ""
+47 -7
View File
@@ -72,17 +72,33 @@ install_dependencies() {
esac esac
} }
# Check if running in a virtual environment
in_virtualenv() {
[ -n "$VIRTUAL_ENV" ] || [ -n "$CONDA_DEFAULT_ENV" ]
}
# Install Python package # Install Python package
install_package() { install_package() {
local install_mode=$1 local install_mode=$1
if [ "$install_mode" = "system" ]; then case "$install_mode" in
print_info "Installing pyPhotoAlbum system-wide..." system)
sudo pip install . print_info "Installing pyPhotoAlbum system-wide..."
else sudo pip install --upgrade .
print_info "Installing pyPhotoAlbum for current user..." ;;
pip install --user . venv)
fi 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
@@ -192,6 +208,30 @@ main() {
install_mode="system" install_mode="system"
fi 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" print_info "Installation mode: $install_mode"
echo "" echo ""
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
# pyPhotoAlbum launch script
# Runs the application from the project directory using the local venv
# Get the directory where this script is located
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VENV_DIR="$SCRIPT_DIR/venv"
# Check if venv exists
if [ ! -d "$VENV_DIR" ]; then
echo "Error: Virtual environment not found at $VENV_DIR"
echo "Please run install-debian.sh first to set up the environment."
exit 1
fi
# Activate venv and run the application
source "$VENV_DIR/bin/activate"
exec python "$SCRIPT_DIR/pyPhotoAlbum/main.py" "$@"
+1 -1
View File
@@ -136,7 +136,7 @@ When loading:
```python ```python
from pyPhotoAlbum.project_serializer import load_from_zip from pyPhotoAlbum.project_serializer import load_from_zip
project, error = load_from_zip("myalbum.ppz") project = load_from_zip("myalbum.ppz")
# Embedded templates are automatically restored # Embedded templates are automatically restored
# Create template manager to access them # Create template manager to access them
+395 -145
View File
@@ -6,6 +6,263 @@ from typing import List, Tuple
from pyPhotoAlbum.models import BaseLayoutElement 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: class AlignmentManager:
"""Manages alignment and distribution operations on multiple elements""" """Manages alignment and distribution operations on multiple elements"""
@@ -114,7 +371,9 @@ class AlignmentManager:
return changes return changes
@staticmethod @staticmethod
def align_horizontal_center(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]: def align_horizontal_center(
elements: List[BaseLayoutElement],
) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
""" """
Align all elements to horizontal center. Align all elements to horizontal center.
@@ -162,7 +421,9 @@ class AlignmentManager:
return changes return changes
@staticmethod @staticmethod
def make_same_size(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]: def make_same_size(
elements: List[BaseLayoutElement],
) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
""" """
Make all elements the same size as the first element. Make all elements the same size as the first element.
@@ -184,7 +445,9 @@ class AlignmentManager:
return changes return changes
@staticmethod @staticmethod
def make_same_width(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]: def make_same_width(
elements: List[BaseLayoutElement],
) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
""" """
Make all elements the same width as the first element. Make all elements the same width as the first element.
@@ -206,7 +469,9 @@ class AlignmentManager:
return changes return changes
@staticmethod @staticmethod
def make_same_height(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]: def make_same_height(
elements: List[BaseLayoutElement],
) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
""" """
Make all elements the same height as the first element. Make all elements the same height as the first element.
@@ -228,7 +493,9 @@ class AlignmentManager:
return changes return changes
@staticmethod @staticmethod
def distribute_horizontally(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]: def distribute_horizontally(
elements: List[BaseLayoutElement],
) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
""" """
Distribute elements evenly across horizontal span. Distribute elements evenly across horizontal span.
@@ -362,7 +629,9 @@ class AlignmentManager:
return changes return changes
@staticmethod @staticmethod
def fit_to_page_width(element: BaseLayoutElement, page_width: float) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]: def fit_to_page_width(
element: BaseLayoutElement, page_width: float
) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
""" """
Resize element to fit page width while maintaining aspect ratio. Resize element to fit page width while maintaining aspect ratio.
@@ -387,7 +656,9 @@ class AlignmentManager:
return (element, old_pos, old_size) return (element, old_pos, old_size)
@staticmethod @staticmethod
def fit_to_page_height(element: BaseLayoutElement, page_height: float) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]: def fit_to_page_height(
element: BaseLayoutElement, page_height: float
) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
""" """
Resize element to fit page height while maintaining aspect ratio. Resize element to fit page height while maintaining aspect ratio.
@@ -412,7 +683,9 @@ class AlignmentManager:
return (element, old_pos, old_size) return (element, old_pos, old_size)
@staticmethod @staticmethod
def fit_to_page(element: BaseLayoutElement, page_width: float, page_height: float) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]: def fit_to_page(
element: BaseLayoutElement, page_width: float, page_height: float
) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
""" """
Resize element to fit within page dimensions while maintaining aspect ratio. Resize element to fit within page dimensions while maintaining aspect ratio.
@@ -451,7 +724,7 @@ class AlignmentManager:
page_size: Tuple[float, float], page_size: Tuple[float, float],
min_gap: float = 2.0, min_gap: float = 2.0,
max_iterations: int = 100, max_iterations: int = 100,
growth_rate: float = 0.05 growth_rate: float = 0.05,
) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]: ) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
""" """
Maximize element sizes using a crystal growth algorithm. Maximize element sizes using a crystal growth algorithm.
@@ -470,156 +743,133 @@ class AlignmentManager:
if not elements: if not elements:
return [] 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 page_width, page_height = page_size
changes = [] old_pos = element.position
old_size = element.size
# Record initial states x, y = element.position
for elem in elements: w, h = element.size
changes.append((elem, elem.position, elem.size))
# Helper function to check if element would collide with boundaries or other elements # Calculate maximum expansion in each direction
def check_collision(elem_idx: int, new_size: Tuple[float, float]) -> bool: # Start with page boundaries
elem = elements[elem_idx] max_left = x - min_gap # How much we can expand left
x, y = elem.position max_right = (page_width - min_gap) - (x + w) # How much we can expand right
w, h = new_size 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 page boundaries # Check constraints from other elements
if x < min_gap or y < min_gap: # We need to be conservative and check ALL elements against ALL expansion directions
return True for other in other_elements:
if x + w > page_width - min_gap: ox, oy = other.position
return True ow, oh = other.size
if y + h > page_height - min_gap:
return True
# Check collision with other elements # Calculate the other element's bounds
for i, other in enumerate(elements): other_left = ox
if i == elem_idx: other_right = ox + ow
continue other_top = oy
other_bottom = oy + oh
other_x, other_y = other.position # Calculate current element's bounds
other_w, other_h = other.size elem_left = x
elem_right = x + w
elem_top = y
elem_bottom = y + h
# Calculate distances between rectangles # Check leftward expansion
horizontal_gap = max( # An element blocks leftward expansion if:
other_x - (x + w), # Other is to the right # 1. It's to the left of our left edge (other_right <= elem_left)
x - (other_x + other_w) # Other is to the 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)
vertical_gap = max( # Check rightward expansion
other_y - (y + h), # Other is below if other_left >= elem_right:
y - (other_y + other_h) # Other is above # 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)
# If rectangles overlap or are too close in both dimensions # Check upward expansion
if horizontal_gap < min_gap and vertical_gap < min_gap: if other_bottom <= elem_top:
return True # 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)
return False # 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)
# Helper function to get the maximum scale factor for an element # Ensure non-negative expansion
def get_max_scale(elem_idx: int, current_scale: float) -> float: max_left = max(0, max_left)
elem = elements[elem_idx] max_right = max(0, max_right)
old_size = changes[elem_idx][2] max_top = max(0, max_top)
max_bottom = max(0, max_bottom)
# Binary search for maximum scale # Expand to fill all available space (no aspect ratio constraint)
low, high = current_scale, current_scale * 3.0 width_increase = max_left + max_right
best_scale = current_scale height_increase = max_top + max_bottom
for _ in range(20): # Binary search iterations # Calculate new size
mid = (low + high) / 2.0 new_width = w + width_increase
test_size = (old_size[0] * mid, old_size[1] * mid) new_height = h + height_increase
if check_collision(elem_idx, test_size): # Calculate new position (expand from center to maintain relative position)
high = mid # Distribute the expansion proportionally to available space on each side
else: if max_left + max_right > 0:
best_scale = mid left_ratio = max_left / (max_left + max_right)
low = mid new_x = x - (width_increase * left_ratio)
else:
new_x = x
if high - low < 0.001: if max_top + max_bottom > 0:
break top_ratio = max_top / (max_top + max_bottom)
new_y = y - (height_increase * top_ratio)
else:
new_y = y
return best_scale # Apply the new position and size
element.position = (new_x, new_y)
element.size = (new_width, new_height)
# Growth algorithm - iterative expansion return (element, old_pos, old_size)
scales = [1.0] * len(elements)
for iteration in range(max_iterations):
any_growth = False
for i, elem in enumerate(elements):
old_size = 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 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 = get_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
# If no element could grow, we're done
if not any_growth:
break
# Optional: Center elements slightly within their constrained space
for elem in elements:
x, y = elem.position
w, h = elem.size
# Calculate available space on each side
space_left = x - min_gap
space_right = (page_width - min_gap) - (x + w)
space_top = y - min_gap
space_bottom = (page_height - min_gap) - (y + h)
# Micro-adjust position to center in available space
if space_left >= 0 and space_right >= 0:
adjust_x = (space_right - space_left) / 4.0 # Gentle centering
new_x = max(min_gap, min(page_width - w - min_gap, x + adjust_x))
# Verify this doesn't cause collision
old_pos = elem.position
elem.position = (new_x, y)
collision = False
for other in elements:
if other is elem:
continue
ox, oy = other.position
ow, oh = other.size
if (abs((new_x + w/2) - (ox + ow/2)) < (w + ow)/2 + min_gap and
abs((y + h/2) - (oy + oh/2)) < (h + oh)/2 + min_gap):
collision = True
break
if collision:
elem.position = old_pos
if space_top >= 0 and space_bottom >= 0:
adjust_y = (space_bottom - space_top) / 4.0
new_y = max(min_gap, min(page_height - h - min_gap, y + adjust_y))
old_pos = elem.position
elem.position = (elem.position[0], new_y)
collision = False
for other in elements:
if other is elem:
continue
ox, oy = other.position
ow, oh = other.size
if (abs((elem.position[0] + w/2) - (ox + ow/2)) < (w + ow)/2 + min_gap and
abs((new_y + h/2) - (oy + oh/2)) < (h + oh)/2 + min_gap):
collision = True
break
if collision:
elem.position = old_pos
return changes
+112 -45
View File
@@ -3,11 +3,19 @@ Asset healing dialog for reconnecting missing images
""" """
import os import os
import shutil
from typing import List, Dict, Set from typing import List, Dict, Set
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QDialog,
QListWidget, QListWidgetItem, QFileDialog, QGroupBox, QVBoxLayout,
QMessageBox QHBoxLayout,
QLabel,
QPushButton,
QListWidget,
QListWidgetItem,
QFileDialog,
QGroupBox,
QMessageBox,
) )
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
@@ -78,24 +86,38 @@ class AssetHealDialog(QDialog):
self.setLayout(layout) self.setLayout(layout)
def _scan_missing_assets(self): def _scan_missing_assets(self):
"""Scan project for missing assets""" """Scan project for missing assets - only assets in project's assets folder are valid"""
from pyPhotoAlbum.models import ImageData from pyPhotoAlbum.models import ImageData
self.missing_assets.clear() self.missing_assets.clear()
self.missing_list.clear() self.missing_list.clear()
# Check all pages for missing images # 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 page in self.project.pages:
for element in page.layout.elements: for element in page.layout.elements:
if isinstance(element, ImageData) and element.image_path: if isinstance(element, ImageData) and element.image_path:
# Check if path exists needs_healing = False
if os.path.isabs(element.image_path): reason = ""
full_path = element.image_path
else:
full_path = os.path.join(self.project.folder_path, element.image_path)
if not os.path.exists(full_path): # 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) self.missing_assets.add(element.image_path)
print(f"Asset needs healing: {element.image_path} ({reason})")
# Display missing assets # Display missing assets
if self.missing_assets: if self.missing_assets:
@@ -109,10 +131,7 @@ class AssetHealDialog(QDialog):
def _add_search_path(self): def _add_search_path(self):
"""Add a search path""" """Add a search path"""
directory = QFileDialog.getExistingDirectory( directory = QFileDialog.getExistingDirectory(
self, self, "Select Search Path for Assets", "", QFileDialog.Option.ShowDirsOnly
"Select Search Path for Assets",
"",
QFileDialog.Option.ShowDirsOnly
) )
if directory: if directory:
@@ -128,52 +147,97 @@ class AssetHealDialog(QDialog):
self.search_list.takeItem(current_row) self.search_list.takeItem(current_row)
def _attempt_healing(self): def _attempt_healing(self):
"""Attempt to heal missing assets using search paths""" """Attempt to heal missing assets by resolving stored paths and using search paths"""
from pyPhotoAlbum.models import ImageData, set_asset_resolution_context from pyPhotoAlbum.models import ImageData, set_asset_resolution_context
if not self.search_paths:
QMessageBox.warning(
self,
"No Search Paths",
"Please add at least one search path before attempting to heal assets."
)
return
healed_count = 0 healed_count = 0
imported_count = 0
still_missing = [] still_missing = []
# Update asset resolution context with search paths # Update asset resolution context with search paths (for rendering after heal)
set_asset_resolution_context(self.project.folder_path, self.search_paths) set_asset_resolution_context(self.project.folder_path, self.search_paths)
# Try to find each missing asset # 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: for asset_path in self.missing_assets:
found = False found_path = None
filename = os.path.basename(asset_path) filename = os.path.basename(asset_path)
# Search in each search path # FIRST: Try to resolve the stored path directly from project folder
for search_path in self.search_paths: # This handles paths like "../../home/user/Photos/image.jpg"
# Try direct match if not os.path.isabs(asset_path):
candidate = os.path.join(search_path, filename) resolved = os.path.normpath(os.path.join(self.project.folder_path, asset_path))
if os.path.exists(candidate): if os.path.exists(resolved):
found = True found_path = resolved
healed_count += 1 print(f"Resolved relative path: {asset_path}{resolved}")
print(f"Healed: {asset_path}{candidate}")
break
# Try with same relative path # SECOND: If it's an absolute path, check if it exists directly
candidate = os.path.join(search_path, asset_path) if not found_path and os.path.isabs(asset_path):
if os.path.exists(candidate): if os.path.exists(asset_path):
found = True found_path = asset_path
healed_count += 1 print(f"Found at absolute path: {asset_path}")
print(f"Healed: {asset_path}{candidate}")
break
if not found: # 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) still_missing.append(asset_path)
# Report results # Report results
message = f"Healing complete!\n\n" message = f"Healing complete!\n\n"
message += f"Assets healed: {healed_count}\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)}" message += f"Still missing: {len(still_missing)}"
if still_missing: if still_missing:
@@ -184,5 +248,8 @@ class AssetHealDialog(QDialog):
QMessageBox.information(self, "Healing Results", message) 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 # Rescan to update the list
self._scan_missing_assets() self._scan_missing_assets()
+305 -28
View File
@@ -2,56 +2,82 @@
Asset management system for pyPhotoAlbum with automatic reference counting Asset management system for pyPhotoAlbum with automatic reference counting
""" """
import hashlib
import os import os
import shutil import shutil
from typing import Dict, Optional from typing import Dict, List, Optional, Tuple
from pathlib import Path 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: class AssetManager:
"""Manages project assets with automatic reference counting and cleanup""" """Manages project assets with automatic reference counting and cleanup"""
def __init__(self, project_folder: str): def __init__(self, project_folder: str):
""" """
Initialize AssetManager. Initialize AssetManager.
Args: Args:
project_folder: Root folder for the project project_folder: Root folder for the project
""" """
self.project_folder = project_folder self.project_folder = project_folder
self.assets_folder = os.path.join(project_folder, "assets") self.assets_folder = os.path.join(project_folder, "assets")
self.reference_counts: Dict[str, int] = {} # {relative_path: count} 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 # Create assets folder if it doesn't exist
os.makedirs(self.assets_folder, exist_ok=True) os.makedirs(self.assets_folder, exist_ok=True)
def import_asset(self, source_path: str) -> str: def import_asset(self, source_path: str) -> str:
""" """
Import an asset into the project by copying it to the assets folder. Import an asset into the project by copying it to the assets folder.
Args: Args:
source_path: Path to the source file source_path: Path to the source file
Returns: Returns:
Relative path to the imported asset (e.g., "assets/photo_001.jpg") Relative path to the imported asset (e.g., "assets/photo_001.jpg")
""" """
if not os.path.exists(source_path): if not os.path.exists(source_path):
raise FileNotFoundError(f"Source file not found: {source_path}") raise FileNotFoundError(f"Source file not found: {source_path}")
# Get filename and extension # Get filename and extension
filename = os.path.basename(source_path) filename = os.path.basename(source_path)
name, ext = os.path.splitext(filename) name, ext = os.path.splitext(filename)
# Find a unique filename if there's a collision # Find a unique filename if there's a collision
counter = 1 counter = 1
dest_filename = filename dest_filename = filename
dest_path = os.path.join(self.assets_folder, dest_filename) dest_path = os.path.join(self.assets_folder, dest_filename)
while os.path.exists(dest_path): while os.path.exists(dest_path):
dest_filename = f"{name}_{counter:03d}{ext}" dest_filename = f"{name}_{counter:03d}{ext}"
dest_path = os.path.join(self.assets_folder, dest_filename) dest_path = os.path.join(self.assets_folder, dest_filename)
counter += 1 counter += 1
# Copy the file # Copy the file
shutil.copy2(source_path, dest_path) shutil.copy2(source_path, dest_path)
@@ -65,17 +91,17 @@ class AssetManager:
# Return relative path for storage in elements # Return relative path for storage in elements
return relative_path return relative_path
def acquire_reference(self, asset_path: str): def acquire_reference(self, asset_path: str):
""" """
Increment the reference count for an asset. Increment the reference count for an asset.
Args: Args:
asset_path: Relative path to the asset asset_path: Relative path to the asset
""" """
if not asset_path: if not asset_path:
return return
if asset_path in self.reference_counts: if asset_path in self.reference_counts:
self.reference_counts[asset_path] += 1 self.reference_counts[asset_path] += 1
print(f"AssetManager: Acquired reference to {asset_path} (refs={self.reference_counts[asset_path]})") print(f"AssetManager: Acquired reference to {asset_path} (refs={self.reference_counts[asset_path]})")
@@ -87,25 +113,25 @@ class AssetManager:
print(f"AssetManager: Acquired reference to existing asset {asset_path} (refs=1)") print(f"AssetManager: Acquired reference to existing asset {asset_path} (refs=1)")
else: else:
print(f"AssetManager: Warning - asset not found: {asset_path}") print(f"AssetManager: Warning - asset not found: {asset_path}")
def release_reference(self, asset_path: str): def release_reference(self, asset_path: str):
""" """
Decrement the reference count for an asset. Decrement the reference count for an asset.
If count reaches zero, delete the asset file. If count reaches zero, delete the asset file.
Args: Args:
asset_path: Relative path to the asset asset_path: Relative path to the asset
""" """
if not asset_path: if not asset_path:
return return
if asset_path not in self.reference_counts: if asset_path not in self.reference_counts:
print(f"AssetManager: Warning - attempting to release unknown asset: {asset_path}") print(f"AssetManager: Warning - attempting to release unknown asset: {asset_path}")
return return
self.reference_counts[asset_path] -= 1 self.reference_counts[asset_path] -= 1
print(f"AssetManager: Released reference to {asset_path} (refs={self.reference_counts[asset_path]})") print(f"AssetManager: Released reference to {asset_path} (refs={self.reference_counts[asset_path]})")
if self.reference_counts[asset_path] <= 0: if self.reference_counts[asset_path] <= 0:
# No more references - safe to delete # No more references - safe to delete
full_path = os.path.join(self.project_folder, asset_path) full_path = os.path.join(self.project_folder, asset_path)
@@ -116,38 +142,289 @@ class AssetManager:
del self.reference_counts[asset_path] del self.reference_counts[asset_path]
except Exception as e: except Exception as e:
print(f"AssetManager: Error deleting asset {asset_path}: {e}") print(f"AssetManager: Error deleting asset {asset_path}: {e}")
def get_absolute_path(self, relative_path: str) -> str: def get_absolute_path(self, relative_path: str) -> str:
""" """
Convert a relative asset path to an absolute path. Convert a relative asset path to an absolute path.
Args: Args:
relative_path: Relative path from project folder relative_path: Relative path from project folder
Returns: Returns:
Absolute path to the asset Absolute path to the asset
""" """
return os.path.join(self.project_folder, relative_path) return os.path.join(self.project_folder, relative_path)
def get_reference_count(self, asset_path: str) -> int: def get_reference_count(self, asset_path: str) -> int:
""" """
Get the current reference count for an asset. Get the current reference count for an asset.
Args: Args:
asset_path: Relative path to the asset asset_path: Relative path to the asset
Returns: Returns:
Reference count (0 if not tracked) Reference count (0 if not tracked)
""" """
return self.reference_counts.get(asset_path, 0) return self.reference_counts.get(asset_path, 0)
def serialize(self) -> Dict: def serialize(self) -> Dict:
"""Serialize asset manager state""" """Serialize asset manager state"""
return { return {
"reference_counts": self.reference_counts "reference_counts": self.reference_counts,
"asset_hashes": self.asset_hashes,
} }
def deserialize(self, data: Dict): def deserialize(self, data: Dict):
"""Deserialize asset manager state""" """Deserialize asset manager state"""
self.reference_counts = data.get("reference_counts", {}) self.reference_counts = data.get("reference_counts", {})
self.asset_hashes = data.get("asset_hashes", {})
print(f"AssetManager: Loaded {len(self.reference_counts)} asset references") print(f"AssetManager: Loaded {len(self.reference_counts)} asset references")
def compute_asset_hash(self, asset_path: str) -> Optional[str]:
"""
Compute and cache the MD5 hash for an asset.
Args:
asset_path: Relative path to the asset
Returns:
MD5 hash as hex string, or None if computation fails
"""
full_path = self.get_absolute_path(asset_path)
md5_hash = compute_file_md5(full_path)
if md5_hash:
self.asset_hashes[asset_path] = md5_hash
return md5_hash
def compute_all_hashes(self) -> Dict[str, str]:
"""
Compute MD5 hashes for all assets in the assets folder.
Returns:
Dictionary mapping relative paths to MD5 hashes
"""
self.asset_hashes.clear()
if not os.path.exists(self.assets_folder):
return self.asset_hashes
for root, dirs, files in os.walk(self.assets_folder):
for filename in files:
file_path = os.path.join(root, filename)
relative_path = os.path.relpath(file_path, self.project_folder)
md5_hash = compute_file_md5(file_path)
if md5_hash:
self.asset_hashes[relative_path] = md5_hash
print(f"AssetManager: Computed hashes for {len(self.asset_hashes)} assets")
return self.asset_hashes
def find_duplicates(self) -> Dict[str, List[str]]:
"""
Find duplicate assets based on MD5 hash.
Returns:
Dictionary mapping MD5 hash to list of asset paths with that hash.
Only includes hashes with more than one file.
"""
# Compute hashes if not already done
if not self.asset_hashes:
self.compute_all_hashes()
# Group assets by hash
hash_to_paths: Dict[str, List[str]] = {}
for path, md5_hash in self.asset_hashes.items():
if md5_hash not in hash_to_paths:
hash_to_paths[md5_hash] = []
hash_to_paths[md5_hash].append(path)
# Filter to only duplicates (more than one file with same hash)
duplicates = {h: paths for h, paths in hash_to_paths.items() if len(paths) > 1}
if duplicates:
total_dups = sum(len(paths) - 1 for paths in duplicates.values())
print(f"AssetManager: Found {total_dups} duplicate files in {len(duplicates)} groups")
return duplicates
def deduplicate_assets(self, update_references_callback=None) -> Tuple[int, int]:
"""
Remove duplicate assets, keeping one canonical copy and updating references.
Args:
update_references_callback: Optional callback function that takes
(old_path, new_path) to update external references (e.g., ImageData elements)
Returns:
Tuple of (files_removed, bytes_saved)
"""
duplicates = self.find_duplicates()
if not duplicates:
print("AssetManager: No duplicates found")
return (0, 0)
files_removed = 0
bytes_saved = 0
for md5_hash, paths in duplicates.items():
# Sort paths to get consistent canonical path (first alphabetically)
paths.sort()
canonical_path = paths[0]
# Remove duplicates and update references
for dup_path in paths[1:]:
full_dup_path = self.get_absolute_path(dup_path)
# Get file size before deletion
try:
file_size = os.path.getsize(full_dup_path)
except OSError:
file_size = 0
# Update references if callback provided
if update_references_callback:
update_references_callback(dup_path, canonical_path)
# Transfer reference count to canonical path
if dup_path in self.reference_counts:
dup_refs = self.reference_counts[dup_path]
if canonical_path in self.reference_counts:
self.reference_counts[canonical_path] += dup_refs
else:
self.reference_counts[canonical_path] = dup_refs
del self.reference_counts[dup_path]
# Delete the duplicate file
try:
if os.path.exists(full_dup_path):
os.remove(full_dup_path)
files_removed += 1
bytes_saved += file_size
print(f"AssetManager: Removed duplicate {dup_path} (kept {canonical_path})")
except Exception as e:
print(f"AssetManager: Error removing duplicate {dup_path}: {e}")
# Remove from hash tracking
if dup_path in self.asset_hashes:
del self.asset_hashes[dup_path]
print(f"AssetManager: Deduplication complete - removed {files_removed} files, saved {bytes_saved} bytes")
return (files_removed, bytes_saved)
def get_duplicate_stats(self) -> Tuple[int, int, int]:
"""
Get statistics about duplicate assets without modifying anything.
Returns:
Tuple of (duplicate_groups, total_duplicate_files, estimated_bytes_to_save)
"""
duplicates = self.find_duplicates()
if not duplicates:
return (0, 0, 0)
duplicate_groups = len(duplicates)
total_duplicate_files = sum(len(paths) - 1 for paths in duplicates.values())
# Calculate bytes that would be saved
bytes_to_save = 0
for paths in duplicates.values():
# Skip the first (canonical) file, count size of the rest
for dup_path in paths[1:]:
full_path = self.get_absolute_path(dup_path)
try:
bytes_to_save += os.path.getsize(full_path)
except OSError:
pass
return (duplicate_groups, total_duplicate_files, bytes_to_save)
def find_unused_assets(self) -> List[str]:
"""
Find assets that exist in the assets folder but have no references.
Returns:
List of relative paths to unused assets
"""
unused: list[str] = []
if not os.path.exists(self.assets_folder):
return unused
# Get all files in assets folder
for root, dirs, files in os.walk(self.assets_folder):
for filename in files:
file_path = os.path.join(root, filename)
relative_path = os.path.relpath(file_path, self.project_folder)
# Check if this asset has any references
ref_count = self.reference_counts.get(relative_path, 0)
if ref_count <= 0:
unused.append(relative_path)
if unused:
print(f"AssetManager: Found {len(unused)} unused assets")
return unused
def get_unused_stats(self) -> Tuple[int, int]:
"""
Get statistics about unused assets without modifying anything.
Returns:
Tuple of (unused_file_count, total_bytes)
"""
unused = self.find_unused_assets()
if not unused:
return (0, 0)
total_bytes = 0
for asset_path in unused:
full_path = self.get_absolute_path(asset_path)
try:
total_bytes += os.path.getsize(full_path)
except OSError:
pass
return (len(unused), total_bytes)
def remove_unused_assets(self) -> Tuple[int, int]:
"""
Remove all unused assets from the assets folder.
Returns:
Tuple of (files_removed, bytes_freed)
"""
unused = self.find_unused_assets()
if not unused:
print("AssetManager: No unused assets to remove")
return (0, 0)
files_removed = 0
bytes_freed = 0
for asset_path in unused:
full_path = self.get_absolute_path(asset_path)
try:
file_size = os.path.getsize(full_path)
except OSError:
file_size = 0
try:
if os.path.exists(full_path):
os.remove(full_path)
files_removed += 1
bytes_freed += file_size
print(f"AssetManager: Removed unused asset {asset_path}")
# Clean up tracking
if asset_path in self.reference_counts:
del self.reference_counts[asset_path]
if asset_path in self.asset_hashes:
del self.asset_hashes[asset_path]
except Exception as e:
print(f"AssetManager: Error removing unused asset {asset_path}: {e}")
print(f"AssetManager: Removed {files_removed} unused assets, freed {bytes_freed} bytes")
return (files_removed, bytes_freed)
+188 -105
View File
@@ -12,29 +12,72 @@ import asyncio
import logging import logging
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import IntEnum
from pathlib import Path from pathlib import Path
from typing import Optional, Callable, Dict, Any, Tuple from typing import Optional, Callable, Dict, Any, Tuple, Union
from concurrent.futures import Future
from collections import OrderedDict from collections import OrderedDict
import threading import threading
from PIL import Image from PIL import Image
from PyQt6.QtCore import QObject, pyqtSignal from PyQt6.QtCore import QObject, pyqtSignal
from pyPhotoAlbum.image_utils import convert_to_rgba, resize_to_fit
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class LoadPriority(Enum): class LoadPriority(IntEnum):
"""Priority levels for load requests.""" """Priority levels for load requests."""
LOW = 0 # Offscreen, not visible
NORMAL = 1 # Potentially visible soon LOW = 0 # Offscreen, not visible
HIGH = 2 # Visible on screen NORMAL = 1 # Potentially visible soon
URGENT = 3 # User is actively interacting with 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) @dataclass(order=True)
class LoadRequest: class LoadRequest:
"""Request to load and process an image.""" """Request to load and process an image."""
priority: LoadPriority = field(compare=True) priority: LoadPriority = field(compare=True)
request_id: int = field(compare=True) # Tie-breaker for same priority request_id: int = field(compare=True) # Tie-breaker for same priority
path: Path = field(compare=False) path: Path = field(compare=False)
@@ -68,7 +111,7 @@ class ImageCache:
"""Estimate memory size of PIL image in bytes.""" """Estimate memory size of PIL image in bytes."""
# PIL images are typically width * height * bytes_per_pixel # PIL images are typically width * height * bytes_per_pixel
# RGBA = 4 bytes, RGB = 3 bytes, L = 1 byte # RGBA = 4 bytes, RGB = 3 bytes, L = 1 byte
mode_sizes = {'RGBA': 4, 'RGB': 3, 'L': 1, 'LA': 2} mode_sizes = {"RGBA": 4, "RGB": 3, "L": 1, "LA": 2}
bytes_per_pixel = mode_sizes.get(img.mode, 4) bytes_per_pixel = mode_sizes.get(img.mode, 4)
return img.width * img.height * bytes_per_pixel return img.width * img.height * bytes_per_pixel
@@ -121,8 +164,7 @@ class ImageCache:
self.current_memory_bytes -= old_size self.current_memory_bytes -= old_size
# Evict LRU items if needed # Evict LRU items if needed
while (self.current_memory_bytes + img_size > self.max_memory_bytes while self.current_memory_bytes + img_size > self.max_memory_bytes and len(self._cache) > 0:
and len(self._cache) > 0):
evicted_key, (evicted_img, evicted_size) = self._cache.popitem(last=False) evicted_key, (evicted_img, evicted_size) = self._cache.popitem(last=False)
self.current_memory_bytes -= evicted_size self.current_memory_bytes -= evicted_size
logger.debug(f"Cache EVICT: {evicted_key} ({evicted_size / 1024 / 1024:.1f}MB)") logger.debug(f"Cache EVICT: {evicted_key} ({evicted_size / 1024 / 1024:.1f}MB)")
@@ -131,10 +173,12 @@ class ImageCache:
self._cache[key] = (img.copy(), img_size) self._cache[key] = (img.copy(), img_size)
self.current_memory_bytes += img_size self.current_memory_bytes += img_size
logger.debug(f"Cache PUT: {key} ({img_size / 1024 / 1024:.1f}MB) " logger.debug(
f"[Total: {self.current_memory_bytes / 1024 / 1024:.1f}MB / " f"Cache PUT: {key} ({img_size / 1024 / 1024:.1f}MB) "
f"{self.max_memory_bytes / 1024 / 1024:.1f}MB, " f"[Total: {self.current_memory_bytes / 1024 / 1024:.1f}MB / "
f"Items: {len(self._cache)}]") f"{self.max_memory_bytes / 1024 / 1024:.1f}MB, "
f"Items: {len(self._cache)}]"
)
def clear(self): def clear(self):
"""Clear entire cache.""" """Clear entire cache."""
@@ -147,10 +191,10 @@ class ImageCache:
"""Get cache statistics.""" """Get cache statistics."""
with self._lock: with self._lock:
return { return {
'items': len(self._cache), "items": len(self._cache),
'memory_mb': self.current_memory_bytes / 1024 / 1024, "memory_mb": self.current_memory_bytes / 1024 / 1024,
'max_memory_mb': self.max_memory_bytes / 1024 / 1024, "max_memory_mb": self.max_memory_bytes / 1024 / 1024,
'utilization': (self.current_memory_bytes / self.max_memory_bytes) * 100 "utilization": (self.current_memory_bytes / self.max_memory_bytes) * 100,
} }
@@ -170,7 +214,7 @@ class AsyncImageLoader(QObject):
# Signals for Qt integration # Signals for Qt integration
image_loaded = pyqtSignal(object, object, object) # (path, image, user_data) image_loaded = pyqtSignal(object, object, object) # (path, image, user_data)
load_failed = pyqtSignal(object, str, object) # (path, error_msg, user_data) load_failed = pyqtSignal(object, str, object) # (path, error_msg, user_data)
def __init__(self, cache: Optional[ImageCache] = None, max_workers: int = 4): def __init__(self, cache: Optional[ImageCache] = None, max_workers: int = 4):
""" """
@@ -184,11 +228,10 @@ class AsyncImageLoader(QObject):
self.cache = cache or ImageCache() self.cache = cache or ImageCache()
self.max_workers = max_workers self.max_workers = max_workers
self.executor = ThreadPoolExecutor(max_workers=max_workers, self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="ImageLoader")
thread_name_prefix="ImageLoader")
# Priority queue and tracking # Priority queue and tracking
self._queue: asyncio.PriorityQueue = None # Created when event loop starts self._queue: Optional[asyncio.PriorityQueue[Any]] = None # Created when event loop starts
self._pending_requests: Dict[Path, LoadRequest] = {} self._pending_requests: Dict[Path, LoadRequest] = {}
self._active_tasks: Dict[Path, asyncio.Task] = {} self._active_tasks: Dict[Path, asyncio.Task] = {}
self._next_request_id = 0 self._next_request_id = 0
@@ -208,9 +251,9 @@ class AsyncImageLoader(QObject):
return return
self._shutdown = False self._shutdown = False
self._loop_thread = threading.Thread(target=self._run_event_loop, self._loop_thread = threading.Thread(
daemon=True, target=self._run_event_loop, daemon=True, name="AsyncImageLoader-EventLoop"
name="AsyncImageLoader-EventLoop") )
self._loop_thread.start() self._loop_thread.start()
logger.info("AsyncImageLoader event loop started") logger.info("AsyncImageLoader event loop started")
@@ -222,9 +265,14 @@ class AsyncImageLoader(QObject):
logger.info("Stopping AsyncImageLoader...") logger.info("Stopping AsyncImageLoader...")
self._shutdown = True self._shutdown = True
# Cancel all active tasks # Cancel all active tasks and wait for them to finish
if self._loop and not self._loop.is_closed(): if self._loop and not self._loop.is_closed():
asyncio.run_coroutine_threadsafe(self._cancel_all_tasks(), self._loop) future = asyncio.run_coroutine_threadsafe(self._cancel_all_tasks(), self._loop)
try:
# Wait for cancellation to complete with timeout
future.result(timeout=2.0)
except Exception as e:
logger.warning(f"Error during task cancellation: {e}")
# Stop the event loop # Stop the event loop
self._loop.call_soon_threadsafe(self._loop.stop) self._loop.call_soon_threadsafe(self._loop.stop)
@@ -302,6 +350,10 @@ class AsyncImageLoader(QObject):
target_size = request.target_size target_size = request.target_size
try: try:
# Check if shutting down
if self._shutdown:
return
# Check cache first # Check cache first
cached_img = self.cache.get(path, target_size) cached_img = self.cache.get(path, target_size)
if cached_img is not None: if cached_img is not None:
@@ -311,12 +363,11 @@ class AsyncImageLoader(QObject):
# Load in thread pool (I/O bound) # Load in thread pool (I/O bound)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
img = await loop.run_in_executor( img = await loop.run_in_executor(self.executor, self._load_and_process_image, path, target_size)
self.executor,
self._load_and_process_image, # Check again if shutting down before emitting
path, if self._shutdown:
target_size return
)
# Cache result # Cache result
self.cache.put(path, img, target_size) self.cache.put(path, img, target_size)
@@ -326,9 +377,16 @@ class AsyncImageLoader(QObject):
logger.debug(f"Loaded: {path} (size: {img.size})") 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: except Exception as e:
logger.error(f"Failed to load {path}: {e}", exc_info=True) # Only emit error if not shutting down
self._emit_failed(path, str(e), request.user_data) 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: finally:
# Cleanup tracking # Cleanup tracking
@@ -336,7 +394,7 @@ class AsyncImageLoader(QObject):
self._pending_requests.pop(path, None) self._pending_requests.pop(path, None)
self._active_tasks.pop(path, None) self._active_tasks.pop(path, None)
def _load_and_process_image(self, path: Path, target_size: Optional[Tuple[int, int]]) -> Image.Image: def _load_and_process_image(self, path: Path, target_size: Optional[Tuple[int, int]]) -> "Image.Image":
""" """
Load image from disk and process (runs in thread pool). Load image from disk and process (runs in thread pool).
@@ -347,35 +405,48 @@ class AsyncImageLoader(QObject):
Returns: Returns:
Processed PIL Image Processed PIL Image
""" """
# Load image img: Image.Image = Image.open(path)
img = Image.open(path) img = convert_to_rgba(img)
# Convert to RGBA for consistency # Downsample if target size specified (preserving aspect ratio)
if img.mode != 'RGBA':
img = img.convert('RGBA')
# Downsample if target size specified
if target_size: if target_size:
current_size = img.size max_size = target_size[0] # Assume square target (2048, 2048)
if current_size[0] > target_size[0] or current_size[1] > target_size[1]: original_size = img.size
img = img.resize(target_size, Image.Resampling.LANCZOS) img = resize_to_fit(img, max_size)
logger.debug(f"Downsampled {path}: {current_size} -> {target_size}") if img.size != original_size:
logger.debug(f"Downsampled {path}: {original_size} -> {img.size}")
return img return img
def _emit_loaded(self, path: Path, img: Image.Image, user_data: Any): def _emit_loaded(self, path: Path, img: Image.Image, user_data: Any):
"""Emit image_loaded signal (thread-safe).""" """Emit image_loaded signal (thread-safe)."""
self.image_loaded.emit(path, img, user_data) # Check if object is still valid before emitting
if self._shutdown:
return
try:
self.image_loaded.emit(path, img, user_data)
except RuntimeError as e:
# Object was deleted - log but don't crash
logger.debug(f"Could not emit image_loaded for {path}: {e}")
def _emit_failed(self, path: Path, error_msg: str, user_data: Any): def _emit_failed(self, path: Path, error_msg: str, user_data: Any):
"""Emit load_failed signal (thread-safe).""" """Emit load_failed signal (thread-safe)."""
self.load_failed.emit(path, error_msg, user_data) # Check if object is still valid before emitting
if self._shutdown:
return
try:
self.load_failed.emit(path, error_msg, user_data)
except RuntimeError as e:
# Object was deleted - log but don't crash
logger.debug(f"Could not emit load_failed for {path}: {e}")
def request_load(self, def request_load(
path: Path, self,
priority: LoadPriority = LoadPriority.NORMAL, path: Path,
target_size: Optional[Tuple[int, int]] = None, priority: LoadPriority = LoadPriority.NORMAL,
user_data: Any = None) -> bool: target_size: Optional[Tuple[int, int]] = None,
user_data: Any = None,
) -> bool:
""" """
Request image load with specified priority. Request image load with specified priority.
@@ -406,7 +477,7 @@ class AsyncImageLoader(QObject):
request_id=self._next_request_id, request_id=self._next_request_id,
path=path, path=path,
target_size=target_size, target_size=target_size,
user_data=user_data user_data=user_data,
) )
self._next_request_id += 1 self._next_request_id += 1
@@ -414,10 +485,8 @@ class AsyncImageLoader(QObject):
self._pending_requests[path] = request self._pending_requests[path] = request
# Submit to queue (thread-safe) # Submit to queue (thread-safe)
asyncio.run_coroutine_threadsafe( assert self._queue is not None
self._queue.put(request), asyncio.run_coroutine_threadsafe(self._queue.put(request), self._loop)
self._loop
)
logger.debug(f"Queued load: {path} (priority: {priority.name})") logger.debug(f"Queued load: {path} (priority: {priority.name})")
return True return True
@@ -454,9 +523,9 @@ class AsyncImageLoader(QObject):
"""Get loader statistics.""" """Get loader statistics."""
with self._lock: with self._lock:
return { return {
'pending': len(self._pending_requests), "pending": len(self._pending_requests),
'active': len(self._active_tasks), "active": len(self._active_tasks),
'cache': self.cache.get_stats() "cache": self.cache.get_stats(),
} }
@@ -477,8 +546,8 @@ class AsyncPDFGenerator(QObject):
# Signals for Qt integration # Signals for Qt integration
progress_updated = pyqtSignal(int, int, str) # (current, total, message) progress_updated = pyqtSignal(int, int, str) # (current, total, message)
export_complete = pyqtSignal(bool, list) # (success, warnings) export_complete = pyqtSignal(bool, list) # (success, warnings)
export_failed = pyqtSignal(str) # (error_message) export_failed = pyqtSignal(str) # (error_message)
def __init__(self, image_cache: Optional[ImageCache] = None, max_workers: int = 2): def __init__(self, image_cache: Optional[ImageCache] = None, max_workers: int = 2):
""" """
@@ -492,13 +561,12 @@ class AsyncPDFGenerator(QObject):
self.image_cache = image_cache or ImageCache() self.image_cache = image_cache or ImageCache()
self.max_workers = max_workers self.max_workers = max_workers
self.executor = ThreadPoolExecutor(max_workers=max_workers, self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="PDFGenerator")
thread_name_prefix="PDFGenerator")
# Export state # Export state
self._current_export: Optional[asyncio.Task] = None self._current_export: Optional[Future[Any]] = None
self._cancel_requested = False self._cancel_requested = False
self._lock = threading.Lock() self._lock = threading.RLock() # Use RLock to allow re-entrant locking
self._shutdown = False self._shutdown = False
# Event loop for async operations # Event loop for async operations
@@ -514,9 +582,9 @@ class AsyncPDFGenerator(QObject):
return return
self._shutdown = False self._shutdown = False
self._loop_thread = threading.Thread(target=self._run_event_loop, self._loop_thread = threading.Thread(
daemon=True, target=self._run_event_loop, daemon=True, name="AsyncPDFGenerator-EventLoop"
name="AsyncPDFGenerator-EventLoop") )
self._loop_thread.start() self._loop_thread.start()
logger.info("AsyncPDFGenerator event loop started") logger.info("AsyncPDFGenerator event loop started")
@@ -581,8 +649,7 @@ class AsyncPDFGenerator(QObject):
# Submit export task # Submit export task
self._current_export = asyncio.run_coroutine_threadsafe( self._current_export = asyncio.run_coroutine_threadsafe(
self._export_pdf_async(project, output_path, export_dpi), self._export_pdf_async(project, output_path, export_dpi), self._loop
self._loop
) )
logger.info(f"PDF export started: {output_path}") logger.info(f"PDF export started: {output_path}")
@@ -614,41 +681,53 @@ class AsyncPDFGenerator(QObject):
# Progress callback wrapper # Progress callback wrapper
def progress_callback(current, total, message): def progress_callback(current, total, message):
if self._cancel_requested: if self._cancel_requested or self._shutdown:
return False # Signal cancellation return False # Signal cancellation
self.progress_updated.emit(current, total, message) try:
self.progress_updated.emit(current, total, message)
except RuntimeError as e:
# Object was deleted - log but don't crash
logger.debug(f"Could not emit progress_updated: {e}")
return False
return True return True
# Run export in thread pool # Run export in thread pool
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
success, warnings = await loop.run_in_executor( success, warnings = await loop.run_in_executor(
self.executor, self.executor, self._export_with_cache, exporter, output_path, progress_callback
self._export_with_cache,
exporter,
output_path,
progress_callback
) )
# Emit completion signal # Emit completion signal
if not self._cancel_requested: if not self._cancel_requested and not self._shutdown:
self.export_complete.emit(success, warnings) try:
logger.info(f"PDF export completed: {output_path} (warnings: {len(warnings)})") 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: else:
logger.info("PDF export cancelled") logger.info("PDF export cancelled")
except asyncio.CancelledError: except asyncio.CancelledError:
logger.info("PDF export cancelled by user") logger.info("PDF export cancelled by user")
self.export_failed.emit("Export cancelled") if not self._shutdown:
try:
self.export_failed.emit("Export cancelled")
except RuntimeError as e:
logger.debug(f"Could not emit export_failed: {e}")
except Exception as e: except Exception as e:
logger.error(f"PDF export failed: {e}", exc_info=True) logger.error(f"PDF export failed: {e}", exc_info=True)
self.export_failed.emit(str(e)) if not self._shutdown:
try:
self.export_failed.emit(str(e))
except RuntimeError as e:
logger.debug(f"Could not emit export_failed: {e}")
finally: finally:
with self._lock: with self._lock:
self._current_export = None self._current_export = None
def _export_with_cache(self, exporter, output_path: str, progress_callback) -> Tuple[bool, list]: def _export_with_cache(self, exporter: Any, output_path: str, progress_callback: Any) -> Tuple[bool, list[Any]]:
""" """
Run PDF export with image cache integration. Run PDF export with image cache integration.
@@ -667,23 +746,31 @@ class AsyncPDFGenerator(QObject):
# Patch Image.open to use cache # Patch Image.open to use cache
def cached_open(path, *args, **kwargs): def cached_open(path, *args, **kwargs):
# Try cache first # Only use cache for file paths, not BytesIO or other file-like objects
cached_img = self.image_cache.get(Path(path)) is_file_path = isinstance(path, (str, Path))
if cached_img:
logger.debug(f"PDF using cached image: {path}")
return cached_img
# Load and cache if is_file_path:
img = original_open(path, *args, **kwargs) # Try cache first
if img.mode != 'RGBA': # Note: We cache the unrotated image so rotation can be applied per-element
img = img.convert('RGBA') path_obj = Path(path) if isinstance(path, str) else path
self.image_cache.put(Path(path), img) cached_img = self.image_cache.get(path_obj)
return img 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 # Temporarily patch Image.open
try: try:
Image.open = cached_open Image.open = cached_open # type: ignore[assignment]
return exporter.export(output_path, progress_callback) return exporter.export(output_path, progress_callback) # type: ignore[no-any-return]
finally: finally:
# Restore original # Restore original
Image.open = original_open Image.open = original_open
@@ -691,13 +778,9 @@ class AsyncPDFGenerator(QObject):
def is_exporting(self) -> bool: def is_exporting(self) -> bool:
"""Check if export is currently in progress.""" """Check if export is currently in progress."""
with self._lock: with self._lock:
return (self._current_export is not None return self._current_export is not None and not self._current_export.done()
and not self._current_export.done())
def get_stats(self) -> Dict[str, Any]: def get_stats(self) -> Dict[str, Any]:
"""Get generator statistics.""" """Get generator statistics."""
with self._lock: with self._lock:
return { return {"exporting": self.is_exporting(), "cache": self.image_cache.get_stats()}
'exporting': self.is_exporting(),
'cache': self.image_cache.get_stats()
}
+248
View 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
View 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
+310 -305
View File
File diff suppressed because it is too large Load Diff
+176 -83
View File
@@ -2,17 +2,18 @@
Decorator system for pyPhotoAlbum ribbon UI Decorator system for pyPhotoAlbum ribbon UI
""" """
import copy
from functools import wraps from functools import wraps
from typing import Optional, Callable from typing import Any, Optional, Callable
class RibbonAction: class RibbonAction:
""" """
Decorator to mark methods as ribbon actions. Decorator to mark methods as ribbon actions.
This decorator stores metadata about UI actions that should appear in the ribbon. This decorator stores metadata about UI actions that should appear in the ribbon.
The metadata is used to auto-generate the ribbon configuration. The metadata is used to auto-generate the ribbon configuration.
Example: Example:
@RibbonAction( @RibbonAction(
label="New", label="New",
@@ -25,7 +26,7 @@ class RibbonAction:
def new_project(self): def new_project(self):
... ...
""" """
def __init__( def __init__(
self, self,
label: str, label: str,
@@ -36,11 +37,11 @@ class RibbonAction:
shortcut: Optional[str] = None, shortcut: Optional[str] = None,
requires_page: bool = False, requires_page: bool = False,
requires_selection: bool = False, requires_selection: bool = False,
min_selection: int = 0 min_selection: int = 0,
): ):
""" """
Initialize the ribbon action decorator. Initialize the ribbon action decorator.
Args: Args:
label: Button label text label: Button label text
tooltip: Tooltip text shown on hover tooltip: Tooltip text shown on hover
@@ -61,35 +62,36 @@ class RibbonAction:
self.requires_page = requires_page self.requires_page = requires_page
self.requires_selection = requires_selection self.requires_selection = requires_selection
self.min_selection = min_selection self.min_selection = min_selection
def __call__(self, func: Callable) -> Callable: def __call__(self, func: Callable) -> Callable:
""" """
Decorate the function with ribbon action metadata. Decorate the function with ribbon action metadata.
Args: Args:
func: The function to decorate func: The function to decorate
Returns: Returns:
The decorated function with metadata attached The decorated function with metadata attached
""" """
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args: Any, **kwargs: Any) -> Any:
return func(*args, **kwargs) return func(*args, **kwargs)
# Store metadata on wrapper function # Store metadata on wrapper function
wrapper._ribbon_action = { wrapper._ribbon_action = { # type: ignore[attr-defined]
'label': self.label, "label": self.label,
'tooltip': self.tooltip, "tooltip": self.tooltip,
'tab': self.tab, "tab": self.tab,
'group': self.group, "group": self.group,
'icon': self.icon, "icon": self.icon,
'shortcut': self.shortcut, "shortcut": self.shortcut,
'action': func.__name__, "action": func.__name__,
'requires_page': self.requires_page, "requires_page": self.requires_page,
'requires_selection': self.requires_selection, "requires_selection": self.requires_selection,
'min_selection': self.min_selection "min_selection": self.min_selection,
} }
return wrapper return wrapper
@@ -102,13 +104,13 @@ def ribbon_action(
shortcut: Optional[str] = None, shortcut: Optional[str] = None,
requires_page: bool = False, requires_page: bool = False,
requires_selection: bool = False, requires_selection: bool = False,
min_selection: int = 0 min_selection: int = 0,
) -> Callable: ) -> Callable:
""" """
Convenience function for the RibbonAction decorator. Convenience function for the RibbonAction decorator.
This provides a lowercase function-style interface to the decorator. This provides a lowercase function-style interface to the decorator.
Args: Args:
label: Button label text label: Button label text
tooltip: Tooltip text shown on hover tooltip: Tooltip text shown on hover
@@ -119,7 +121,7 @@ def ribbon_action(
requires_page: Whether this action requires an active page requires_page: Whether this action requires an active page
requires_selection: Whether this action requires selected elements requires_selection: Whether this action requires selected elements
min_selection: Minimum number of selected elements required min_selection: Minimum number of selected elements required
Returns: Returns:
RibbonAction decorator instance RibbonAction decorator instance
""" """
@@ -132,17 +134,17 @@ def ribbon_action(
shortcut=shortcut, shortcut=shortcut,
requires_page=requires_page, requires_page=requires_page,
requires_selection=requires_selection, requires_selection=requires_selection,
min_selection=min_selection min_selection=min_selection,
) )
class NumericalInput: class NumericalInput:
""" """
Decorator to mark methods that require numerical width/height inputs. Decorator to mark methods that require numerical width/height inputs.
This decorator stores metadata about numerical input fields that should This decorator stores metadata about numerical input fields that should
be presented in dialogs for methods that work with page dimensions. be presented in dialogs for methods that work with page dimensions.
Example: Example:
@numerical_input( @numerical_input(
fields=[ fields=[
@@ -153,49 +155,48 @@ class NumericalInput:
def set_page_size(self, width, height): def set_page_size(self, width, height):
... ...
""" """
def __init__(self, fields: list): def __init__(self, fields: list):
""" """
Initialize the numerical input decorator. Initialize the numerical input decorator.
Args: Args:
fields: List of tuples, each containing: fields: List of tuples, each containing:
(param_name, label, unit, min_value, max_value) (param_name, label, unit, min_value, max_value)
""" """
self.fields = fields self.fields = fields
def __call__(self, func: Callable) -> Callable: def __call__(self, func: Callable) -> Callable:
""" """
Decorate the function with numerical input metadata. Decorate the function with numerical input metadata.
Args: Args:
func: The function to decorate func: The function to decorate
Returns: Returns:
The decorated function with metadata attached The decorated function with metadata attached
""" """
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args: Any, **kwargs: Any) -> Any:
return func(*args, **kwargs) return func(*args, **kwargs)
# Store metadata on wrapper function # Store metadata on wrapper function
wrapper._numerical_input = { wrapper._numerical_input = {"fields": self.fields} # type: ignore[attr-defined]
'fields': self.fields
}
return wrapper return wrapper
def numerical_input(fields: list) -> Callable: def numerical_input(fields: list) -> Callable:
""" """
Convenience function for the NumericalInput decorator. Convenience function for the NumericalInput decorator.
This provides a lowercase function-style interface to the decorator. This provides a lowercase function-style interface to the decorator.
Args: Args:
fields: List of tuples, each containing: fields: List of tuples, each containing:
(param_name, label, unit, min_value, max_value) (param_name, label, unit, min_value, max_value)
Returns: Returns:
NumericalInput decorator instance NumericalInput decorator instance
""" """
@@ -205,10 +206,10 @@ def numerical_input(fields: list) -> Callable:
class UndoableOperation: class UndoableOperation:
""" """
Decorator to automatically create undo/redo commands for operations. Decorator to automatically create undo/redo commands for operations.
This decorator captures state before and after an operation, then creates This decorator captures state before and after an operation, then creates
a StateChangeCommand for undo/redo functionality. a StateChangeCommand for undo/redo functionality.
Example: Example:
@undoable_operation(capture='page_elements') @undoable_operation(capture='page_elements')
def apply_template(self): def apply_template(self):
@@ -216,11 +217,11 @@ class UndoableOperation:
self.template_manager.apply_template(...) self.template_manager.apply_template(...)
# Decorator handles undo/redo automatically # Decorator handles undo/redo automatically
""" """
def __init__(self, capture: str = 'page_elements', description: str = None): def __init__(self, capture: str = "page_elements", description: Optional[str] = None):
""" """
Initialize the undoable operation decorator. Initialize the undoable operation decorator.
Args: Args:
capture: What to capture for undo/redo: capture: What to capture for undo/redo:
- 'page_elements': Capture elements of current page - 'page_elements': Capture elements of current page
@@ -229,100 +230,192 @@ class UndoableOperation:
""" """
self.capture = capture self.capture = capture
self.description = description self.description = description
def __call__(self, func: Callable) -> Callable: def __call__(self, func: Callable) -> Callable:
""" """
Decorate the function with automatic undo/redo. Decorate the function with automatic undo/redo.
Args: Args:
func: The function to decorate func: The function to decorate
Returns: Returns:
The decorated function The decorated function
""" """
@wraps(func) @wraps(func)
def wrapper(self_instance, *args, **kwargs): def wrapper(self_instance, *args, **kwargs):
# Get description # Get description
description = self.description or func.__name__.replace('_', ' ').title() description = self.description or func.__name__.replace("_", " ").title()
# Capture before state # Capture before state
before_state = self._capture_state(self_instance, self.capture) before_state = self._capture_state(self_instance, self.capture)
# Execute the operation # Execute the operation
result = func(self_instance, *args, **kwargs) result = func(self_instance, *args, **kwargs)
# Capture after state # Capture after state
after_state = self._capture_state(self_instance, self.capture) after_state = self._capture_state(self_instance, self.capture)
# Create restore function # Create restore function
def restore_state(state): def restore_state(state):
self._restore_state(self_instance, self.capture, state) self._restore_state(self_instance, self.capture, state)
# Update view after restoring # Update view after restoring
if hasattr(self_instance, 'update_view'): if hasattr(self_instance, "update_view"):
self_instance.update_view() self_instance.update_view()
# Create and execute command # Create and execute command
from pyPhotoAlbum.commands import StateChangeCommand from pyPhotoAlbum.commands import StateChangeCommand
cmd = StateChangeCommand(description, restore_state, before_state, after_state) cmd = StateChangeCommand(description, restore_state, before_state, after_state)
if hasattr(self_instance, 'project') and hasattr(self_instance.project, 'history'): if hasattr(self_instance, "project") and hasattr(self_instance.project, "history"):
self_instance.project.history.execute(cmd) self_instance.project.history.execute(cmd)
print(f"Undoable operation '{description}' executed") print(f"Undoable operation '{description}' executed")
return result return result
return wrapper return wrapper
def _capture_state(self, instance, capture_type: str): def _capture_state(self, instance, capture_type: str):
"""Capture current state based on capture type""" """Capture current state based on capture type"""
if capture_type == 'page_elements': if capture_type == "page_elements":
# Capture elements from current page # Capture elements from current page
current_page = instance.get_current_page() if hasattr(instance, 'get_current_page') else None current_page = instance.get_current_page() if hasattr(instance, "get_current_page") else None
if current_page: if current_page:
# Deep copy elements # Deep copy elements
import copy
return [copy.deepcopy(elem.serialize()) for elem in current_page.layout.elements] return [copy.deepcopy(elem.serialize()) for elem in current_page.layout.elements]
return [] return []
return None return None
def _restore_state(self, instance, capture_type: str, state): def _restore_state(self, instance, capture_type: str, state):
"""Restore state based on capture type""" """Restore state based on capture type"""
if capture_type == 'page_elements': if capture_type == "page_elements":
# Restore elements to current page # Restore elements to current page
current_page = instance.get_current_page() if hasattr(instance, 'get_current_page') else None current_page = instance.get_current_page() if hasattr(instance, "get_current_page") else None
if current_page and state is not None: if current_page and state is not None:
# Clear existing elements # Clear existing elements
current_page.layout.elements.clear() current_page.layout.elements.clear()
# Restore elements from serialized state # Restore elements from serialized state
from pyPhotoAlbum.models import ImageData, PlaceholderData, TextBoxData from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData
for elem_data in state: for elem_data in state:
elem_type = elem_data.get('type') elem_type = elem_data.get("type")
if elem_type == 'image': elem: BaseLayoutElement
if elem_type == "image":
elem = ImageData() elem = ImageData()
elif elem_type == 'placeholder': elif elem_type == "placeholder":
elem = PlaceholderData() elem = PlaceholderData()
elif elem_type == 'textbox': elif elem_type == "textbox":
elem = TextBoxData() elem = TextBoxData()
else: else:
continue continue
elem.deserialize(elem_data) elem.deserialize(elem_data)
current_page.layout.add_element(elem) current_page.layout.add_element(elem)
def undoable_operation(capture: str = 'page_elements', description: str = None) -> Callable: def undoable_operation(capture: str = "page_elements", description: Optional[str] = None) -> Callable:
""" """
Convenience function for the UndoableOperation decorator. Convenience function for the UndoableOperation decorator.
This provides a lowercase function-style interface to the decorator. This provides a lowercase function-style interface to the decorator.
Args: Args:
capture: What to capture for undo/redo capture: What to capture for undo/redo
description: Human-readable description of the operation description: Human-readable description of the operation
Returns: Returns:
UndoableOperation decorator instance UndoableOperation decorator instance
""" """
return UndoableOperation(capture=capture, description=description) 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)
+11
View File
@@ -0,0 +1,11 @@
"""
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
from .print_settings_dialog import PrintSettingsDialog
__all__ = ["PageSetupDialog", "PrintSettingsDialog"]
+352
View File
@@ -0,0 +1,352 @@
"""
Frame picker dialog for pyPhotoAlbum
Dialog for selecting decorative frames to apply to images.
"""
from typing import Optional, Tuple
from PyQt6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QLabel,
QPushButton,
QTabWidget,
QWidget,
QGridLayout,
QScrollArea,
QFrame,
QGroupBox,
QCheckBox,
)
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QPainter, QColor, QPen
from pyPhotoAlbum.frame_manager import get_frame_manager, FrameCategory, FrameDefinition, FrameType
class FramePreviewWidget(QFrame):
"""Widget that shows a preview of a frame"""
clicked = pyqtSignal(str) # Emits frame name when clicked
def __init__(self, frame: FrameDefinition, parent=None):
super().__init__(parent)
self.frame = frame
self.selected = False
self.setFixedSize(100, 100)
self.setFrameStyle(QFrame.Shape.Box)
self.setCursor(Qt.CursorShape.PointingHandCursor)
def paintEvent(self, event):
super().paintEvent(event)
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# Background
if self.selected:
painter.fillRect(self.rect(), QColor(200, 220, 255))
else:
painter.fillRect(self.rect(), QColor(245, 245, 245))
# Draw a simple preview of the frame style
margin = 15
rect = self.rect().adjusted(margin, margin, -margin, -margin)
# Draw "photo" placeholder
painter.fillRect(rect, QColor(180, 200, 220))
# Draw frame preview based on type
pen = QPen(QColor(80, 80, 80))
pen.setWidth(2)
painter.setPen(pen)
if self.frame.frame_type.value == "corners":
# Draw corner decorations
corner_size = 12
x, y, w, h = rect.x(), rect.y(), rect.width(), rect.height()
# Top-left
painter.drawLine(x, y + corner_size, x, y)
painter.drawLine(x, y, x + corner_size, y)
# Top-right
painter.drawLine(x + w - corner_size, y, x + w, y)
painter.drawLine(x + w, y, x + w, y + corner_size)
# Bottom-right
painter.drawLine(x + w, y + h - corner_size, x + w, y + h)
painter.drawLine(x + w, y + h, x + w - corner_size, y + h)
# Bottom-left
painter.drawLine(x + corner_size, y + h, x, y + h)
painter.drawLine(x, y + h, x, y + h - corner_size)
else:
# Draw full border
painter.drawRect(rect.adjusted(-3, -3, 3, 3))
painter.drawRect(rect)
# Draw frame name
painter.setPen(QColor(0, 0, 0))
text_rect = self.rect().adjusted(0, 0, 0, 0)
text_rect.setTop(self.rect().bottom() - 20)
painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, self.frame.display_name)
def mousePressEvent(self, event):
self.clicked.emit(self.frame.name)
def set_selected(self, selected: bool):
self.selected = selected
self.update()
class FramePickerDialog(QDialog):
"""Dialog for selecting a decorative frame"""
def __init__(
self,
parent,
current_frame: Optional[str] = None,
current_color: Tuple[int, int, int] = (0, 0, 0),
current_corners: Tuple[bool, bool, bool, bool] = (True, True, True, True),
):
super().__init__(parent)
self.setWindowTitle("Select Frame")
self.setMinimumSize(500, 500)
self.selected_frame: Optional[str] = current_frame
self.frame_color = current_color
self.frame_corners = current_corners # (TL, TR, BR, BL)
self.frame_widgets: dict[str, FramePreviewWidget] = {}
self._setup_ui()
def _setup_ui(self):
layout = QVBoxLayout(self)
# Tab widget for categories
self.tab_widget = QTabWidget()
# All frames tab
all_tab = self._create_category_tab(None)
self.tab_widget.addTab(all_tab, "All")
# Category tabs
for category in FrameCategory:
tab = self._create_category_tab(category)
self.tab_widget.addTab(tab, category.value.title())
layout.addWidget(self.tab_widget)
# Selected frame info
info_group = QGroupBox("Selected Frame")
info_layout = QVBoxLayout(info_group)
# Frame name and color row
name_color_layout = QHBoxLayout()
self.selected_label = QLabel("None")
name_color_layout.addWidget(self.selected_label)
# Color button
from pyPhotoAlbum.dialogs.style_dialogs import ColorButton
name_color_layout.addWidget(QLabel("Color:"))
self.color_btn = ColorButton(self.frame_color)
name_color_layout.addWidget(self.color_btn)
name_color_layout.addStretch()
info_layout.addLayout(name_color_layout)
# Corner selection (for corner-type frames)
self.corners_group = QGroupBox("Corner Decorations")
corners_layout = QGridLayout(self.corners_group)
# Create a visual grid for corner checkboxes
self.corner_tl = QCheckBox("Top-Left")
self.corner_tl.setChecked(self.frame_corners[0])
self.corner_tl.stateChanged.connect(self._update_corners)
self.corner_tr = QCheckBox("Top-Right")
self.corner_tr.setChecked(self.frame_corners[1])
self.corner_tr.stateChanged.connect(self._update_corners)
self.corner_br = QCheckBox("Bottom-Right")
self.corner_br.setChecked(self.frame_corners[2])
self.corner_br.stateChanged.connect(self._update_corners)
self.corner_bl = QCheckBox("Bottom-Left")
self.corner_bl.setChecked(self.frame_corners[3])
self.corner_bl.stateChanged.connect(self._update_corners)
corners_layout.addWidget(self.corner_tl, 0, 0)
corners_layout.addWidget(self.corner_tr, 0, 1)
corners_layout.addWidget(self.corner_bl, 1, 0)
corners_layout.addWidget(self.corner_br, 1, 1)
# Quick selection buttons
quick_btns_layout = QHBoxLayout()
all_btn = QPushButton("All")
all_btn.clicked.connect(self._select_all_corners)
none_btn = QPushButton("None")
none_btn.clicked.connect(self._select_no_corners)
diag_btn = QPushButton("Diagonal")
diag_btn.clicked.connect(self._select_diagonal_corners)
quick_btns_layout.addWidget(all_btn)
quick_btns_layout.addWidget(none_btn)
quick_btns_layout.addWidget(diag_btn)
quick_btns_layout.addStretch()
corners_layout.addLayout(quick_btns_layout, 2, 0, 1, 2)
info_layout.addWidget(self.corners_group)
layout.addWidget(info_group)
# Update corners group visibility based on frame type
self._update_corners_visibility()
# Buttons
button_layout = QHBoxLayout()
clear_btn = QPushButton("No Frame")
clear_btn.clicked.connect(self._clear_selection)
button_layout.addWidget(clear_btn)
button_layout.addStretch()
ok_btn = QPushButton("OK")
ok_btn.clicked.connect(self.accept)
button_layout.addWidget(ok_btn)
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.reject)
button_layout.addWidget(cancel_btn)
layout.addLayout(button_layout)
# Update selection display
self._update_selection_display()
def _create_category_tab(self, category: Optional[FrameCategory]) -> QWidget:
"""Create a tab for a frame category"""
widget = QWidget()
layout = QVBoxLayout(widget)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
content = QWidget()
grid = QGridLayout(content)
grid.setSpacing(10)
frame_manager = get_frame_manager()
if category:
frames = frame_manager.get_frames_by_category(category)
else:
frames = frame_manager.get_all_frames()
row, col = 0, 0
max_cols = 4
for frame in frames:
preview = FramePreviewWidget(frame)
preview.clicked.connect(self._on_frame_clicked)
if frame.name == self.selected_frame:
preview.set_selected(True)
grid.addWidget(preview, row, col)
self.frame_widgets[frame.name] = preview
col += 1
if col >= max_cols:
col = 0
row += 1
# Add stretch at the bottom
grid.setRowStretch(row + 1, 1)
scroll.setWidget(content)
layout.addWidget(scroll)
return widget
def _on_frame_clicked(self, frame_name: str):
"""Handle frame selection"""
# Deselect previous
if self.selected_frame and self.selected_frame in self.frame_widgets:
self.frame_widgets[self.selected_frame].set_selected(False)
# Select new
self.selected_frame = frame_name
if frame_name in self.frame_widgets:
self.frame_widgets[frame_name].set_selected(True)
self._update_selection_display()
self._update_corners_visibility()
def _clear_selection(self):
"""Clear frame selection"""
if self.selected_frame and self.selected_frame in self.frame_widgets:
self.frame_widgets[self.selected_frame].set_selected(False)
self.selected_frame = None
self._update_selection_display()
self._update_corners_visibility()
def _update_selection_display(self):
"""Update the selected frame label"""
if self.selected_frame:
frame = get_frame_manager().get_frame(self.selected_frame)
if frame:
self.selected_label.setText(f"{frame.display_name} - {frame.description}")
else:
self.selected_label.setText(self.selected_frame)
else:
self.selected_label.setText("None")
def _update_corners(self):
"""Update corner selection from checkboxes"""
self.frame_corners = (
self.corner_tl.isChecked(),
self.corner_tr.isChecked(),
self.corner_br.isChecked(),
self.corner_bl.isChecked(),
)
def _update_corners_visibility(self):
"""Show/hide corners group based on selected frame type"""
if self.selected_frame:
frame = get_frame_manager().get_frame(self.selected_frame)
if frame and frame.frame_type == FrameType.CORNERS:
self.corners_group.setVisible(True)
return
self.corners_group.setVisible(False)
def _select_all_corners(self):
"""Select all corners"""
self.corner_tl.setChecked(True)
self.corner_tr.setChecked(True)
self.corner_br.setChecked(True)
self.corner_bl.setChecked(True)
self._update_corners()
def _select_no_corners(self):
"""Deselect all corners"""
self.corner_tl.setChecked(False)
self.corner_tr.setChecked(False)
self.corner_br.setChecked(False)
self.corner_bl.setChecked(False)
self._update_corners()
def _select_diagonal_corners(self):
"""Select diagonal corners (TL and BR)"""
self.corner_tl.setChecked(True)
self.corner_tr.setChecked(False)
self.corner_br.setChecked(True)
self.corner_bl.setChecked(False)
self._update_corners()
def get_values(self) -> Tuple[Optional[str], Tuple[int, int, int], Tuple[bool, bool, bool, bool]]:
"""Get selected frame name, color, and corner configuration"""
return self.selected_frame, self.color_btn.get_color(), self.frame_corners
+340
View File
@@ -0,0 +1,340 @@
"""
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,
QRadioButton,
QButtonGroup,
)
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)
# Apply scope radio buttons
scope_label = QLabel("Apply to:")
layout.addWidget(scope_label)
self._apply_scope_group = QButtonGroup(self)
self.scope_page_only = QRadioButton("This page only")
self.scope_non_manual = QRadioButton("All non-manual pages")
self.scope_all_pages = QRadioButton("All pages (override manual sizing)")
self.scope_page_only.setChecked(True)
self._apply_scope_group.addButton(self.scope_page_only, 0)
self._apply_scope_group.addButton(self.scope_non_manual, 1)
self._apply_scope_group.addButton(self.scope_all_pages, 2)
layout.addWidget(self.scope_page_only)
layout.addWidget(self.scope_non_manual)
layout.addWidget(self.scope_all_pages)
group.setLayout(layout)
return group
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.scope_non_manual.setEnabled(not is_cover)
self.scope_all_pages.setEnabled(not is_cover)
if is_cover:
self.scope_page_only.setChecked(True)
def _update_spine_info(self):
"""Update the spine information display."""
if self.cover_checkbox.isChecked():
# Calculate spine width with current settings
content_pages = sum(p.get_page_count() for p in self.project.pages if not p.is_cover)
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(),
"apply_scope": self._apply_scope_group.checkedId(),
}
@@ -0,0 +1,90 @@
"""
Print Settings Dialog for pyPhotoAlbum
Project-level bleed and safe-area configuration applied uniformly to all pages.
"""
from typing import Any, Dict
from PyQt6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QLabel,
QDoubleSpinBox,
QPushButton,
QGroupBox,
)
from pyPhotoAlbum.project import Project
class PrintSettingsDialog(QDialog):
"""Dialog for configuring project-wide print settings (bleed and safe area)."""
def __init__(self, parent, project: Project):
super().__init__(parent)
self.project = project
self._setup_ui()
def _setup_ui(self):
self.setWindowTitle("Print Settings")
self.setMinimumWidth(340)
layout = QVBoxLayout()
group = QGroupBox("Bleed && Safe Area (applied to all pages)")
group_layout = QVBoxLayout()
# Bleed
bleed_layout = QHBoxLayout()
bleed_layout.addWidget(QLabel("Bleed Margin:"))
self.bleed_spinbox = QDoubleSpinBox()
self.bleed_spinbox.setRange(0.0, 20.0)
self.bleed_spinbox.setSingleStep(0.5)
self.bleed_spinbox.setDecimals(1)
self.bleed_spinbox.setSuffix(" mm")
self.bleed_spinbox.setValue(self.project.page_bleed_mm)
self.bleed_spinbox.setToolTip(
"Extra white space added around each page in the exported PDF.\n"
"The printer cuts here — 3 mm is standard."
)
bleed_layout.addWidget(self.bleed_spinbox)
group_layout.addLayout(bleed_layout)
# Safe area
safe_layout = QHBoxLayout()
safe_layout.addWidget(QLabel("Safe Area:"))
self.safe_spinbox = QDoubleSpinBox()
self.safe_spinbox.setRange(0.0, 50.0)
self.safe_spinbox.setSingleStep(0.5)
self.safe_spinbox.setDecimals(1)
self.safe_spinbox.setSuffix(" mm")
self.safe_spinbox.setValue(self.project.page_safe_area_mm)
self.safe_spinbox.setToolTip(
"Keep text and important content inside this distance from the cut/trim line.\n"
"Shown as a red guide in the editor."
)
safe_layout.addWidget(self.safe_spinbox)
group_layout.addLayout(safe_layout)
group.setLayout(group_layout)
layout.addWidget(group)
# Buttons
btn_layout = QHBoxLayout()
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.reject)
ok_btn = QPushButton("OK")
ok_btn.clicked.connect(self.accept)
ok_btn.setDefault(True)
btn_layout.addStretch()
btn_layout.addWidget(cancel_btn)
btn_layout.addWidget(ok_btn)
layout.addLayout(btn_layout)
self.setLayout(layout)
def get_values(self) -> Dict[str, Any]:
return {
"page_bleed_mm": self.bleed_spinbox.value(),
"page_safe_area_mm": self.safe_spinbox.value(),
}
+297
View File
@@ -0,0 +1,297 @@
"""
Style dialogs for pyPhotoAlbum
Dialogs for configuring image styling options:
- Corner radius
- Border (width and color)
- Drop shadow
"""
from typing import Tuple
from PyQt6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QLabel,
QSlider,
QSpinBox,
QDoubleSpinBox,
QPushButton,
QCheckBox,
QColorDialog,
QGroupBox,
QFormLayout,
QWidget,
)
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QColor
class CornerRadiusDialog(QDialog):
"""Dialog for setting corner radius"""
def __init__(self, parent, current_radius: float = 0.0):
super().__init__(parent)
self.setWindowTitle("Corner Radius")
self.setMinimumWidth(300)
layout = QVBoxLayout(self)
# Slider with label
slider_layout = QHBoxLayout()
slider_layout.addWidget(QLabel("Radius:"))
self.slider = QSlider(Qt.Orientation.Horizontal)
self.slider.setMinimum(0)
self.slider.setMaximum(50)
self.slider.setValue(int(current_radius))
self.slider.valueChanged.connect(self._on_slider_changed)
slider_layout.addWidget(self.slider)
self.value_label = QLabel(f"{int(current_radius)}%")
self.value_label.setMinimumWidth(40)
slider_layout.addWidget(self.value_label)
layout.addLayout(slider_layout)
# Preset buttons
preset_layout = QHBoxLayout()
for value, label in [(0, "None"), (5, "Slight"), (15, "Medium"), (25, "Large"), (50, "Circle")]:
btn = QPushButton(label)
btn.clicked.connect(lambda checked, v=value: self.slider.setValue(v))
preset_layout.addWidget(btn)
layout.addLayout(preset_layout)
# OK/Cancel buttons
button_layout = QHBoxLayout()
ok_btn = QPushButton("OK")
ok_btn.clicked.connect(self.accept)
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.reject)
button_layout.addStretch()
button_layout.addWidget(ok_btn)
button_layout.addWidget(cancel_btn)
layout.addLayout(button_layout)
def _on_slider_changed(self, value):
self.value_label.setText(f"{value}%")
def get_value(self) -> float:
return float(self.slider.value())
class ColorButton(QPushButton):
"""Button that shows a color and opens color picker on click"""
def __init__(self, color: Tuple[int, int, int], parent=None):
super().__init__(parent)
self.setFixedSize(40, 25)
self._color = color
self._update_style()
self.clicked.connect(self._pick_color)
def _update_style(self):
r, g, b = self._color
self.setStyleSheet(f"background-color: rgb({r}, {g}, {b}); border: 1px solid #666;")
def _pick_color(self):
r, g, b = self._color
initial = QColor(r, g, b)
color = QColorDialog.getColor(initial, self, "Select Color")
if color.isValid():
self._color = (color.red(), color.green(), color.blue())
self._update_style()
def get_color(self) -> Tuple[int, int, int]:
return self._color
class BorderDialog(QDialog):
"""Dialog for configuring border"""
def __init__(
self,
parent,
current_width: float = 0.0,
current_color: Tuple[int, int, int] = (0, 0, 0),
):
super().__init__(parent)
self.setWindowTitle("Border Settings")
self.setMinimumWidth(300)
layout = QVBoxLayout(self)
# Border width
width_layout = QHBoxLayout()
width_layout.addWidget(QLabel("Width (mm):"))
self.width_spin = QDoubleSpinBox()
self.width_spin.setRange(0, 20)
self.width_spin.setSingleStep(0.5)
self.width_spin.setValue(current_width)
self.width_spin.setDecimals(1)
width_layout.addWidget(self.width_spin)
layout.addLayout(width_layout)
# Border color
color_layout = QHBoxLayout()
color_layout.addWidget(QLabel("Color:"))
self.color_btn = ColorButton(current_color)
color_layout.addWidget(self.color_btn)
color_layout.addStretch()
layout.addLayout(color_layout)
# Preset buttons
preset_layout = QHBoxLayout()
presets = [
("None", 0, (0, 0, 0)),
("Thin Black", 0.5, (0, 0, 0)),
("White", 2, (255, 255, 255)),
("Gold", 1.5, (212, 175, 55)),
]
for label, width, color in presets:
btn = QPushButton(label)
btn.clicked.connect(lambda checked, w=width, c=color: self._apply_preset(w, c))
preset_layout.addWidget(btn)
layout.addLayout(preset_layout)
# OK/Cancel buttons
button_layout = QHBoxLayout()
ok_btn = QPushButton("OK")
ok_btn.clicked.connect(self.accept)
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.reject)
button_layout.addStretch()
button_layout.addWidget(ok_btn)
button_layout.addWidget(cancel_btn)
layout.addLayout(button_layout)
def _apply_preset(self, width, color):
self.width_spin.setValue(width)
self.color_btn._color = color
self.color_btn._update_style()
def get_values(self) -> Tuple[float, Tuple[int, int, int]]:
return self.width_spin.value(), self.color_btn.get_color()
class ShadowDialog(QDialog):
"""Dialog for configuring drop shadow"""
def __init__(
self,
parent,
enabled: bool = False,
offset: Tuple[float, float] = (2.0, 2.0),
blur: float = 3.0,
color: Tuple[int, int, int, int] = (0, 0, 0, 128),
):
super().__init__(parent)
self.setWindowTitle("Shadow Settings")
self.setMinimumWidth(350)
layout = QVBoxLayout(self)
# Enable checkbox
self.enabled_check = QCheckBox("Enable Drop Shadow")
self.enabled_check.setChecked(enabled)
self.enabled_check.stateChanged.connect(self._update_controls)
layout.addWidget(self.enabled_check)
# Settings group
self.settings_group = QGroupBox("Shadow Settings")
form = QFormLayout(self.settings_group)
# Offset X
self.offset_x = QDoubleSpinBox()
self.offset_x.setRange(-20, 20)
self.offset_x.setSingleStep(0.5)
self.offset_x.setValue(offset[0])
self.offset_x.setDecimals(1)
form.addRow("Offset X (mm):", self.offset_x)
# Offset Y
self.offset_y = QDoubleSpinBox()
self.offset_y.setRange(-20, 20)
self.offset_y.setSingleStep(0.5)
self.offset_y.setValue(offset[1])
self.offset_y.setDecimals(1)
form.addRow("Offset Y (mm):", self.offset_y)
# Blur
self.blur_spin = QDoubleSpinBox()
self.blur_spin.setRange(0, 20)
self.blur_spin.setSingleStep(0.5)
self.blur_spin.setValue(blur)
self.blur_spin.setDecimals(1)
form.addRow("Blur (mm):", self.blur_spin)
# Color
color_widget = QWidget()
color_layout = QHBoxLayout(color_widget)
color_layout.setContentsMargins(0, 0, 0, 0)
self.color_btn = ColorButton(color[:3])
color_layout.addWidget(self.color_btn)
color_layout.addStretch()
form.addRow("Color:", color_widget)
# Opacity
self.opacity_slider = QSlider(Qt.Orientation.Horizontal)
self.opacity_slider.setRange(0, 255)
self.opacity_slider.setValue(color[3] if len(color) > 3 else 128)
opacity_layout = QHBoxLayout()
opacity_layout.addWidget(self.opacity_slider)
self.opacity_label = QLabel(f"{self.opacity_slider.value()}")
self.opacity_label.setMinimumWidth(30)
opacity_layout.addWidget(self.opacity_label)
self.opacity_slider.valueChanged.connect(lambda v: self.opacity_label.setText(str(v)))
form.addRow("Opacity:", opacity_layout)
layout.addWidget(self.settings_group)
# Preset buttons
preset_layout = QHBoxLayout()
presets = [
("Subtle", True, (1.0, 1.0), 2.0, (0, 0, 0, 60)),
("Normal", True, (2.0, 2.0), 3.0, (0, 0, 0, 100)),
("Strong", True, (3.0, 3.0), 5.0, (0, 0, 0, 150)),
]
for label, en, off, bl, col in presets:
btn = QPushButton(label)
btn.clicked.connect(lambda checked, e=en, o=off, b=bl, c=col: self._apply_preset(e, o, b, c))
preset_layout.addWidget(btn)
layout.addLayout(preset_layout)
# OK/Cancel buttons
button_layout = QHBoxLayout()
ok_btn = QPushButton("OK")
ok_btn.clicked.connect(self.accept)
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.reject)
button_layout.addStretch()
button_layout.addWidget(ok_btn)
button_layout.addWidget(cancel_btn)
layout.addLayout(button_layout)
self._update_controls()
def _update_controls(self):
self.settings_group.setEnabled(self.enabled_check.isChecked())
def _apply_preset(self, enabled, offset, blur, color):
self.enabled_check.setChecked(enabled)
self.offset_x.setValue(offset[0])
self.offset_y.setValue(offset[1])
self.blur_spin.setValue(blur)
self.color_btn._color = color[:3]
self.color_btn._update_style()
self.opacity_slider.setValue(color[3] if len(color) > 3 else 128)
def get_values(self) -> Tuple[bool, Tuple[float, float], float, Tuple[int, int, int, int]]:
color_rgb = self.color_btn.get_color()
color_rgba = color_rgb + (self.opacity_slider.value(),)
return (
self.enabled_check.isChecked(),
(self.offset_x.value(), self.offset_y.value()),
self.blur_spin.value(),
color_rgba,
)
+943
View File
@@ -0,0 +1,943 @@
"""
Frame manager for pyPhotoAlbum
Manages decorative frames that can be applied to images:
- Loading frame assets (SVG/PNG)
- Rendering frames in OpenGL and PDF
- Frame categories (modern, vintage)
- Color override for SVG frames
"""
import io
import os
import re
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass, field
from enum import Enum
from PIL import Image
class FrameCategory(Enum):
"""Categories for organizing frames"""
MODERN = "modern"
VINTAGE = "vintage"
GEOMETRIC = "geometric"
CUSTOM = "custom"
class FrameType(Enum):
"""How the frame is structured"""
CORNERS = "corners" # 4 corner pieces, rotated/mirrored
FULL = "full" # Complete frame as single image
EDGES = "edges" # Tileable edge pieces
@dataclass
class FrameDefinition:
"""Definition of a decorative frame"""
name: str
display_name: str
category: FrameCategory
frame_type: FrameType
description: str = ""
# Asset path (relative to frames/corners directory for CORNERS type)
# For CORNERS type: single SVG that gets rotated for each corner
asset_path: Optional[str] = None
# Which corner the SVG asset is designed for: "tl", "tr", "br", "bl"
# This determines how to flip for other corners
asset_corner: str = "tl"
# Whether the frame can be tinted with a custom color
colorizable: bool = True
# Default thickness as percentage of shorter image side
default_thickness: float = 5.0
# Cached textures for OpenGL rendering: key = (color, size) tuple
_texture_cache: Dict[tuple, int] = field(default_factory=dict, repr=False)
_image_cache: Dict[tuple, Image.Image] = field(default_factory=dict, repr=False)
class FrameManager:
"""
Manages loading and rendering of decorative frames.
Frames are stored in the frames/ directory with the following structure:
frames/
corners/
floral_corner.svg
ornate_corner.svg
CREDITS.txt
"""
def __init__(self):
self.frames: Dict[str, FrameDefinition] = {}
self._frames_dir = self._get_frames_directory()
self._load_bundled_frames()
def _get_frames_directory(self) -> Path:
"""Get the frames directory path"""
app_dir = Path(__file__).parent
return app_dir / "frames"
def _load_bundled_frames(self):
"""Load bundled frame definitions"""
# Modern frames (programmatic - no SVG assets)
self._register_frame(
FrameDefinition(
name="simple_line",
display_name="Simple Line",
category=FrameCategory.MODERN,
frame_type=FrameType.FULL,
description="Clean single-line border",
colorizable=True,
default_thickness=2.0,
)
)
self._register_frame(
FrameDefinition(
name="double_line",
display_name="Double Line",
category=FrameCategory.MODERN,
frame_type=FrameType.FULL,
description="Double parallel lines",
colorizable=True,
default_thickness=4.0,
)
)
# Geometric frames (programmatic)
self._register_frame(
FrameDefinition(
name="geometric_corners",
display_name="Geometric Corners",
category=FrameCategory.GEOMETRIC,
frame_type=FrameType.CORNERS,
description="Angular geometric corner decorations",
colorizable=True,
default_thickness=8.0,
)
)
# SVG-based vintage frames
# Each SVG is designed for a specific corner position:
# corner_decoration.svg -> top left (tl)
# corner_ornament.svg -> bottom left (bl)
# floral_corner.svg -> bottom left (bl)
# floral_flourish.svg -> bottom right (br)
# ornate_corner.svg -> top left (tl)
# simple_corner.svg -> top left (tl)
corners_dir = self._frames_dir / "corners"
# Floral Corner (designed for bottom-left)
if (corners_dir / "floral_corner.svg").exists():
self._register_frame(
FrameDefinition(
name="floral_corner",
display_name="Floral Corner",
category=FrameCategory.VINTAGE,
frame_type=FrameType.CORNERS,
description="Decorative floral corner ornament",
asset_path="corners/floral_corner.svg",
asset_corner="bl",
colorizable=True,
default_thickness=12.0,
)
)
# Floral Flourish (designed for bottom-right)
if (corners_dir / "floral_flourish.svg").exists():
self._register_frame(
FrameDefinition(
name="floral_flourish",
display_name="Floral Flourish",
category=FrameCategory.VINTAGE,
frame_type=FrameType.CORNERS,
description="Elegant floral flourish design",
asset_path="corners/floral_flourish.svg",
asset_corner="br",
colorizable=True,
default_thickness=10.0,
)
)
# Ornate Corner (designed for top-left)
if (corners_dir / "ornate_corner.svg").exists():
self._register_frame(
FrameDefinition(
name="ornate_corner",
display_name="Ornate Corner",
category=FrameCategory.VINTAGE,
frame_type=FrameType.CORNERS,
description="Classic ornate line art corner",
asset_path="corners/ornate_corner.svg",
asset_corner="tl",
colorizable=True,
default_thickness=10.0,
)
)
# Simple Corner (designed for top-left)
if (corners_dir / "simple_corner.svg").exists():
self._register_frame(
FrameDefinition(
name="simple_corner",
display_name="Simple Corner",
category=FrameCategory.VINTAGE,
frame_type=FrameType.CORNERS,
description="Simple decorative corner ornament",
asset_path="corners/simple_corner.svg",
asset_corner="tl",
colorizable=True,
default_thickness=8.0,
)
)
# Corner Decoration (designed for top-left)
if (corners_dir / "corner_decoration.svg").exists():
self._register_frame(
FrameDefinition(
name="corner_decoration",
display_name="Corner Decoration",
category=FrameCategory.VINTAGE,
frame_type=FrameType.CORNERS,
description="Decorative corner piece",
asset_path="corners/corner_decoration.svg",
asset_corner="tl",
colorizable=True,
default_thickness=10.0,
)
)
# Corner Ornament (designed for bottom-left)
if (corners_dir / "corner_ornament.svg").exists():
self._register_frame(
FrameDefinition(
name="corner_ornament",
display_name="Corner Ornament",
category=FrameCategory.VINTAGE,
frame_type=FrameType.CORNERS,
description="Vintage corner ornament design",
asset_path="corners/corner_ornament.svg",
asset_corner="bl",
colorizable=True,
default_thickness=10.0,
)
)
def _register_frame(self, frame: FrameDefinition):
"""Register a frame definition"""
self.frames[frame.name] = frame
def get_frame(self, name: str) -> Optional[FrameDefinition]:
"""Get a frame by name"""
return self.frames.get(name)
def get_frames_by_category(self, category: FrameCategory) -> List[FrameDefinition]:
"""Get all frames in a category"""
return [f for f in self.frames.values() if f.category == category]
def get_all_frames(self) -> List[FrameDefinition]:
"""Get all available frames"""
return list(self.frames.values())
def get_frame_names(self) -> List[str]:
"""Get list of all frame names"""
return list(self.frames.keys())
def _load_svg_as_image(
self,
svg_path: Path,
target_size: int,
color: Optional[Tuple[int, int, int]] = None,
) -> Optional[Image.Image]:
"""
Load an SVG file and render it to a PIL Image.
Args:
svg_path: Path to the SVG file
target_size: Target size in pixels for the corner
color: Optional color override as RGB tuple (0-255)
Returns:
PIL Image with alpha channel, or None if loading fails
"""
try:
import cairosvg
except ImportError:
print("Warning: cairosvg not installed, SVG frames will use fallback rendering")
return None
# Validate svg_path type
if not isinstance(svg_path, (str, Path)):
print(f"Warning: Invalid svg_path type: {type(svg_path)}, expected Path or str")
return None
# Ensure svg_path is a Path object
if isinstance(svg_path, str):
svg_path = Path(svg_path)
if not svg_path.exists():
return None
try:
# Read SVG content
svg_content = svg_path.read_text()
# Apply color override if specified
if color is not None:
svg_content = self._recolor_svg(svg_content, color)
# Render SVG to PNG bytes
png_data = cairosvg.svg2png(
bytestring=svg_content.encode("utf-8"),
output_width=target_size,
output_height=target_size,
)
# Validate png_data type
if not isinstance(png_data, bytes):
print(f"Warning: cairosvg returned {type(png_data)} instead of bytes")
return None
# Load as PIL Image from bytes buffer
buffer = io.BytesIO(png_data)
img: Image.Image = Image.open(buffer)
if img.mode != "RGBA":
img = img.convert("RGBA")
# Force load the image data to avoid issues with BytesIO going out of scope
img.load()
return img
except Exception as e:
import traceback
print(f"Error loading SVG {svg_path}: {e}")
traceback.print_exc()
return None
def _recolor_svg(self, svg_content: str, color: Tuple[int, int, int]) -> str:
"""
Recolor an SVG by replacing fill and stroke colors.
Args:
svg_content: SVG file content as string
color: New color as RGB tuple (0-255)
Returns:
Modified SVG content with new colors
"""
r, g, b = color
hex_color = f"#{r:02x}{g:02x}{b:02x}"
rgb_color = f"rgb({r},{g},{b})"
# Replace common color patterns
# Replace fill colors (hex, rgb, named colors)
svg_content = re.sub(
r'fill\s*[:=]\s*["\']?(?:#[0-9a-fA-F]{3,6}|rgb\([^)]+\)|black|white|none)["\']?',
f'fill="{hex_color}"',
svg_content,
flags=re.IGNORECASE,
)
# Replace stroke colors
svg_content = re.sub(
r'stroke\s*[:=]\s*["\']?(?:#[0-9a-fA-F]{3,6}|rgb\([^)]+\)|black|white)["\']?',
f'stroke="{hex_color}"',
svg_content,
flags=re.IGNORECASE,
)
# Replace style-based fill/stroke
svg_content = re.sub(
r"(fill\s*:\s*)(?:#[0-9a-fA-F]{3,6}|rgb\([^)]+\)|black|white)",
f"\\1{hex_color}",
svg_content,
flags=re.IGNORECASE,
)
svg_content = re.sub(
r"(stroke\s*:\s*)(?:#[0-9a-fA-F]{3,6}|rgb\([^)]+\)|black|white)",
f"\\1{hex_color}",
svg_content,
flags=re.IGNORECASE,
)
return svg_content
def _get_corner_image(
self,
frame: FrameDefinition,
corner_size: int,
color: Tuple[int, int, int],
) -> Optional[Image.Image]:
"""
Get a corner image, using cache if available.
Args:
frame: Frame definition
corner_size: Size in pixels
color: Color as RGB tuple
Returns:
PIL Image or None
"""
cache_key = (color, corner_size)
if cache_key in frame._image_cache:
return frame._image_cache[cache_key]
if frame.asset_path:
try:
svg_path = self._frames_dir / frame.asset_path
img = self._load_svg_as_image(svg_path, corner_size, color)
if img:
frame._image_cache[cache_key] = img
return img
except Exception as e:
import traceback
print(f"Error getting corner image for {frame.name}: {e}")
traceback.print_exc()
return None
return None
def render_frame_opengl(
self,
frame_name: str,
x: float,
y: float,
width: float,
height: float,
color: Tuple[int, int, int] = (0, 0, 0),
thickness: Optional[float] = None,
corners: Optional[Tuple[bool, bool, bool, bool]] = None,
):
"""
Render a decorative frame using OpenGL.
Args:
frame_name: Name of the frame to render
x, y: Position of the image
width, height: Size of the image
color: Frame color as RGB (0-255)
thickness: Frame thickness (None = use default)
corners: Which corners to render (TL, TR, BR, BL). None = all corners
"""
frame = self.get_frame(frame_name)
if not frame:
return
# Default to all corners if not specified
if corners is None:
corners = (True, True, True, True)
from pyPhotoAlbum.gl_imports import (
glColor3f,
glColor4f,
glBegin,
glEnd,
glVertex2f,
GL_LINE_LOOP,
glLineWidth,
glEnable,
glDisable,
GL_BLEND,
glBlendFunc,
GL_SRC_ALPHA,
GL_ONE_MINUS_SRC_ALPHA,
GL_TEXTURE_2D,
glBindTexture,
glTexCoord2f,
GL_QUADS,
)
# Calculate thickness
shorter_side = min(width, height)
frame_thickness = thickness if thickness else (shorter_side * frame.default_thickness / 100)
glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
# Try to render with SVG asset if available
if frame.asset_path and frame.frame_type == FrameType.CORNERS:
corner_size = int(frame_thickness * 2)
if self._render_svg_corners_gl(frame, x, y, width, height, corner_size, color, corners):
glDisable(GL_BLEND)
return
# Fall back to programmatic rendering
r, g, b = color[0] / 255.0, color[1] / 255.0, color[2] / 255.0
glColor3f(r, g, b)
if frame.frame_type == FrameType.CORNERS:
self._render_corner_frame_gl(x, y, width, height, frame_thickness, frame_name, corners)
elif frame.frame_type == FrameType.FULL:
self._render_full_frame_gl(x, y, width, height, frame_thickness)
glDisable(GL_BLEND)
def _render_svg_corners_gl(
self,
frame: FrameDefinition,
x: float,
y: float,
w: float,
h: float,
corner_size: int,
color: Tuple[int, int, int],
corners: Tuple[bool, bool, bool, bool],
) -> bool:
"""
Render SVG-based corners using OpenGL textures.
Returns True if rendering was successful, False to fall back to programmatic.
"""
from pyPhotoAlbum.gl_imports import (
glEnable,
glDisable,
glBindTexture,
glTexCoord2f,
glVertex2f,
glBegin,
glEnd,
glColor4f,
GL_TEXTURE_2D,
GL_QUADS,
glGenTextures,
glTexParameteri,
glTexImage2D,
GL_TEXTURE_MIN_FILTER,
GL_TEXTURE_MAG_FILTER,
GL_LINEAR,
GL_RGBA,
GL_UNSIGNED_BYTE,
)
# Get or create corner image
corner_img = self._get_corner_image(frame, corner_size, color)
if corner_img is None:
return False
# Create texture if not cached
cache_key = (color, corner_size, "texture")
if cache_key not in frame._texture_cache:
img_data = corner_img.tobytes()
texture_id = glGenTextures(1)
glBindTexture(GL_TEXTURE_2D, texture_id)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
glTexImage2D(
GL_TEXTURE_2D,
0,
GL_RGBA,
corner_img.width,
corner_img.height,
0,
GL_RGBA,
GL_UNSIGNED_BYTE,
img_data,
)
frame._texture_cache[cache_key] = texture_id
texture_id = frame._texture_cache[cache_key]
# Render corners
glEnable(GL_TEXTURE_2D)
glBindTexture(GL_TEXTURE_2D, texture_id)
glColor4f(1.0, 1.0, 1.0, 1.0) # White to show texture colors
tl, tr, br, bl = corners
cs = float(corner_size)
# Helper to draw a textured quad with optional flipping
# flip_h: flip horizontally, flip_v: flip vertically
def draw_corner_quad(cx, cy, flip_h=False, flip_v=False):
# Calculate texture coordinates based on flipping
u0, u1 = (1, 0) if flip_h else (0, 1)
v0, v1 = (1, 0) if flip_v else (0, 1)
glBegin(GL_QUADS)
glTexCoord2f(u0, v0)
glVertex2f(cx, cy)
glTexCoord2f(u1, v0)
glVertex2f(cx + cs, cy)
glTexCoord2f(u1, v1)
glVertex2f(cx + cs, cy + cs)
glTexCoord2f(u0, v1)
glVertex2f(cx, cy + cs)
glEnd()
# Calculate flips based on the asset's designed corner vs target corner
# Each SVG is designed for a specific corner (asset_corner field)
# To render it at a different corner, we flip horizontally and/or vertically
#
# Corner positions:
# tl (top-left) tr (top-right)
# bl (bottom-left) br (bottom-right)
#
# To go from asset corner to target corner:
# - flip_h if horizontal position differs (l->r or r->l)
# - flip_v if vertical position differs (t->b or b->t)
asset_corner = frame.asset_corner # e.g., "tl", "bl", "br", "tr"
asset_h = asset_corner[1] # 'l' or 'r'
asset_v = asset_corner[0] # 't' or 'b'
def get_flips(target_corner: str) -> Tuple[bool, bool]:
"""Calculate flip_h, flip_v to transform from asset_corner to target_corner"""
target_h = target_corner[1] # 'l' or 'r'
target_v = target_corner[0] # 't' or 'b'
flip_h = asset_h != target_h
flip_v = asset_v != target_v
return flip_h, flip_v
# Top-left corner
if tl:
flip_h, flip_v = get_flips("tl")
draw_corner_quad(x, y, flip_h=flip_h, flip_v=flip_v)
# Top-right corner
if tr:
flip_h, flip_v = get_flips("tr")
draw_corner_quad(x + w - cs, y, flip_h=flip_h, flip_v=flip_v)
# Bottom-right corner
if br:
flip_h, flip_v = get_flips("br")
draw_corner_quad(x + w - cs, y + h - cs, flip_h=flip_h, flip_v=flip_v)
# Bottom-left corner
if bl:
flip_h, flip_v = get_flips("bl")
draw_corner_quad(x, y + h - cs, flip_h=flip_h, flip_v=flip_v)
glDisable(GL_TEXTURE_2D)
return True
def _render_corner_frame_gl(
self,
x: float,
y: float,
w: float,
h: float,
thickness: float,
frame_name: str,
corners: Tuple[bool, bool, bool, bool] = (True, True, True, True),
):
"""Render corner-style frame decorations (programmatic fallback)."""
from pyPhotoAlbum.gl_imports import glBegin, glEnd, glVertex2f, glLineWidth, GL_LINE_STRIP
corner_size = thickness * 2
glLineWidth(2.0)
tl, tr, br, bl = corners
# Top-left corner
if tl:
glBegin(GL_LINE_STRIP)
glVertex2f(x, y + corner_size)
glVertex2f(x, y)
glVertex2f(x + corner_size, y)
glEnd()
# Top-right corner
if tr:
glBegin(GL_LINE_STRIP)
glVertex2f(x + w - corner_size, y)
glVertex2f(x + w, y)
glVertex2f(x + w, y + corner_size)
glEnd()
# Bottom-right corner
if br:
glBegin(GL_LINE_STRIP)
glVertex2f(x + w, y + h - corner_size)
glVertex2f(x + w, y + h)
glVertex2f(x + w - corner_size, y + h)
glEnd()
# Bottom-left corner
if bl:
glBegin(GL_LINE_STRIP)
glVertex2f(x + corner_size, y + h)
glVertex2f(x, y + h)
glVertex2f(x, y + h - corner_size)
glEnd()
# Add decorative swirls for vintage frames
if "leafy" in frame_name or "ornate" in frame_name or "flourish" in frame_name:
self._render_decorative_swirls_gl(x, y, w, h, corner_size, corners)
glLineWidth(1.0)
def _render_decorative_swirls_gl(
self,
x: float,
y: float,
w: float,
h: float,
size: float,
corners: Tuple[bool, bool, bool, bool] = (True, True, True, True),
):
"""Render decorative swirl elements at corners (programmatic fallback)."""
from pyPhotoAlbum.gl_imports import glBegin, glEnd, glVertex2f, GL_LINE_STRIP
import math
steps = 8
radius = size * 0.4
tl, tr, br, bl = corners
corner_data = [
(tl, x + size * 0.5, y + size * 0.5, math.pi),
(tr, x + w - size * 0.5, y + size * 0.5, math.pi * 1.5),
(br, x + w - size * 0.5, y + h - size * 0.5, 0),
(bl, x + size * 0.5, y + h - size * 0.5, math.pi * 0.5),
]
for enabled, cx, cy, start_angle in corner_data:
if not enabled:
continue
glBegin(GL_LINE_STRIP)
for i in range(steps + 1):
angle = start_angle + (math.pi * 0.5 * i / steps)
px = cx + radius * math.cos(angle)
py = cy + radius * math.sin(angle)
glVertex2f(px, py)
glEnd()
def _render_full_frame_gl(self, x: float, y: float, w: float, h: float, thickness: float):
"""Render full-border frame (programmatic)"""
from pyPhotoAlbum.gl_imports import glBegin, glEnd, glVertex2f, GL_LINE_LOOP, glLineWidth
glLineWidth(max(1.0, thickness * 0.5))
glBegin(GL_LINE_LOOP)
glVertex2f(x - thickness * 0.5, y - thickness * 0.5)
glVertex2f(x + w + thickness * 0.5, y - thickness * 0.5)
glVertex2f(x + w + thickness * 0.5, y + h + thickness * 0.5)
glVertex2f(x - thickness * 0.5, y + h + thickness * 0.5)
glEnd()
glBegin(GL_LINE_LOOP)
glVertex2f(x + thickness * 0.3, y + thickness * 0.3)
glVertex2f(x + w - thickness * 0.3, y + thickness * 0.3)
glVertex2f(x + w - thickness * 0.3, y + h - thickness * 0.3)
glVertex2f(x + thickness * 0.3, y + h - thickness * 0.3)
glEnd()
glLineWidth(1.0)
def render_frame_pdf(
self,
canvas,
frame_name: str,
x_pt: float,
y_pt: float,
width_pt: float,
height_pt: float,
color: Tuple[int, int, int] = (0, 0, 0),
thickness_pt: Optional[float] = None,
corners: Optional[Tuple[bool, bool, bool, bool]] = None,
):
"""
Render a decorative frame on a PDF canvas.
Args:
canvas: ReportLab canvas
frame_name: Name of the frame to render
x_pt, y_pt: Position in points
width_pt, height_pt: Size in points
color: Frame color as RGB (0-255)
thickness_pt: Frame thickness in points (None = use default)
corners: Which corners to render (TL, TR, BR, BL). None = all corners
"""
frame = self.get_frame(frame_name)
if not frame:
return
if corners is None:
corners = (True, True, True, True)
shorter_side = min(width_pt, height_pt)
frame_thickness = thickness_pt if thickness_pt else (shorter_side * frame.default_thickness / 100)
r, g, b = color[0] / 255.0, color[1] / 255.0, color[2] / 255.0
canvas.saveState()
canvas.setStrokeColorRGB(r, g, b)
canvas.setLineWidth(max(0.5, frame_thickness * 0.3))
# Try SVG rendering for PDF
if frame.asset_path and frame.frame_type == FrameType.CORNERS:
corner_size_pt = frame_thickness * 2
if self._render_svg_corners_pdf(
canvas, frame, x_pt, y_pt, width_pt, height_pt, corner_size_pt, color, corners
):
canvas.restoreState()
return
# Fall back to programmatic
if frame.frame_type == FrameType.CORNERS:
self._render_corner_frame_pdf(canvas, x_pt, y_pt, width_pt, height_pt, frame_thickness, frame_name, corners)
elif frame.frame_type == FrameType.FULL:
self._render_full_frame_pdf(canvas, x_pt, y_pt, width_pt, height_pt, frame_thickness)
canvas.restoreState()
def _render_svg_corners_pdf(
self,
canvas,
frame: FrameDefinition,
x: float,
y: float,
w: float,
h: float,
corner_size_pt: float,
color: Tuple[int, int, int],
corners: Tuple[bool, bool, bool, bool],
) -> bool:
"""Render SVG corners on PDF canvas. Returns True if successful."""
from reportlab.lib.utils import ImageReader
# Get corner image at high resolution for PDF
corner_size_px = int(corner_size_pt * 4) # 4x for PDF quality
if corner_size_px < 1:
corner_size_px = 1
corner_img = self._get_corner_image(frame, corner_size_px, color)
if corner_img is None:
return False
tl, tr, br, bl = corners
cs = corner_size_pt
# For PDF, we use PIL to flip the image rather than canvas transformations
# This is more reliable across different PDF renderers
def get_flipped_image(target_corner: str) -> Image.Image:
"""Get image flipped appropriately for the target corner"""
asset_corner = frame.asset_corner
asset_h = asset_corner[1] # 'l' or 'r'
asset_v = asset_corner[0] # 't' or 'b'
target_h = target_corner[1]
target_v = target_corner[0]
img = corner_img.copy()
# Flip horizontally if h position differs
if asset_h != target_h:
img = img.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
# Flip vertically if v position differs
if asset_v != target_v:
img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
return img
# Note: PDF Y-axis is bottom-up, so corners are positioned differently
# Top-left in screen coordinates = high Y in PDF
if tl:
img = get_flipped_image("tl")
img_reader = ImageReader(img)
canvas.drawImage(img_reader, x, y + h - cs, cs, cs, mask="auto")
# Top-right
if tr:
img = get_flipped_image("tr")
img_reader = ImageReader(img)
canvas.drawImage(img_reader, x + w - cs, y + h - cs, cs, cs, mask="auto")
# Bottom-right
if br:
img = get_flipped_image("br")
img_reader = ImageReader(img)
canvas.drawImage(img_reader, x + w - cs, y, cs, cs, mask="auto")
# Bottom-left
if bl:
img = get_flipped_image("bl")
img_reader = ImageReader(img)
canvas.drawImage(img_reader, x, y, cs, cs, mask="auto")
return True
def _render_corner_frame_pdf(
self,
canvas,
x: float,
y: float,
w: float,
h: float,
thickness: float,
frame_name: str,
corners: Tuple[bool, bool, bool, bool] = (True, True, True, True),
):
"""Render corner-style frame on PDF (programmatic fallback)."""
corner_size = thickness * 2
tl, tr, br, bl = corners
path = canvas.beginPath()
if tl:
path.moveTo(x, y + h - corner_size)
path.lineTo(x, y + h)
path.lineTo(x + corner_size, y + h)
if tr:
path.moveTo(x + w - corner_size, y + h)
path.lineTo(x + w, y + h)
path.lineTo(x + w, y + h - corner_size)
if br:
path.moveTo(x + w, y + corner_size)
path.lineTo(x + w, y)
path.lineTo(x + w - corner_size, y)
if bl:
path.moveTo(x + corner_size, y)
path.lineTo(x, y)
path.lineTo(x, y + corner_size)
canvas.drawPath(path, stroke=1, fill=0)
def _render_full_frame_pdf(self, canvas, x: float, y: float, w: float, h: float, thickness: float):
"""Render full-border frame on PDF"""
canvas.rect(
x - thickness * 0.5,
y - thickness * 0.5,
w + thickness,
h + thickness,
stroke=1,
fill=0,
)
canvas.rect(
x + thickness * 0.3,
y + thickness * 0.3,
w - thickness * 0.6,
h - thickness * 0.6,
stroke=1,
fill=0,
)
# Global frame manager instance
_frame_manager: Optional[FrameManager] = None
def get_frame_manager() -> FrameManager:
"""Get the global frame manager instance"""
global _frame_manager
if _frame_manager is None:
_frame_manager = FrameManager()
return _frame_manager
+23
View File
@@ -0,0 +1,23 @@
Decorative Frame Assets - Credits and Licenses
===============================================
All decorative corner SVG assets in this directory are sourced from FreeSVG.org
and are released under the Creative Commons Zero (CC0) Public Domain license.
This means you can copy, modify, distribute, and use them for commercial purposes,
all without asking permission or providing attribution.
However, we gratefully acknowledge the following sources:
Corner Decorations
------------------
- corner_decoration.svg - FreeSVG.org (OpenClipart)
- corner_ornament.svg - FreeSVG.org (RebeccaRead/OpenClipart)
- floral_corner.svg - FreeSVG.org (OpenClipart)
- floral_flourish.svg - FreeSVG.org (OpenClipart)
- ornate_corner.svg - FreeSVG.org (OpenClipart)
- simple_corner.svg - FreeSVG.org (OpenClipart)
Source: https://freesvg.org
License: CC0 1.0 Universal (Public Domain)
License URL: https://creativecommons.org/publicdomain/zero/1.0/
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

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

After

Width:  |  Height:  |  Size: 4.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 32 KiB

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

After

Width:  |  Height:  |  Size: 43 KiB

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

After

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 138 KiB

+110
View 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
+265 -6
View File
@@ -4,11 +4,12 @@ OpenGL widget for pyPhotoAlbum rendering - refactored with mixins
from PyQt6.QtOpenGLWidgets import QOpenGLWidget from PyQt6.QtOpenGLWidgets import QOpenGLWidget
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
from OpenGL.GL import * from pyPhotoAlbum.gl_imports import *
# Import all mixins # Import all mixins
from pyPhotoAlbum.mixins.viewport import ViewportMixin from pyPhotoAlbum.mixins.viewport import ViewportMixin
from pyPhotoAlbum.mixins.rendering import RenderingMixin from pyPhotoAlbum.mixins.rendering import RenderingMixin
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
from pyPhotoAlbum.mixins.asset_drop import AssetDropMixin from pyPhotoAlbum.mixins.asset_drop import AssetDropMixin
from pyPhotoAlbum.mixins.page_navigation import PageNavigationMixin from pyPhotoAlbum.mixins.page_navigation import PageNavigationMixin
from pyPhotoAlbum.mixins.image_pan import ImagePanMixin from pyPhotoAlbum.mixins.image_pan import ImagePanMixin
@@ -17,12 +18,14 @@ from pyPhotoAlbum.mixins.element_selection import ElementSelectionMixin
from pyPhotoAlbum.mixins.mouse_interaction import MouseInteractionMixin from pyPhotoAlbum.mixins.mouse_interaction import MouseInteractionMixin
from pyPhotoAlbum.mixins.interaction_undo import UndoableInteractionMixin from pyPhotoAlbum.mixins.interaction_undo import UndoableInteractionMixin
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
from pyPhotoAlbum.mixins.keyboard_navigation import KeyboardNavigationMixin
class GLWidget( class GLWidget(
AsyncLoadingMixin, AsyncLoadingMixin,
ViewportMixin, ViewportMixin,
RenderingMixin, RenderingMixin,
AssetPathMixin,
AssetDropMixin, AssetDropMixin,
PageNavigationMixin, PageNavigationMixin,
ImagePanMixin, ImagePanMixin,
@@ -30,7 +33,8 @@ class GLWidget(
ElementSelectionMixin, ElementSelectionMixin,
MouseInteractionMixin, MouseInteractionMixin,
UndoableInteractionMixin, UndoableInteractionMixin,
QOpenGLWidget KeyboardNavigationMixin,
QOpenGLWidget,
): ):
"""OpenGL widget for pyPhotoAlbum rendering and user interaction """OpenGL widget for pyPhotoAlbum rendering and user interaction
@@ -49,29 +53,70 @@ class GLWidget(
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
# Store reference to main window for accessing project
self._main_window = parent
# Initialize async loading system # Initialize async loading system
self._init_async_loading() self._init_async_loading()
# Initialize OpenGL # Set up OpenGL surface format with explicit double buffering
self.setFormat(self.format()) 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) self.setUpdateBehavior(QOpenGLWidget.UpdateBehavior.NoPartialUpdate)
# Enable mouse tracking and drag-drop # Enable mouse tracking and drag-drop
self.setMouseTracking(True) self.setMouseTracking(True)
self.setAcceptDrops(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): def closeEvent(self, event):
"""Handle widget close event.""" """Handle widget close event."""
# Cleanup async loading # Cleanup async loading
self._cleanup_async_loading() self._cleanup_async_loading()
super().closeEvent(event) 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): def keyPressEvent(self, event):
"""Handle key press events""" """Handle key press events"""
if event.key() == Qt.Key.Key_Delete or event.key() == Qt.Key.Key_Backspace: if event.key() == Qt.Key.Key_Delete or event.key() == Qt.Key.Key_Backspace:
if self.selected_element: if self.selected_element:
main_window = self.window() main_window = self.window()
if hasattr(main_window, 'delete_selected_element'): if hasattr(main_window, "delete_selected_element"):
main_window.delete_selected_element() main_window.delete_selected_element()
elif event.key() == Qt.Key.Key_Escape: elif event.key() == Qt.Key.Key_Escape:
@@ -84,7 +129,7 @@ class GLWidget(
if self.selected_element: if self.selected_element:
self.rotation_mode = not self.rotation_mode self.rotation_mode = not self.rotation_mode
main_window = self.window() main_window = self.window()
if hasattr(main_window, 'show_status'): if hasattr(main_window, "show_status"):
mode_text = "Rotation Mode" if self.rotation_mode else "Move/Resize Mode" mode_text = "Rotation Mode" if self.rotation_mode else "Move/Resize Mode"
main_window.show_status(f"Switched to {mode_text}", 2000) main_window.show_status(f"Switched to {mode_text}", 2000)
print(f"Rotation mode: {self.rotation_mode}") print(f"Rotation mode: {self.rotation_mode}")
@@ -93,5 +138,219 @@ class GLWidget(
else: else:
super().keyPressEvent(event) 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: else:
super().keyPressEvent(event) 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()
+432
View File
@@ -0,0 +1,432 @@
"""
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
+184
View File
@@ -0,0 +1,184 @@
"""
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()
+278 -38
View File
@@ -6,12 +6,19 @@ This version uses the mixin architecture with auto-generated ribbon configuratio
""" """
import sys import sys
from datetime import datetime
from pathlib import Path from pathlib import Path
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QApplication, QMainWindow, QVBoxLayout, QWidget, QApplication,
QStatusBar QMainWindow,
QVBoxLayout,
QWidget,
QStatusBar,
QScrollBar,
QHBoxLayout,
QMessageBox,
) )
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt, QSize, QTimer
from PyQt6.QtGui import QIcon from PyQt6.QtGui import QIcon
from pyPhotoAlbum.project import Project from pyPhotoAlbum.project import Project
@@ -19,9 +26,12 @@ from pyPhotoAlbum.template_manager import TemplateManager
from pyPhotoAlbum.ribbon_widget import RibbonWidget from pyPhotoAlbum.ribbon_widget import RibbonWidget
from pyPhotoAlbum.ribbon_builder import build_ribbon_config, print_ribbon_summary from pyPhotoAlbum.ribbon_builder import build_ribbon_config, print_ribbon_summary
from pyPhotoAlbum.gl_widget import GLWidget from pyPhotoAlbum.gl_widget import GLWidget
from pyPhotoAlbum.autosave_manager import AutosaveManager
from pyPhotoAlbum.thumbnail_browser import ThumbnailBrowserDock
# Import mixins # Import mixins
from pyPhotoAlbum.mixins.base import ApplicationStateMixin from pyPhotoAlbum.mixins.base import ApplicationStateMixin
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
from pyPhotoAlbum.mixins.operations import ( from pyPhotoAlbum.mixins.operations import (
FileOperationsMixin, FileOperationsMixin,
EditOperationsMixin, EditOperationsMixin,
@@ -33,12 +43,15 @@ from pyPhotoAlbum.mixins.operations import (
DistributionOperationsMixin, DistributionOperationsMixin,
SizeOperationsMixin, SizeOperationsMixin,
ZOrderOperationsMixin, ZOrderOperationsMixin,
MergeOperationsMixin,
StyleOperationsMixin,
) )
class MainWindow( class MainWindow(
QMainWindow, QMainWindow,
ApplicationStateMixin, ApplicationStateMixin,
AssetPathMixin,
FileOperationsMixin, FileOperationsMixin,
EditOperationsMixin, EditOperationsMixin,
ElementOperationsMixin, ElementOperationsMixin,
@@ -49,27 +62,40 @@ class MainWindow(
DistributionOperationsMixin, DistributionOperationsMixin,
SizeOperationsMixin, SizeOperationsMixin,
ZOrderOperationsMixin, ZOrderOperationsMixin,
MergeOperationsMixin,
StyleOperationsMixin,
): ):
""" """
Main application window using mixin architecture. Main application window using mixin architecture.
This class composes functionality from multiple mixins rather than This class composes functionality from multiple mixins rather than
implementing everything directly. The ribbon configuration is implementing everything directly. The ribbon configuration is
automatically generated from decorated methods in the mixins. automatically generated from decorated methods in the mixins.
""" """
def __init__(self): def __init__(self):
super().__init__() super().__init__()
# Initialize autosave manager
self._autosave_manager = AutosaveManager()
# Initialize shared state first # Initialize shared state first
self._init_state() self._init_state()
# Initialize UI # Initialize UI
self._init_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 # Add a sample page for demonstration
#self._add_sample_page() # self._add_sample_page()
def _init_state(self): def _init_state(self):
"""Initialize shared application state""" """Initialize shared application state"""
# Initialize project # Initialize project
@@ -77,17 +103,18 @@ class MainWindow(
# Set asset resolution context # Set asset resolution context
from pyPhotoAlbum.models import set_asset_resolution_context from pyPhotoAlbum.models import set_asset_resolution_context
set_asset_resolution_context(self._project.folder_path) set_asset_resolution_context(self._project.folder_path)
# Initialize template manager # Initialize template manager
self._template_manager = TemplateManager() self._template_manager = TemplateManager()
def _init_ui(self): def _init_ui(self):
"""Initialize user interface""" """Initialize user interface"""
# Basic window setup # Basic window setup
self.setWindowTitle("pyPhotoAlbum") self.setWindowTitle("pyPhotoAlbum")
self.resize(1200, 800) self.resize(1200, 800)
# Set window icon # Set window icon
icon_path = Path(__file__).parent / "icons" / "icon.png" icon_path = Path(__file__).parent / "icons" / "icon.png"
print(f"Window icon path: {icon_path}") print(f"Window icon path: {icon_path}")
@@ -96,95 +123,308 @@ class MainWindow(
icon = QIcon(str(icon_path)) icon = QIcon(str(icon_path))
print(f"Icon is null: {icon.isNull()}") print(f"Icon is null: {icon.isNull()}")
self.setWindowIcon(icon) self.setWindowIcon(icon)
# Create main widget with layout # Create main widget with layout
main_widget = QWidget() main_widget = QWidget()
main_layout = QVBoxLayout() main_layout = QVBoxLayout()
main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0) main_layout.setSpacing(0)
main_widget.setLayout(main_layout) main_widget.setLayout(main_layout)
# Build ribbon config from decorated methods # Build ribbon config from decorated methods
ribbon_config = build_ribbon_config(self.__class__) ribbon_config = build_ribbon_config(self.__class__)
# Print summary (for debugging) # Print summary (for debugging)
print_ribbon_summary(ribbon_config) print_ribbon_summary(ribbon_config)
# Create ribbon with auto-generated config # Create ribbon with auto-generated config
self.ribbon = RibbonWidget(self, ribbon_config) self.ribbon = RibbonWidget(self, ribbon_config)
main_layout.addWidget(self.ribbon, 0) 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 # Create OpenGL widget
self._gl_widget = GLWidget(self) self._gl_widget = GLWidget(self)
main_layout.addWidget(self._gl_widget, 1) 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) 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 # Create status bar
self._status_bar = QStatusBar() self._status_bar = QStatusBar()
self.setStatusBar(self._status_bar) self.setStatusBar(self._status_bar)
# Register keyboard shortcuts # Register keyboard shortcuts
self._register_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): def _register_shortcuts(self):
"""Register keyboard shortcuts from decorated methods""" """Register keyboard shortcuts from decorated methods"""
from PyQt6.QtGui import QShortcut, QKeySequence from PyQt6.QtGui import QShortcut, QKeySequence
from pyPhotoAlbum.ribbon_builder import get_keyboard_shortcuts from pyPhotoAlbum.ribbon_builder import get_keyboard_shortcuts
shortcuts = get_keyboard_shortcuts(self.__class__) shortcuts = get_keyboard_shortcuts(self.__class__)
for shortcut_str, method_name in shortcuts.items(): for shortcut_str, method_name in shortcuts.items():
if hasattr(self, method_name): if hasattr(self, method_name):
shortcut = QShortcut(QKeySequence(shortcut_str), self) shortcut = QShortcut(QKeySequence(shortcut_str), self)
method = getattr(self, method_name) method = getattr(self, method_name)
shortcut.activated.connect(method) shortcut.activated.connect(method)
print(f"Registered shortcut: {shortcut_str} -> {method_name}") print(f"Registered shortcut: {shortcut_str} -> {method_name}")
# Register additional Ctrl+Shift+Z shortcut for redo # Register additional Ctrl+Shift+Z shortcut for redo
if hasattr(self, 'redo'): if hasattr(self, "redo"):
redo_shortcut = QShortcut(QKeySequence("Ctrl+Shift+Z"), self) redo_shortcut = QShortcut(QKeySequence("Ctrl+Shift+Z"), self)
redo_shortcut.activated.connect(self.redo) redo_shortcut.activated.connect(self.redo)
print("Registered shortcut: Ctrl+Shift+Z -> 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): def _add_sample_page(self):
"""Add a sample page with some elements for demonstration""" """Add a sample page with some elements for demonstration"""
from pyPhotoAlbum.project import Page from pyPhotoAlbum.project import Page
from pyPhotoAlbum.page_layout import PageLayout, GridLayout from pyPhotoAlbum.page_layout import PageLayout, GridLayout
from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
# Create a page with project default size # Create a page with project default size
width_mm, height_mm = self.project.page_size_mm width_mm, height_mm = self.project.page_size_mm
page_layout = PageLayout(width=width_mm, height=height_mm) page_layout = PageLayout(width=width_mm, height=height_mm)
grid = GridLayout(rows=2, columns=2, spacing=20.0) grid = GridLayout(rows=2, columns=2, spacing=20.0)
page_layout.set_grid_layout(grid) page_layout.set_grid_layout(grid)
# Add some sample elements (scaled to new default size) # Add some sample elements (scaled to new default size)
image = ImageData(image_path="sample.jpg", x=20, y=20, width=50, height=50) image = ImageData(image_path="sample.jpg", x=20, y=20, width=50, height=50)
page_layout.add_element(image) page_layout.add_element(image)
text_box = TextBoxData(text_content="Sample Text", x=80, y=20, width=50, height=20) text_box = TextBoxData(text_content="Sample Text", x=80, y=20, width=50, height=20)
page_layout.add_element(text_box) page_layout.add_element(text_box)
placeholder = PlaceholderData(placeholder_type="image", x=20, y=80, width=50, height=50) placeholder = PlaceholderData(placeholder_type="image", x=20, y=80, width=50, height=50)
page_layout.add_element(placeholder) page_layout.add_element(placeholder)
# Create and add the page # Create and add the page
page = Page(layout=page_layout, page_number=1) page = Page(layout=page_layout, page_number=1)
page.manually_sized = False # Not manually sized, uses defaults page.manually_sized = False # Not manually sized, uses defaults
self.project.add_page(page) 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:
# Save is async — ignore event and let on_complete trigger close
self._pending_close = True
save_started = self.save_project()
if not save_started:
# User cancelled the file dialog
self._pending_close = False
event.ignore()
return
elif reply == QMessageBox.StandardButton.Cancel:
# User cancelled exit
event.ignore()
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(): def main():
"""Application entry point""" """Application entry point"""
app = QApplication(sys.argv) app = QApplication(sys.argv)
# Set application identity for proper taskbar/window manager integration # Set application identity for proper taskbar/window manager integration
app.setApplicationName("pyPhotoAlbum") app.setApplicationName("pyPhotoAlbum")
app.setApplicationDisplayName("pyPhotoAlbum") app.setApplicationDisplayName("pyPhotoAlbum")
app.setDesktopFileName("pyphotoalbum.desktop") app.setDesktopFileName("pyphotoalbum.desktop")
# Set application icon # Set application icon
icon_path = Path(__file__).parent / "icons" / "icon.png" icon_path = Path(__file__).parent / "icons" / "icon.png"
print(f"Application icon path: {icon_path}") print(f"Application icon path: {icon_path}")
@@ -193,17 +433,17 @@ def main():
icon = QIcon(str(icon_path)) icon = QIcon(str(icon_path))
print(f"Icon is null: {icon.isNull()}") print(f"Icon is null: {icon.isNull()}")
app.setWindowIcon(icon) app.setWindowIcon(icon)
# Enable high DPI scaling # Enable high DPI scaling
try: try:
app.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True) app.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True)
app.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps, True) app.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps, True)
except AttributeError: except AttributeError:
pass # Qt version doesn't support these attributes pass # Qt version doesn't support these attributes
window = MainWindow() window = MainWindow()
window.show() window.show()
sys.exit(app.exec()) sys.exit(app.exec())
+368
View 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
View 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
+2 -1
View File
@@ -3,5 +3,6 @@ Mixin modules for pyPhotoAlbum
""" """
from pyPhotoAlbum.mixins.base import ApplicationStateMixin from pyPhotoAlbum.mixins.base import ApplicationStateMixin
from pyPhotoAlbum.mixins.dialog_mixin import DialogMixin
__all__ = ['ApplicationStateMixin'] __all__ = ["ApplicationStateMixin", "DialogMixin"]
+108 -86
View File
@@ -14,7 +14,7 @@ class AssetDropMixin:
or updating ImageData elements. or updating ImageData elements.
""" """
IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp'] IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp"]
def dragEnterEvent(self, event): def dragEnterEvent(self, event):
"""Handle drag enter events""" """Handle drag enter events"""
@@ -35,100 +35,122 @@ class AssetDropMixin:
event.ignore() event.ignore()
def dropEvent(self, event): def dropEvent(self, event):
"""Handle drop events""" """Handle drop events - delegates to specialized handlers"""
if not event.mimeData().hasUrls(): image_path = self._extract_image_path(event)
event.ignore()
return
image_path = None
for url in event.mimeData().urls():
file_path = url.toLocalFile()
if any(file_path.lower().endswith(ext) for ext in self.IMAGE_EXTENSIONS):
image_path = file_path
break
if not image_path: if not image_path:
event.ignore() event.ignore()
return return
x, y = event.position().x(), event.position().y() x, y = event.position().x(), event.position().y()
target_element = self._get_element_at(x, y) target_element = self._get_element_at(x, y)
if target_element and isinstance(target_element, (ImageData, PlaceholderData)): if target_element and isinstance(target_element, (ImageData, PlaceholderData)):
if isinstance(target_element, PlaceholderData): self._handle_drop_on_element(image_path, target_element)
new_image = ImageData(
image_path=image_path,
x=target_element.position[0],
y=target_element.position[1],
width=target_element.size[0],
height=target_element.size[1],
z_index=target_element.z_index
)
main_window = self.window()
if hasattr(main_window, 'project') and main_window.project and main_window.project.pages:
for page in main_window.project.pages:
if target_element in page.layout.elements:
page.layout.elements.remove(target_element)
page.layout.add_element(new_image)
break
else:
target_element.image_path = image_path
print(f"Updated element with image: {image_path}")
else: else:
try: self._handle_drop_on_empty_space(image_path, x, y)
from PIL import Image
img = Image.open(image_path)
img_width, img_height = img.size
max_size = 300
if img_width > max_size or img_height > max_size:
scale = min(max_size / img_width, max_size / img_height)
img_width = int(img_width * scale)
img_height = int(img_height * scale)
except Exception as e:
print(f"Error loading image dimensions: {e}")
img_width, img_height = 200, 150
main_window = self.window()
if hasattr(main_window, 'project') and main_window.project and main_window.project.pages:
# Detect which page the drop occurred on
target_page, page_index, page_renderer = self._get_page_at(x, y)
if target_page and page_renderer:
# Update current_page_index
if page_index >= 0:
self.current_page_index = page_index
# Convert screen coordinates to page-local coordinates
page_local_x, page_local_y = page_renderer.screen_to_page(x, y)
try:
asset_path = main_window.project.asset_manager.import_asset(image_path)
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}")
except Exception as e:
print(f"Error adding dropped image: {e}")
else:
print("Drop location not on any page")
event.acceptProposedAction() event.acceptProposedAction()
self.update() 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
View 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
+53 -54
View File
@@ -2,18 +2,34 @@
Async loading mixin for non-blocking image loading and PDF generation. Async loading mixin for non-blocking image loading and PDF generation.
""" """
import os
from pathlib import Path from pathlib import Path
from typing import Optional from typing import TYPE_CHECKING, Optional, cast
import logging import logging
from PyQt6.QtCore import QObject from PyQt6.QtCore import QObject
from PyQt6.QtWidgets import QProgressDialog
from pyPhotoAlbum.async_backend import AsyncImageLoader, AsyncPDFGenerator, ImageCache, LoadPriority from pyPhotoAlbum.async_backend import AsyncImageLoader, AsyncPDFGenerator, ImageCache, LoadPriority
if TYPE_CHECKING:
from PyQt6.QtWidgets import QMainWindow
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class AsyncLoadingMixin: class AsyncLoadingMixin:
# Type hints for expected attributes from mixing class
_pdf_progress_dialog: Optional[QProgressDialog]
def update(self) -> None: # type: ignore[empty-body]
"""Expected from QWidget"""
...
def window(self) -> "QMainWindow": # type: ignore[empty-body]
"""Expected from QWidget"""
...
""" """
Mixin to add async loading capabilities to GLWidget. Mixin to add async loading capabilities to GLWidget.
@@ -47,13 +63,13 @@ class AsyncLoadingMixin:
"""Cleanup async loading components.""" """Cleanup async loading components."""
logger.info("Cleaning up async loading system...") logger.info("Cleaning up async loading system...")
if hasattr(self, 'async_image_loader'): if hasattr(self, "async_image_loader"):
self.async_image_loader.stop() self.async_image_loader.stop()
if hasattr(self, 'async_pdf_generator'): if hasattr(self, "async_pdf_generator"):
self.async_pdf_generator.stop() self.async_pdf_generator.stop()
if hasattr(self, 'image_cache'): if hasattr(self, "image_cache"):
self.image_cache.clear() self.image_cache.clear()
logger.info("Async loading system cleaned up") logger.info("Async loading system cleaned up")
@@ -69,7 +85,7 @@ class AsyncLoadingMixin:
""" """
logger.debug(f"Image loaded callback: {path}") logger.debug(f"Image loaded callback: {path}")
if user_data and hasattr(user_data, '_on_async_image_loaded'): if user_data and hasattr(user_data, "_on_async_image_loaded"):
user_data._on_async_image_loaded(image) user_data._on_async_image_loaded(image)
# Trigger re-render to show newly loaded image # Trigger re-render to show newly loaded image
@@ -86,7 +102,7 @@ class AsyncLoadingMixin:
""" """
logger.warning(f"Image load failed: {path} - {error_msg}") logger.warning(f"Image load failed: {path} - {error_msg}")
if user_data and hasattr(user_data, '_on_async_image_load_failed'): if user_data and hasattr(user_data, "_on_async_image_load_failed"):
user_data._on_async_image_load_failed(error_msg) user_data._on_async_image_load_failed(error_msg)
def _on_pdf_progress(self, current: int, total: int, message: str): def _on_pdf_progress(self, current: int, total: int, message: str):
@@ -101,9 +117,11 @@ class AsyncLoadingMixin:
logger.debug(f"PDF progress: {current}/{total} - {message}") logger.debug(f"PDF progress: {current}/{total} - {message}")
# Update progress dialog if it exists # Update progress dialog if it exists
if hasattr(self, '_pdf_progress_dialog') and self._pdf_progress_dialog: # Use local reference to avoid race condition
self._pdf_progress_dialog.setValue(current) dialog = getattr(self, "_pdf_progress_dialog", None)
self._pdf_progress_dialog.setLabelText(message) if dialog is not None:
dialog.setValue(current)
dialog.setLabelText(message)
def _on_pdf_complete(self, success: bool, warnings: list): def _on_pdf_complete(self, success: bool, warnings: list):
""" """
@@ -116,19 +134,16 @@ class AsyncLoadingMixin:
logger.info(f"PDF export complete: success={success}, warnings={len(warnings)}") logger.info(f"PDF export complete: success={success}, warnings={len(warnings)}")
# Close progress dialog # Close progress dialog
if hasattr(self, '_pdf_progress_dialog') and self._pdf_progress_dialog: if hasattr(self, "_pdf_progress_dialog") and self._pdf_progress_dialog:
self._pdf_progress_dialog.close() self._pdf_progress_dialog.close()
self._pdf_progress_dialog = None self._pdf_progress_dialog = None
# Show completion message # Show completion message
main_window = self.window() main_window = self.window()
if hasattr(main_window, 'show_status'): if hasattr(main_window, "show_status"):
if success: if success:
if warnings: if warnings:
main_window.show_status( main_window.show_status(f"PDF exported successfully with {len(warnings)} warnings", 5000)
f"PDF exported successfully with {len(warnings)} warnings",
5000
)
else: else:
main_window.show_status("PDF exported successfully", 3000) main_window.show_status("PDF exported successfully", 3000)
else: else:
@@ -144,13 +159,13 @@ class AsyncLoadingMixin:
logger.error(f"PDF export failed: {error_msg}") logger.error(f"PDF export failed: {error_msg}")
# Close progress dialog # Close progress dialog
if hasattr(self, '_pdf_progress_dialog') and self._pdf_progress_dialog: if hasattr(self, "_pdf_progress_dialog") and self._pdf_progress_dialog:
self._pdf_progress_dialog.close() self._pdf_progress_dialog.close()
self._pdf_progress_dialog = None self._pdf_progress_dialog = None
# Show error message # Show error message
main_window = self.window() main_window = self.window()
if hasattr(main_window, 'show_status'): if hasattr(main_window, "show_status"):
main_window.show_status(f"PDF export failed: {error_msg}", 5000) main_window.show_status(f"PDF export failed: {error_msg}", 5000)
def request_image_load(self, image_data, priority: LoadPriority = LoadPriority.NORMAL): def request_image_load(self, image_data, priority: LoadPriority = LoadPriority.NORMAL):
@@ -161,32 +176,23 @@ class AsyncLoadingMixin:
image_data: ImageData element to load image_data: ImageData element to load
priority: Load priority level priority: Load priority level
""" """
if not hasattr(self, 'async_image_loader'): if not hasattr(self, "async_image_loader"):
logger.warning("Async image loader not initialized") logger.warning("Async image loader not initialized")
return return
if not image_data.image_path: if not image_data.image_path:
return return
# Resolve path # Security: only load images from the assets folder
from pyPhotoAlbum.models import get_asset_search_paths if not image_data.image_path.startswith("assets/"):
import os logger.warning(f"Skipping path not in assets folder (needs healing): {image_data.image_path}")
return
image_full_path = image_data.image_path # Use ImageData's path resolution (delegates to project layer)
if not os.path.isabs(image_data.image_path): image_full_path = image_data.resolve_image_path()
project_folder, search_paths = get_asset_search_paths() if not image_full_path:
possible_paths = [] logger.warning(f"Image not found (needs healing): {image_data.image_path}")
return
if project_folder:
possible_paths.append(os.path.join(project_folder, image_data.image_path))
for search_path in search_paths:
possible_paths.append(os.path.join(search_path, image_data.image_path))
for path in possible_paths:
if os.path.exists(path):
image_full_path = path
break
# Calculate target size (max 2048px like original) # Calculate target size (max 2048px like original)
target_size = (2048, 2048) # Will be downsampled if larger target_size = (2048, 2048) # Will be downsampled if larger
@@ -196,7 +202,7 @@ class AsyncLoadingMixin:
Path(image_full_path), Path(image_full_path),
priority=priority, priority=priority,
target_size=target_size, target_size=target_size,
user_data=image_data # Pass element for callback user_data=image_data, # Pass element for callback
) )
def export_pdf_async(self, project, output_path: str, export_dpi: int = 300): def export_pdf_async(self, project, output_path: str, export_dpi: int = 300):
@@ -208,7 +214,7 @@ class AsyncLoadingMixin:
output_path: Output PDF file path output_path: Output PDF file path
export_dpi: Export DPI (default 300) export_dpi: Export DPI (default 300)
""" """
if not hasattr(self, 'async_pdf_generator'): if not hasattr(self, "async_pdf_generator"):
logger.warning("Async PDF generator not initialized") logger.warning("Async PDF generator not initialized")
return False return False
@@ -216,18 +222,11 @@ class AsyncLoadingMixin:
from PyQt6.QtWidgets import QProgressDialog from PyQt6.QtWidgets import QProgressDialog
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
total_pages = sum( total_pages = sum(1 if page.is_cover else (2 if page.is_double_spread else 1) for page in project.pages)
1 if page.is_cover else (2 if page.is_double_spread else 1)
for page in project.pages
)
self._pdf_progress_dialog = QProgressDialog( from PyQt6.QtWidgets import QWidget
"Exporting to PDF...",
"Cancel", self._pdf_progress_dialog = QProgressDialog("Exporting to PDF...", "Cancel", 0, total_pages, cast(QWidget, self))
0,
total_pages,
self
)
self._pdf_progress_dialog.setWindowModality(Qt.WindowModality.WindowModal) self._pdf_progress_dialog.setWindowModality(Qt.WindowModality.WindowModal)
self._pdf_progress_dialog.setWindowTitle("PDF Export") self._pdf_progress_dialog.setWindowTitle("PDF Export")
self._pdf_progress_dialog.canceled.connect(self._on_pdf_cancel) self._pdf_progress_dialog.canceled.connect(self._on_pdf_cancel)
@@ -240,17 +239,17 @@ class AsyncLoadingMixin:
"""Handle PDF export cancellation.""" """Handle PDF export cancellation."""
logger.info("User requested PDF export cancellation") logger.info("User requested PDF export cancellation")
if hasattr(self, 'async_pdf_generator'): if hasattr(self, "async_pdf_generator"):
self.async_pdf_generator.cancel_export() self.async_pdf_generator.cancel_export()
def get_async_stats(self) -> dict: def get_async_stats(self) -> dict:
"""Get async loading system statistics.""" """Get async loading system statistics."""
stats = {} stats = {}
if hasattr(self, 'async_image_loader'): if hasattr(self, "async_image_loader"):
stats['image_loader'] = self.async_image_loader.get_stats() stats["image_loader"] = self.async_image_loader.get_stats()
if hasattr(self, 'async_pdf_generator'): if hasattr(self, "async_pdf_generator"):
stats['pdf_generator'] = self.async_pdf_generator.get_stats() stats["pdf_generator"] = self.async_pdf_generator.get_stats()
return stats return stats
+88 -50
View File
@@ -2,173 +2,211 @@
Base mixin providing shared application state access Base mixin providing shared application state access
""" """
from typing import Optional from typing import Any, Optional, cast
from PyQt6.QtWidgets import QStatusBar, QMessageBox from PyQt6.QtWidgets import QStatusBar, QMessageBox, QWidget
class ApplicationStateMixin: class ApplicationStateMixin:
""" """
Base mixin providing access to shared application state. Base mixin providing access to shared application state.
This mixin provides properties and helper methods for accessing This mixin provides properties and helper methods for accessing
core application objects that are shared across all operation mixins. core application objects that are shared across all operation mixins.
Required attributes (must be set by MainWindow): Required attributes (must be set by MainWindow):
_project: Project instance _project: Project instance
_gl_widget: GLWidget instance _gl_widget: GLWidget instance
_status_bar: QStatusBar instance _status_bar: QStatusBar instance
_template_manager: TemplateManager instance _template_manager: TemplateManager instance
""" """
@property @property
def project(self): def project(self):
"""Access to current project""" """Access to current project"""
if not hasattr(self, '_project'): if not hasattr(self, "_project"):
raise AttributeError("MainWindow must set _project attribute") raise AttributeError("MainWindow must set _project attribute")
return self._project return self._project
@project.setter @project.setter
def project(self, value): def project(self, value):
"""Set the current project""" """Set the current project"""
self._project = value self._project = value
@property @property
def gl_widget(self): def gl_widget(self):
"""Access to GL rendering widget""" """Access to GL rendering widget"""
if not hasattr(self, '_gl_widget'): if not hasattr(self, "_gl_widget"):
raise AttributeError("MainWindow must set _gl_widget attribute") raise AttributeError("MainWindow must set _gl_widget attribute")
return self._gl_widget return self._gl_widget
@property @property
def status_bar(self) -> QStatusBar: def status_bar(self) -> QStatusBar:
"""Access to status bar""" """Access to status bar"""
if not hasattr(self, '_status_bar'): if not hasattr(self, "_status_bar"):
raise AttributeError("MainWindow must set _status_bar attribute") raise AttributeError("MainWindow must set _status_bar attribute")
return self._status_bar return cast(QStatusBar, self._status_bar)
@property @property
def template_manager(self): def template_manager(self):
"""Access to template manager""" """Access to template manager"""
if not hasattr(self, '_template_manager'): if not hasattr(self, "_template_manager"):
raise AttributeError("MainWindow must set _template_manager attribute") raise AttributeError("MainWindow must set _template_manager attribute")
return self._template_manager return self._template_manager
# Common helper methods # 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): def get_current_page(self):
""" """
Get currently selected page. Get currently visible page (most visible in viewport).
Returns: Returns:
Page instance or None if no page is selected Page instance or None if no page is selected
""" """
if not self.project or not self.project.pages: if not self.project or not self.project.pages:
return None return None
index = self.gl_widget.current_page_index index = self._get_most_visible_page_index()
if 0 <= index < len(self.project.pages): if 0 <= index < len(self.project.pages):
return self.project.pages[index] return self.project.pages[index]
return None return None
def get_current_page_index(self) -> int: def get_current_page_index(self) -> int:
""" """
Get current page index. Get current page index.
Returns: Returns:
Current page index, or -1 if no page Current page index, or -1 if no page
""" """
if not self.project or not self.project.pages: if not self.project or not self.project.pages:
return -1 return -1
return self.gl_widget.current_page_index return int(self.gl_widget.current_page_index)
def show_status(self, message: str, timeout: int = 2000): def show_status(self, message: str, timeout: int = 2000):
""" """
Show message in status bar. Show message in status bar.
Args: Args:
message: Message to display message: Message to display
timeout: Display duration in milliseconds timeout: Display duration in milliseconds
""" """
if self.status_bar: if self.status_bar:
self.status_bar.showMessage(message, timeout) self.status_bar.showMessage(message, timeout)
def show_error(self, title: str, message: str): def show_error(self, title: str, message: str):
""" """
Show error dialog. Show error dialog.
Args: Args:
title: Dialog title title: Dialog title
message: Error message message: Error message
""" """
QMessageBox.critical(self, title, message) QMessageBox.critical(cast(QWidget, self), title, message)
def show_warning(self, title: str, message: str): def show_warning(self, title: str, message: str):
""" """
Show warning dialog. Show warning dialog.
Args: Args:
title: Dialog title title: Dialog title
message: Warning message message: Warning message
""" """
QMessageBox.warning(self, title, message) QMessageBox.warning(cast(QWidget, self), title, message)
def show_info(self, title: str, message: str): def show_info(self, title: str, message: str):
""" """
Show information dialog. Show information dialog.
Args: Args:
title: Dialog title title: Dialog title
message: Information message message: Information message
""" """
QMessageBox.information(self, title, message) QMessageBox.information(cast(QWidget, self), title, message)
def require_page(self, show_warning: bool = True) -> bool: def require_page(self, show_warning: bool = True) -> bool:
""" """
Check if a page is available and optionally show warning. Check if a page is available and optionally show warning.
Args: Args:
show_warning: Whether to show warning dialog if no page exists show_warning: Whether to show warning dialog if no page exists
Returns: Returns:
True if page exists, False otherwise True if page exists, False otherwise
""" """
current_page = self.get_current_page() current_page = self.get_current_page()
if current_page is None: if current_page is None:
if show_warning: if show_warning:
self.show_warning("No Page", "Please create a page first.") self.show_warning("No Page", "Please create a page first.")
return False return False
return True return True
def require_selection(self, min_count: int = 1, show_warning: bool = True) -> bool: def require_selection(self, min_count: int = 1, show_warning: bool = True) -> bool:
""" """
Check if required number of elements are selected. Check if required number of elements are selected.
Args: Args:
min_count: Minimum number of selected elements required min_count: Minimum number of selected elements required
show_warning: Whether to show warning dialog if requirement not met show_warning: Whether to show warning dialog if requirement not met
Returns: Returns:
True if requirements met, False otherwise True if requirements met, False otherwise
""" """
selected_count = len(self.gl_widget.selected_elements) selected_count = len(self.gl_widget.selected_elements)
if selected_count < min_count: if selected_count < min_count:
if show_warning: if show_warning:
if min_count == 1: if min_count == 1:
self.show_info("No Selection", "Please select an element.") self.show_info("No Selection", "Please select an element.")
else: else:
self.show_info( self.show_info("Selection Required", f"Please select at least {min_count} elements.")
"Selection Required",
f"Please select at least {min_count} elements."
)
return False return False
return True return True
def update_view(self): def update_view(self):
"""Trigger GL widget update to refresh the view""" """Trigger GL widget update to refresh the view"""
if self.gl_widget: if self.gl_widget:
self.gl_widget.update() self.gl_widget.update()
# Update scrollbars to reflect new content
if hasattr(self, "update_scrollbars"):
self.update_scrollbars()
+66
View 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
+38 -21
View File
@@ -2,18 +2,31 @@
Element manipulation mixin for GLWidget - handles element transformations Element manipulation mixin for GLWidget - handles element transformations
""" """
from typing import Optional, Tuple from typing import TYPE_CHECKING, Any, Optional, Tuple
if TYPE_CHECKING:
from pyPhotoAlbum.models import BaseLayoutElement
from PyQt6.QtWidgets import QMainWindow
class ElementManipulationMixin: class ElementManipulationMixin:
""" # Type hints for expected attributes from mixing class
Mixin providing element transformation functionality. selected_element: Optional["BaseLayoutElement"]
drag_start_pos: Optional[Tuple[float, float]]
drag_start_element_pos: Optional[Tuple[float, float]]
This mixin handles resizing, rotating, and moving elements, including def window(self) -> "QMainWindow": # type: ignore[empty-body]
snapping support and cross-page element transfers. """Expected from QWidget"""
""" ...
def __init__(self, *args, **kwargs): 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) super().__init__(*args, **kwargs)
# Resize state # Resize state
@@ -27,11 +40,7 @@ class ElementManipulationMixin:
self.rotation_snap_angle: int = 15 # Default snap angle in degrees self.rotation_snap_angle: int = 15 # Default snap angle in degrees
# Snap state tracking # Snap state tracking
self.snap_state = { self.snap_state = {"is_snapped": False, "last_position": None, "last_size": None}
'is_snapped': False,
'last_position': None,
'last_size': None
}
def _resize_element(self, dx: float, dy: float): def _resize_element(self, dx: float, dy: float):
""" """
@@ -49,27 +58,31 @@ class ElementManipulationMixin:
# Get the snapping system from the element's parent page # Get the snapping system from the element's parent page
main_window = self.window() main_window = self.window()
if not hasattr(self.selected_element, '_parent_page'): if not hasattr(self.selected_element, "_parent_page"):
self._resize_element_no_snap(dx, dy) self._resize_element_no_snap(dx, dy)
return return
parent_page = self.selected_element._parent_page parent_page = self.selected_element._parent_page # type: ignore[attr-defined]
snap_sys = parent_page.layout.snapping_system snap_sys = parent_page.layout.snapping_system
# Get page size # Get page size
page_size = parent_page.layout.size page_size = parent_page.layout.size
dpi = main_window.project.working_dpi dpi = main_window.project.working_dpi # type: ignore[attr-defined]
# Apply snapping to resize # Apply snapping to resize
new_pos, new_size = snap_sys.snap_resize( from pyPhotoAlbum.snapping import SnapResizeParams
params = SnapResizeParams(
position=self.resize_start_pos, position=self.resize_start_pos,
size=self.resize_start_size, size=self.resize_start_size,
dx=dx, dx=dx,
dy=dy, dy=dy,
resize_handle=self.resize_handle, resize_handle=self.resize_handle,
page_size=page_size, page_size=page_size,
dpi=dpi dpi=dpi,
project=main_window.project, # type: ignore[attr-defined]
) )
new_pos, new_size = snap_sys.snap_resize(params)
# Apply the snapped values # Apply the snapped values
self.selected_element.position = new_pos self.selected_element.position = new_pos
@@ -93,20 +106,22 @@ class ElementManipulationMixin:
""" """
if not self.resize_start_pos or not self.resize_start_size: if not self.resize_start_pos or not self.resize_start_size:
return return
if self.selected_element is None:
return
start_x, start_y = self.resize_start_pos start_x, start_y = self.resize_start_pos
start_w, start_h = self.resize_start_size start_w, start_h = self.resize_start_size
if self.resize_handle == 'nw': if self.resize_handle == "nw":
self.selected_element.position = (start_x + dx, start_y + dy) self.selected_element.position = (start_x + dx, start_y + dy)
self.selected_element.size = (start_w - dx, start_h - dy) self.selected_element.size = (start_w - dx, start_h - dy)
elif self.resize_handle == 'ne': elif self.resize_handle == "ne":
self.selected_element.position = (start_x, start_y + dy) self.selected_element.position = (start_x, start_y + dy)
self.selected_element.size = (start_w + dx, start_h - dy) self.selected_element.size = (start_w + dx, start_h - dy)
elif self.resize_handle == 'sw': elif self.resize_handle == "sw":
self.selected_element.position = (start_x + dx, start_y) self.selected_element.position = (start_x + dx, start_y)
self.selected_element.size = (start_w - dx, start_h + dy) self.selected_element.size = (start_w - dx, start_h + dy)
elif self.resize_handle == 'se': elif self.resize_handle == "se":
self.selected_element.size = (start_w + dx, start_h + dy) self.selected_element.size = (start_w + dx, start_h + dy)
# Ensure minimum size # Ensure minimum size
@@ -118,7 +133,9 @@ class ElementManipulationMixin:
w, _ = self.selected_element.size w, _ = self.selected_element.size
self.selected_element.size = (w, min_size) self.selected_element.size = (w, min_size)
def _transfer_element_to_page(self, element, source_page, target_page, mouse_x: float, mouse_y: float, target_renderer): def _transfer_element_to_page(
self, element, source_page, target_page, mouse_x: float, mouse_y: float, target_renderer
):
""" """
Transfer an element from one page to another during drag operation. Transfer an element from one page to another during drag operation.
+30 -76
View File
@@ -2,11 +2,22 @@
Element selection mixin for GLWidget - handles element selection and hit detection Element selection mixin for GLWidget - handles element selection and hit detection
""" """
from typing import Optional, Set from typing import Any, TYPE_CHECKING, Optional, Set
if TYPE_CHECKING:
from PyQt6.QtWidgets import QMainWindow
from pyPhotoAlbum.models import BaseLayoutElement from pyPhotoAlbum.models import BaseLayoutElement
class ElementSelectionMixin: class ElementSelectionMixin:
# Type hints for expected attributes from mixing class
_page_renderers: list
def window(self) -> "QMainWindow": # type: ignore[empty-body]
"""Expected from QWidget"""
...
""" """
Mixin providing element selection and hit detection functionality. Mixin providing element selection and hit detection functionality.
@@ -54,16 +65,14 @@ class ElementSelectionMixin:
Returns: Returns:
BaseLayoutElement or None: The topmost element at the position, or None BaseLayoutElement or None: The topmost element at the position, or None
""" """
if not hasattr(self, '_page_renderers') or not self._page_renderers: if not hasattr(self, "_page_renderers") or not self._page_renderers:
return None return None
# Check each page from top to bottom (reverse z-order) # Check each page from top to bottom (reverse z-order)
for renderer, page in reversed(self._page_renderers): for renderer, page in reversed(self._page_renderers):
# Check if click is within this page bounds
if not renderer.is_point_in_page(x, y):
continue
# Convert screen coordinates to page-local coordinates # 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) page_x, page_y = renderer.screen_to_page(x, y)
# Check elements in this page (highest in list = on top, so check in reverse) # Check elements in this page (highest in list = on top, so check in reverse)
@@ -72,42 +81,12 @@ class ElementSelectionMixin:
ex, ey = element.position ex, ey = element.position
ew, eh = element.size ew, eh = element.size
# Handle rotated elements # Simple bounds check (no rotation transformation needed - images are already rotated)
if hasattr(element, 'rotation') and element.rotation != 0: if ex <= page_x <= ex + ew and ey <= page_y <= ey + eh:
# Transform click point through inverse rotation # Store the renderer with the element for later use
import math element._page_renderer = renderer # type: ignore[attr-defined]
element._parent_page = page # type: ignore[attr-defined]
# Get element center return element # type: ignore[no-any-return]
center_x = ex + ew / 2
center_y = ey + eh / 2
# Translate to origin
rel_x = page_x - center_x
rel_y = page_y - center_y
# Apply inverse rotation
angle_rad = -math.radians(element.rotation)
cos_a = math.cos(angle_rad)
sin_a = math.sin(angle_rad)
# Rotate the point
rotated_x = rel_x * cos_a - rel_y * sin_a
rotated_y = rel_x * sin_a + rel_y * cos_a
# Check if rotated point is in unrotated bounds
if (-ew / 2 <= rotated_x <= ew / 2 and
-eh / 2 <= rotated_y <= eh / 2):
# Store the renderer with the element for later use
element._page_renderer = renderer
element._parent_page = page
return element
else:
# No rotation - simple bounds check
if ex <= page_x <= ex + ew and ey <= page_y <= ey + eh:
# Store the renderer with the element for later use
element._page_renderer = renderer
element._parent_page = page
return element
return None return None
@@ -128,14 +107,14 @@ class ElementSelectionMixin:
return None return None
main_window = self.window() main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages: if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return None return None
# Get the PageRenderer for this element (stored when element was selected) # Get the PageRenderer for this element (stored when element was selected)
if not hasattr(self.selected_element, '_page_renderer'): if not hasattr(self.selected_element, "_page_renderer"):
return None return None
renderer = self.selected_element._page_renderer renderer: Any = self.selected_element._page_renderer # type: ignore[attr-defined]
# Get element position and size in page-local coordinates # Get element position and size in page-local coordinates
elem_x, elem_y = self.selected_element.position elem_x, elem_y = self.selected_element.position
@@ -147,41 +126,16 @@ class ElementSelectionMixin:
ew = elem_w * renderer.zoom ew = elem_w * renderer.zoom
eh = elem_h * renderer.zoom eh = elem_h * renderer.zoom
# Calculate center point # Check handles (no rotation transformation needed - images are already rotated)
center_x = ex + ew / 2
center_y = ey + eh / 2
# If element is rotated, transform mouse coordinates through inverse rotation
test_x, test_y = x, y
if hasattr(self.selected_element, 'rotation') and self.selected_element.rotation != 0:
import math
# Translate mouse to origin (relative to center)
rel_x = x - center_x
rel_y = y - center_y
# Apply inverse rotation
angle_rad = -math.radians(self.selected_element.rotation)
cos_a = math.cos(angle_rad)
sin_a = math.sin(angle_rad)
# Rotate the point
rotated_x = rel_x * cos_a - rel_y * sin_a
rotated_y = rel_x * sin_a + rel_y * cos_a
# Translate back
test_x = center_x + rotated_x
test_y = center_y + rotated_y
# Now check handles in non-rotated coordinate system
handles = { handles = {
'nw': (ex - handle_size/2, ey - handle_size/2), "nw": (ex - handle_size / 2, ey - handle_size / 2),
'ne': (ex + ew - 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), "sw": (ex - handle_size / 2, ey + eh - handle_size / 2),
'se': (ex + ew - 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(): for name, (hx, hy) in handles.items():
if hx <= test_x <= hx + handle_size and hy <= test_y <= hy + handle_size: if hx <= x <= hx + handle_size and hy <= y <= hy + handle_size:
return name return name
return None return None
+7 -2
View File
@@ -2,11 +2,16 @@
Image pan mixin for GLWidget - handles panning images within frames Image pan mixin for GLWidget - handles panning images within frames
""" """
from typing import Optional, Tuple from typing import TYPE_CHECKING, Optional, Tuple
from pyPhotoAlbum.models import ImageData from pyPhotoAlbum.models import ImageData
class ImagePanMixin: 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. Mixin providing image panning functionality.
@@ -21,7 +26,7 @@ class ImagePanMixin:
self.image_pan_mode: bool = False # True when Control+dragging an ImageData element self.image_pan_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 self.image_pan_start_crop: Optional[Tuple[float, float, float, float]] = None # Starting crop_info
def _handle_image_pan_move(self, x: float, y: float, element: ImageData): def _handle_image_pan_move(self, x: float, y: float, element: "ImageData"):
""" """
Handle image panning within a frame during mouse move. Handle image panning within a frame during mouse move.
@@ -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
@@ -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: Dict[str, Any] = {}
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
+52 -141
View File
@@ -4,202 +4,113 @@ Mixin for automatic undo/redo handling in interactive mouse operations
from typing import Optional from typing import Optional
from pyPhotoAlbum.models import BaseLayoutElement from pyPhotoAlbum.models import BaseLayoutElement
from .interaction_command_factory import InteractionCommandFactory, InteractionState
class UndoableInteractionMixin: class UndoableInteractionMixin:
""" """
Mixin providing automatic undo/redo for interactive mouse operations. Mixin providing automatic undo/redo for interactive mouse operations.
This mixin tracks the state of elements before interactive operations This mixin tracks the state of elements before interactive operations
(move, resize, rotate) and automatically creates appropriate Command (move, resize, rotate) and automatically creates appropriate Command
objects when the interaction completes. objects when the interaction completes.
""" """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Interaction tracking state # Command factory for creating undo/redo commands
self._interaction_element: Optional[BaseLayoutElement] = None self._command_factory = InteractionCommandFactory()
self._interaction_type: Optional[str] = None
self._interaction_start_pos: Optional[tuple] = None # Interaction state tracking
self._interaction_start_size: Optional[tuple] = None self._interaction_state = InteractionState()
self._interaction_start_rotation: Optional[float] = None
def _begin_move(self, element: BaseLayoutElement): def _begin_move(self, element: BaseLayoutElement):
""" """
Begin tracking a move operation. Begin tracking a move operation.
Args: Args:
element: The element being moved element: The element being moved
""" """
self._interaction_element = element self._interaction_state.element = element
self._interaction_type = 'move' self._interaction_state.interaction_type = "move"
self._interaction_start_pos = element.position self._interaction_state.position = element.position
self._interaction_start_size = None
self._interaction_start_rotation = None
def _begin_resize(self, element: BaseLayoutElement): def _begin_resize(self, element: BaseLayoutElement):
""" """
Begin tracking a resize operation. Begin tracking a resize operation.
Args: Args:
element: The element being resized element: The element being resized
""" """
self._interaction_element = element self._interaction_state.element = element
self._interaction_type = 'resize' self._interaction_state.interaction_type = "resize"
self._interaction_start_pos = element.position self._interaction_state.position = element.position
self._interaction_start_size = element.size self._interaction_state.size = element.size
self._interaction_start_rotation = None
def _begin_rotate(self, element: BaseLayoutElement): def _begin_rotate(self, element: BaseLayoutElement):
""" """
Begin tracking a rotate operation. Begin tracking a rotate operation.
Args: Args:
element: The element being rotated element: The element being rotated
""" """
self._interaction_element = element self._interaction_state.element = element
self._interaction_type = 'rotate' self._interaction_state.interaction_type = "rotate"
self._interaction_start_pos = None self._interaction_state.rotation = element.rotation
self._interaction_start_size = None
self._interaction_start_rotation = element.rotation
def _begin_image_pan(self, element): def _begin_image_pan(self, element):
""" """
Begin tracking an image pan operation. Begin tracking an image pan operation.
Args: Args:
element: The ImageData element being panned element: The ImageData element being panned
""" """
from pyPhotoAlbum.models import ImageData from pyPhotoAlbum.models import ImageData
if not isinstance(element, ImageData): if not isinstance(element, ImageData):
return return
self._interaction_element = element self._interaction_state.element = element
self._interaction_type = 'image_pan' self._interaction_state.interaction_type = "image_pan"
self._interaction_start_pos = None self._interaction_state.crop_info = element.crop_info
self._interaction_start_size = None
self._interaction_start_rotation = None
self._interaction_start_crop_info = element.crop_info
def _end_interaction(self): def _end_interaction(self):
""" """
End the current interaction and create appropriate undo/redo command. End the current interaction and create appropriate undo/redo command.
This method checks what changed during the interaction and creates This method uses the command factory to create the appropriate
the appropriate Command object (MoveElementCommand, ResizeElementCommand, Command object based on what changed during the interaction.
or RotateElementCommand).
""" """
if not self._interaction_element or not self._interaction_type: # Validate interaction state
if not self._interaction_state.is_valid():
self._clear_interaction_state() self._clear_interaction_state()
return return
element = self._interaction_element
# Get main window to access project history # Get main window to access project history
main_window = self.window() main_window = self.window()
if not hasattr(main_window, 'project'): if not hasattr(main_window, "project"):
self._clear_interaction_state() self._clear_interaction_state()
return return
# Create appropriate command based on interaction type # Use factory to create command based on interaction type and changes
command = None command = self._command_factory.create_command(
interaction_type=self._interaction_state.interaction_type,
if self._interaction_type == 'move': element=self._interaction_state.element,
# Check if position actually changed start_state=self._interaction_state.to_dict(),
new_pos = element.position )
if self._interaction_start_pos and new_pos != self._interaction_start_pos:
# Check for significant change (> 0.1 units)
dx = abs(new_pos[0] - self._interaction_start_pos[0])
dy = abs(new_pos[1] - self._interaction_start_pos[1])
if dx > 0.1 or dy > 0.1:
from pyPhotoAlbum.commands import MoveElementCommand
command = MoveElementCommand(
element,
self._interaction_start_pos,
new_pos
)
print(f"Move command created: {self._interaction_start_pos}{new_pos}")
elif self._interaction_type == 'resize':
# Check if position or size actually changed
new_pos = element.position
new_size = element.size
if self._interaction_start_pos and self._interaction_start_size:
pos_changed = new_pos != self._interaction_start_pos
size_changed = new_size != self._interaction_start_size
if pos_changed or size_changed:
# Check for significant change
dx = abs(new_pos[0] - self._interaction_start_pos[0])
dy = abs(new_pos[1] - self._interaction_start_pos[1])
dw = abs(new_size[0] - self._interaction_start_size[0])
dh = abs(new_size[1] - self._interaction_start_size[1])
if dx > 0.1 or dy > 0.1 or dw > 0.1 or dh > 0.1:
from pyPhotoAlbum.commands import ResizeElementCommand
command = ResizeElementCommand(
element,
self._interaction_start_pos,
self._interaction_start_size,
new_pos,
new_size
)
print(f"Resize command created: {self._interaction_start_size}{new_size}")
elif self._interaction_type == 'rotate':
# Check if rotation actually changed
new_rotation = element.rotation
if self._interaction_start_rotation is not None:
if abs(new_rotation - self._interaction_start_rotation) > 0.1:
from pyPhotoAlbum.commands import RotateElementCommand
command = RotateElementCommand(
element,
self._interaction_start_rotation,
new_rotation
)
print(f"Rotation command created: {self._interaction_start_rotation:.1f}° → {new_rotation:.1f}°")
elif self._interaction_type == 'image_pan':
# Check if crop_info actually changed
from pyPhotoAlbum.models import ImageData
if isinstance(element, ImageData):
new_crop_info = element.crop_info
if hasattr(self, '_interaction_start_crop_info') and self._interaction_start_crop_info is not None:
# Check if crop changed significantly (more than 0.001 in any coordinate)
if new_crop_info != self._interaction_start_crop_info:
old_crop = self._interaction_start_crop_info
significant_change = any(
abs(new_crop_info[i] - old_crop[i]) > 0.001
for i in range(4)
)
if significant_change:
from pyPhotoAlbum.commands import AdjustImageCropCommand
command = AdjustImageCropCommand(
element,
self._interaction_start_crop_info,
new_crop_info
)
print(f"Image pan command created: {self._interaction_start_crop_info}{new_crop_info}")
# Execute the command through history if one was created # Execute the command through history if one was created
if command: if command:
main_window.project.history.execute(command) main_window.project.history.execute(command)
# Clear interaction state # Clear interaction state
self._clear_interaction_state() self._clear_interaction_state()
def _clear_interaction_state(self): def _clear_interaction_state(self):
"""Clear all interaction tracking state""" """Clear all interaction tracking state"""
self._interaction_element = None self._interaction_state.clear()
self._interaction_type = None
self._interaction_start_pos = None
self._interaction_start_size = None
self._interaction_start_rotation = None
if hasattr(self, '_interaction_start_crop_info'):
self._interaction_start_crop_info = None
def _cancel_interaction(self): def _cancel_interaction(self):
"""Cancel the current interaction without creating a command""" """Cancel the current interaction without creating a command"""
self._clear_interaction_state() self._clear_interaction_state()
@@ -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
View 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)
+294 -185
View File
@@ -2,8 +2,15 @@
Mouse interaction mixin for GLWidget - coordinates all mouse events Mouse interaction mixin for GLWidget - coordinates all mouse events
""" """
import math
from typing import TYPE_CHECKING, Any, Optional, Set
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
from pyPhotoAlbum.models import ImageData from PyQt6.QtGui import QCursor
from pyPhotoAlbum.models import ImageData, BaseLayoutElement
if TYPE_CHECKING:
from PyQt6.QtWidgets import QMainWindow
class MouseInteractionMixin: class MouseInteractionMixin:
@@ -14,6 +21,39 @@ class MouseInteractionMixin:
the current interaction state. the current interaction state.
""" """
# Type declarations for attributes provided by other mixins/base classes
selected_elements: Set[BaseLayoutElement]
selected_element: Optional[BaseLayoutElement]
rotation_mode: bool
rotation_snap_angle: int
rotation_start_angle: Optional[float]
pan_offset: list
zoom_level: float
image_pan_mode: bool
current_page_index: int
_page_renderers: list
resize_start_pos: Optional[Any]
resize_start_size: Optional[Any]
if TYPE_CHECKING:
def window(self) -> "QMainWindow": ...
def update(self) -> None: ...
def setCursor(self, cursor: Any) -> None: ...
def setFocus(self, *args: Any) -> None: ...
def _begin_rotate(self, element: Any) -> None: ...
def _begin_resize(self, element: Any) -> None: ...
def _begin_image_pan(self, element: Any) -> None: ...
def _begin_move(self, element: Any) -> None: ...
def _end_interaction(self) -> None: ...
def _resize_element(self, dx: float, dy: float) -> None: ...
def _get_page_at(self, x: float, y: float) -> Any: ...
def _get_element_at(self, x: float, y: float) -> Optional[BaseLayoutElement]: ...
def _get_resize_handle_at(self, x: float, y: float) -> Optional[str]: ...
def _check_ghost_page_click(self, x: float, y: float) -> bool: ...
def _transfer_element_to_page(self, element: Any, source: Any, target: Any, x: float, y: float, renderer: Any) -> None: ...
def _handle_image_pan_move(self, x: float, y: float, element: Any) -> None: ...
def _update_page_status(self, x: float, y: float) -> None: ...
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -23,200 +63,229 @@ class MouseInteractionMixin:
self.is_dragging = False self.is_dragging = False
self.is_panning = False self.is_panning = False
def _handle_rotation_start(self, x: float, y: float):
"""Start rotation interaction for selected element."""
assert self.selected_element is not None
self._begin_rotate(self.selected_element)
self.drag_start_pos = (x, y)
self.rotation_start_angle = self.selected_element.rotation
self.is_dragging = True
def _handle_resize_start(self, x: float, y: float, handle):
"""Start resize interaction for selected element."""
assert self.selected_element is not None
self._begin_resize(self.selected_element)
self.resize_handle = handle
self.drag_start_pos = (x, y)
self.resize_start_pos = self.selected_element.position
self.resize_start_size = self.selected_element.size
self.is_dragging = True
def _handle_image_pan_start(self, x: float, y: float, element):
"""Start image pan mode for an ImageData element."""
self.selected_elements = {element}
self.drag_start_pos = (x, y)
self.image_pan_mode = True
self.image_pan_start_crop = element.crop_info
self._begin_image_pan(element)
self.is_dragging = True
self.setCursor(Qt.CursorShape.SizeAllCursor)
def _handle_multi_select(self, element):
"""Toggle element in multi-selection."""
if element in self.selected_elements:
self.selected_elements.remove(element)
else:
self.selected_elements.add(element)
def _handle_element_drag_start(self, x: float, y: float, element):
"""Start dragging an element."""
self.selected_elements = {element}
self.drag_start_pos = (x, y)
self.drag_start_element_pos = element.position
if not self.rotation_mode:
self._begin_move(element)
self.is_dragging = True
def mousePressEvent(self, event): def mousePressEvent(self, event):
"""Handle mouse press events""" """Handle mouse press events"""
self.setFocus()
if event.button() == Qt.MouseButton.LeftButton: if event.button() == Qt.MouseButton.LeftButton:
x, y = event.position().x(), event.position().y() self._handle_left_click(event)
ctrl_pressed = event.modifiers() & Qt.KeyboardModifier.ControlModifier
shift_pressed = event.modifiers() & Qt.KeyboardModifier.ShiftModifier
# Check if clicking on ghost page button
if self._check_ghost_page_click(x, y):
return
# Update current_page_index based on where user clicked
page, page_index, renderer = self._get_page_at(x, y)
if page_index >= 0:
self.current_page_index = page_index
if len(self.selected_elements) == 1 and self.selected_element:
if self.rotation_mode:
# In rotation mode, start rotation tracking
self._begin_rotate(self.selected_element)
self.drag_start_pos = (x, y)
self.rotation_start_angle = self.selected_element.rotation
self.is_dragging = True
return
else:
# In normal mode, check for resize handles
handle = self._get_resize_handle_at(x, y)
if handle:
self._begin_resize(self.selected_element)
self.resize_handle = handle
self.drag_start_pos = (x, y)
self.resize_start_pos = self.selected_element.position
self.resize_start_size = self.selected_element.size
self.is_dragging = True
return
element = self._get_element_at(x, y)
if element:
print(f"DEBUG: Clicked on element: {element}, ctrl_pressed: {ctrl_pressed}, shift_pressed: {shift_pressed}")
# Check if Shift is pressed and element is ImageData - enter image pan mode
if shift_pressed and isinstance(element, ImageData) and not self.rotation_mode:
# Enter image pan mode - pan image within frame
self.selected_elements = {element}
self.drag_start_pos = (x, y)
self.image_pan_mode = True
self.image_pan_start_crop = element.crop_info
self._begin_image_pan(element)
self.is_dragging = True
self.setCursor(Qt.CursorShape.SizeAllCursor)
print(f"Entered image pan mode for {element}")
elif ctrl_pressed:
# Multi-select mode
print(f"DEBUG: Multi-select mode triggered")
if element in self.selected_elements:
print(f"DEBUG: Removing element from selection")
self.selected_elements.remove(element)
else:
print(f"DEBUG: Adding element to selection. Current count: {len(self.selected_elements)}")
self.selected_elements.add(element)
print(f"DEBUG: Total selected elements: {len(self.selected_elements)}")
else:
# Normal drag mode
print(f"DEBUG: Normal drag mode - single selection")
self.selected_elements = {element}
self.drag_start_pos = (x, y)
self.drag_start_element_pos = element.position
if not self.rotation_mode:
self._begin_move(element)
self.is_dragging = True
else:
if not ctrl_pressed:
self.selected_elements.clear()
self.update()
elif event.button() == Qt.MouseButton.MiddleButton: elif event.button() == Qt.MouseButton.MiddleButton:
self.is_panning = True self.is_panning = True
self.drag_start_pos = (event.position().x(), event.position().y()) self.drag_start_pos = (event.position().x(), event.position().y())
self.setCursor(Qt.CursorShape.ClosedHandCursor) self.setCursor(Qt.CursorShape.ClosedHandCursor)
def _handle_left_click(self, event):
"""Handle left mouse button click."""
x, y = event.position().x(), event.position().y()
ctrl_pressed = event.modifiers() & Qt.KeyboardModifier.ControlModifier
shift_pressed = event.modifiers() & Qt.KeyboardModifier.ShiftModifier
# Check if clicking on ghost page button
if self._check_ghost_page_click(x, y):
return
# Update current_page_index based on where user clicked
page, page_index, renderer = self._get_page_at(x, y)
if page_index >= 0:
self.current_page_index = page_index
# Handle interaction with already-selected element
if len(self.selected_elements) == 1 and self.selected_element:
if self.rotation_mode:
self._handle_rotation_start(x, y)
return
else:
handle = self._get_resize_handle_at(x, y)
if handle:
self._handle_resize_start(x, y, handle)
return
# Handle click on element
element = self._get_element_at(x, y)
if element:
if ctrl_pressed and isinstance(element, ImageData) and not self.rotation_mode:
self._handle_image_pan_start(x, y, element)
elif ctrl_pressed or shift_pressed:
self._handle_multi_select(element)
else:
self._handle_element_drag_start(x, y, element)
else:
if not ctrl_pressed:
self.selected_elements.clear()
self.update()
def _handle_canvas_pan(self, x: float, y: float):
"""Handle canvas panning with middle mouse button."""
dx = x - self.drag_start_pos[0]
dy = y - self.drag_start_pos[1]
self.pan_offset[0] += dx
self.pan_offset[1] += dy
if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset()
self.drag_start_pos = (x, y)
self.update()
main_window = self.window()
if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars()
def _handle_rotation_move(self, x: float, y: float):
"""Handle element rotation during drag."""
if self.selected_element is None or not hasattr(self.selected_element, "_page_renderer"):
return
renderer = self.selected_element._page_renderer # type: ignore[attr-defined]
elem_x, elem_y = self.selected_element.position
elem_w, elem_h = self.selected_element.size
center_page_x = elem_x + elem_w / 2
center_page_y = elem_y + elem_h / 2
screen_center_x, screen_center_y = renderer.page_to_screen(center_page_x, center_page_y)
dx = x - screen_center_x
dy = y - screen_center_y
angle = math.degrees(math.atan2(dy, dx))
angle = round(angle / self.rotation_snap_angle) * self.rotation_snap_angle
angle = angle % 360
self.selected_element.rotation = angle
main_window = self.window()
if hasattr(main_window, "show_status"):
main_window.show_status(f"Rotation: {angle:.1f}°", 100)
def _handle_resize_move(self, x: float, y: float):
"""Handle element resize during drag."""
screen_dx = x - self.drag_start_pos[0]
screen_dy = y - self.drag_start_pos[1]
total_dx = screen_dx / self.zoom_level
total_dy = screen_dy / self.zoom_level
self._resize_element(total_dx, total_dy)
def _handle_element_move(self, x: float, y: float):
"""Handle element movement during drag, including page transfer."""
assert self.selected_element is not None
current_page, current_page_index, current_renderer = self._get_page_at(x, y)
if current_page and hasattr(self.selected_element, "_parent_page"):
source_page = self.selected_element._parent_page # type: ignore[attr-defined]
if current_page is not source_page:
self._transfer_element_to_page(self.selected_element, source_page, current_page, x, y, current_renderer)
else:
self._move_element_within_page(x, y, source_page)
else:
# No page context - simple move without snapping
total_dx = (x - self.drag_start_pos[0]) / self.zoom_level
total_dy = (y - self.drag_start_pos[1]) / self.zoom_level
new_x = self.drag_start_element_pos[0] + total_dx
new_y = self.drag_start_element_pos[1] + total_dy
self.selected_element.position = (new_x, new_y)
def _move_element_within_page(self, x: float, y: float, page):
"""Move element within its current page with snapping."""
total_dx = (x - self.drag_start_pos[0]) / self.zoom_level
total_dy = (y - self.drag_start_pos[1]) / self.zoom_level
new_x = self.drag_start_element_pos[0] + total_dx
new_y = self.drag_start_element_pos[1] + total_dy
assert self.selected_element is not None
main_window = self.window()
snap_sys = page.layout.snapping_system
page_size = page.layout.size
dpi = main_window.project.working_dpi # type: ignore[attr-defined]
snapped_pos = snap_sys.snap_position(
position=(new_x, new_y),
size=self.selected_element.size,
page_size=page_size,
dpi=dpi,
project=main_window.project, # type: ignore[attr-defined]
)
self.selected_element.position = snapped_pos
def mouseMoveEvent(self, event): def mouseMoveEvent(self, event):
"""Handle mouse move events""" """Handle mouse move events"""
x, y = event.position().x(), event.position().y() x, y = event.position().x(), event.position().y()
# Update status bar with page information
self._update_page_status(x, y) self._update_page_status(x, y)
# Canvas panning (middle mouse button)
if self.is_panning and self.drag_start_pos: if self.is_panning and self.drag_start_pos:
dx = x - self.drag_start_pos[0] self._handle_canvas_pan(x, y)
dy = y - self.drag_start_pos[1]
self.pan_offset[0] += dx
self.pan_offset[1] += dy
self.drag_start_pos = (x, y)
self.update()
return return
if not self.is_dragging or not self.drag_start_pos: if not self.is_dragging or not self.drag_start_pos:
return return
if self.selected_element: if not self.selected_element:
if self.image_pan_mode: return
# Image pan mode - delegate to ImagePanMixin
self._handle_image_pan_move(x, y, self.selected_element)
elif self.rotation_mode: # Dispatch to appropriate handler based on interaction mode
# Rotation mode if self.image_pan_mode:
import math 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)
if not hasattr(self.selected_element, '_page_renderer'): self.update()
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)
elif self.resize_handle:
# Resize mode
screen_dx = x - self.drag_start_pos[0]
screen_dy = y - self.drag_start_pos[1]
total_dx = screen_dx / self.zoom_level
total_dy = screen_dy / self.zoom_level
if self.selected_element.rotation != 0:
import math
angle_rad = -math.radians(self.selected_element.rotation)
cos_a = math.cos(angle_rad)
sin_a = math.sin(angle_rad)
rotated_dx = total_dx * cos_a - total_dy * sin_a
rotated_dy = total_dx * sin_a + total_dy * cos_a
total_dx = rotated_dx
total_dy = rotated_dy
self._resize_element(total_dx, total_dy)
else:
# Move mode
current_page, current_page_index, current_renderer = self._get_page_at(x, y)
if current_page and hasattr(self.selected_element, '_parent_page'):
source_page = self.selected_element._parent_page
if current_page is not source_page:
self._transfer_element_to_page(self.selected_element, source_page, current_page, x, y, current_renderer)
else:
total_dx = (x - self.drag_start_pos[0]) / self.zoom_level
total_dy = (y - self.drag_start_pos[1]) / self.zoom_level
new_x = self.drag_start_element_pos[0] + total_dx
new_y = self.drag_start_element_pos[1] + total_dy
main_window = self.window()
snap_sys = source_page.layout.snapping_system
page_size = source_page.layout.size
dpi = main_window.project.working_dpi
snapped_pos = snap_sys.snap_position(
position=(new_x, new_y),
size=self.selected_element.size,
page_size=page_size,
dpi=dpi
)
self.selected_element.position = snapped_pos
else:
total_dx = (x - self.drag_start_pos[0]) / self.zoom_level
total_dy = (y - self.drag_start_pos[1]) / self.zoom_level
new_x = self.drag_start_element_pos[0] + total_dx
new_y = self.drag_start_element_pos[1] + total_dy
self.selected_element.position = (new_x, new_y)
self.update()
def mouseReleaseEvent(self, event): def mouseReleaseEvent(self, event):
"""Handle mouse release events""" """Handle mouse release events"""
@@ -230,11 +299,7 @@ class MouseInteractionMixin:
self.rotation_start_angle = None self.rotation_start_angle = None
self.image_pan_mode = False self.image_pan_mode = False
self.image_pan_start_crop = None self.image_pan_start_crop = None
self.snap_state = { self.snap_state = {"is_snapped": False, "last_position": None, "last_size": None}
'is_snapped': False,
'last_position': None,
'last_size': None
}
self.setCursor(Qt.CursorShape.ArrowCursor) self.setCursor(Qt.CursorShape.ArrowCursor)
elif event.button() == Qt.MouseButton.MiddleButton: elif event.button() == Qt.MouseButton.MiddleButton:
@@ -249,6 +314,7 @@ class MouseInteractionMixin:
element = self._get_element_at(x, y) element = self._get_element_at(x, y)
from pyPhotoAlbum.models import TextBoxData from pyPhotoAlbum.models import TextBoxData
if isinstance(element, TextBoxData): if isinstance(element, TextBoxData):
self._edit_text_element(element) self._edit_text_element(element)
return return
@@ -272,22 +338,65 @@ class MouseInteractionMixin:
new_zoom = self.zoom_level * zoom_factor new_zoom = self.zoom_level * zoom_factor
if 0.1 <= new_zoom <= 5.0: 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.zoom_level = new_zoom
self.pan_offset[0] = mouse_x - world_x * self.zoom_level self.pan_offset[0] = mouse_x - world_x * self.zoom_level
self.pan_offset[1] = mouse_y - world_y * 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() self.update()
main_window = self.window() main_window = self.window()
if hasattr(main_window, 'status_bar'): if hasattr(main_window, "status_bar"):
main_window.status_bar.showMessage(f"Zoom: {int(self.zoom_level * 100)}%", 2000) 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: else:
# Regular wheel: Vertical scroll # Regular wheel: Two-finger scroll (vertical and horizontal)
scroll_amount = delta * 0.5 delta_x = event.angleDelta().x()
self.pan_offset[1] += scroll_amount 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() 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): def _edit_text_element(self, text_element):
"""Open dialog to edit text element""" """Open dialog to edit text element"""
from pyPhotoAlbum.text_edit_dialog import TextEditDialog from pyPhotoAlbum.text_edit_dialog import TextEditDialog
@@ -296,9 +405,9 @@ class MouseInteractionMixin:
if dialog.exec() == TextEditDialog.DialogCode.Accepted: if dialog.exec() == TextEditDialog.DialogCode.Accepted:
values = dialog.get_values() values = dialog.get_values()
text_element.text_content = values['text_content'] text_element.text_content = values["text_content"]
text_element.font_settings = values['font_settings'] text_element.font_settings = values["font_settings"]
text_element.alignment = values['alignment'] text_element.alignment = values["alignment"]
self.update() self.update()
+14 -10
View File
@@ -12,16 +12,20 @@ from pyPhotoAlbum.mixins.operations.alignment_ops import AlignmentOperationsMixi
from pyPhotoAlbum.mixins.operations.distribution_ops import DistributionOperationsMixin from pyPhotoAlbum.mixins.operations.distribution_ops import DistributionOperationsMixin
from pyPhotoAlbum.mixins.operations.size_ops import SizeOperationsMixin from pyPhotoAlbum.mixins.operations.size_ops import SizeOperationsMixin
from pyPhotoAlbum.mixins.operations.zorder_ops import ZOrderOperationsMixin 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__ = [ __all__ = [
'FileOperationsMixin', "FileOperationsMixin",
'EditOperationsMixin', "EditOperationsMixin",
'ElementOperationsMixin', "ElementOperationsMixin",
'PageOperationsMixin', "PageOperationsMixin",
'TemplateOperationsMixin', "TemplateOperationsMixin",
'ViewOperationsMixin', "ViewOperationsMixin",
'AlignmentOperationsMixin', "AlignmentOperationsMixin",
'DistributionOperationsMixin', "DistributionOperationsMixin",
'SizeOperationsMixin', "SizeOperationsMixin",
'ZOrderOperationsMixin', "ZOrderOperationsMixin",
"MergeOperationsMixin",
"StyleOperationsMixin",
] ]
+39 -74
View File
@@ -9,136 +9,101 @@ from pyPhotoAlbum.commands import AlignElementsCommand, ResizeElementsCommand
class AlignmentOperationsMixin: class AlignmentOperationsMixin:
"""Mixin providing element alignment operations""" """Mixin providing element alignment operations"""
def _get_selected_elements_list(self): def _get_selected_elements_list(self):
"""Get list of selected elements for alignment operations""" """Get list of selected elements for alignment operations"""
return list(self.gl_widget.selected_elements) if self.gl_widget.selected_elements else [] return list(self.gl_widget.selected_elements) if self.gl_widget.selected_elements else []
def _execute_alignment(self, alignment_func, status_msg: str):
"""
Execute an alignment operation with common boilerplate.
Args:
alignment_func: AlignmentManager method to call with elements
status_msg: Status message format string (will receive element count)
"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2): # type: ignore[attr-defined]
return
changes = alignment_func(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd) # type: ignore[attr-defined]
self.update_view() # type: ignore[attr-defined]
self.show_status(status_msg.format(len(elements)), 2000) # type: ignore[attr-defined]
@ribbon_action( @ribbon_action(
label="Align Left", label="Align Left",
tooltip="Align selected elements to the left", tooltip="Align selected elements to the left",
tab="Arrange", tab="Arrange",
group="Align", group="Align",
requires_selection=True, requires_selection=True,
min_selection=2 min_selection=2,
) )
def align_left(self): def align_left(self):
"""Align selected elements to the left""" """Align selected elements to the left"""
elements = self._get_selected_elements_list() self._execute_alignment(AlignmentManager.align_left, "Aligned {} elements to left")
if not self.require_selection(min_count=2):
return
changes = AlignmentManager.align_left(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Aligned {len(elements)} elements to left", 2000)
@ribbon_action( @ribbon_action(
label="Align Right", label="Align Right",
tooltip="Align selected elements to the right", tooltip="Align selected elements to the right",
tab="Arrange", tab="Arrange",
group="Align", group="Align",
requires_selection=True, requires_selection=True,
min_selection=2 min_selection=2,
) )
def align_right(self): def align_right(self):
"""Align selected elements to the right""" """Align selected elements to the right"""
elements = self._get_selected_elements_list() self._execute_alignment(AlignmentManager.align_right, "Aligned {} elements to right")
if not self.require_selection(min_count=2):
return
changes = AlignmentManager.align_right(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Aligned {len(elements)} elements to right", 2000)
@ribbon_action( @ribbon_action(
label="Align Top", label="Align Top",
tooltip="Align selected elements to the top", tooltip="Align selected elements to the top",
tab="Arrange", tab="Arrange",
group="Align", group="Align",
requires_selection=True, requires_selection=True,
min_selection=2 min_selection=2,
) )
def align_top(self): def align_top(self):
"""Align selected elements to the top""" """Align selected elements to the top"""
elements = self._get_selected_elements_list() self._execute_alignment(AlignmentManager.align_top, "Aligned {} elements to top")
if not self.require_selection(min_count=2):
return
changes = AlignmentManager.align_top(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Aligned {len(elements)} elements to top", 2000)
@ribbon_action( @ribbon_action(
label="Align Bottom", label="Align Bottom",
tooltip="Align selected elements to the bottom", tooltip="Align selected elements to the bottom",
tab="Arrange", tab="Arrange",
group="Align", group="Align",
requires_selection=True, requires_selection=True,
min_selection=2 min_selection=2,
) )
def align_bottom(self): def align_bottom(self):
"""Align selected elements to the bottom""" """Align selected elements to the bottom"""
elements = self._get_selected_elements_list() self._execute_alignment(AlignmentManager.align_bottom, "Aligned {} elements to bottom")
if not self.require_selection(min_count=2):
return
changes = AlignmentManager.align_bottom(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Aligned {len(elements)} elements to bottom", 2000)
@ribbon_action( @ribbon_action(
label="Align H-Center", label="Align H-Center",
tooltip="Align selected elements to horizontal center", tooltip="Align selected elements to horizontal center",
tab="Arrange", tab="Arrange",
group="Align", group="Align",
requires_selection=True, requires_selection=True,
min_selection=2 min_selection=2,
) )
def align_horizontal_center(self): def align_horizontal_center(self):
"""Align selected elements to horizontal center""" """Align selected elements to horizontal center"""
elements = self._get_selected_elements_list() self._execute_alignment(AlignmentManager.align_horizontal_center, "Aligned {} elements to horizontal center")
if not self.require_selection(min_count=2):
return
changes = AlignmentManager.align_horizontal_center(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Aligned {len(elements)} elements to horizontal center", 2000)
@ribbon_action( @ribbon_action(
label="Align V-Center", label="Align V-Center",
tooltip="Align selected elements to vertical center", tooltip="Align selected elements to vertical center",
tab="Arrange", tab="Arrange",
group="Align", group="Align",
requires_selection=True, requires_selection=True,
min_selection=2 min_selection=2,
) )
def align_vertical_center(self): def align_vertical_center(self):
"""Align selected elements to vertical center""" """Align selected elements to vertical center"""
elements = self._get_selected_elements_list() self._execute_alignment(AlignmentManager.align_vertical_center, "Aligned {} elements to vertical center")
if not self.require_selection(min_count=2):
return
changes = AlignmentManager.align_vertical_center(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Aligned {len(elements)} elements to vertical center", 2000)
@ribbon_action( @ribbon_action(
label="Maximize Pattern", label="Maximize Pattern",
@@ -146,7 +111,7 @@ class AlignmentOperationsMixin:
tab="Arrange", tab="Arrange",
group="Size", group="Size",
requires_selection=True, requires_selection=True,
min_selection=1 min_selection=1,
) )
def maximize_pattern(self): def maximize_pattern(self):
"""Maximize selected elements until they are close to borders or each other""" """Maximize selected elements until they are close to borders or each other"""
@@ -9,91 +9,74 @@ from pyPhotoAlbum.commands import AlignElementsCommand
class DistributionOperationsMixin: class DistributionOperationsMixin:
"""Mixin providing element distribution and spacing operations""" """Mixin providing element distribution and spacing operations"""
def _get_selected_elements_list(self): def _get_selected_elements_list(self):
"""Get list of selected elements for distribution operations""" """Get list of selected elements for distribution operations"""
return list(self.gl_widget.selected_elements) if self.gl_widget.selected_elements else [] return list(self.gl_widget.selected_elements) if self.gl_widget.selected_elements else []
def _execute_distribution(self, distribution_func, status_msg: str):
"""
Execute a distribution operation with common boilerplate.
Args:
distribution_func: AlignmentManager method to call with elements
status_msg: Status message format string (will receive element count)
"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=3): # type: ignore[attr-defined]
return
changes = distribution_func(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd) # type: ignore[attr-defined]
self.update_view() # type: ignore[attr-defined]
self.show_status(status_msg.format(len(elements)), 2000) # type: ignore[attr-defined]
@ribbon_action( @ribbon_action(
label="Distribute H", label="Distribute H",
tooltip="Distribute selected elements evenly horizontally", tooltip="Distribute selected elements evenly horizontally",
tab="Arrange", tab="Arrange",
group="Distribute", group="Distribute",
requires_selection=True, requires_selection=True,
min_selection=3 min_selection=3,
) )
def distribute_horizontally(self): def distribute_horizontally(self):
"""Distribute selected elements evenly horizontally""" """Distribute selected elements evenly horizontally"""
elements = self._get_selected_elements_list() self._execute_distribution(AlignmentManager.distribute_horizontally, "Distributed {} elements horizontally")
if not self.require_selection(min_count=3):
return
changes = AlignmentManager.distribute_horizontally(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Distributed {len(elements)} elements horizontally", 2000)
@ribbon_action( @ribbon_action(
label="Distribute V", label="Distribute V",
tooltip="Distribute selected elements evenly vertically", tooltip="Distribute selected elements evenly vertically",
tab="Arrange", tab="Arrange",
group="Distribute", group="Distribute",
requires_selection=True, requires_selection=True,
min_selection=3 min_selection=3,
) )
def distribute_vertically(self): def distribute_vertically(self):
"""Distribute selected elements evenly vertically""" """Distribute selected elements evenly vertically"""
elements = self._get_selected_elements_list() self._execute_distribution(AlignmentManager.distribute_vertically, "Distributed {} elements vertically")
if not self.require_selection(min_count=3):
return
changes = AlignmentManager.distribute_vertically(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Distributed {len(elements)} elements vertically", 2000)
@ribbon_action( @ribbon_action(
label="Space H", label="Space H",
tooltip="Space selected elements equally horizontally", tooltip="Space selected elements equally horizontally",
tab="Arrange", tab="Arrange",
group="Distribute", group="Distribute",
requires_selection=True, requires_selection=True,
min_selection=3 min_selection=3,
) )
def space_horizontally(self): def space_horizontally(self):
"""Space selected elements equally horizontally""" """Space selected elements equally horizontally"""
elements = self._get_selected_elements_list() self._execute_distribution(AlignmentManager.space_horizontally, "Spaced {} elements horizontally")
if not self.require_selection(min_count=3):
return
changes = AlignmentManager.space_horizontally(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Spaced {len(elements)} elements horizontally", 2000)
@ribbon_action( @ribbon_action(
label="Space V", label="Space V",
tooltip="Space selected elements equally vertically", tooltip="Space selected elements equally vertically",
tab="Arrange", tab="Arrange",
group="Distribute", group="Distribute",
requires_selection=True, requires_selection=True,
min_selection=3 min_selection=3,
) )
def space_vertically(self): def space_vertically(self):
"""Space selected elements equally vertically""" """Space selected elements equally vertically"""
elements = self._get_selected_elements_list() self._execute_distribution(AlignmentManager.space_vertically, "Spaced {} elements vertically")
if not self.require_selection(min_count=3):
return
changes = AlignmentManager.space_vertically(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Spaced {len(elements)} elements vertically", 2000)
+35 -49
View File
@@ -8,14 +8,8 @@ from pyPhotoAlbum.commands import DeleteElementCommand, RotateElementCommand
class EditOperationsMixin: class EditOperationsMixin:
"""Mixin providing edit-related operations""" """Mixin providing edit-related operations"""
@ribbon_action( @ribbon_action(label="Undo", tooltip="Undo last action (Ctrl+Z)", tab="Home", group="Edit", shortcut="Ctrl+Z")
label="Undo",
tooltip="Undo last action (Ctrl+Z)",
tab="Home",
group="Edit",
shortcut="Ctrl+Z"
)
def undo(self): def undo(self):
"""Undo last action""" """Undo last action"""
if self.project.history.undo(): if self.project.history.undo():
@@ -25,13 +19,9 @@ class EditOperationsMixin:
else: else:
self.show_status("Nothing to undo", 2000) self.show_status("Nothing to undo", 2000)
print("Nothing to undo") print("Nothing to undo")
@ribbon_action( @ribbon_action(
label="Redo", label="Redo", tooltip="Redo last action (Ctrl+Y or Ctrl+Shift+Z)", tab="Home", group="Edit", shortcut="Ctrl+Y"
tooltip="Redo last action (Ctrl+Y or Ctrl+Shift+Z)",
tab="Home",
group="Edit",
shortcut="Ctrl+Y"
) )
def redo(self): def redo(self):
"""Redo last action""" """Redo last action"""
@@ -42,117 +32,113 @@ class EditOperationsMixin:
else: else:
self.show_status("Nothing to redo", 2000) self.show_status("Nothing to redo", 2000)
print("Nothing to redo") print("Nothing to redo")
@ribbon_action( @ribbon_action(
label="Delete", label="Delete",
tooltip="Delete selected element (Delete key)", tooltip="Delete selected element (Delete key)",
tab="Home", tab="Home",
group="Edit", group="Edit",
shortcut="Delete", shortcut="Delete",
requires_selection=True requires_selection=True,
) )
def delete_selected_element(self): def delete_selected_element(self):
"""Delete the currently selected element""" """Delete the currently selected element"""
if not self.require_selection(min_count=1): if not self.require_selection(min_count=1):
return return
current_page = self.get_current_page() current_page = self.get_current_page()
if not current_page: if not current_page:
return return
# Delete the first selected element (for backward compatibility) # Delete the first selected element (for backward compatibility)
# In the future, we could delete all selected elements # In the future, we could delete all selected elements
selected_element = next(iter(self.gl_widget.selected_elements)) selected_element = next(iter(self.gl_widget.selected_elements))
try: try:
cmd = DeleteElementCommand( cmd = DeleteElementCommand(current_page.layout, selected_element, asset_manager=self.project.asset_manager)
current_page.layout,
selected_element,
asset_manager=self.project.asset_manager
)
self.project.history.execute(cmd) self.project.history.execute(cmd)
# Clear selection # Clear selection
self.gl_widget.selected_elements.clear() self.gl_widget.selected_elements.clear()
# Update display # Update display
self.update_view() self.update_view()
self.show_status("Element deleted (Ctrl+Z to undo)", 2000) self.show_status("Element deleted (Ctrl+Z to undo)", 2000)
print("Deleted selected element") print("Deleted selected element")
except Exception as e: except Exception as e:
self.show_error("Error", f"Failed to delete element: {str(e)}") self.show_error("Error", f"Failed to delete element: {str(e)}")
print(f"Error deleting element: {e}") print(f"Error deleting element: {e}")
@ribbon_action( @ribbon_action(
label="Rotate Left", label="Rotate Left",
tooltip="Rotate selected element 90° counter-clockwise", tooltip="Rotate selected element 90° counter-clockwise",
tab="Home", tab="Arrange",
group="Transform", group="Transform",
requires_selection=True requires_selection=True,
) )
def rotate_left(self): def rotate_left(self):
"""Rotate selected element 90 degrees counter-clockwise""" """Rotate selected element 90 degrees counter-clockwise"""
if not self.require_selection(min_count=1): if not self.require_selection(min_count=1):
return return
selected_element = next(iter(self.gl_widget.selected_elements)) selected_element = next(iter(self.gl_widget.selected_elements))
old_rotation = selected_element.rotation old_rotation = selected_element.rotation
new_rotation = (old_rotation - 90) % 360 new_rotation = (old_rotation - 90) % 360
cmd = RotateElementCommand(selected_element, old_rotation, new_rotation) cmd = RotateElementCommand(selected_element, old_rotation, new_rotation)
self.project.history.execute(cmd) self.project.history.execute(cmd)
self.update_view() self.update_view()
self.show_status(f"Rotated to {new_rotation}° (Ctrl+Z to undo)", 2000) self.show_status(f"Rotated left (Ctrl+Z to undo)", 2000)
print(f"Rotated element left: {old_rotation}° → {new_rotation}°") print(f"Rotated element left: {old_rotation}° → {new_rotation}°")
@ribbon_action( @ribbon_action(
label="Rotate Right", label="Rotate Right",
tooltip="Rotate selected element 90° clockwise", tooltip="Rotate selected element 90° clockwise",
tab="Home", tab="Arrange",
group="Transform", group="Transform",
requires_selection=True requires_selection=True,
) )
def rotate_right(self): def rotate_right(self):
"""Rotate selected element 90 degrees clockwise""" """Rotate selected element 90 degrees clockwise"""
if not self.require_selection(min_count=1): if not self.require_selection(min_count=1):
return return
selected_element = next(iter(self.gl_widget.selected_elements)) selected_element = next(iter(self.gl_widget.selected_elements))
old_rotation = selected_element.rotation old_rotation = selected_element.rotation
new_rotation = (old_rotation + 90) % 360 new_rotation = (old_rotation + 90) % 360
cmd = RotateElementCommand(selected_element, old_rotation, new_rotation) cmd = RotateElementCommand(selected_element, old_rotation, new_rotation)
self.project.history.execute(cmd) self.project.history.execute(cmd)
self.update_view() self.update_view()
self.show_status(f"Rotated to {new_rotation}° (Ctrl+Z to undo)", 2000) self.show_status(f"Rotated right (Ctrl+Z to undo)", 2000)
print(f"Rotated element right: {old_rotation}° → {new_rotation}°") print(f"Rotated element right: {old_rotation}° → {new_rotation}°")
@ribbon_action( @ribbon_action(
label="Reset Rotation", label="Reset Rotation",
tooltip="Reset selected element rotation to 0°", tooltip="Reset selected element rotation to 0°",
tab="Home", tab="Arrange",
group="Transform", group="Transform",
requires_selection=True requires_selection=True,
) )
def reset_rotation(self): def reset_rotation(self):
"""Reset selected element rotation to 0 degrees""" """Reset selected element rotation to 0 degrees"""
if not self.require_selection(min_count=1): if not self.require_selection(min_count=1):
return return
selected_element = next(iter(self.gl_widget.selected_elements)) selected_element = next(iter(self.gl_widget.selected_elements))
old_rotation = selected_element.rotation old_rotation = selected_element.rotation
if old_rotation == 0: if old_rotation == 0:
self.show_status("Element already at 0°", 2000) self.show_status("Element already at 0°", 2000)
return return
cmd = RotateElementCommand(selected_element, old_rotation, 0) cmd = RotateElementCommand(selected_element, old_rotation, 0)
self.project.history.execute(cmd) self.project.history.execute(cmd)
self.update_view() self.update_view()
self.show_status("Reset rotation to 0° (Ctrl+Z to undo)", 2000) self.show_status("Reset rotation to 0° (Ctrl+Z to undo)", 2000)
print(f"Reset element rotation: {old_rotation}° → 0°") print(f"Reset element rotation: {old_rotation}° → 0°")
+45 -78
View File
@@ -3,164 +3,131 @@ Element operations mixin for pyPhotoAlbum
""" """
from PyQt6.QtWidgets import QFileDialog from PyQt6.QtWidgets import QFileDialog
from PIL import Image
from pyPhotoAlbum.decorators import ribbon_action from pyPhotoAlbum.decorators import ribbon_action
from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
from pyPhotoAlbum.commands import AddElementCommand from pyPhotoAlbum.commands import AddElementCommand
from pyPhotoAlbum.async_backend import get_image_dimensions
class ElementOperationsMixin: class ElementOperationsMixin:
"""Mixin providing element creation and manipulation operations""" """Mixin providing element creation and manipulation operations"""
@ribbon_action( @ribbon_action(
label="Image", label="Image", tooltip="Add an image to the current page", tab="Insert", group="Media", requires_page=True
tooltip="Add an image to the current page",
tab="Insert",
group="Media",
requires_page=True
) )
def add_image(self): def add_image(self):
"""Add an image to the current page""" """Add an image to the current page"""
if not self.require_page(): if not self.require_page():
return return
file_path, _ = QFileDialog.getOpenFileName( file_path, _ = QFileDialog.getOpenFileName(
self, self, "Select Image", "", "Image Files (*.jpg *.jpeg *.png *.gif *.bmp *.tiff *.webp);;All Files (*)"
"Select Image",
"",
"Image Files (*.jpg *.jpeg *.png *.gif *.bmp *.tiff *.webp);;All Files (*)"
) )
if not file_path: if not file_path:
return return
current_page = self.get_current_page() current_page = self.get_current_page()
if not current_page: if not current_page:
return return
try: try:
# Import asset to project # Import asset to project
asset_path = self.project.asset_manager.import_asset(file_path) asset_path = self.project.asset_manager.import_asset(file_path)
# Load image to get dimensions # Get dimensions using centralized utility (max 300px for UI display)
img = Image.open(file_path) full_asset_path = self.get_asset_full_path(asset_path)
img_width, img_height = img.size dimensions = get_image_dimensions(full_asset_path, max_size=300)
if dimensions:
# Scale to reasonable size (max 300px) img_width, img_height = dimensions
max_size = 300 else:
if img_width > max_size or img_height > max_size: # Fallback dimensions if image cannot be read
scale = min(max_size / img_width, max_size / img_height) img_width, img_height = 200, 150
img_width = int(img_width * scale)
img_height = int(img_height * scale)
# Create image element at center of page # Create image element at center of page
page_width_mm = current_page.layout.size[0] page_width_mm = current_page.layout.size[0]
page_height_mm = current_page.layout.size[1] page_height_mm = current_page.layout.size[1]
# Center position # Center position
x = (page_width_mm - img_width) / 2 x = (page_width_mm - img_width) / 2
y = (page_height_mm - img_height) / 2 y = (page_height_mm - img_height) / 2
new_image = ImageData( new_image = ImageData(image_path=asset_path, x=x, y=y, width=img_width, height=img_height)
image_path=asset_path,
x=x,
y=y,
width=img_width,
height=img_height
)
# Add element using command pattern for undo/redo # Add element using command pattern for undo/redo
cmd = AddElementCommand( cmd = AddElementCommand(current_page.layout, new_image, asset_manager=self.project.asset_manager)
current_page.layout,
new_image,
asset_manager=self.project.asset_manager
)
self.project.history.execute(cmd) self.project.history.execute(cmd)
self.update_view() self.update_view()
self.show_status("Added image (Ctrl+Z to undo)", 2000) self.show_status("Added image (Ctrl+Z to undo)", 2000)
print(f"Added image to page {self.get_current_page_index() + 1}: {asset_path}") print(f"Added image to page {self.get_current_page_index() + 1}: {asset_path}")
except Exception as e: except Exception as e:
self.show_error("Error", f"Failed to add image: {str(e)}") self.show_error("Error", f"Failed to add image: {str(e)}")
print(f"Error adding image: {e}") print(f"Error adding image: {e}")
@ribbon_action( @ribbon_action(
label="Text", label="Text", tooltip="Add a text box to the current page", tab="Insert", group="Media", requires_page=True
tooltip="Add a text box to the current page",
tab="Insert",
group="Media",
requires_page=True
) )
def add_text(self): def add_text(self):
"""Add text to the current page""" """Add text to the current page"""
if not self.require_page(): if not self.require_page():
return return
current_page = self.get_current_page() current_page = self.get_current_page()
if not current_page: if not current_page:
return return
# Create text box element at center of page # Create text box element at center of page
page_width_mm = current_page.layout.size[0] page_width_mm = current_page.layout.size[0]
page_height_mm = current_page.layout.size[1] page_height_mm = current_page.layout.size[1]
text_width = 200 text_width = 200
text_height = 50 text_height = 50
# Center position # Center position
x = (page_width_mm - text_width) / 2 x = (page_width_mm - text_width) / 2
y = (page_height_mm - text_height) / 2 y = (page_height_mm - text_height) / 2
new_text = TextBoxData( new_text = TextBoxData(text_content="New Text", x=x, y=y, width=text_width, height=text_height)
text_content="New Text",
x=x,
y=y,
width=text_width,
height=text_height
)
current_page.layout.add_element(new_text) current_page.layout.add_element(new_text)
self.update_view() self.update_view()
print(f"Added text box to page {self.get_current_page_index() + 1}") print(f"Added text box to page {self.get_current_page_index() + 1}")
@ribbon_action( @ribbon_action(
label="Placeholder", label="Placeholder",
tooltip="Add a placeholder to the current page", tooltip="Add a placeholder to the current page",
tab="Insert", tab="Insert",
group="Media", group="Media",
requires_page=True requires_page=True,
) )
def add_placeholder(self): def add_placeholder(self):
"""Add a placeholder to the current page""" """Add a placeholder to the current page"""
if not self.require_page(): if not self.require_page():
return return
current_page = self.get_current_page() current_page = self.get_current_page()
if not current_page: if not current_page:
return return
# Create placeholder element at center of page # Create placeholder element at center of page
page_width_mm = current_page.layout.size[0] page_width_mm = current_page.layout.size[0]
page_height_mm = current_page.layout.size[1] page_height_mm = current_page.layout.size[1]
placeholder_width = 200 placeholder_width = 200
placeholder_height = 150 placeholder_height = 150
# Center position # Center position
x = (page_width_mm - placeholder_width) / 2 x = (page_width_mm - placeholder_width) / 2
y = (page_height_mm - placeholder_height) / 2 y = (page_height_mm - placeholder_height) / 2
new_placeholder = PlaceholderData( new_placeholder = PlaceholderData(
placeholder_type="image", placeholder_type="image", x=x, y=y, width=placeholder_width, height=placeholder_height
x=x,
y=y,
width=placeholder_width,
height=placeholder_height
) )
current_page.layout.add_element(new_placeholder) current_page.layout.add_element(new_placeholder)
self.update_view() self.update_view()
print(f"Added placeholder to page {self.get_current_page_index() + 1}") print(f"Added placeholder to page {self.get_current_page_index() + 1}")
File diff suppressed because it is too large Load Diff
+178
View 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)
+207 -351
View File
@@ -2,405 +2,261 @@
Page operations mixin for pyPhotoAlbum Page operations mixin for pyPhotoAlbum
""" """
from pyPhotoAlbum.decorators import ribbon_action from pyPhotoAlbum.decorators import ribbon_action, dialog_action
from pyPhotoAlbum.dialogs import PageSetupDialog
from pyPhotoAlbum.project import Page from pyPhotoAlbum.project import Page
from pyPhotoAlbum.page_layout import PageLayout from pyPhotoAlbum.page_layout import PageLayout
class PageOperationsMixin: class PageOperationsMixin:
"""Mixin providing page management operations""" """Mixin providing page management operations"""
# Note: Previous/Next page navigation removed - now using scrollable multi-page view # Note: Previous/Next page navigation removed - now using scrollable multi-page view
# User can scroll through all pages vertically # User can scroll through all pages vertically
@ribbon_action( @ribbon_action(label="Add Page", tooltip="Add a new page to the project", tab="Layout", group="Page")
label="Add Page",
tooltip="Add a new page to the project",
tab="Layout",
group="Page"
)
def add_page(self): def add_page(self):
"""Add a new page to the project""" """Add a new page to the project after the current page"""
new_page_number = len(self.project.pages) + 1 # 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 # Create layout with project default size
width_mm, height_mm = self.project.page_size_mm width_mm, height_mm = self.project.page_size_mm
new_layout = PageLayout(width=width_mm, height=height_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_page = Page(layout=new_layout, page_number=new_page_number)
# New pages are not manually sized - they use project defaults # New pages are not manually sized - they use project defaults
new_page.manually_sized = False new_page.manually_sized = False
self.project.add_page(new_page) # 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() self.update_view()
print(f"Added page {new_page_number} with size {width_mm}×{height_mm} mm")
# Get display name for status message
@ribbon_action( new_page_name = self.project.get_page_display_name(new_page)
label="Page Setup", print(f"Added {new_page_name} at position {insert_index + 1} with size {width_mm}×{height_mm} mm")
tooltip="Configure page size and settings",
tab="Layout", @ribbon_action(label="Page Setup", tooltip="Configure page size and settings", tab="Layout", group="Page")
group="Page" @dialog_action(dialog_class=PageSetupDialog, requires_pages=True)
) def page_setup(self, values):
def page_setup(self): """
"""Open page setup dialog""" Apply page setup configuration.
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox, QComboBox, QCheckBox
This method contains only business logic. UI presentation
# Check if we have pages is handled by PageSetupDialog and the dialog_action decorator.
if not self.project.pages:
return Args:
values: Dictionary of values from the dialog
# Create dialog """
dialog = QDialog(self) selected_page = values["selected_page"]
dialog.setWindowTitle("Page Setup") selected_index = values["selected_index"]
dialog.setMinimumWidth(450)
# Update project cover settings
layout = QVBoxLayout() self.project.paper_thickness_mm = values["paper_thickness_mm"]
self.project.cover_bleed_mm = values["cover_bleed_mm"]
# Page selection group
page_select_group = QGroupBox("Select Page") # Handle cover designation (only for first page)
page_select_layout = QVBoxLayout() if selected_index == 0:
was_cover = selected_page.is_cover
page_combo = QComboBox() is_cover = values["is_cover"]
for i, page in enumerate(self.project.pages):
# Use display name helper if was_cover != is_cover:
page_label = self.project.get_page_display_name(page) selected_page.is_cover = is_cover
if page.is_double_spread and not page.is_cover: self.project.has_cover = is_cover
page_label += f" (Double Spread)"
if page.manually_sized: if is_cover:
page_label += " *" # Calculate and set cover dimensions
page_combo.addItem(page_label, i) self.project.update_cover_dimensions()
page_select_layout.addWidget(page_combo) print(f"Page 1 designated as cover")
# Add info label
info_label = QLabel("* = Manually sized page")
info_label.setStyleSheet("font-size: 9pt; color: gray;")
page_select_layout.addWidget(info_label)
page_select_group.setLayout(page_select_layout)
layout.addWidget(page_select_group)
# Cover settings group (only show if first page is selected)
cover_group = QGroupBox("Cover Settings")
cover_layout = QVBoxLayout()
# Cover checkbox
cover_checkbox = QCheckBox("Designate as Cover")
cover_checkbox.setToolTip("Mark this page as the book cover with wrap-around front/spine/back")
cover_layout.addWidget(cover_checkbox)
# Paper thickness
thickness_layout = QHBoxLayout()
thickness_layout.addWidget(QLabel("Paper Thickness:"))
thickness_spinbox = QDoubleSpinBox()
thickness_spinbox.setRange(0.05, 1.0)
thickness_spinbox.setSingleStep(0.05)
thickness_spinbox.setValue(self.project.paper_thickness_mm)
thickness_spinbox.setSuffix(" mm")
thickness_spinbox.setToolTip("Thickness of paper for spine calculation")
thickness_layout.addWidget(thickness_spinbox)
cover_layout.addLayout(thickness_layout)
# Bleed margin
bleed_layout = QHBoxLayout()
bleed_layout.addWidget(QLabel("Bleed Margin:"))
bleed_spinbox = QDoubleSpinBox()
bleed_spinbox.setRange(0, 10)
bleed_spinbox.setSingleStep(0.5)
bleed_spinbox.setValue(self.project.cover_bleed_mm)
bleed_spinbox.setSuffix(" mm")
bleed_spinbox.setToolTip("Extra margin around cover for printing bleed")
bleed_layout.addWidget(bleed_spinbox)
cover_layout.addLayout(bleed_layout)
# Calculated spine width display
spine_info_label = QLabel()
spine_info_label.setStyleSheet("font-size: 9pt; color: #0066cc; padding: 5px;")
spine_info_label.setWordWrap(True)
cover_layout.addWidget(spine_info_label)
cover_group.setLayout(cover_layout)
layout.addWidget(cover_group)
# Page size group
size_group = QGroupBox("Page Size")
size_layout = QVBoxLayout()
# Width
width_layout = QHBoxLayout()
width_layout.addWidget(QLabel("Width:"))
width_spinbox = QDoubleSpinBox()
width_spinbox.setRange(10, 1000)
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.setSuffix(" mm")
height_layout.addWidget(height_spinbox)
size_layout.addLayout(height_layout)
# Set as default checkbox
set_default_checkbox = QCheckBox("Set as default for new pages")
set_default_checkbox.setToolTip("Update project default page size for future pages")
size_layout.addWidget(set_default_checkbox)
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)
# Function to update displayed values when page selection changes
def on_page_changed(index):
selected_page = self.project.pages[index]
# Show/hide cover settings based on page selection
is_first_page = (index == 0)
cover_group.setVisible(is_first_page)
# Update cover checkbox
if is_first_page:
cover_checkbox.setChecked(selected_page.is_cover)
update_spine_info()
# Get base 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]
width_spinbox.setValue(display_width)
height_spinbox.setValue(selected_page.layout.size[1])
# Disable size editing for covers (auto-calculated)
if selected_page.is_cover:
width_spinbox.setEnabled(False)
height_spinbox.setEnabled(False)
set_default_checkbox.setEnabled(False)
else:
width_spinbox.setEnabled(True)
height_spinbox.setEnabled(True)
set_default_checkbox.setEnabled(True)
def update_spine_info():
"""Update the spine information display"""
if 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)
import math
sheets = math.ceil(content_pages / 4)
spine_width = sheets * thickness_spinbox.value() * 2
page_width = self.project.page_size_mm[0]
total_width = (page_width * 2) + spine_width + (bleed_spinbox.value() * 2)
spine_info_label.setText(
f"Cover Layout: Front ({page_width:.0f}mm) + Spine ({spine_width:.2f}mm) + "
f"Back ({page_width:.0f}mm) + Bleed ({bleed_spinbox.value():.1f}mm × 2)\n"
f"Total Width: {total_width:.1f}mm | Content Pages: {content_pages} | Sheets: {sheets}"
)
else:
spine_info_label.setText("")
# Connect signals
cover_checkbox.stateChanged.connect(lambda: update_spine_info())
thickness_spinbox.valueChanged.connect(lambda: update_spine_info())
bleed_spinbox.valueChanged.connect(lambda: update_spine_info())
# Connect page selection change
page_combo.currentIndexChanged.connect(on_page_changed)
# Initialize with first page
on_page_changed(0)
# 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 selected page
selected_index = page_combo.currentData()
selected_page = self.project.pages[selected_index]
# Update project cover settings
self.project.paper_thickness_mm = thickness_spinbox.value()
self.project.cover_bleed_mm = bleed_spinbox.value()
# Handle cover designation (only for first page)
if selected_index == 0:
was_cover = selected_page.is_cover
is_cover = cover_checkbox.isChecked()
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 = width_spinbox.value()
height_mm = height_spinbox.value()
# 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)} (double spread) updated to {width_mm}×{height_mm} mm per page")
else: else:
old_size = selected_page.layout.size # Restore normal page size
size_changed = (old_size != (width_mm, height_mm)) selected_page.layout.size = self.project.page_size_mm
print(f"Cover removed from page 1")
if size_changed:
# Update single page # Get new values
selected_page.layout.size = (width_mm, height_mm) width_mm = values["width_mm"]
selected_page.layout.base_width = width_mm height_mm = values["height_mm"]
selected_page.manually_sized = True
print(f"{self.project.get_page_display_name(selected_page)} updated to {width_mm}×{height_mm} mm") # Don't allow manual size changes for covers
if not selected_page.is_cover:
# Update DPI settings # Check if size actually changed
self.project.working_dpi = working_dpi_spinbox.value() # For double spreads, compare with base width
self.project.export_dpi = export_dpi_spinbox.value() if selected_page.is_double_spread:
old_base_width = (
# Set as default if checkbox is checked selected_page.layout.base_width
if set_default_checkbox.isChecked(): if hasattr(selected_page.layout, "base_width")
self.project.page_size_mm = (width_mm, height_mm) else selected_page.layout.size[0] / 2
print(f"Project default page size set to {width_mm}×{height_mm} mm") )
old_height = selected_page.layout.size[1]
self.update_view() size_changed = old_base_width != width_mm or old_height != height_mm
# Build status message if size_changed:
page_name = self.project.get_page_display_name(selected_page) # Update double spread
if selected_page.is_cover: selected_page.layout.base_width = width_mm
status_msg = f"{page_name} updated" 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: else:
status_msg = f"{page_name} size: {width_mm}×{height_mm} mm" old_size = selected_page.layout.size
if set_default_checkbox.isChecked(): size_changed = old_size != (width_mm, height_mm)
status_msg += " (set as default)"
self.show_status(status_msg, 2000) 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"]
# Apply to other pages based on scope
# 0 = page only, 1 = non-manual pages, 2 = all pages
apply_scope = values.get("apply_scope", 0)
if apply_scope in (1, 2):
self.project.page_size_mm = (width_mm, height_mm)
print(f"Project default page size set to {width_mm}×{height_mm} mm")
for page in self.project.pages:
if page is selected_page or page.is_cover:
continue
if apply_scope == 1 and page.manually_sized:
continue
if page.is_double_spread:
page.layout.base_width = width_mm
page.layout.size = (width_mm * 2, height_mm)
else:
page.layout.size = (width_mm, height_mm)
page.layout.base_width = width_mm
self.update_view()
# Build status message
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.get("apply_scope", 0) in (1, 2):
status_msg += " (set as default)"
self.show_status(status_msg, 2000)
@ribbon_action( @ribbon_action(
label="Toggle Spread", label="Toggle Spread", tooltip="Toggle double page spread for current page", tab="Layout", group="Page"
tooltip="Toggle double page spread for last page",
tab="Layout",
group="Page"
) )
def toggle_double_spread(self): def toggle_double_spread(self):
"""Toggle double spread for the last page""" """Toggle double spread for the current page"""
if not self.project.pages: if not self.project.pages:
return return
current_page = self.project.pages[-1]
# 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 # Toggle the state
is_double = not current_page.is_double_spread is_double = not current_page.is_double_spread
current_page.is_double_spread = is_double current_page.is_double_spread = is_double
# Mark as manually sized when toggling spread # Mark as manually sized when toggling spread
current_page.manually_sized = True current_page.manually_sized = True
# Update the page layout width # Update the page layout width
current_width = current_page.layout.size[0] current_width = current_page.layout.size[0]
current_height = current_page.layout.size[1] current_height = current_page.layout.size[1]
# Get base width (might already be doubled) # Get base width (might already be doubled)
if hasattr(current_page.layout, 'base_width'): if hasattr(current_page.layout, "base_width"):
base_width = current_page.layout.base_width base_width = current_page.layout.base_width
else: else:
# Assume current width is single if not marked as facing # 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 base_width = current_width / 2 if current_page.layout.is_facing_page else current_width
# Set new width based on double spread state # Set new width based on double spread state
new_width = base_width * 2 if is_double else base_width new_width = base_width * 2 if is_double else base_width
current_page.layout.base_width = base_width current_page.layout.base_width = base_width
current_page.layout.is_facing_page = is_double current_page.layout.is_facing_page = is_double
current_page.layout.size = (new_width, current_height) current_page.layout.size = (new_width, current_height)
# Update display # Update display
self.update_view() self.update_view()
status = "enabled" if is_double else "disabled" status = "enabled" if is_double else "disabled"
self.show_status(f"Double spread {status}: width = {new_width:.0f}mm", 2000) page_name = self.project.get_page_display_name(current_page)
print(f"Double spread {status}: width = {new_width}mm") 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", @ribbon_action(label="Remove Page", tooltip="Remove the currently selected page", tab="Layout", group="Page")
tooltip="Remove the last page",
tab="Layout",
group="Page"
)
def remove_page(self): def remove_page(self):
"""Remove the last page""" """Remove the currently selected page"""
if len(self.project.pages) <= 1: if len(self.project.pages) <= 1:
self.show_warning("Cannot Remove", "Must have at least one page") self.show_warning("Cannot Remove", "Must have at least one page")
print("Cannot remove page - must have at least one page") print("Cannot remove page - must have at least one page")
return return
# Remove last page # Get the most visible page in viewport
last_page = self.project.pages[-1] page_index = self._get_most_visible_page_index()
self.project.remove_page(last_page)
# Ensure index is valid
# Renumber remaining pages if page_index < 0 or page_index >= len(self.project.pages):
for i, page in enumerate(self.project.pages): page_index = len(self.project.pages) - 1
page.page_number = i + 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 # Update display
self.update_view() self.update_view()
print(f"Removed page, now have {len(self.project.pages)} pages") print(f"Removed {page_name}, now have {len(self.project.pages)} pages")
+90 -85
View File
@@ -9,73 +9,90 @@ from pyPhotoAlbum.commands import ResizeElementsCommand
class SizeOperationsMixin: class SizeOperationsMixin:
"""Mixin providing element sizing operations""" """Mixin providing element sizing operations"""
def _get_selected_elements_list(self): def _get_selected_elements_list(self):
"""Get list of selected elements for size operations""" """Get list of selected elements for size operations"""
return list(self.gl_widget.selected_elements) if self.gl_widget.selected_elements else [] return list(self.gl_widget.selected_elements) if self.gl_widget.selected_elements else []
def _execute_resize(self, resize_func, status_msg: str):
"""
Execute a resize operation on multiple elements.
Args:
resize_func: AlignmentManager method to call with elements
status_msg: Status message format string (will receive element count)
"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2): # type: ignore[attr-defined]
return
changes = resize_func(elements)
if changes:
cmd = ResizeElementsCommand(changes)
self.project.history.execute(cmd) # type: ignore[attr-defined]
self.update_view() # type: ignore[attr-defined]
self.show_status(status_msg.format(len(elements)), 2000) # type: ignore[attr-defined]
def _execute_fit_to_page(self, fit_func, status_msg: str):
"""
Execute a fit-to-page operation on a single element.
Args:
fit_func: Function that takes (element, page) and returns a change tuple
status_msg: Status message to display on success
"""
if not self.require_selection(min_count=1): # type: ignore[attr-defined]
return
page = self.get_current_page() # type: ignore[attr-defined]
if not page:
self.show_warning("No Page", "Please create a page first.") # type: ignore[attr-defined]
return
element = next(iter(self.gl_widget.selected_elements)) # type: ignore[attr-defined]
change = fit_func(element, page)
if change:
cmd = ResizeElementsCommand([change])
self.project.history.execute(cmd) # type: ignore[attr-defined]
self.update_view() # type: ignore[attr-defined]
self.show_status(status_msg, 2000) # type: ignore[attr-defined]
@ribbon_action( @ribbon_action(
label="Same Size", label="Same Size",
tooltip="Make all selected elements the same size", tooltip="Make all selected elements the same size",
tab="Arrange", tab="Arrange",
group="Size", group="Size",
requires_selection=True, requires_selection=True,
min_selection=2 min_selection=2,
) )
def make_same_size(self): def make_same_size(self):
"""Make all selected elements the same size""" """Make all selected elements the same size"""
elements = self._get_selected_elements_list() self._execute_resize(AlignmentManager.make_same_size, "Resized {} elements to same size")
if not self.require_selection(min_count=2):
return
changes = AlignmentManager.make_same_size(elements)
if changes:
cmd = ResizeElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Resized {len(elements)} elements to same size", 2000)
@ribbon_action( @ribbon_action(
label="Same Width", label="Same Width",
tooltip="Make all selected elements the same width", tooltip="Make all selected elements the same width",
tab="Arrange", tab="Arrange",
group="Size", group="Size",
requires_selection=True, requires_selection=True,
min_selection=2 min_selection=2,
) )
def make_same_width(self): def make_same_width(self):
"""Make all selected elements the same width""" """Make all selected elements the same width"""
elements = self._get_selected_elements_list() self._execute_resize(AlignmentManager.make_same_width, "Resized {} elements to same width")
if not self.require_selection(min_count=2):
return
changes = AlignmentManager.make_same_width(elements)
if changes:
cmd = ResizeElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Resized {len(elements)} elements to same width", 2000)
@ribbon_action( @ribbon_action(
label="Same Height", label="Same Height",
tooltip="Make all selected elements the same height", tooltip="Make all selected elements the same height",
tab="Arrange", tab="Arrange",
group="Size", group="Size",
requires_selection=True, requires_selection=True,
min_selection=2 min_selection=2,
) )
def make_same_height(self): def make_same_height(self):
"""Make all selected elements the same height""" """Make all selected elements the same height"""
elements = self._get_selected_elements_list() self._execute_resize(AlignmentManager.make_same_height, "Resized {} elements to same height")
if not self.require_selection(min_count=2):
return
changes = AlignmentManager.make_same_height(elements)
if changes:
cmd = ResizeElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Resized {len(elements)} elements to same height", 2000)
@ribbon_action( @ribbon_action(
label="Fit Width", label="Fit Width",
@@ -83,30 +100,14 @@ class SizeOperationsMixin:
tab="Arrange", tab="Arrange",
group="Size", group="Size",
requires_selection=True, requires_selection=True,
min_selection=1 min_selection=1,
) )
def fit_to_width(self): def fit_to_width(self):
"""Fit selected element to page width""" """Fit selected element to page width"""
if not self.require_selection(min_count=1): self._execute_fit_to_page(
return lambda elem, page: AlignmentManager.fit_to_page_width(elem, page.layout.size[0]),
"Fitted element to page width",
page = self.get_current_page() )
if not page:
self.show_warning("No Page", "Please create a page first.")
return
# Get the first selected element
element = next(iter(self.gl_widget.selected_elements))
# Fit to page width
page_width = page.layout.size[0]
change = AlignmentManager.fit_to_page_width(element, page_width)
if change:
cmd = ResizeElementsCommand([change])
self.project.history.execute(cmd)
self.update_view()
self.show_status("Fitted element to page width", 2000)
@ribbon_action( @ribbon_action(
label="Fit Height", label="Fit Height",
@@ -114,30 +115,14 @@ class SizeOperationsMixin:
tab="Arrange", tab="Arrange",
group="Size", group="Size",
requires_selection=True, requires_selection=True,
min_selection=1 min_selection=1,
) )
def fit_to_height(self): def fit_to_height(self):
"""Fit selected element to page height""" """Fit selected element to page height"""
if not self.require_selection(min_count=1): self._execute_fit_to_page(
return lambda elem, page: AlignmentManager.fit_to_page_height(elem, page.layout.size[1]),
"Fitted element to page height",
page = self.get_current_page() )
if not page:
self.show_warning("No Page", "Please create a page first.")
return
# Get the first selected element
element = next(iter(self.gl_widget.selected_elements))
# Fit to page height
page_height = page.layout.size[1]
change = AlignmentManager.fit_to_page_height(element, page_height)
if change:
cmd = ResizeElementsCommand([change])
self.project.history.execute(cmd)
self.update_view()
self.show_status("Fitted element to page height", 2000)
@ribbon_action( @ribbon_action(
label="Fit to Page", label="Fit to Page",
@@ -145,10 +130,25 @@ class SizeOperationsMixin:
tab="Arrange", tab="Arrange",
group="Size", group="Size",
requires_selection=True, requires_selection=True,
min_selection=1 min_selection=1,
) )
def fit_to_page(self): def fit_to_page(self):
"""Fit selected element to page dimensions""" """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): if not self.require_selection(min_count=1):
return return
@@ -160,13 +160,18 @@ class SizeOperationsMixin:
# Get the first selected element # Get the first selected element
element = next(iter(self.gl_widget.selected_elements)) element = next(iter(self.gl_widget.selected_elements))
# Fit to page # Get other elements on the same page (excluding the selected one)
page_width = page.layout.size[0] other_elements = [e for e in page.layout.elements if e is not element]
page_height = page.layout.size[1]
change = AlignmentManager.fit_to_page(element, page_width, page_height) # 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: if change:
cmd = ResizeElementsCommand([change]) cmd = ResizeElementsCommand([change])
self.project.history.execute(cmd) self.project.history.execute(cmd)
self.update_view() self.update_view()
self.show_status("Fitted element to page", 2000) self.show_status(f"Expanded image with {min_gap}mm gap", 2000)
+373
View File
@@ -0,0 +1,373 @@
"""
Style operations mixin for pyPhotoAlbum
Provides ribbon actions for applying visual styles to images:
- Rounded corners
- Borders
- Drop shadows
- (Future) Decorative frames
"""
from pyPhotoAlbum.decorators import ribbon_action
from pyPhotoAlbum.models import ImageData, ImageStyle
class StyleOperationsMixin:
"""Mixin providing element styling operations"""
def _get_selected_images(self):
"""Get list of selected ImageData elements"""
if not self.gl_widget.selected_elements:
return []
return [e for e in self.gl_widget.selected_elements if isinstance(e, ImageData)]
def _apply_style_change(self, style_updater, description: str):
"""
Apply a style change to selected images with undo support.
Args:
style_updater: Function that takes an ImageStyle and modifies it
description: Description for undo history
"""
images = self._get_selected_images()
if not images:
self.show_status("No images selected", 2000) # type: ignore[attr-defined]
return
# Store old styles for undo
old_styles = [(img, img.style.copy()) for img in images]
# Create undo command
from pyPhotoAlbum.commands import Command
class StyleChangeCommand(Command):
def __init__(self, old_styles, new_style_updater, desc):
self.old_styles = old_styles
self.new_style_updater = new_style_updater
self.description = desc
def _invalidate_texture(self, img):
"""Invalidate the image texture so it will be regenerated."""
# Clear the style hash to force regeneration check
if hasattr(img, "_texture_style_hash"):
delattr(img, "_texture_style_hash")
# Clear async load state so it will reload
img._async_load_requested = False
# Delete texture if it exists (will be recreated on next render)
if hasattr(img, "_texture_id") and img._texture_id:
from pyPhotoAlbum.gl_imports import glDeleteTextures
try:
glDeleteTextures([img._texture_id])
except Exception:
pass # GL context might not be available
delattr(img, "_texture_id")
def execute(self):
for img, _ in self.old_styles:
self.new_style_updater(img.style)
self._invalidate_texture(img)
def undo(self):
for img, old_style in self.old_styles:
img.style = old_style.copy()
self._invalidate_texture(img)
def redo(self):
self.execute()
def serialize(self):
# Style changes are not serialized (session-only undo)
return {"type": "style_change", "description": self.description}
@staticmethod
def deserialize(data, project):
# Style changes cannot be deserialized (session-only)
return None
cmd = StyleChangeCommand(old_styles, style_updater, description)
self.project.history.execute(cmd) # type: ignore[attr-defined]
self.update_view() # type: ignore[attr-defined]
self.show_status(f"{description} applied to {len(images)} image(s)", 2000) # type: ignore[attr-defined]
# =========================================================================
# Corner Radius
# =========================================================================
@ribbon_action(
label="Round Corners",
tooltip="Set corner radius for selected images",
tab="Style",
group="Corners",
requires_selection=True,
)
def show_corner_radius_dialog(self):
"""Show dialog to set corner radius"""
images = self._get_selected_images()
if not images:
self.show_status("No images selected", 2000) # type: ignore[attr-defined]
return
from pyPhotoAlbum.dialogs.style_dialogs import CornerRadiusDialog
# Get current radius from first selected image
current_radius = images[0].style.corner_radius
dialog = CornerRadiusDialog(self, current_radius)
if dialog.exec():
new_radius = dialog.get_value()
self._apply_style_change(
lambda style: setattr(style, "corner_radius", new_radius),
f"Set corner radius to {new_radius}%",
)
@ribbon_action(
label="No Corners",
tooltip="Remove rounded corners from selected images",
tab="Style",
group="Corners",
requires_selection=True,
)
def remove_corner_radius(self):
"""Remove corner radius (set to 0)"""
self._apply_style_change(
lambda style: setattr(style, "corner_radius", 0.0),
"Remove corner radius",
)
# =========================================================================
# Borders
# =========================================================================
@ribbon_action(
label="Border...",
tooltip="Set border for selected images",
tab="Style",
group="Border",
requires_selection=True,
)
def show_border_dialog(self):
"""Show dialog to configure border"""
images = self._get_selected_images()
if not images:
self.show_status("No images selected", 2000) # type: ignore[attr-defined]
return
from pyPhotoAlbum.dialogs.style_dialogs import BorderDialog
# Get current border from first selected image
current_style = images[0].style
dialog = BorderDialog(self, current_style.border_width, current_style.border_color)
if dialog.exec():
width, color = dialog.get_values()
def update_border(style):
style.border_width = width
style.border_color = color
self._apply_style_change(update_border, f"Set border ({width}mm)")
@ribbon_action(
label="No Border",
tooltip="Remove border from selected images",
tab="Style",
group="Border",
requires_selection=True,
)
def remove_border(self):
"""Remove border (set width to 0)"""
self._apply_style_change(
lambda style: setattr(style, "border_width", 0.0),
"Remove border",
)
# =========================================================================
# Shadows
# =========================================================================
@ribbon_action(
label="Shadow...",
tooltip="Configure drop shadow for selected images",
tab="Style",
group="Effects",
requires_selection=True,
)
def show_shadow_dialog(self):
"""Show dialog to configure drop shadow"""
images = self._get_selected_images()
if not images:
self.show_status("No images selected", 2000) # type: ignore[attr-defined]
return
from pyPhotoAlbum.dialogs.style_dialogs import ShadowDialog
# Get current shadow settings from first selected image
current_style = images[0].style
dialog = ShadowDialog(
self,
current_style.shadow_enabled,
current_style.shadow_offset,
current_style.shadow_blur,
current_style.shadow_color,
)
if dialog.exec():
enabled, offset, blur, color = dialog.get_values()
def update_shadow(style):
style.shadow_enabled = enabled
style.shadow_offset = offset
style.shadow_blur = blur
style.shadow_color = color
self._apply_style_change(update_shadow, "Configure shadow")
@ribbon_action(
label="Toggle Shadow",
tooltip="Toggle drop shadow on/off for selected images",
tab="Style",
group="Effects",
requires_selection=True,
)
def toggle_shadow(self):
"""Toggle shadow enabled/disabled"""
images = self._get_selected_images()
if not images:
self.show_status("No images selected", 2000) # type: ignore[attr-defined]
return
# Toggle based on first selected image
new_state = not images[0].style.shadow_enabled
self._apply_style_change(
lambda style: setattr(style, "shadow_enabled", new_state),
"Enable shadow" if new_state else "Disable shadow",
)
# =========================================================================
# Style Presets
# =========================================================================
@ribbon_action(
label="Polaroid",
tooltip="Apply Polaroid-style frame (white border, shadow)",
tab="Style",
group="Presets",
requires_selection=True,
)
def apply_polaroid_style(self):
"""Apply Polaroid-style preset"""
def apply_preset(style):
style.corner_radius = 0.0
style.border_width = 3.0 # 3mm white border
style.border_color = (255, 255, 255)
style.shadow_enabled = True
style.shadow_offset = (2.0, 2.0)
style.shadow_blur = 4.0
style.shadow_color = (0, 0, 0, 100)
self._apply_style_change(apply_preset, "Apply Polaroid style")
@ribbon_action(
label="Rounded",
tooltip="Apply rounded photo style",
tab="Style",
group="Presets",
requires_selection=True,
)
def apply_rounded_style(self):
"""Apply rounded corners preset"""
def apply_preset(style):
style.corner_radius = 10.0 # 10% rounded
style.border_width = 0.0
style.shadow_enabled = True
style.shadow_offset = (1.5, 1.5)
style.shadow_blur = 3.0
style.shadow_color = (0, 0, 0, 80)
self._apply_style_change(apply_preset, "Apply rounded style")
@ribbon_action(
label="Clear Style",
tooltip="Remove all styling from selected images",
tab="Style",
group="Presets",
requires_selection=True,
)
def clear_style(self):
"""Remove all styling (reset to defaults)"""
def clear_all(style):
style.corner_radius = 0.0
style.border_width = 0.0
style.border_color = (0, 0, 0)
style.shadow_enabled = False
style.shadow_offset = (2.0, 2.0)
style.shadow_blur = 3.0
style.shadow_color = (0, 0, 0, 128)
style.frame_style = None
style.frame_color = (0, 0, 0)
style.frame_corners = (True, True, True, True)
self._apply_style_change(clear_all, "Clear style")
# =========================================================================
# Decorative Frames
# =========================================================================
@ribbon_action(
label="Frame...",
tooltip="Add decorative frame to selected images",
tab="Style",
group="Frame",
requires_selection=True,
)
def show_frame_picker(self):
"""Show dialog to select decorative frame"""
images = self._get_selected_images()
if not images:
self.show_status("No images selected", 2000) # type: ignore[attr-defined]
return
from pyPhotoAlbum.dialogs.frame_picker_dialog import FramePickerDialog
# Get current frame settings from first selected image
current_style = images[0].style
dialog = FramePickerDialog(
self,
current_frame=current_style.frame_style,
current_color=current_style.frame_color,
current_corners=current_style.frame_corners,
)
if dialog.exec():
frame_name, color, corners = dialog.get_values()
def update_frame(style):
style.frame_style = frame_name
style.frame_color = color
style.frame_corners = corners
desc = f"Apply frame '{frame_name}'" if frame_name else "Remove frame"
self._apply_style_change(update_frame, desc)
@ribbon_action(
label="Remove Frame",
tooltip="Remove decorative frame from selected images",
tab="Style",
group="Frame",
requires_selection=True,
)
def remove_frame(self):
"""Remove decorative frame"""
def clear_frame(style):
style.frame_style = None
style.frame_color = (0, 0, 0)
style.frame_corners = (True, True, True, True)
self._apply_style_change(clear_frame, "Remove frame")
+199 -148
View File
@@ -3,190 +3,102 @@ Template operations mixin for pyPhotoAlbum
""" """
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QInputDialog, QDialog, QVBoxLayout, QLabel, QComboBox, QInputDialog,
QRadioButton, QButtonGroup, QPushButton, QHBoxLayout, QDialog,
QDoubleSpinBox QVBoxLayout,
QLabel,
QComboBox,
QRadioButton,
QButtonGroup,
QPushButton,
QHBoxLayout,
QDoubleSpinBox,
) )
from pyPhotoAlbum.decorators import ribbon_action, undoable_operation from pyPhotoAlbum.decorators import ribbon_action, undoable_operation
class TemplateOperationsMixin: class TemplateOperationsMixin:
"""Mixin providing template-related operations""" """Mixin providing template-related operations"""
@ribbon_action( @ribbon_action(
label="Save as Template", label="Save as Template",
tooltip="Save current page as a reusable template", tooltip="Save current page as a reusable template",
tab="Layout", tab="Layout",
group="Templates", group="Templates",
requires_page=True requires_page=True,
) )
def save_page_as_template(self): def save_page_as_template(self):
"""Save current page as a template""" """Save current page as a template"""
current_page = self.get_current_page() current_page = self.get_current_page()
if not current_page: if not current_page:
return return
# Check if page has any elements # Check if page has any elements
if not current_page.layout.elements: if not current_page.layout.elements:
self.show_warning("Empty Page", "Cannot save an empty page as a template.") self.show_warning("Empty Page", "Cannot save an empty page as a template.")
return return
# Ask for template name # Ask for template name
name, ok = QInputDialog.getText( name, ok = QInputDialog.getText(
self, self,
"Save Template", "Save Template",
"Enter template name:", "Enter template name:",
text=f"Template_{len(self.template_manager.list_templates()) + 1}" text=f"Template_{len(self.template_manager.list_templates()) + 1}",
) )
if not ok or not name: if not ok or not name:
return return
# Ask for optional description # Ask for optional description
description, ok = QInputDialog.getText( description, ok = QInputDialog.getText(self, "Template Description", "Enter description (optional):")
self,
"Template Description",
"Enter description (optional):"
)
if not ok: if not ok:
description = "" description = ""
try: try:
# Create template from page # Create template from page
template = self.template_manager.create_template_from_page( template = self.template_manager.create_template_from_page(current_page, name, description)
current_page,
name,
description
)
# Save template # Save template
self.template_manager.save_template(template) self.template_manager.save_template(template)
self.show_info( self.show_info("Template Saved", f"Template '{name}' has been saved successfully.")
"Template Saved",
f"Template '{name}' has been saved successfully."
)
print(f"Saved template: {name}") print(f"Saved template: {name}")
except Exception as e: except Exception as e:
self.show_error("Error", f"Failed to save template: {str(e)}") self.show_error("Error", f"Failed to save template: {str(e)}")
print(f"Error saving template: {e}") print(f"Error saving template: {e}")
@ribbon_action( @ribbon_action(
label="New from Template", label="New from Template", tooltip="Create a new page from a template", tab="Layout", group="Templates"
tooltip="Create a new page from a template",
tab="Layout",
group="Templates"
) )
def new_page_from_template(self): def new_page_from_template(self):
"""Create a new page from a template""" """Create a new page from a template"""
# Get available templates # Get available templates
templates = self.template_manager.list_templates() templates = self.template_manager.list_templates()
if not templates: if not templates:
self.show_info( self.show_info(
"No Templates", "No Templates", "No templates available. Create a template first by using 'Save as Template'."
"No templates available. Create a template first by using 'Save as Template'."
) )
return return
# Ask user to select template # Create dialog for template selection and options
template_name, ok = QInputDialog.getItem(
self,
"Select Template",
"Choose a template:",
templates,
0,
False
)
if not ok:
return
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
)
# 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}")
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 = QDialog(self)
dialog.setWindowTitle("Apply Template") dialog.setWindowTitle("New Page from Template")
dialog.setMinimumWidth(400) dialog.setMinimumWidth(400)
layout = QVBoxLayout() layout = QVBoxLayout()
# Template selection # Template selection
layout.addWidget(QLabel("Select Template:")) layout.addWidget(QLabel("Select Template:"))
template_combo = QComboBox() template_combo = QComboBox()
template_combo.addItems(templates) template_combo.addItems(templates)
layout.addWidget(template_combo) layout.addWidget(template_combo)
layout.addSpacing(10) 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 # Margin/Spacing percentage
layout.addWidget(QLabel("Margin/Spacing:")) layout.addWidget(QLabel("Margin/Spacing:"))
margin_layout = QHBoxLayout() margin_layout = QHBoxLayout()
@@ -200,28 +112,171 @@ class TemplateOperationsMixin:
margin_layout.addWidget(margin_spinbox) margin_layout.addWidget(margin_spinbox)
margin_layout.addStretch() margin_layout.addStretch()
layout.addLayout(margin_layout) layout.addLayout(margin_layout)
layout.addSpacing(10) layout.addSpacing(10)
# Scaling selection # Scaling selection
layout.addWidget(QLabel("Scaling:")) layout.addWidget(QLabel("Scaling:"))
scale_group = QButtonGroup(dialog) scale_group = QButtonGroup(dialog)
proportional_radio = QRadioButton("Proportional (maintain aspect ratio)") proportional_radio = QRadioButton("Proportional (maintain aspect ratio)")
proportional_radio.setChecked(True)
scale_group.addButton(proportional_radio, 0) scale_group.addButton(proportional_radio, 0)
layout.addWidget(proportional_radio) layout.addWidget(proportional_radio)
stretch_radio = QRadioButton("Stretch to fit") stretch_radio = QRadioButton("Stretch to fit")
stretch_radio.setChecked(True)
scale_group.addButton(stretch_radio, 1) scale_group.addButton(stretch_radio, 1)
layout.addWidget(stretch_radio) layout.addWidget(stretch_radio)
center_radio = QRadioButton("Center (no scaling)") center_radio = QRadioButton("Center (no scaling)")
scale_group.addButton(center_radio, 2) scale_group.addButton(center_radio, 2)
layout.addWidget(center_radio) layout.addWidget(center_radio)
layout.addSpacing(20) 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 # Buttons
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
cancel_btn = QPushButton("Cancel") cancel_btn = QPushButton("Cancel")
@@ -229,46 +284,42 @@ class TemplateOperationsMixin:
apply_btn = QPushButton("Apply") apply_btn = QPushButton("Apply")
apply_btn.clicked.connect(dialog.accept) apply_btn.clicked.connect(dialog.accept)
apply_btn.setDefault(True) apply_btn.setDefault(True)
button_layout.addStretch() button_layout.addStretch()
button_layout.addWidget(cancel_btn) button_layout.addWidget(cancel_btn)
button_layout.addWidget(apply_btn) button_layout.addWidget(apply_btn)
layout.addLayout(button_layout) layout.addLayout(button_layout)
dialog.setLayout(layout) dialog.setLayout(layout)
# Show dialog # Show dialog
if dialog.exec() != QDialog.DialogCode.Accepted: if dialog.exec() != QDialog.DialogCode.Accepted:
return return
# Get selections # Get selections
template_name = template_combo.currentText() template_name = template_combo.currentText()
mode_id = mode_group.checkedId() mode_id = mode_group.checkedId()
scale_id = scale_group.checkedId() scale_id = scale_group.checkedId()
margin_percent = margin_spinbox.value() margin_percent = margin_spinbox.value()
mode = "replace" if mode_id == 0 else "reflow" mode = "replace" if mode_id == 0 else "reflow"
scale_mode = ["proportional", "stretch", "center"][scale_id] scale_mode = ["proportional", "stretch", "center"][scale_id]
try: try:
# Load template # Load template
template = self.template_manager.load_template(template_name) template = self.template_manager.load_template(template_name)
# Apply template to page # Apply template to page
self.template_manager.apply_template_to_page( self.template_manager.apply_template_to_page(
template, template, current_page, mode=mode, scale_mode=scale_mode, margin_percent=margin_percent
current_page,
mode=mode,
scale_mode=scale_mode,
margin_percent=margin_percent
) )
# Update display # Update display
self.update_view() self.update_view()
self.show_status(f"Applied template '{template_name}' to current page", 3000) 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}") print(f"Applied template '{template_name}' with mode={mode}, scale_mode={scale_mode}")
except Exception as e: except Exception as e:
self.show_error("Error", f"Failed to apply template: {str(e)}") self.show_error("Error", f"Failed to apply template: {str(e)}")
print(f"Error applying template: {e}") print(f"Error applying template: {e}")
+147 -193
View File
@@ -3,18 +3,13 @@ View operations mixin for pyPhotoAlbum
""" """
from pyPhotoAlbum.decorators import ribbon_action from pyPhotoAlbum.decorators import ribbon_action
from pyPhotoAlbum.dialogs import PrintSettingsDialog
class ViewOperationsMixin: class ViewOperationsMixin:
"""Mixin providing view-related operations""" """Mixin providing view-related operations"""
@ribbon_action( @ribbon_action(label="Zoom In", tooltip="Zoom in", tab="View", group="Zoom", shortcut="Ctrl++")
label="Zoom In",
tooltip="Zoom in",
tab="View",
group="Zoom",
shortcut="Ctrl++"
)
def zoom_in(self): def zoom_in(self):
"""Zoom in""" """Zoom in"""
self.gl_widget.zoom_level *= 1.2 self.gl_widget.zoom_level *= 1.2
@@ -22,14 +17,8 @@ class ViewOperationsMixin:
self.gl_widget.zoom_level = 5.0 self.gl_widget.zoom_level = 5.0
self.update_view() self.update_view()
self.show_status(f"Zoom: {int(self.gl_widget.zoom_level * 100)}%", 2000) self.show_status(f"Zoom: {int(self.gl_widget.zoom_level * 100)}%", 2000)
@ribbon_action( @ribbon_action(label="Zoom Out", tooltip="Zoom out", tab="View", group="Zoom", shortcut="Ctrl+-")
label="Zoom Out",
tooltip="Zoom out",
tab="View",
group="Zoom",
shortcut="Ctrl+-"
)
def zoom_out(self): def zoom_out(self):
"""Zoom out""" """Zoom out"""
self.gl_widget.zoom_level /= 1.2 self.gl_widget.zoom_level /= 1.2
@@ -37,230 +26,250 @@ class ViewOperationsMixin:
self.gl_widget.zoom_level = 0.1 self.gl_widget.zoom_level = 0.1
self.update_view() self.update_view()
self.show_status(f"Zoom: {int(self.gl_widget.zoom_level * 100)}%", 2000) self.show_status(f"Zoom: {int(self.gl_widget.zoom_level * 100)}%", 2000)
@ribbon_action( @ribbon_action(label="Fit to Window", tooltip="Fit page to window", tab="View", group="Zoom", shortcut="Ctrl+0")
label="Fit to Window",
tooltip="Fit page to window",
tab="View",
group="Zoom",
shortcut="Ctrl+0"
)
def zoom_fit(self): def zoom_fit(self):
"""Fit page to window""" """Fit page to window"""
if not self.project.pages: if not self.project.pages:
return return
current_page = self.project.pages[self.gl_widget.current_page_index] current_page = self.project.pages[self.gl_widget.current_page_index]
page_width_mm = current_page.layout.size[0] page_width_mm = current_page.layout.size[0]
page_height_mm = current_page.layout.size[1] page_height_mm = current_page.layout.size[1]
# Convert to pixels # Convert to pixels
dpi = self.project.working_dpi dpi = self.project.working_dpi
page_width_px = page_width_mm * dpi / 25.4 page_width_px = page_width_mm * dpi / 25.4
page_height_px = page_height_mm * dpi / 25.4 page_height_px = page_height_mm * dpi / 25.4
# Get widget size # Get widget size
widget_width = self.gl_widget.width() - 100 # Margins widget_width = self.gl_widget.width() - 100 # Margins
widget_height = self.gl_widget.height() - 100 widget_height = self.gl_widget.height() - 100
# Calculate zoom to fit # Calculate zoom to fit
zoom_w = widget_width / page_width_px zoom_w = widget_width / page_width_px
zoom_h = widget_height / page_height_px zoom_h = widget_height / page_height_px
self.gl_widget.zoom_level = min(zoom_w, zoom_h) 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.gl_widget.zoom_level = max(0.1, min(5.0, self.gl_widget.zoom_level))
self.update_view() self.update_view()
self.show_status(f"Zoom: {int(self.gl_widget.zoom_level * 100)}%", 2000) self.show_status(f"Zoom: {int(self.gl_widget.zoom_level * 100)}%", 2000)
@ribbon_action( @ribbon_action(
label="Toggle Grid Snap", label="Grid Snap",
tooltip="Toggle snapping to grid", tooltip="Enable/disable snapping to grid (Ctrl+G)",
tab="View", tab="Insert",
group="Snapping" group="Snapping",
shortcut="Ctrl+G",
) )
def toggle_grid_snap(self): def toggle_grid_snap(self):
"""Toggle grid snapping""" """Toggle grid snapping"""
current_page = self.get_current_page() if not self.project:
if not current_page:
return return
snap_sys = current_page.layout.snapping_system self.project.snap_to_grid = not self.project.snap_to_grid
snap_sys.snap_to_grid = not snap_sys.snap_to_grid
status = "enabled" if self.project.snap_to_grid else "disabled"
status = "enabled" if snap_sys.snap_to_grid else "disabled"
self.update_view() self.update_view()
self.show_status(f"Grid snapping {status}", 2000) self.show_status(f"Grid snapping {status}", 2000)
print(f"Grid snapping {status}") print(f"Grid snapping {status}")
@ribbon_action( @ribbon_action(
label="Toggle Edge Snap", label="Edge Snap",
tooltip="Toggle snapping to page edges", tooltip="Enable/disable snapping to page edges (Ctrl+E)",
tab="View", tab="Insert",
group="Snapping" group="Snapping",
shortcut="Ctrl+E",
) )
def toggle_edge_snap(self): def toggle_edge_snap(self):
"""Toggle edge snapping""" """Toggle edge snapping"""
current_page = self.get_current_page() if not self.project:
if not current_page:
return return
snap_sys = current_page.layout.snapping_system self.project.snap_to_edges = not self.project.snap_to_edges
snap_sys.snap_to_edges = not snap_sys.snap_to_edges
status = "enabled" if self.project.snap_to_edges else "disabled"
status = "enabled" if snap_sys.snap_to_edges else "disabled"
self.update_view() self.update_view()
self.show_status(f"Edge snapping {status}", 2000) self.show_status(f"Edge snapping {status}", 2000)
print(f"Edge snapping {status}") print(f"Edge snapping {status}")
@ribbon_action( @ribbon_action(label="Guide Snap", tooltip="Enable/disable snapping to guides", tab="Insert", group="Snapping")
label="Toggle Guide Snap",
tooltip="Toggle snapping to guides",
tab="View",
group="Snapping"
)
def toggle_guide_snap(self): def toggle_guide_snap(self):
"""Toggle guide snapping""" """Toggle guide snapping"""
current_page = self.get_current_page() if not self.project:
if not current_page:
return return
snap_sys = current_page.layout.snapping_system self.project.snap_to_guides = not self.project.snap_to_guides
snap_sys.snap_to_guides = not snap_sys.snap_to_guides
status = "enabled" if self.project.snap_to_guides else "disabled"
status = "enabled" if snap_sys.snap_to_guides else "disabled"
self.update_view() self.update_view()
self.show_status(f"Guide snapping {status}", 2000) self.show_status(f"Guide snapping {status}", 2000)
print(f"Guide snapping {status}") print(f"Guide snapping {status}")
@ribbon_action( @ribbon_action(label="Show Grid", tooltip="Toggle visibility of grid lines", tab="Insert", group="Snapping")
label="Toggle Snap Lines", def toggle_show_grid(self):
tooltip="Toggle visibility of snap lines", """Toggle grid visibility"""
tab="View", if not self.project:
group="Snapping"
)
def toggle_snap_lines(self):
"""Toggle snap lines visibility"""
current_page = self.get_current_page()
if not current_page:
return return
current_page.layout.show_snap_lines = not current_page.layout.show_snap_lines self.project.show_grid = not self.project.show_grid
status = "visible" if current_page.layout.show_snap_lines else "hidden" status = "visible" if self.project.show_grid else "hidden"
self.update_view() self.update_view()
self.show_status(f"Snap lines {status}", 2000) self.show_status(f"Grid {status}", 2000)
print(f"Snap lines {status}") print(f"Grid {status}")
@ribbon_action( @ribbon_action(
label="Add H Guide", label="Print Settings...", tooltip="Configure bleed and safe area for all pages", tab="View", group="Guides"
tooltip="Add horizontal guide at page center",
tab="View",
group="Guides"
) )
def open_print_settings(self):
"""Open the print settings dialog (bleed and safe area)"""
if not self.project:
return
dialog = PrintSettingsDialog(self, self.project)
if dialog.exec():
values = dialog.get_values()
self.project.page_bleed_mm = values["page_bleed_mm"]
self.project.page_safe_area_mm = values["page_safe_area_mm"]
self.update_view()
self.show_status(
f"Bleed: {values['page_bleed_mm']:.1f}mm, Safe area: {values['page_safe_area_mm']:.1f}mm", 2000
)
@ribbon_action(
label="Print Guides", tooltip="Toggle bleed/cut/safe-area guide lines in the editor", tab="View", group="Guides"
)
def toggle_print_guides(self):
"""Toggle print guide lines (bleed/cut/safe area)"""
if not self.project:
return
self.project.show_print_guides = not self.project.show_print_guides
status = "visible" if self.project.show_print_guides else "hidden"
self.update_view()
self.show_status(f"Print guides {status}", 2000)
@ribbon_action(label="Show Guides", tooltip="Toggle visibility of guide lines", tab="Insert", group="Snapping")
def toggle_snap_lines(self):
"""Toggle guide lines visibility"""
if not self.project:
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): def add_horizontal_guide(self):
"""Add a horizontal guide at page center""" """Add a horizontal guide at page center"""
current_page = self.get_current_page() current_page = self.get_current_page()
if not current_page: if not current_page:
return return
# Add guide at vertical center (in mm) # Add guide at vertical center (in mm)
center_y = current_page.layout.size[1] / 2.0 center_y = current_page.layout.size[1] / 2.0
current_page.layout.snapping_system.add_guide(center_y, 'horizontal') current_page.layout.snapping_system.add_guide(center_y, "horizontal")
self.update_view() self.update_view()
self.show_status(f"Added horizontal guide at {center_y:.1f} mm", 2000) self.show_status(f"Added horizontal guide at {center_y:.1f} mm", 2000)
print(f"Added horizontal guide at {center_y:.1f} mm") print(f"Added horizontal guide at {center_y:.1f} mm")
@ribbon_action( @ribbon_action(label="Add V Guide", tooltip="Add vertical guide at page center", tab="View", group="Guides")
label="Add V Guide",
tooltip="Add vertical guide at page center",
tab="View",
group="Guides"
)
def add_vertical_guide(self): def add_vertical_guide(self):
"""Add a vertical guide at page center""" """Add a vertical guide at page center"""
current_page = self.get_current_page() current_page = self.get_current_page()
if not current_page: if not current_page:
return return
# Add guide at horizontal center (in mm) # Add guide at horizontal center (in mm)
center_x = current_page.layout.size[0] / 2.0 center_x = current_page.layout.size[0] / 2.0
current_page.layout.snapping_system.add_guide(center_x, 'vertical') current_page.layout.snapping_system.add_guide(center_x, "vertical")
self.update_view() self.update_view()
self.show_status(f"Added vertical guide at {center_x:.1f} mm", 2000) self.show_status(f"Added vertical guide at {center_x:.1f} mm", 2000)
print(f"Added vertical guide at {center_x:.1f} mm") print(f"Added vertical guide at {center_x:.1f} mm")
@ribbon_action( @ribbon_action(label="Clear Guides", tooltip="Clear all guides from current page", tab="View", group="Guides")
label="Clear Guides",
tooltip="Clear all guides from current page",
tab="View",
group="Guides"
)
def clear_guides(self): def clear_guides(self):
"""Clear all guides from current page""" """Clear all guides from current page"""
current_page = self.get_current_page() current_page = self.get_current_page()
if not current_page: if not current_page:
return return
guide_count = len(current_page.layout.snapping_system.guides) guide_count = len(current_page.layout.snapping_system.guides)
current_page.layout.snapping_system.clear_guides() current_page.layout.snapping_system.clear_guides()
self.update_view() self.update_view()
self.show_status(f"Cleared {guide_count} guides", 2000) self.show_status(f"Cleared {guide_count} guides", 2000)
print(f"Cleared {guide_count} guides") print(f"Cleared {guide_count} guides")
@ribbon_action( @ribbon_action(
label="Set Grid Size...", label="Image Browser",
tooltip="Configure grid spacing for snapping", tooltip="Show/hide the image browser panel",
tab="View", tab="View",
group="Snapping" group="Panels",
shortcut="Ctrl+B",
)
def toggle_image_browser(self):
"""Toggle the thumbnail browser visibility"""
if hasattr(self, "_thumbnail_browser"):
if self._thumbnail_browser.isVisible():
self._thumbnail_browser.hide()
self.show_status("Image browser hidden", 2000)
else:
self._thumbnail_browser.show()
self.show_status("Image browser shown", 2000)
@ribbon_action(
label="Grid Settings...", tooltip="Configure grid size and snap threshold", tab="Insert", group="Snapping"
) )
def set_grid_size(self): def set_grid_size(self):
"""Open dialog to set grid size""" """Open dialog to set grid size"""
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QDoubleSpinBox, QPushButton from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QDoubleSpinBox, QPushButton
current_page = self.get_current_page() if not self.project:
if not current_page:
return return
snap_sys = current_page.layout.snapping_system
# Create dialog # Create dialog
dialog = QDialog(self) dialog = QDialog(self)
dialog.setWindowTitle("Grid Settings") dialog.setWindowTitle("Grid Settings")
dialog.setMinimumWidth(300) dialog.setMinimumWidth(300)
layout = QVBoxLayout() layout = QVBoxLayout()
# Grid size setting # Grid size setting
size_layout = QHBoxLayout() size_layout = QHBoxLayout()
size_layout.addWidget(QLabel("Grid Size:")) size_layout.addWidget(QLabel("Grid Size:"))
size_spinbox = QDoubleSpinBox() size_spinbox = QDoubleSpinBox()
size_spinbox.setRange(1.0, 100.0) size_spinbox.setRange(1.0, 100.0)
size_spinbox.setValue(snap_sys.grid_size_mm) size_spinbox.setValue(self.project.grid_size_mm)
size_spinbox.setSuffix(" mm") size_spinbox.setSuffix(" mm")
size_spinbox.setDecimals(1) size_spinbox.setDecimals(1)
size_spinbox.setSingleStep(1.0) size_spinbox.setSingleStep(1.0)
size_layout.addWidget(size_spinbox) size_layout.addWidget(size_spinbox)
layout.addLayout(size_layout) layout.addLayout(size_layout)
# Snap threshold setting # Snap threshold setting
threshold_layout = QHBoxLayout() threshold_layout = QHBoxLayout()
threshold_layout.addWidget(QLabel("Snap Threshold:")) threshold_layout.addWidget(QLabel("Snap Threshold:"))
threshold_spinbox = QDoubleSpinBox() threshold_spinbox = QDoubleSpinBox()
threshold_spinbox.setRange(0.5, 20.0) threshold_spinbox.setRange(0.5, 20.0)
threshold_spinbox.setValue(snap_sys.snap_threshold_mm) threshold_spinbox.setValue(self.project.snap_threshold_mm)
threshold_spinbox.setSuffix(" mm") threshold_spinbox.setSuffix(" mm")
threshold_spinbox.setDecimals(1) threshold_spinbox.setDecimals(1)
threshold_spinbox.setSingleStep(0.5) threshold_spinbox.setSingleStep(0.5)
threshold_layout.addWidget(threshold_spinbox) threshold_layout.addWidget(threshold_spinbox)
layout.addLayout(threshold_layout) layout.addLayout(threshold_layout)
# Buttons # Buttons
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
cancel_btn = QPushButton("Cancel") cancel_btn = QPushButton("Cancel")
@@ -268,77 +277,22 @@ class ViewOperationsMixin:
ok_btn = QPushButton("OK") ok_btn = QPushButton("OK")
ok_btn.clicked.connect(dialog.accept) ok_btn.clicked.connect(dialog.accept)
ok_btn.setDefault(True) ok_btn.setDefault(True)
button_layout.addStretch() button_layout.addStretch()
button_layout.addWidget(cancel_btn) button_layout.addWidget(cancel_btn)
button_layout.addWidget(ok_btn) button_layout.addWidget(ok_btn)
layout.addLayout(button_layout) layout.addLayout(button_layout)
dialog.setLayout(layout) dialog.setLayout(layout)
# Show dialog and apply if accepted # Show dialog and apply if accepted
if dialog.exec() == QDialog.DialogCode.Accepted: if dialog.exec() == QDialog.DialogCode.Accepted:
new_grid_size = size_spinbox.value() new_grid_size = size_spinbox.value()
new_threshold = threshold_spinbox.value() new_threshold = threshold_spinbox.value()
snap_sys.grid_size_mm = new_grid_size self.project.grid_size_mm = new_grid_size
snap_sys.snap_threshold_mm = new_threshold self.project.snap_threshold_mm = new_threshold
self.update_view() self.update_view()
self.show_status(f"Grid size: {new_grid_size:.1f}mm, Threshold: {new_threshold:.1f}mm", 2000) 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") print(f"Updated grid settings - Size: {new_grid_size:.1f}mm, Threshold: {new_threshold:.1f}mm")
# ===== Layout Tab Snapping Controls =====
# These provide easy access to snapping features during layout work
@ribbon_action(
label="Grid Snap",
tooltip="Enable/disable snapping to grid (Ctrl+G)",
tab="Layout",
group="Snapping",
shortcut="Ctrl+G"
)
def layout_toggle_grid_snap(self):
"""Toggle grid snapping (Layout tab)"""
self.toggle_grid_snap()
@ribbon_action(
label="Edge Snap",
tooltip="Enable/disable snapping to page edges (Ctrl+E)",
tab="Layout",
group="Snapping",
shortcut="Ctrl+E"
)
def layout_toggle_edge_snap(self):
"""Toggle edge snapping (Layout tab)"""
self.toggle_edge_snap()
@ribbon_action(
label="Guide Snap",
tooltip="Enable/disable snapping to guides",
tab="Layout",
group="Snapping"
)
def layout_toggle_guide_snap(self):
"""Toggle guide snapping (Layout tab)"""
self.toggle_guide_snap()
@ribbon_action(
label="Show Grid",
tooltip="Toggle visibility of snap lines",
tab="Layout",
group="Snapping"
)
def layout_toggle_snap_lines(self):
"""Toggle snap lines visibility (Layout tab)"""
self.toggle_snap_lines()
@ribbon_action(
label="Grid Settings...",
tooltip="Configure grid size and snap threshold",
tab="Layout",
group="Snapping"
)
def layout_set_grid_size(self):
"""Open grid settings dialog (Layout tab)"""
self.set_grid_size()
+43 -43
View File
@@ -8,159 +8,159 @@ from pyPhotoAlbum.commands import ChangeZOrderCommand
class ZOrderOperationsMixin: class ZOrderOperationsMixin:
"""Mixin providing z-order/layer control operations""" """Mixin providing z-order/layer control operations"""
@ribbon_action( @ribbon_action(
label="Bring to Front", label="Bring to Front",
tooltip="Bring selected element to front", tooltip="Bring selected element to front",
tab="Arrange", tab="Arrange",
group="Order", group="Order",
shortcut="Ctrl+Shift+]", shortcut="Ctrl+Shift+]",
requires_selection=True requires_selection=True,
) )
def bring_to_front(self): def bring_to_front(self):
"""Bring selected element to front (end of list)""" """Bring selected element to front (end of list)"""
if not self.gl_widget.selected_element: if not self.gl_widget.selected_element:
return return
current_page = self.get_current_page() current_page = self.get_current_page()
if not current_page: if not current_page:
return return
element = self.gl_widget.selected_element element = self.gl_widget.selected_element
elements = current_page.layout.elements elements = current_page.layout.elements
if element not in elements: if element not in elements:
return return
old_index = elements.index(element) old_index = elements.index(element)
new_index = len(elements) - 1 new_index = len(elements) - 1
if old_index == new_index: if old_index == new_index:
self.show_status("Element is already at front", 2000) self.show_status("Element is already at front", 2000)
return return
# Create and execute command # Create and execute command
cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index) cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index)
self.project.history.execute(cmd) self.project.history.execute(cmd)
self.update_view() self.update_view()
self.show_status("Brought element to front (Ctrl+Z to undo)", 2000) self.show_status("Brought element to front (Ctrl+Z to undo)", 2000)
print(f"Brought element to front: {old_index}{new_index}") print(f"Brought element to front: {old_index}{new_index}")
@ribbon_action( @ribbon_action(
label="Send to Back", label="Send to Back",
tooltip="Send selected element to back", tooltip="Send selected element to back",
tab="Arrange", tab="Arrange",
group="Order", group="Order",
shortcut="Ctrl+Shift+[", shortcut="Ctrl+Shift+[",
requires_selection=True requires_selection=True,
) )
def send_to_back(self): def send_to_back(self):
"""Send selected element to back (start of list)""" """Send selected element to back (start of list)"""
if not self.gl_widget.selected_element: if not self.gl_widget.selected_element:
return return
current_page = self.get_current_page() current_page = self.get_current_page()
if not current_page: if not current_page:
return return
element = self.gl_widget.selected_element element = self.gl_widget.selected_element
elements = current_page.layout.elements elements = current_page.layout.elements
if element not in elements: if element not in elements:
return return
old_index = elements.index(element) old_index = elements.index(element)
new_index = 0 new_index = 0
if old_index == new_index: if old_index == new_index:
self.show_status("Element is already at back", 2000) self.show_status("Element is already at back", 2000)
return return
# Create and execute command # Create and execute command
cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index) cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index)
self.project.history.execute(cmd) self.project.history.execute(cmd)
self.update_view() self.update_view()
self.show_status("Sent element to back (Ctrl+Z to undo)", 2000) self.show_status("Sent element to back (Ctrl+Z to undo)", 2000)
print(f"Sent element to back: {old_index}{new_index}") print(f"Sent element to back: {old_index}{new_index}")
@ribbon_action( @ribbon_action(
label="Bring Forward", label="Bring Forward",
tooltip="Bring selected element forward one layer", tooltip="Bring selected element forward one layer",
tab="Arrange", tab="Arrange",
group="Order", group="Order",
shortcut="Ctrl+]", shortcut="Ctrl+]",
requires_selection=True requires_selection=True,
) )
def bring_forward(self): def bring_forward(self):
"""Move selected element forward one position in list""" """Move selected element forward one position in list"""
if not self.gl_widget.selected_element: if not self.gl_widget.selected_element:
return return
current_page = self.get_current_page() current_page = self.get_current_page()
if not current_page: if not current_page:
return return
element = self.gl_widget.selected_element element = self.gl_widget.selected_element
elements = current_page.layout.elements elements = current_page.layout.elements
if element not in elements: if element not in elements:
return return
old_index = elements.index(element) old_index = elements.index(element)
new_index = old_index + 1 new_index = old_index + 1
if new_index >= len(elements): if new_index >= len(elements):
self.show_status("Element is already at front", 2000) self.show_status("Element is already at front", 2000)
return return
# Create and execute command # Create and execute command
cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index) cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index)
self.project.history.execute(cmd) self.project.history.execute(cmd)
self.update_view() self.update_view()
self.show_status("Brought element forward (Ctrl+Z to undo)", 2000) self.show_status("Brought element forward (Ctrl+Z to undo)", 2000)
print(f"Brought element forward: {old_index}{new_index}") print(f"Brought element forward: {old_index}{new_index}")
@ribbon_action( @ribbon_action(
label="Send Backward", label="Send Backward",
tooltip="Send selected element backward one layer", tooltip="Send selected element backward one layer",
tab="Arrange", tab="Arrange",
group="Order", group="Order",
shortcut="Ctrl+[", shortcut="Ctrl+[",
requires_selection=True requires_selection=True,
) )
def send_backward(self): def send_backward(self):
"""Move selected element backward one position in list""" """Move selected element backward one position in list"""
if not self.gl_widget.selected_element: if not self.gl_widget.selected_element:
return return
current_page = self.get_current_page() current_page = self.get_current_page()
if not current_page: if not current_page:
return return
element = self.gl_widget.selected_element element = self.gl_widget.selected_element
elements = current_page.layout.elements elements = current_page.layout.elements
if element not in elements: if element not in elements:
return return
old_index = elements.index(element) old_index = elements.index(element)
new_index = old_index - 1 new_index = old_index - 1
if new_index < 0: if new_index < 0:
self.show_status("Element is already at back", 2000) self.show_status("Element is already at back", 2000)
return return
# Create and execute command # Create and execute command
cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index) cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index)
self.project.history.execute(cmd) self.project.history.execute(cmd)
self.update_view() self.update_view()
self.show_status("Sent element backward (Ctrl+Z to undo)", 2000) self.show_status("Sent element backward (Ctrl+Z to undo)", 2000)
print(f"Sent element backward: {old_index}{new_index}") print(f"Sent element backward: {old_index}{new_index}")
@ribbon_action( @ribbon_action(
label="Swap Order", label="Swap Order",
tooltip="Swap z-order of two selected elements", tooltip="Swap z-order of two selected elements",
@@ -168,21 +168,21 @@ class ZOrderOperationsMixin:
group="Order", group="Order",
shortcut="Ctrl+Shift+X", shortcut="Ctrl+Shift+X",
requires_selection=True, requires_selection=True,
min_selection=2 min_selection=2,
) )
def swap_order(self): def swap_order(self):
"""Swap the z-order of two selected elements""" """Swap the z-order of two selected elements"""
if len(self.gl_widget.selected_elements) != 2: if len(self.gl_widget.selected_elements) != 2:
self.show_status("Please select exactly 2 elements to swap", 2000) self.show_status("Please select exactly 2 elements to swap", 2000)
return return
current_page = self.get_current_page() current_page = self.get_current_page()
if not current_page: if not current_page:
return return
elements = current_page.layout.elements elements = current_page.layout.elements
selected = list(self.gl_widget.selected_elements) selected = list(self.gl_widget.selected_elements)
# Get indices of both elements # Get indices of both elements
try: try:
index1 = elements.index(selected[0]) index1 = elements.index(selected[0])
@@ -190,10 +190,10 @@ class ZOrderOperationsMixin:
except ValueError: except ValueError:
self.show_status("Selected elements not found on current page", 2000) self.show_status("Selected elements not found on current page", 2000)
return return
# Swap them in the list # Swap them in the list
elements[index1], elements[index2] = elements[index2], elements[index1] elements[index1], elements[index2] = elements[index2], elements[index1]
self.update_view() self.update_view()
self.show_status(f"Swapped z-order of elements", 2000) self.show_status(f"Swapped z-order of elements", 2000)
print(f"Swapped elements at indices {index1} and {index2}") print(f"Swapped elements at indices {index1} and {index2}")
+51 -26
View File
@@ -2,10 +2,25 @@
Page navigation mixin for GLWidget - handles page detection and ghost pages Page navigation mixin for GLWidget - handles page detection and ghost pages
""" """
from typing import Optional, Tuple, List from typing import TYPE_CHECKING, Optional, Tuple, List
if TYPE_CHECKING:
from PyQt6.QtWidgets import QMainWindow
class PageNavigationMixin: class PageNavigationMixin:
# Type hints for expected attributes from mixing class
pan_offset: Tuple[float, float]
zoom_level: float
def update(self) -> None: # type: ignore[empty-body]
"""Expected from QWidget"""
...
def window(self) -> "QMainWindow": # type: ignore[empty-body]
"""Expected from QWidget"""
...
""" """
Mixin providing page navigation and ghost page functionality. Mixin providing page navigation and ghost page functionality.
@@ -33,11 +48,11 @@ class PageNavigationMixin:
Returns: Returns:
Tuple of (page, page_index, renderer) or (None, -1, None) if no page at coordinates Tuple of (page, page_index, renderer) or (None, -1, None) if no page at coordinates
""" """
if not hasattr(self, '_page_renderers') or not self._page_renderers: if not hasattr(self, "_page_renderers") or not self._page_renderers:
return None, -1, None return None, -1, None
main_window = self.window() main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages: if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return None, -1, None return None, -1, None
# Check each page to find which one contains the coordinates # Check each page to find which one contains the coordinates
@@ -56,15 +71,23 @@ class PageNavigationMixin:
Returns: Returns:
List of tuples (page_type, page_or_ghost_data, y_offset) List of tuples (page_type, page_or_ghost_data, y_offset)
""" """
main_window = self.window() # Use stored reference to main window
if not hasattr(main_window, 'project'): 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 [] return []
dpi = main_window.project.working_dpi dpi = project.working_dpi
# Use project's page_spacing_mm setting (default is 10mm = 1cm) # Use project's page_spacing_mm setting (default is 10mm = 1cm)
# Convert to pixels at working DPI # Convert to pixels at working DPI
spacing_mm = main_window.project.page_spacing_mm spacing_mm = project.page_spacing_mm
spacing_px = spacing_mm * dpi / 25.4 spacing_px = spacing_mm * dpi / 25.4
# Start with a small top margin (5mm) # Start with a small top margin (5mm)
@@ -75,9 +98,9 @@ class PageNavigationMixin:
current_y = top_margin_px # Initial top offset in pixels (not screen pixels) current_y = top_margin_px # Initial top offset in pixels (not screen pixels)
# First, render cover if it exists # First, render cover if it exists
for page in main_window.project.pages: for page in project.pages:
if page.is_cover: if page.is_cover:
result.append(('page', page, current_y)) result.append(("page", page, current_y))
# Calculate cover height in pixels # Calculate cover height in pixels
page_height_mm = page.layout.size[1] page_height_mm = page.layout.size[1]
@@ -88,10 +111,10 @@ class PageNavigationMixin:
break # Only one cover allowed break # Only one cover allowed
# Get page layout with ghosts from project (this excludes cover) # Get page layout with ghosts from project (this excludes cover)
layout_with_ghosts = main_window.project.calculate_page_layout_with_ghosts() layout_with_ghosts = project.calculate_page_layout_with_ghosts()
for page_type, page_obj, logical_pos in layout_with_ghosts: for page_type, page_obj, logical_pos in layout_with_ghosts:
if page_type == 'page': if page_type == "page":
# Regular page (single or double spread) # Regular page (single or double spread)
result.append((page_type, page_obj, current_y)) result.append((page_type, page_obj, current_y))
@@ -103,9 +126,9 @@ class PageNavigationMixin:
# Move to next position (add height + spacing) # Move to next position (add height + spacing)
current_y += page_height_px + spacing_px current_y += page_height_px + spacing_px
elif page_type == 'ghost': elif page_type == "ghost":
# Ghost page - use default page size # Ghost page - use default page size
page_size_mm = main_window.project.page_size_mm page_size_mm = project.page_size_mm
from pyPhotoAlbum.models import GhostPageData from pyPhotoAlbum.models import GhostPageData
# Create ghost page data with correct size # Create ghost page data with correct size
@@ -131,11 +154,11 @@ class PageNavigationMixin:
Returns: Returns:
bool: True if a ghost page was clicked and a new page was created bool: True if a ghost page was clicked and a new page was created
""" """
if not hasattr(self, '_page_renderers'): if not hasattr(self, "_page_renderers"):
return False return False
main_window = self.window() main_window = self.window()
if not hasattr(main_window, 'project'): if not hasattr(main_window, "project"):
return False return False
# Get page positions which includes ghosts # Get page positions which includes ghosts
@@ -144,7 +167,7 @@ class PageNavigationMixin:
# Check each position for ghost pages # Check each position for ghost pages
for idx, (page_type, page_or_ghost, y_offset) in enumerate(page_positions): for idx, (page_type, page_or_ghost, y_offset) in enumerate(page_positions):
# Skip non-ghost pages # Skip non-ghost pages
if page_type != 'ghost': if page_type != "ghost":
continue continue
ghost = page_or_ghost ghost = page_or_ghost
@@ -156,20 +179,21 @@ class PageNavigationMixin:
screen_y = (y_offset * self.zoom_level) + self.pan_offset[1] screen_y = (y_offset * self.zoom_level) + self.pan_offset[1]
from pyPhotoAlbum.page_renderer import PageRenderer from pyPhotoAlbum.page_renderer import PageRenderer
renderer = PageRenderer( renderer = PageRenderer(
page_width_mm=ghost_width_mm, page_width_mm=ghost_width_mm,
page_height_mm=ghost_height_mm, page_height_mm=ghost_height_mm,
screen_x=screen_x, screen_x=screen_x,
screen_y=screen_y, screen_y=screen_y,
dpi=dpi, dpi=dpi,
zoom=self.zoom_level zoom=self.zoom_level,
) )
# Check if click is anywhere on the ghost page (entire page is clickable) # Check if click is anywhere on the ghost page (entire page is clickable)
if renderer.is_point_in_page(x, y): if renderer.is_point_in_page(x, y):
# User clicked the ghost page! # User clicked the ghost page!
# Calculate the insertion index (count real pages before this ghost in page_positions) # Calculate the insertion index (count real pages before this ghost in page_positions)
insert_index = sum(1 for i, (pt, _, _) in enumerate(page_positions) if i < idx and pt == 'page') insert_index = sum(1 for i, (pt, _, _) in enumerate(page_positions) if i < idx and pt == "page")
print(f"Ghost page clicked at index {insert_index} - inserting new page in place") print(f"Ghost page clicked at index {insert_index} - inserting new page in place")
@@ -181,10 +205,9 @@ class PageNavigationMixin:
new_page_number = insert_index + 1 new_page_number = insert_index + 1
new_page = Page( new_page = Page(
layout=PageLayout( layout=PageLayout(
width=main_window.project.page_size_mm[0], width=main_window.project.page_size_mm[0], height=main_window.project.page_size_mm[1]
height=main_window.project.page_size_mm[1]
), ),
page_number=new_page_number page_number=new_page_number,
) )
# Insert the page at the correct position # Insert the page at the correct position
@@ -209,10 +232,10 @@ class PageNavigationMixin:
y: Screen Y coordinate y: Screen Y coordinate
""" """
main_window = self.window() main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages: if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return return
if not hasattr(self, '_page_renderers') or not self._page_renderers: if not hasattr(self, "_page_renderers") or not self._page_renderers:
return return
# Get total page count (accounting for double spreads = 2 pages each) # Get total page count (accounting for double spreads = 2 pages each)
@@ -228,7 +251,7 @@ class PageNavigationMixin:
if page.is_double_spread: if page.is_double_spread:
side = renderer.get_sub_page_at(x, is_facing_page=True) side = renderer.get_sub_page_at(x, is_facing_page=True)
page_nums = page.get_page_numbers() page_nums = page.get_page_numbers()
if side == 'left': if side == "left":
current_page_info = f"Page {page_nums[0]}" current_page_info = f"Page {page_nums[0]}"
else: else:
current_page_info = f"Page {page_nums[1]}" current_page_info = f"Page {page_nums[1]}"
@@ -237,8 +260,10 @@ class PageNavigationMixin:
break break
# Update status bar # Update status bar
if hasattr(main_window, 'status_bar'): if hasattr(main_window, "status_bar"):
if current_page_info: if current_page_info:
main_window.status_bar.showMessage(f"{current_page_info} of {total_pages} | Zoom: {int(self.zoom_level * 100)}%") main_window.status_bar.showMessage(
f"{current_page_info} of {total_pages} | Zoom: {int(self.zoom_level * 100)}%"
)
else: else:
main_window.status_bar.showMessage(f"Total pages: {total_pages} | Zoom: {int(self.zoom_level * 100)}%") main_window.status_bar.showMessage(f"Total pages: {total_pages} | Zoom: {int(self.zoom_level * 100)}%")
+154 -48
View File
@@ -2,7 +2,9 @@
Rendering mixin for GLWidget - handles OpenGL rendering Rendering mixin for GLWidget - handles OpenGL rendering
""" """
from OpenGL.GL import * import math
from pyPhotoAlbum.gl_imports import *
from PyQt6.QtGui import QPainter, QFont, QColor, QPen from PyQt6.QtGui import QPainter, QFont, QColor, QPen
from PyQt6.QtCore import Qt, QRectF from PyQt6.QtCore import Qt, QRectF
from pyPhotoAlbum.models import TextBoxData from pyPhotoAlbum.models import TextBoxData
@@ -23,16 +25,36 @@ class RenderingMixin:
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
glLoadIdentity() glLoadIdentity()
main_window = self.window() # Use stored reference to main window
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages: 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 return
# Set initial zoom if not done yet 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: if not self.initial_zoom_set:
self.zoom_level = self._calculate_fit_to_screen_zoom() 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 self.initial_zoom_set = True
dpi = main_window.project.working_dpi # 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 # Calculate page positions with ghosts
page_positions = self._get_page_positions() page_positions = self._get_page_positions()
@@ -44,10 +66,11 @@ class RenderingMixin:
PAGE_MARGIN = 50 PAGE_MARGIN = 50
# Render all pages # Render all pages
pages_rendered = 0
for page_info in page_positions: for page_info in page_positions:
page_type, page_or_ghost, y_offset = page_info page_type, page_or_ghost, y_offset = page_info
if page_type == 'page': if page_type == "page":
page = page_or_ghost page = page_or_ghost
page_width_mm, page_height_mm = page.layout.size page_width_mm, page_height_mm = page.layout.size
@@ -60,7 +83,7 @@ class RenderingMixin:
screen_x=screen_x, screen_x=screen_x,
screen_y=screen_y, screen_y=screen_y,
dpi=dpi, dpi=dpi,
zoom=self.zoom_level zoom=self.zoom_level,
) )
self._page_renderers.append((renderer, page)) self._page_renderers.append((renderer, page))
@@ -68,10 +91,15 @@ class RenderingMixin:
renderer.begin_render() renderer.begin_render()
# Pass widget reference for async loading # Pass widget reference for async loading
page.layout._parent_widget = self page.layout._parent_widget = self
page.layout.render(dpi=dpi) page.layout.render(dpi=dpi, project=project)
renderer.end_render() renderer.end_render()
elif page_type == 'ghost': # Draw bleed/cut/safe-area guides for this page
self._draw_page_print_guides(renderer, project)
pages_rendered += 1
elif page_type == "ghost":
ghost = page_or_ghost ghost = page_or_ghost
ghost_width_mm, ghost_height_mm = ghost.page_size ghost_width_mm, ghost_height_mm = ghost.page_size
@@ -84,14 +112,14 @@ class RenderingMixin:
screen_x=screen_x, screen_x=screen_x,
screen_y=screen_y, screen_y=screen_y,
dpi=dpi, dpi=dpi,
zoom=self.zoom_level zoom=self.zoom_level,
) )
self._render_ghost_page(ghost, renderer) self._render_ghost_page(ghost, renderer)
# Update PageRenderer references for selected elements # Update PageRenderer references for selected elements
for element in self.selected_elements: for element in self.selected_elements:
if hasattr(element, '_parent_page'): if hasattr(element, "_parent_page"):
for renderer, page in self._page_renderers: for renderer, page in self._page_renderers:
if page is element._parent_page: if page is element._parent_page:
element._page_renderer = renderer element._page_renderer = renderer
@@ -101,7 +129,8 @@ class RenderingMixin:
for element in self.selected_elements: for element in self.selected_elements:
self._draw_selection_handles(element) self._draw_selection_handles(element)
# Render text overlays # Render text overlays using QPainter
# Qt will handle OpenGL/QPainter coordination automatically
self._render_text_overlays() self._render_text_overlays()
def _draw_selection_handles(self, element): def _draw_selection_handles(self, element):
@@ -110,10 +139,10 @@ class RenderingMixin:
return return
main_window = self.window() main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages: if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return return
if not hasattr(element, '_page_renderer'): if not hasattr(element, "_page_renderer"):
return return
renderer = element._page_renderer renderer = element._page_renderer
@@ -129,12 +158,7 @@ class RenderingMixin:
center_x = x + w / 2 center_x = x + w / 2
center_y = y + h / 2 center_y = y + h / 2
if element.rotation != 0: # No rotation transformation needed - images are already rotated at PIL level
glPushMatrix()
glTranslatef(center_x, center_y, 0)
glRotatef(element.rotation, 0, 0, 1)
glTranslatef(-w / 2, -h / 2, 0)
x, y = 0, 0
if self.rotation_mode: if self.rotation_mode:
glColor3f(1.0, 0.5, 0.0) glColor3f(1.0, 0.5, 0.0)
@@ -151,7 +175,6 @@ class RenderingMixin:
glLineWidth(1.0) glLineWidth(1.0)
if self.rotation_mode: if self.rotation_mode:
import math
handle_radius = 6 handle_radius = 6
handles = [(x, y), (x + w, y), (x, y + h), (x + w, y + h)] handles = [(x, y), (x + w, y), (x, y + h), (x + w, y + h)]
@@ -186,10 +209,10 @@ class RenderingMixin:
glEnd() glEnd()
else: else:
handles = [ handles = [
(x - handle_size/2, y - handle_size/2), (x - handle_size / 2, y - handle_size / 2),
(x + w - 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 - handle_size / 2, y + h - handle_size / 2),
(x + w - 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) glColor3f(1.0, 1.0, 1.0)
@@ -210,12 +233,9 @@ class RenderingMixin:
glVertex2f(hx, hy + handle_size) glVertex2f(hx, hy + handle_size)
glEnd() glEnd()
if element.rotation != 0:
glPopMatrix()
def _render_text_overlays(self): def _render_text_overlays(self):
"""Render text content for TextBoxData elements using QPainter overlay""" """Render text content for TextBoxData elements using QPainter overlay"""
if not hasattr(self, '_page_renderers') or not self._page_renderers: if not hasattr(self, "_page_renderers") or not self._page_renderers:
return return
painter = QPainter(self) painter = QPainter(self)
@@ -237,41 +257,44 @@ class RenderingMixin:
screen_w = w * renderer.zoom screen_w = w * renderer.zoom
screen_h = h * renderer.zoom screen_h = h * renderer.zoom
font_family = element.font_settings.get('family', 'Arial') font_family = element.font_settings.get("family", "Arial")
font_size = int(element.font_settings.get('size', 12) * renderer.zoom) # 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) font = QFont(font_family, font_size)
painter.setFont(font) painter.setFont(font)
font_color = element.font_settings.get('color', (0, 0, 0)) font_color = element.font_settings.get("color", (0, 0, 0))
if all(isinstance(c, int) and c > 1 for c in font_color): if all(isinstance(c, int) and c > 1 for c in font_color):
color = QColor(*font_color) color = QColor(*font_color)
else: else:
color = QColor(int(font_color[0] * 255), int(font_color[1] * 255), int(font_color[2] * 255)) color = QColor(int(font_color[0] * 255), int(font_color[1] * 255), int(font_color[2] * 255))
painter.setPen(QPen(color)) 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: if element.rotation != 0:
painter.save() painter.translate(w / 2, h / 2)
center_x = screen_x + screen_w / 2
center_y = screen_y + screen_h / 2
painter.translate(center_x, center_y)
painter.rotate(element.rotation) painter.rotate(element.rotation)
painter.translate(-screen_w / 2, -screen_h / 2) painter.translate(-w / 2, -h / 2)
rect = QRectF(0, 0, screen_w, screen_h)
else: rect = QRectF(0, 0, w, h)
rect = QRectF(screen_x, screen_y, screen_w, screen_h)
alignment = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop alignment = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop
if element.alignment == 'center': if element.alignment == "center":
alignment = Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop alignment = Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop
elif element.alignment == 'right': elif element.alignment == "right":
alignment = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop alignment = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop
elif element.alignment == "justify":
alignment = Qt.AlignmentFlag.AlignJustify | Qt.AlignmentFlag.AlignTop
text_flags = Qt.TextFlag.TextWordWrap text_flags = Qt.TextFlag.TextWordWrap
painter.drawText(rect, int(alignment | text_flags), element.text_content) painter.drawText(rect, int(alignment | text_flags), element.text_content)
if element.rotation != 0: painter.restore()
painter.restore()
finally: finally:
painter.end() painter.end()
@@ -290,15 +313,98 @@ class RenderingMixin:
px, py, pw, ph = ghost_data.get_page_rect() px, py, pw, ph = ghost_data.get_page_rect()
screen_x, screen_y = renderer.page_to_screen(px, py) screen_x, screen_y = renderer.page_to_screen(px, py)
screen_w = pw * renderer.zoom
screen_h = ph * renderer.zoom
font = QFont("Arial", int(16 * renderer.zoom), QFont.Weight.Bold) # Use base font size without zoom - zoom is applied via painter transform
font = QFont("Arial", 16, QFont.Weight.Bold)
painter.setFont(font) painter.setFont(font)
painter.setPen(QColor(120, 120, 120)) painter.setPen(QColor(120, 120, 120))
rect = QRectF(screen_x, screen_y, screen_w, screen_h) painter.save()
painter.translate(screen_x, screen_y)
painter.scale(renderer.zoom, renderer.zoom)
rect = QRectF(0, 0, pw, ph)
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "Click to Add Page") painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "Click to Add Page")
painter.restore()
finally: finally:
painter.end() painter.end()
def _draw_page_print_guides(self, renderer, project):
"""
Draw bleed/cut/safe-area guide lines around a page using OpenGL.
- Green dashed rectangle: bleed boundary (extend backgrounds to here)
- Magenta rectangle: cut/trim line (finished page edge, only shown when bleed > 0)
- Red rectangle: safe area (keep text and logos inside)
Lines are only drawn when the corresponding project settings are non-zero.
"""
if not getattr(project, "show_print_guides", False):
return
bleed_mm = getattr(project, "page_bleed_mm", 0.0)
safe_mm = getattr(project, "page_safe_area_mm", 0.0)
if bleed_mm <= 0.0 and safe_mm <= 0.0:
return
dpi = project.working_dpi
zoom = renderer.zoom
sx = renderer.screen_x
sy = renderer.screen_y
sw = renderer.screen_width
sh = renderer.screen_height
# Convert mm to screen pixels
bleed_screen = bleed_mm * dpi / 25.4 * zoom
safe_screen = safe_mm * dpi / 25.4 * zoom
glLineWidth(1.0)
def _draw_rect_outline(x, y, w, h, r, g, b, dashed=False):
glColor3f(r, g, b)
if dashed:
glEnable(GL_LINE_STIPPLE)
glLineStipple(1, 0x00FF)
else:
glDisable(GL_LINE_STIPPLE)
glBegin(GL_LINE_LOOP)
glVertex2f(x, y)
glVertex2f(x + w, y)
glVertex2f(x + w, y + h)
glVertex2f(x, y + h)
glEnd()
glDisable(GL_LINE_STIPPLE)
# Bleed boundary green dashed rectangle outside the page
if bleed_screen > 0:
_draw_rect_outline(
sx - bleed_screen,
sy - bleed_screen,
sw + 2 * bleed_screen,
sh + 2 * bleed_screen,
0.0,
0.67,
0.0,
dashed=True,
)
# Cut/trim line magenta rectangle at the page edge (only meaningful when bleed > 0)
if bleed_screen > 0:
_draw_rect_outline(sx, sy, sw, sh, 0.8, 0.0, 0.8)
# Safe area red rectangle inside the page
if safe_screen > 0:
_draw_rect_outline(
sx + safe_screen,
sy + safe_screen,
sw - 2 * safe_screen,
sh - 2 * safe_screen,
0.8,
0.0,
0.0,
)
glColor3f(1.0, 1.0, 1.0) # Reset colour
+224 -2
View File
@@ -2,7 +2,7 @@
Viewport mixin for GLWidget - handles zoom and pan Viewport mixin for GLWidget - handles zoom and pan
""" """
from OpenGL.GL import * from pyPhotoAlbum.gl_imports import *
class ViewportMixin: class ViewportMixin:
@@ -21,6 +21,9 @@ class ViewportMixin:
self.pan_offset = [0, 0] self.pan_offset = [0, 0]
self.initial_zoom_set = False # Track if we've set initial fit-to-screen zoom 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): def initializeGL(self):
"""Initialize OpenGL resources""" """Initialize OpenGL resources"""
glClearColor(1.0, 1.0, 1.0, 1.0) glClearColor(1.0, 1.0, 1.0, 1.0)
@@ -33,8 +36,34 @@ class ViewportMixin:
glLoadIdentity() glLoadIdentity()
glOrtho(0, w, h, 0, -1, 1) glOrtho(0, w, h, 0, -1, 1)
glMatrixMode(GL_MODELVIEW) 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() 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): def _calculate_fit_to_screen_zoom(self):
""" """
Calculate zoom level to fit first page to screen. Calculate zoom level to fit first page to screen.
@@ -43,7 +72,7 @@ class ViewportMixin:
float: Zoom level (1.0 = 100%, 0.5 = 50%, etc.) float: Zoom level (1.0 = 100%, 0.5 = 50%, etc.)
""" """
main_window = self.window() main_window = self.window()
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages: if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return 1.0 return 1.0
window_width = self.width() window_width = self.width()
@@ -65,3 +94,196 @@ class ViewportMixin:
# Use the smaller zoom to ensure entire page fits # Use the smaller zoom to ensure entire page fits
return min(zoom_w, zoom_h, 1.0) # Don't zoom in beyond 100% 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: float = self.pan_offset[1]
if content_height > viewport_height:
max_pan_up = 0 # Can't pan beyond top edge
min_pan_up = -(content_height - viewport_height) # Can't pan beyond bottom edge
self.pan_offset[1] = max(min_pan_up, min(max_pan_up, self.pan_offset[1]))
return original_pan_y
def _build_page_centerlines(self, pages, dpi: float) -> list:
"""Build list of (center_y, center_x, width) tuples for each page."""
PAGE_MARGIN = 50
PAGE_SPACING = 50
centerlines = []
current_y = PAGE_MARGIN
for page in pages:
page_width_mm, page_height_mm = page.layout.size
screen_page_width = page_width_mm * dpi / 25.4 * self.zoom_level
screen_page_height = page_height_mm * dpi / 25.4 * self.zoom_level
page_center_y = current_y + screen_page_height / 2
page_center_x = PAGE_MARGIN + screen_page_width / 2
centerlines.append((page_center_y, page_center_x, screen_page_width))
current_y += screen_page_height + PAGE_SPACING
return centerlines
def _interpolate_target_centerline(self, centerlines: list, viewport_center_y: float) -> tuple:
"""Find target centerline by interpolating between pages based on viewport position."""
if not centerlines:
return 0, 0
# Find the page index we're at or past
page_idx = self._find_page_at_viewport_y(centerlines, viewport_center_y)
if page_idx == 0:
return centerlines[0][1], centerlines[0][2]
# Interpolate between previous and current page
prev_y, prev_x, prev_w = centerlines[page_idx - 1]
curr_y, curr_x, curr_w = centerlines[page_idx]
if curr_y == prev_y:
return curr_x, curr_w
t = max(0, min(1, (viewport_center_y - prev_y) / (curr_y - prev_y)))
return prev_x + t * (curr_x - prev_x), prev_w + t * (curr_w - prev_w)
def _find_page_at_viewport_y(self, centerlines: list, viewport_center_y: float) -> int:
"""Find index of page at or after viewport Y position."""
for i, (page_y, _, _) in enumerate(centerlines):
if viewport_center_y <= page_y:
return i
return len(centerlines) - 1 # Below all pages - use last
def _clamp_horizontal_pan(self, viewport_width: float, target_centerline_x: float, target_page_width: float):
"""Clamp horizontal pan to keep viewport centered on target page."""
ideal_pan_x = viewport_width / 2 - target_centerline_x
if target_page_width > viewport_width:
max_deviation = (target_page_width / 2) + (viewport_width / 4)
else:
max_deviation = 100 # Small margin to avoid jitter
min_pan_x = ideal_pan_x - max_deviation
max_pan_x = ideal_pan_x + max_deviation
self.pan_offset[0] = max(min_pan_x, min(max_pan_x, self.pan_offset[0]))
def clamp_pan_offset(self):
"""
Clamp pan offset to prevent scrolling beyond content bounds.
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)
+668 -218
View File
File diff suppressed because it is too large Load Diff
+108 -63
View File
@@ -2,9 +2,32 @@
Page layout and template system for pyPhotoAlbum Page layout and template system for pyPhotoAlbum
""" """
from typing import List, Dict, Any, Optional, Tuple from typing import List, Dict, Any, Optional, Tuple, TYPE_CHECKING
if TYPE_CHECKING:
from PyQt6.QtWidgets import QWidget
from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData
from pyPhotoAlbum.snapping import SnappingSystem 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 PageLayout:
"""Class to manage page layout and templates""" """Class to manage page layout and templates"""
@@ -12,7 +35,7 @@ class PageLayout:
def __init__(self, width: float = 210, height: float = 297, is_facing_page: bool = False): def __init__(self, width: float = 210, height: float = 297, is_facing_page: bool = False):
""" """
Initialize page layout. Initialize page layout.
Args: Args:
width: Width in mm (doubled automatically if is_facing_page=True) width: Width in mm (doubled automatically if is_facing_page=True)
height: Height in mm height: Height in mm
@@ -26,42 +49,43 @@ class PageLayout:
self.background_color = (1.0, 1.0, 1.0) # White background self.background_color = (1.0, 1.0, 1.0) # White background
self.snapping_system = SnappingSystem() self.snapping_system = SnappingSystem()
self.show_snap_lines = True # Show snap lines while dragging 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): def add_element(self, element: BaseLayoutElement):
"""Add a layout element to the page""" """Add a layout element to the page"""
self.elements.append(element) if element not in self.elements:
self.elements.append(element)
def remove_element(self, element: BaseLayoutElement): def remove_element(self, element: BaseLayoutElement):
"""Remove a layout element from the page""" """Remove a layout element from the page"""
self.elements.remove(element) self.elements.remove(element)
def set_grid_layout(self, grid: 'GridLayout'): def set_grid_layout(self, grid: "GridLayout"):
"""Set a grid layout for the page""" """Set a grid layout for the page"""
self.grid_layout = grid self.grid_layout = grid
def render(self, dpi: int = 300): def render(self, dpi: int = 300, project=None):
""" """
Render all elements on the page in page-local coordinates. Render all elements on the page in page-local coordinates.
Note: This method assumes OpenGL transformations have already been set up Note: This method assumes OpenGL transformations have already been set up
by PageRenderer.begin_render(). All coordinates here are in page-local space. by PageRenderer.begin_render(). All coordinates here are in page-local space.
Args: Args:
dpi: Working DPI for converting mm to pixels dpi: Working DPI for converting mm to pixels
project: Optional project instance for global snapping settings
""" """
from OpenGL.GL import glBegin, glEnd, glVertex2f, glColor3f, GL_QUADS, GL_LINE_LOOP, GL_LINES, glLineWidth, glDisable, glEnable, GL_DEPTH_TEST
# Disable depth testing for 2D rendering # Disable depth testing for 2D rendering
glDisable(GL_DEPTH_TEST) glDisable(GL_DEPTH_TEST)
# Convert size from mm to pixels based on DPI # Convert size from mm to pixels based on DPI
width_px = self.size[0] * dpi / 25.4 width_px = self.size[0] * dpi / 25.4
height_px = self.size[1] * dpi / 25.4 height_px = self.size[1] * dpi / 25.4
# All rendering is at page origin (0, 0) in page-local coordinates # All rendering is at page origin (0, 0) in page-local coordinates
page_x = 0 page_x = 0
page_y = 0 page_y = 0
# Draw drop shadow FIRST (behind everything) # Draw drop shadow FIRST (behind everything)
shadow_offset = 5 shadow_offset = 5
glColor3f(0.5, 0.5, 0.5) glColor3f(0.5, 0.5, 0.5)
@@ -71,14 +95,14 @@ class PageLayout:
glVertex2f(page_x + width_px + shadow_offset, page_y + height_px + shadow_offset) glVertex2f(page_x + width_px + shadow_offset, page_y + height_px + shadow_offset)
glVertex2f(page_x + shadow_offset, page_y + height_px + shadow_offset) glVertex2f(page_x + shadow_offset, page_y + height_px + shadow_offset)
glEnd() glEnd()
glBegin(GL_QUADS) glBegin(GL_QUADS)
glVertex2f(page_x + width_px, page_y + shadow_offset) 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 + shadow_offset)
glVertex2f(page_x + width_px + shadow_offset, page_y + height_px) glVertex2f(page_x + width_px + shadow_offset, page_y + height_px)
glVertex2f(page_x + width_px, page_y + height_px) glVertex2f(page_x + width_px, page_y + height_px)
glEnd() glEnd()
# Draw page background (slightly off-white to distinguish from canvas) # Draw page background (slightly off-white to distinguish from canvas)
glColor3f(0.98, 0.98, 0.98) glColor3f(0.98, 0.98, 0.98)
glBegin(GL_QUADS) glBegin(GL_QUADS)
@@ -92,26 +116,27 @@ class PageLayout:
# For ImageData elements, request async loading if available # For ImageData elements, request async loading if available
for element in self.elements: for element in self.elements:
# Check if this is an ImageData element that needs async loading # Check if this is an ImageData element that needs async loading
if isinstance(element, ImageData) and not hasattr(element, '_texture_id'): if isinstance(element, ImageData) and not hasattr(element, "_texture_id"):
# Try to get async loader from a parent widget # Try to get async loader from a parent widget
if hasattr(self, '_async_loader'): if hasattr(self, "_async_loader"):
loader = self._async_loader loader = self._async_loader
elif hasattr(self, '_parent_widget') and hasattr(self._parent_widget, 'async_image_loader'): elif hasattr(self, "_parent_widget") and hasattr(self._parent_widget, "async_image_loader"):
loader = self._parent_widget.async_image_loader loader = self._parent_widget.async_image_loader # type: ignore[union-attr]
else: else:
loader = None loader = None
# Request async load if loader is available and not already requested # Request async load if loader is available and not already requested
if loader and not element._async_load_requested: if loader and not element._async_load_requested:
from pyPhotoAlbum.async_backend import LoadPriority from pyPhotoAlbum.async_backend import LoadPriority
# Determine priority based on visibility (HIGH for now, can be refined) # Determine priority based on visibility (HIGH for now, can be refined)
if hasattr(self._parent_widget, 'request_image_load'): if hasattr(self._parent_widget, "request_image_load"):
self._parent_widget.request_image_load(element, priority=LoadPriority.HIGH) self._parent_widget.request_image_load(element, priority=LoadPriority.HIGH) # type: ignore[union-attr]
element._async_load_requested = True element._async_load_requested = True
element._async_loading = True element._async_loading = True
element.render() element.render()
# Draw page border LAST (on top of everything) # Draw page border LAST (on top of everything)
glColor3f(0.7, 0.7, 0.7) glColor3f(0.7, 0.7, 0.7)
glLineWidth(2.0) glLineWidth(2.0)
@@ -122,7 +147,7 @@ class PageLayout:
glVertex2f(page_x, page_y + height_px) glVertex2f(page_x, page_y + height_px)
glEnd() glEnd()
glLineWidth(1.0) glLineWidth(1.0)
# Draw center line for facing pages # Draw center line for facing pages
if self.is_facing_page: if self.is_facing_page:
center_x = page_x + (width_px / 2) center_x = page_x + (width_px / 2)
@@ -133,55 +158,76 @@ class PageLayout:
glVertex2f(center_x, page_y + height_px) glVertex2f(center_x, page_y + height_px)
glEnd() glEnd()
glLineWidth(1.0) glLineWidth(1.0)
# Always render snap lines (grid shows when snap_to_grid is on, guides show when show_snap_lines is on) # 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) self._render_snap_lines(dpi, page_x, page_y, project)
# Re-enable depth testing # Re-enable depth testing
glEnable(GL_DEPTH_TEST) glEnable(GL_DEPTH_TEST)
def _render_snap_lines(self, dpi: int, page_x: float, page_y: float): def _render_snap_lines(self, dpi: int, page_x: float, page_y: float, project=None):
"""Render snap lines (grid, edges, guides)""" """Render snap lines (grid, edges, guides)"""
from OpenGL.GL import (glColor3f, glColor4f, glLineWidth, glBegin, glEnd, # Use project settings if available, otherwise fall back to local snapping_system
glVertex2f, GL_LINES, glEnable, glDisable, GL_BLEND, if project:
glBlendFunc, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) # Use project-level global settings
snap_to_grid = project.snap_to_grid
snap_lines = self.snapping_system.get_snap_lines(self.size, dpi) snap_to_edges = project.snap_to_edges
snap_to_guides = project.snap_to_guides
# Enable alpha blending for transparency grid_size_mm = project.grid_size_mm
glEnable(GL_BLEND) snap_threshold_mm = project.snap_threshold_mm
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) show_grid = project.show_grid
show_snap_lines = project.show_snap_lines
# Draw grid lines (darker gray with transparency) - always visible when snap_to_grid is enabled else:
if self.snapping_system.snap_to_grid and snap_lines['grid']: # Fall back to per-page settings (backward compatibility)
glColor4f(0.6, 0.6, 0.6, 0.4) # Gray with 40% opacity 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) glLineWidth(1.0)
for orientation, position in snap_lines['grid']: for orientation, position in snap_lines["grid"]:
glBegin(GL_LINES) glBegin(GL_LINES)
if orientation == 'vertical': if orientation == "vertical":
glVertex2f(page_x + position, page_y) glVertex2f(page_x + position, page_y)
glVertex2f(page_x + position, page_y + self.size[1] * dpi / 25.4) glVertex2f(page_x + position, page_y + self.size[1] * dpi / 25.4)
else: # horizontal else: # horizontal
glVertex2f(page_x, page_y + position) glVertex2f(page_x, page_y + position)
glVertex2f(page_x + self.size[0] * dpi / 25.4, page_y + position) glVertex2f(page_x + self.size[0] * dpi / 25.4, page_y + position)
glEnd() glEnd()
# Draw guides (cyan, more visible with transparency) - only show when show_snap_lines is on # Draw guides (cyan, fully opaque) - only show when show_snap_lines is on
if self.show_snap_lines and snap_lines['guides']: if show_snap_lines and snap_lines["guides"]:
glColor4f(0.0, 0.7, 0.9, 0.8) # Cyan with 80% opacity glColor3f(0.0, 0.7, 0.9) # Cyan, fully opaque
glLineWidth(1.5) glLineWidth(1.5)
for orientation, position in snap_lines['guides']: for orientation, position in snap_lines["guides"]:
glBegin(GL_LINES) glBegin(GL_LINES)
if orientation == 'vertical': if orientation == "vertical":
glVertex2f(page_x + position, page_y) glVertex2f(page_x + position, page_y)
glVertex2f(page_x + position, page_y + self.size[1] * dpi / 25.4) glVertex2f(page_x + position, page_y + self.size[1] * dpi / 25.4)
else: # horizontal else: # horizontal
glVertex2f(page_x, page_y + position) glVertex2f(page_x, page_y + position)
glVertex2f(page_x + self.size[0] * dpi / 25.4, page_y + position) glVertex2f(page_x + self.size[0] * dpi / 25.4, page_y + position)
glEnd() glEnd()
glLineWidth(1.0) glLineWidth(1.0)
glDisable(GL_BLEND)
def serialize(self) -> Dict[str, Any]: def serialize(self) -> Dict[str, Any]:
"""Serialize page layout to dictionary""" """Serialize page layout to dictionary"""
@@ -193,7 +239,7 @@ class PageLayout:
"elements": [elem.serialize() for elem in self.elements], "elements": [elem.serialize() for elem in self.elements],
"grid_layout": self.grid_layout.serialize() if self.grid_layout else None, "grid_layout": self.grid_layout.serialize() if self.grid_layout else None,
"snapping_system": self.snapping_system.serialize(), "snapping_system": self.snapping_system.serialize(),
"show_snap_lines": self.show_snap_lines "show_snap_lines": self.show_snap_lines,
} }
def deserialize(self, data: Dict[str, Any]): def deserialize(self, data: Dict[str, Any]):
@@ -206,9 +252,10 @@ class PageLayout:
# Deserialize elements and sort by z_index to establish list order # Deserialize elements and sort by z_index to establish list order
# This ensures backward compatibility with projects that used z_index # This ensures backward compatibility with projects that used z_index
elem_list = [] elem_list: List[BaseLayoutElement] = []
for elem_data in data.get("elements", []): for elem_data in data.get("elements", []):
elem_type = elem_data.get("type") elem_type = elem_data.get("type")
elem: BaseLayoutElement
if elem_type == "image": if elem_type == "image":
elem = ImageData() elem = ImageData()
elif elem_type == "placeholder": elif elem_type == "placeholder":
@@ -220,7 +267,7 @@ class PageLayout:
elem.deserialize(elem_data) elem.deserialize(elem_data)
elem_list.append(elem) elem_list.append(elem)
# Sort by z_index to establish proper list order (lower z_index = earlier in list = behind) # 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) elem_list.sort(key=lambda e: e.z_index)
self.elements = elem_list self.elements = elem_list
@@ -230,14 +277,15 @@ class PageLayout:
if grid_data: if grid_data:
self.grid_layout = GridLayout() self.grid_layout = GridLayout()
self.grid_layout.deserialize(grid_data) self.grid_layout.deserialize(grid_data)
# Deserialize snapping system # Deserialize snapping system
snap_data = data.get("snapping_system") snap_data = data.get("snapping_system")
if snap_data: if snap_data:
self.snapping_system.deserialize(snap_data) self.snapping_system.deserialize(snap_data)
self.show_snap_lines = data.get("show_snap_lines", True) self.show_snap_lines = data.get("show_snap_lines", True)
class GridLayout: class GridLayout:
"""Class to manage grid layouts""" """Class to manage grid layouts"""
@@ -251,7 +299,9 @@ class GridLayout:
"""Merge cells in the grid""" """Merge cells in the grid"""
self.merged_cells.append((row, col)) self.merged_cells.append((row, col))
def get_cell_position(self, row: int, col: int, page_width: float = 800, page_height: float = 600) -> Tuple[float, float]: def get_cell_position(
self, row: int, col: int, page_width: float = 800, page_height: float = 600
) -> Tuple[float, float]:
"""Get the position of a grid cell""" """Get the position of a grid cell"""
cell_width = (page_width - (self.spacing * (self.columns + 1))) / self.columns cell_width = (page_width - (self.spacing * (self.columns + 1))) / self.columns
cell_height = (page_height - (self.spacing * (self.rows + 1))) / self.rows cell_height = (page_height - (self.spacing * (self.rows + 1))) / self.rows
@@ -270,12 +320,7 @@ class GridLayout:
def serialize(self) -> Dict[str, Any]: def serialize(self) -> Dict[str, Any]:
"""Serialize grid layout to dictionary""" """Serialize grid layout to dictionary"""
return { return {"rows": self.rows, "columns": self.columns, "spacing": self.spacing, "merged_cells": self.merged_cells}
"rows": self.rows,
"columns": self.columns,
"spacing": self.spacing,
"merged_cells": self.merged_cells
}
def deserialize(self, data: Dict[str, Any]): def deserialize(self, data: Dict[str, Any]):
"""Deserialize from dictionary""" """Deserialize from dictionary"""
+35 -37
View File
@@ -11,27 +11,23 @@ Coordinate Systems:
""" """
from typing import Tuple, Optional from typing import Tuple, Optional
from OpenGL.GL import glPushMatrix, glPopMatrix, glScalef, glTranslatef from pyPhotoAlbum.gl_imports import glPushMatrix, glPopMatrix, glScalef, glTranslatef
class PageRenderer: class PageRenderer:
""" """
Handles rendering and coordinate transformations for a single page. Handles rendering and coordinate transformations for a single page.
This class encapsulates all coordinate transformations needed to render This class encapsulates all coordinate transformations needed to render
a page and its elements consistently. a page and its elements consistently.
""" """
def __init__(self, def __init__(
page_width_mm: float, self, page_width_mm: float, page_height_mm: float, screen_x: float, screen_y: float, dpi: int, zoom: float
page_height_mm: float, ):
screen_x: float,
screen_y: float,
dpi: int,
zoom: float):
""" """
Initialize a page renderer. Initialize a page renderer.
Args: Args:
page_width_mm: Page width in millimeters page_width_mm: Page width in millimeters
page_height_mm: Page height in millimeters page_height_mm: Page height in millimeters
@@ -46,81 +42,83 @@ class PageRenderer:
self.screen_y = screen_y self.screen_y = screen_y
self.dpi = dpi self.dpi = dpi
self.zoom = zoom self.zoom = zoom
# Calculate page dimensions in pixels # Calculate page dimensions in pixels
self.page_width_px = page_width_mm * dpi / 25.4 self.page_width_px = page_width_mm * dpi / 25.4
self.page_height_px = page_height_mm * dpi / 25.4 self.page_height_px = page_height_mm * dpi / 25.4
# Calculate screen dimensions (with zoom applied) # Calculate screen dimensions (with zoom applied)
self.screen_width = self.page_width_px * zoom self.screen_width = self.page_width_px * zoom
self.screen_height = self.page_height_px * zoom self.screen_height = self.page_height_px * zoom
def page_to_screen(self, page_x: float, page_y: float) -> Tuple[float, float]: def page_to_screen(self, page_x: float, page_y: float) -> Tuple[float, float]:
""" """
Convert page-local coordinates (in pixels) to screen coordinates. Convert page-local coordinates (in pixels) to screen coordinates.
Args: Args:
page_x: X coordinate in page-local space (pixels) page_x: X coordinate in page-local space (pixels)
page_y: Y coordinate in page-local space (pixels) page_y: Y coordinate in page-local space (pixels)
Returns: Returns:
Tuple of (screen_x, screen_y) Tuple of (screen_x, screen_y)
""" """
screen_x = self.screen_x + page_x * self.zoom screen_x = self.screen_x + page_x * self.zoom
screen_y = self.screen_y + page_y * self.zoom screen_y = self.screen_y + page_y * self.zoom
return (screen_x, screen_y) return (screen_x, screen_y)
def screen_to_page(self, screen_x: float, screen_y: float) -> Tuple[float, float]: def screen_to_page(self, screen_x: float, screen_y: float) -> Tuple[float, float]:
""" """
Convert screen coordinates to page-local coordinates (in pixels). Convert screen coordinates to page-local coordinates (in pixels).
Args: Args:
screen_x: X coordinate in screen space screen_x: X coordinate in screen space
screen_y: Y coordinate in screen space screen_y: Y coordinate in screen space
Returns: Returns:
Tuple of (page_x, page_y) in pixels, or None if outside page bounds Tuple of (page_x, page_y) in pixels, or None if outside page bounds
""" """
page_x = (screen_x - self.screen_x) / self.zoom page_x = (screen_x - self.screen_x) / self.zoom
page_y = (screen_y - self.screen_y) / self.zoom page_y = (screen_y - self.screen_y) / self.zoom
return (page_x, page_y) return (page_x, page_y)
def is_point_in_page(self, screen_x: float, screen_y: float) -> bool: def is_point_in_page(self, screen_x: float, screen_y: float) -> bool:
""" """
Check if a screen coordinate is within the page bounds. Check if a screen coordinate is within the page bounds.
Args: Args:
screen_x: X coordinate in screen space screen_x: X coordinate in screen space
screen_y: Y coordinate in screen space screen_y: Y coordinate in screen space
Returns: Returns:
True if the point is within the page bounds True if the point is within the page bounds
""" """
return (self.screen_x <= screen_x <= self.screen_x + self.screen_width and return (
self.screen_y <= screen_y <= self.screen_y + self.screen_height) 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]: 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. For facing page spreads, determine if mouse is on left or right page.
Args: Args:
screen_x: X coordinate in screen space screen_x: X coordinate in screen space
is_facing_page: Whether this is a facing page spread is_facing_page: Whether this is a facing page spread
Returns: Returns:
'left' or 'right' for facing pages, None for single pages 'left' or 'right' for facing pages, None for single pages
""" """
if not is_facing_page: if not is_facing_page:
return None return None
# Calculate the center line of the spread # Calculate the center line of the spread
center_x = self.screen_x + self.screen_width / 2 center_x = self.screen_x + self.screen_width / 2
if screen_x < center_x: if screen_x < center_x:
return 'left' return "left"
else: else:
return 'right' return "right"
def begin_render(self): def begin_render(self):
""" """
Set up OpenGL transformations for rendering this page. Set up OpenGL transformations for rendering this page.
@@ -131,27 +129,27 @@ class PageRenderer:
glScalef(self.zoom, self.zoom, 1.0) glScalef(self.zoom, self.zoom, 1.0)
# Translate to page position (in zoomed coordinates) # Translate to page position (in zoomed coordinates)
glTranslatef(self.screen_x / self.zoom, self.screen_y / self.zoom, 0.0) glTranslatef(self.screen_x / self.zoom, self.screen_y / self.zoom, 0.0)
def end_render(self): def end_render(self):
""" """
Clean up OpenGL transformations after rendering this page. Clean up OpenGL transformations after rendering this page.
Call this after rendering page content. Call this after rendering page content.
""" """
glPopMatrix() glPopMatrix()
def get_page_bounds_screen(self) -> Tuple[float, float, float, float]: def get_page_bounds_screen(self) -> Tuple[float, float, float, float]:
""" """
Get the page bounds in screen coordinates. Get the page bounds in screen coordinates.
Returns: Returns:
Tuple of (x, y, width, height) in screen space Tuple of (x, y, width, height) in screen space
""" """
return (self.screen_x, self.screen_y, self.screen_width, self.screen_height) return (self.screen_x, self.screen_y, self.screen_width, self.screen_height)
def get_page_bounds_page(self) -> Tuple[float, float, float, float]: def get_page_bounds_page(self) -> Tuple[float, float, float, float]:
""" """
Get the page bounds in page-local coordinates. Get the page bounds in page-local coordinates.
Returns: Returns:
Tuple of (x, y, width, height) in page-local space (pixels) Tuple of (x, y, width, height) in page-local space (pixels)
""" """
File diff suppressed because it is too large Load Diff
+192 -69
View File
@@ -4,18 +4,22 @@ Project and page management for pyPhotoAlbum
import os import os
import math import math
from typing import List, Dict, Any, Optional, Tuple 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.page_layout import PageLayout
from pyPhotoAlbum.commands import CommandHistory from pyPhotoAlbum.commands import CommandHistory
from pyPhotoAlbum.asset_manager import AssetManager from pyPhotoAlbum.asset_manager import AssetManager
class Page: class Page:
"""Class representing a single page in the photo album""" """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): def __init__(self, layout: Optional[PageLayout] = None, page_number: int = 1, is_double_spread: bool = False):
""" """
Initialize a page. Initialize a page.
Args: Args:
layout: PageLayout instance (created automatically if None) layout: PageLayout instance (created automatically if None)
page_number: The page number (for spreads, this is the left page number) page_number: The page number (for spreads, this is the left page number)
@@ -25,7 +29,19 @@ class Page:
self.is_cover = False self.is_cover = False
self.is_double_spread = is_double_spread self.is_double_spread = is_double_spread
self.manually_sized = False # Track if user manually changed page size 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 # Create layout with appropriate width
if layout is None: if layout is None:
self.layout = PageLayout(is_facing_page=is_double_spread) self.layout = PageLayout(is_facing_page=is_double_spread)
@@ -37,21 +53,21 @@ class Page:
self.layout.is_facing_page = is_double_spread self.layout.is_facing_page = is_double_spread
height = self.layout.size[1] height = self.layout.size[1]
# Use the base_width if available, otherwise derive it # Use the base_width if available, otherwise derive it
if hasattr(self.layout, 'base_width'): if hasattr(self.layout, "base_width"):
base_width = self.layout.base_width base_width = self.layout.base_width
else: else:
# If base_width not set, assume current width is correct # If base_width not set, assume current width is correct
# and derive base_width from current state # and derive base_width from current state
base_width = self.layout.size[0] / 2 if not is_double_spread else self.layout.size[0] base_width = self.layout.size[0] / 2 if not is_double_spread else self.layout.size[0]
self.layout.base_width = base_width self.layout.base_width = base_width
# Set the new width based on facing page state # Set the new width based on facing page state
self.layout.size = (base_width * 2 if is_double_spread else base_width, height) self.layout.size = (base_width * 2 if is_double_spread else base_width, height)
def get_page_numbers(self) -> List[int]: def get_page_numbers(self) -> List[int]:
""" """
Get the page numbers this page represents. Get the page numbers this page represents.
Returns: Returns:
List of page numbers (2 numbers for spreads, 1 for single pages) List of page numbers (2 numbers for spreads, 1 for single pages)
""" """
@@ -59,16 +75,26 @@ class Page:
return [self.page_number, self.page_number + 1] return [self.page_number, self.page_number + 1]
else: else:
return [self.page_number] return [self.page_number]
def get_page_count(self) -> int: def get_page_count(self) -> int:
""" """
Get the number of physical pages this represents. Get the number of physical pages this represents.
Returns: Returns:
2 for spreads, 1 for single pages 2 for spreads, 1 for single pages
""" """
return 2 if self.is_double_spread else 1 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): def render(self):
"""Render the entire page""" """Render the entire page"""
print(f"Rendering page {self.page_number}") print(f"Rendering page {self.page_number}")
@@ -81,7 +107,13 @@ class Page:
"is_cover": self.is_cover, "is_cover": self.is_cover,
"is_double_spread": self.is_double_spread, "is_double_spread": self.is_double_spread,
"manually_sized": self.manually_sized, "manually_sized": self.manually_sized,
"layout": self.layout.serialize() "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]): def deserialize(self, data: Dict[str, Any]):
@@ -91,10 +123,19 @@ class Page:
self.is_double_spread = data.get("is_double_spread", False) self.is_double_spread = data.get("is_double_spread", False)
self.manually_sized = data.get("manually_sized", 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", {}) layout_data = data.get("layout", {})
self.layout = PageLayout() self.layout = PageLayout()
self.layout.deserialize(layout_data) self.layout.deserialize(layout_data)
class Project: class Project:
"""Class representing the entire photo album project""" """Class representing the entire photo album project"""
@@ -105,37 +146,90 @@ class Project:
self.default_min_distance = 10.0 # Default minimum distance between images self.default_min_distance = 10.0 # Default minimum distance between images
self.cover_size = (800, 600) # Default cover size in pixels self.cover_size = (800, 600) # Default cover size in pixels
self.page_size = (800, 600) # Default page 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) self.page_size_mm = (140, 140) # Default page size in mm (14cm x 14cm square)
self.working_dpi = 300 # Default working DPI self.working_dpi = 300 # Default working DPI
self.export_dpi = 300 # Default export DPI self.export_dpi = 300 # Default export DPI
self.page_spacing_mm = 10.0 # Default spacing between pages (1cm) 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 # Cover configuration
self.has_cover = False # Whether project has a cover self.has_cover = False # Whether project has a cover
self.paper_thickness_mm = 0.2 # Paper thickness for spine calculation (default 0.2mm) 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.cover_bleed_mm = 0.0 # Bleed margin for cover (default 0mm)
self.binding_type = "saddle_stitch" # Binding type for spine calculation self.binding_type = "saddle_stitch" # Binding type for spine calculation
# Print guide configuration
self.page_bleed_mm = 0.0 # Bleed margin for interior pages (default 0mm, e.g. 3mm for printing)
self.page_safe_area_mm = 5.0 # Safe area margin inside cut line (default 5mm)
self.show_print_guides = False # Show bleed/cut/safe-area guides in the editor
# Embedded templates - templates that travel with the project # Embedded templates - templates that travel with the project
self.embedded_templates: Dict[str, Dict[str, Any]] = {} self.embedded_templates: Dict[str, Dict[str, Any]] = {}
# Temporary directory management (if loaded from .ppz) # Temporary directory management (if loaded from .ppz)
# Using TemporaryDirectory instance that auto-cleans on deletion # Using TemporaryDirectory instance that auto-cleans on deletion
self._temp_dir = None self._temp_dir: Optional[TemporaryDirectory[str]] = None
# Global snapping settings (apply to all pages)
self.snap_to_grid = False
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 # Initialize asset manager
self.asset_manager = AssetManager(self.folder_path) self.asset_manager = AssetManager(self.folder_path)
# Initialize command history with asset manager # Initialize command history with asset manager and project reference
self.history = CommandHistory(max_history=100) self.history = CommandHistory(max_history=100, asset_manager=self.asset_manager, project=self)
self.history.asset_manager = self.asset_manager
def add_page(self, page: Page): # Track unsaved changes
"""Add a page to the project""" self._dirty = False
self.pages.append(page) 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 # Update cover dimensions if we have a cover
if self.has_cover and self.pages: if self.has_cover and self.pages:
self.update_cover_dimensions() self.update_cover_dimensions()
self.mark_dirty()
def remove_page(self, page: Page): def remove_page(self, page: Page):
"""Remove a page from the project""" """Remove a page from the project"""
@@ -143,37 +237,34 @@ class Project:
# Update cover dimensions if we have a cover # Update cover dimensions if we have a cover
if self.has_cover and self.pages: if self.has_cover and self.pages:
self.update_cover_dimensions() self.update_cover_dimensions()
self.mark_dirty()
def calculate_spine_width(self) -> float: def calculate_spine_width(self) -> float:
""" """
Calculate spine width based on page count and paper thickness. Calculate spine width based on page count and paper thickness.
For saddle stitch binding: For saddle stitch binding:
- Each sheet = 4 pages (2 pages per side when folded) - Each sheet = 4 pages (2 pages per side when folded)
- Spine width = (Number of sheets × Paper thickness × 2) - Spine width = (Number of sheets × Paper thickness × 2)
Returns: Returns:
Spine width in mm Spine width in mm
""" """
if not self.has_cover: if not self.has_cover:
return 0.0 return 0.0
# Count content pages (excluding cover) # Count content pages (excluding cover)
content_page_count = sum( content_page_count = sum(page.get_page_count() for page in self.pages if not page.is_cover)
page.get_page_count()
for page in self.pages
if not page.is_cover
)
if self.binding_type == "saddle_stitch": if self.binding_type == "saddle_stitch":
# Calculate number of sheets (each sheet = 4 pages) # Calculate number of sheets (each sheet = 4 pages)
sheets = math.ceil(content_page_count / 4) sheets = math.ceil(content_page_count / 4)
# Spine width = sheets × paper thickness × 2 (folded) # Spine width = sheets × paper thickness × 2 (folded)
spine_width = sheets * self.paper_thickness_mm * 2 spine_width = sheets * self.paper_thickness_mm * 2
return spine_width return spine_width
return 0.0 return 0.0
def update_cover_dimensions(self): def update_cover_dimensions(self):
""" """
Update cover page dimensions based on current page count and settings. Update cover page dimensions based on current page count and settings.
@@ -181,66 +272,68 @@ class Project:
""" """
if not self.has_cover or not self.pages: if not self.has_cover or not self.pages:
return return
# Find cover page (should be first page) # Find cover page (should be first page)
cover_page = None cover_page = None
for page in self.pages: for page in self.pages:
if page.is_cover: if page.is_cover:
cover_page = page cover_page = page
break break
if not cover_page: if not cover_page:
return return
# Get standard page dimensions # Get standard page dimensions
page_width_mm, page_height_mm = self.page_size_mm page_width_mm, page_height_mm = self.page_size_mm
# Calculate spine width # Calculate spine width
spine_width = self.calculate_spine_width() spine_width = self.calculate_spine_width()
# Calculate cover dimensions # Calculate cover dimensions
# Cover = Front + Spine + Back + Bleed on all sides # Cover = Front + Spine + Back + Bleed on all sides
cover_width = (page_width_mm * 2) + spine_width + (self.cover_bleed_mm * 2) cover_width = (page_width_mm * 2) + spine_width + (self.cover_bleed_mm * 2)
cover_height = page_height_mm + (self.cover_bleed_mm * 2) cover_height = page_height_mm + (self.cover_bleed_mm * 2)
# Update cover page layout # Update cover page layout
cover_page.layout.size = (cover_width, cover_height) cover_page.layout.size = (cover_width, cover_height)
cover_page.layout.base_width = page_width_mm # Store base width for reference cover_page.layout.base_width = page_width_mm # Store base width for reference
cover_page.manually_sized = True # Mark as manually sized cover_page.manually_sized = True # Mark as manually sized
print(f"Cover dimensions updated: {cover_width:.1f} × {cover_height:.1f} mm " print(
f"(Front: {page_width_mm}, Spine: {spine_width:.2f}, Back: {page_width_mm}, " f"Cover dimensions updated: {cover_width:.1f} × {cover_height:.1f} mm "
f"Bleed: {self.cover_bleed_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: def get_page_display_name(self, page: Page) -> str:
""" """
Get display name for a page. Get display name for a page.
Args: Args:
page: The page to get the display name for page: The page to get the display name for
Returns: Returns:
Display name like "Cover", "Page 1", "Pages 1-2", etc. Display name like "Cover", "Page 1", "Pages 1-2", etc.
""" """
if page.is_cover: if page.is_cover:
return "Cover" return "Cover"
# Calculate adjusted page number (excluding cover from count) # Calculate adjusted page number (excluding cover from count)
adjusted_num = page.page_number adjusted_num = page.page_number
if self.has_cover: if self.has_cover:
# Subtract 1 to account for cover # Subtract 1 to account for cover
adjusted_num = page.page_number - 1 adjusted_num = page.page_number - 1
if page.is_double_spread: if page.is_double_spread:
return f"Pages {adjusted_num}-{adjusted_num + 1}" return f"Pages {adjusted_num}-{adjusted_num + 1}"
else: else:
return f"Page {adjusted_num}" return f"Page {adjusted_num}"
def calculate_page_layout_with_ghosts(self) -> List[Tuple[str, Any, int]]: def calculate_page_layout_with_ghosts(self) -> List[Tuple[str, Optional["Page"], int]]:
""" """
Calculate page layout including ghost pages for alignment. Calculate page layout including ghost pages for alignment.
Excludes cover from spread calculations. Excludes cover from spread calculations.
Returns: Returns:
List of tuples (page_type, page_or_ghost, logical_position) List of tuples (page_type, page_or_ghost, logical_position)
where page_type is 'page' or 'ghost', where page_type is 'page' or 'ghost',
@@ -248,10 +341,10 @@ class Project:
logical_position is the position in the album (1=right, 2=left, etc.) logical_position is the position in the album (1=right, 2=left, etc.)
""" """
from pyPhotoAlbum.models import GhostPageData from pyPhotoAlbum.models import GhostPageData
layout = [] layout: list[tuple[str, Optional["Page"], int]] = []
current_position = 1 # Start at position 1 (right page) current_position = 1 # Start at position 1 (right page)
for page in self.pages: for page in self.pages:
# Skip cover in spread calculations # Skip cover in spread calculations
if page.is_cover: if page.is_cover:
@@ -267,32 +360,32 @@ class Project:
elif not page.is_double_spread and current_position % 2 == 1: elif not page.is_double_spread and current_position % 2 == 1:
# Current position is odd (right page), single page is fine # Current position is odd (right page), single page is fine
pass pass
# Actually, let me reconsider the logic: # Actually, let me reconsider the logic:
# In a photobook: # In a photobook:
# - Position 1 is the right page (when opened, first content page) # - Position 1 is the right page (when opened, first content page)
# - Position 2 is the left page of the next spread # - Position 2 is the left page of the next spread
# - Position 3 is the right page of the next spread # - Position 3 is the right page of the next spread
# - etc. # - etc.
# #
# Double spreads occupy TWO positions (both left and right of a spread) # 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 # 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 # Check if this is a double spread starting at an odd position
if page.is_double_spread and current_position % 2 == 1: if page.is_double_spread and current_position % 2 == 1:
# Need to insert a ghost page to push the double spread to next position # Need to insert a ghost page to push the double spread to next position
layout.append(('ghost', None, current_position)) layout.append(("ghost", None, current_position))
current_position += 1 current_position += 1
# Add the actual page # Add the actual page
layout.append(('page', page, current_position)) layout.append(("page", page, current_position))
# Update position based on page type # Update position based on page type
if page.is_double_spread: if page.is_double_spread:
current_position += 2 # Double spread takes 2 positions current_position += 2 # Double spread takes 2 positions
else: else:
current_position += 1 # Single page takes 1 position current_position += 1 # Single page takes 1 position
return layout return layout
def render_all_pages(self): def render_all_pages(self):
@@ -316,10 +409,24 @@ class Project:
"paper_thickness_mm": self.paper_thickness_mm, "paper_thickness_mm": self.paper_thickness_mm,
"cover_bleed_mm": self.cover_bleed_mm, "cover_bleed_mm": self.cover_bleed_mm,
"binding_type": self.binding_type, "binding_type": self.binding_type,
"page_bleed_mm": self.page_bleed_mm,
"page_safe_area_mm": self.page_safe_area_mm,
"show_print_guides": self.show_print_guides,
"embedded_templates": self.embedded_templates, "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], "pages": [page.serialize() for page in self.pages],
"history": self.history.serialize(), "history": self.history.serialize(),
"asset_manager": self.asset_manager.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]): def deserialize(self, data: Dict[str, Any]):
@@ -337,10 +444,28 @@ class Project:
self.paper_thickness_mm = data.get("paper_thickness_mm", 0.2) self.paper_thickness_mm = data.get("paper_thickness_mm", 0.2)
self.cover_bleed_mm = data.get("cover_bleed_mm", 0.0) self.cover_bleed_mm = data.get("cover_bleed_mm", 0.0)
self.binding_type = data.get("binding_type", "saddle_stitch") self.binding_type = data.get("binding_type", "saddle_stitch")
self.page_bleed_mm = data.get("page_bleed_mm", 0.0)
self.page_safe_area_mm = data.get("page_safe_area_mm", 5.0)
self.show_print_guides = data.get("show_print_guides", False)
# Deserialize embedded templates # Deserialize embedded templates
self.embedded_templates = data.get("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 = [] self.pages = []
# Deserialize asset manager # Deserialize asset manager
@@ -348,22 +473,20 @@ class Project:
asset_data = data.get("asset_manager") asset_data = data.get("asset_manager")
if asset_data: if asset_data:
self.asset_manager.deserialize(asset_data) self.asset_manager.deserialize(asset_data)
# Deserialize pages # Deserialize pages
for page_data in data.get("pages", []): for page_data in data.get("pages", []):
page = Page() page = Page()
page.deserialize(page_data) page.deserialize(page_data)
self.pages.append(page) self.pages.append(page)
# Deserialize command history with asset manager # Deserialize command history with asset manager and project reference
history_data = data.get("history") history_data = data.get("history")
if history_data: if history_data:
self.history = CommandHistory(max_history=100) self.history = CommandHistory(max_history=100, asset_manager=self.asset_manager, project=self)
self.history.asset_manager = self.asset_manager
self.history.deserialize(history_data, self) self.history.deserialize(history_data, self)
else: else:
self.history = CommandHistory(max_history=100) self.history = CommandHistory(max_history=100, asset_manager=self.asset_manager, project=self)
self.history.asset_manager = self.asset_manager
def cleanup(self): def cleanup(self):
""" """
+271 -109
View File
@@ -7,21 +7,71 @@ import json
import zipfile import zipfile
import shutil import shutil
import tempfile import tempfile
from typing import Optional, Tuple import threading
from typing import Optional, Tuple, Callable
from pathlib import Path from pathlib import Path
from pyPhotoAlbum.project import Project from pyPhotoAlbum.project import Project
from pyPhotoAlbum.version_manager import ( from pyPhotoAlbum.version_manager import (
CURRENT_DATA_VERSION, CURRENT_DATA_VERSION,
check_version_compatibility, check_version_compatibility,
VersionCompatibility, VersionCompatibility,
DataMigration DataMigration,
) )
# Legacy constant for backward compatibility # Legacy constant for backward compatibility
SERIALIZATION_VERSION = CURRENT_DATA_VERSION 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): def _normalize_asset_paths(project: Project, project_folder: str):
""" """
Normalize asset paths in a loaded project to be relative to the project folder. Normalize asset paths in a loaded project to be relative to the project folder.
@@ -41,15 +91,15 @@ def _normalize_asset_paths(project: Project, project_folder: str):
original_path = element.image_path original_path = element.image_path
# Skip if already a simple relative path (assets/...) # Skip if already a simple relative path (assets/...)
if not os.path.isabs(original_path) and not original_path.startswith('./projects/'): if not os.path.isabs(original_path) and not original_path.startswith("./projects/"):
continue continue
# Try to extract just the filename or relative path from assets folder # Try to extract just the filename or relative path from assets folder
# Pattern 1: "./projects/XXX/assets/filename.jpg" -> "assets/filename.jpg" # Pattern 1: "./projects/XXX/assets/filename.jpg" -> "assets/filename.jpg"
if '/assets/' in original_path: if "/assets/" in original_path:
parts = original_path.split('/assets/') parts = original_path.split("/assets/")
if len(parts) == 2: if len(parts) == 2:
new_path = os.path.join('assets', parts[1]) new_path = os.path.join("assets", parts[1])
element.image_path = new_path element.image_path = new_path
normalized_count += 1 normalized_count += 1
print(f"Normalized path: {original_path} -> {new_path}") print(f"Normalized path: {original_path} -> {new_path}")
@@ -73,32 +123,35 @@ def _normalize_asset_paths(project: Project, project_folder: str):
def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]: def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]:
""" """
Save a project to a ZIP file, including all assets. Save a project to a ZIP file, including all assets.
Args: Args:
project: The Project instance to save project: The Project instance to save
zip_path: Path where the ZIP file should be created zip_path: Path where the ZIP file should be created
Returns: Returns:
Tuple of (success: bool, error_message: Optional[str]) Tuple of (success: bool, error_message: Optional[str])
""" """
try: try:
# Ensure .ppz extension # Ensure .ppz extension
if not zip_path.lower().endswith('.ppz'): if not zip_path.lower().endswith(".ppz"):
zip_path += '.ppz' zip_path += ".ppz"
# Check for and import any external images before saving
_import_external_images(project)
# Serialize project to dictionary # Serialize project to dictionary
project_data = project.serialize() project_data = project.serialize()
# Add version information # Add version information
project_data['serialization_version'] = SERIALIZATION_VERSION # Legacy field project_data["serialization_version"] = SERIALIZATION_VERSION # Legacy field
project_data['data_version'] = CURRENT_DATA_VERSION # New versioning system project_data["data_version"] = CURRENT_DATA_VERSION # New versioning system
# Create ZIP file # Create ZIP file
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
# Write project.json # Write project.json with stable sorting for git-friendly diffs
project_json = json.dumps(project_data, indent=2) project_json = json.dumps(project_data, indent=2, sort_keys=True)
zipf.writestr('project.json', project_json) zipf.writestr("project.json", project_json)
# Add all files from the assets folder # Add all files from the assets folder
assets_folder = project.asset_manager.assets_folder assets_folder = project.asset_manager.assets_folder
if os.path.exists(assets_folder): if os.path.exists(assets_folder):
@@ -108,17 +161,138 @@ def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]:
# Store with relative path from project folder # Store with relative path from project folder
arcname = os.path.relpath(file_path, project.folder_path) arcname = os.path.relpath(file_path, project.folder_path)
zipf.write(file_path, arcname) zipf.write(file_path, arcname)
print(f"Project saved to {zip_path}") print(f"Project saved to {zip_path}")
return True, None return True, None
except Exception as e: except Exception as e:
error_msg = f"Error saving project: {str(e)}" error_msg = f"Error saving project: {str(e)}"
print(error_msg) print(error_msg)
return False, error_msg return False, error_msg
def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Tuple[Optional[Project], Optional[str]]: def save_to_zip_async(
project: Project,
zip_path: str,
on_complete: Optional[Callable[[bool, Optional[str]], None]] = None,
on_progress: Optional[Callable[[int, str], None]] = None,
) -> threading.Thread:
"""
Save a project to a ZIP file asynchronously in a background thread.
This provides instant UI responsiveness by:
1. Immediately serializing project.json to a temp folder (fast)
2. Creating the ZIP file in a background thread (slow)
3. Calling on_complete when done
Args:
project: The Project instance to save
zip_path: Path where the ZIP file should be created
on_complete: Optional callback(success: bool, error_msg: Optional[str])
called when save completes
on_progress: Optional callback(progress: int, message: str) where
progress is 0-100 and message describes current step
Returns:
The background thread (already started)
"""
# Ensure .ppz extension
final_zip_path = zip_path
if not final_zip_path.lower().endswith(".ppz"):
final_zip_path += ".ppz"
# ---- Work done on the CALLING (main) thread ----
# Serialization is pure Python and holds the GIL, but it's fast.
# Doing it here keeps the background thread to file-I/O only, which
# releases the GIL and keeps the UI responsive.
if on_progress:
on_progress(0, "Preparing to save...")
_import_external_images(project)
if on_progress:
on_progress(10, "Serializing project data...")
project_data = project.serialize()
project_data["serialization_version"] = SERIALIZATION_VERSION
project_data["data_version"] = CURRENT_DATA_VERSION
project_json_str = json.dumps(project_data, indent=2, sort_keys=True)
# Collect the asset file list now so the background thread doesn't
# need to touch the (potentially temporary) project folder.
assets_folder = project.asset_manager.assets_folder
folder_path = project.folder_path
asset_files: list[tuple[str, str]] = []
if os.path.exists(assets_folder):
for root, _dirs, files in os.walk(assets_folder):
for file in files:
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, folder_path)
asset_files.append((file_path, arcname))
total_files = 1 + len(asset_files) # project.json + assets
if on_progress:
on_progress(20, f"Starting background write ({total_files} files)...")
# ---- Work done on the BACKGROUND thread ----
# Only file I/O here — zipfile/zlib/shutil all release the GIL.
def _background_save():
"""Background thread: write ZIP file from pre-serialized data."""
temp_dir = None
try:
temp_dir = tempfile.mkdtemp(prefix="pyPhotoAlbum_save_")
temp_zip_path = os.path.join(temp_dir, "project.ppz")
if on_progress:
on_progress(25, f"Creating ZIP archive ({total_files} files)...")
with zipfile.ZipFile(temp_zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
zipf.writestr("project.json", project_json_str)
if asset_files:
progress_range = 90 - 25
for idx, (file_path, arcname) in enumerate(asset_files):
zipf.write(file_path, arcname)
if idx % 10 == 0 or idx == len(asset_files) - 1:
progress = 25 + int((idx + 1) / len(asset_files) * progress_range)
if on_progress:
on_progress(progress, f"Adding assets... ({idx + 1}/{len(asset_files)})")
if on_progress:
on_progress(95, "Finalizing save...")
os.makedirs(os.path.dirname(os.path.abspath(final_zip_path)), exist_ok=True)
if os.path.exists(final_zip_path):
os.remove(final_zip_path)
shutil.move(temp_zip_path, final_zip_path)
if on_progress:
on_progress(100, "Save complete!")
print(f"Project saved to {final_zip_path}")
if on_complete:
on_complete(True, None)
except Exception as e:
error_msg = f"Error saving project: {str(e)}"
print(error_msg)
if on_complete:
on_complete(False, error_msg)
finally:
if temp_dir and os.path.exists(temp_dir):
try:
shutil.rmtree(temp_dir)
except Exception:
pass
save_thread = threading.Thread(target=_background_save, daemon=True)
save_thread.start()
return save_thread
def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Project:
""" """
Load a project from a ZIP file. Load a project from a ZIP file.
@@ -128,120 +302,108 @@ def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Tuple[Opti
directory that will be cleaned up when the project is closed. directory that will be cleaned up when the project is closed.
Returns: Returns:
Tuple of (project: Optional[Project], error_message: Optional[str]) Project instance (raises exception on error)
""" """
try: if not os.path.exists(zip_path):
if not os.path.exists(zip_path): raise FileNotFoundError(f"ZIP file not found: {zip_path}")
return None, f"ZIP file not found: {zip_path}"
# Track if we created a temp directory # Track if we created a temp directory
temp_dir_obj = None temp_dir_obj = None
# Determine extraction directory # Determine extraction directory
if extract_to is None: if extract_to is None:
# Create a temporary directory using TemporaryDirectory # Create a temporary directory using TemporaryDirectory
# This will be attached to the Project and auto-cleaned on deletion # This will be attached to the Project and auto-cleaned on deletion
zip_basename = os.path.splitext(os.path.basename(zip_path))[0] zip_basename = os.path.splitext(os.path.basename(zip_path))[0]
temp_dir_obj = tempfile.TemporaryDirectory(prefix=f"pyPhotoAlbum_{zip_basename}_") temp_dir_obj = tempfile.TemporaryDirectory(prefix=f"pyPhotoAlbum_{zip_basename}_")
extract_to = temp_dir_obj.name extract_to = temp_dir_obj.name
else: else:
# Create extraction directory if it doesn't exist # Create extraction directory if it doesn't exist
os.makedirs(extract_to, exist_ok=True) 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):
return None, "Invalid project file: project.json not found"
with open(project_json_path, 'r') as f:
project_data = json.load(f)
# Check version compatibility # Extract ZIP contents
# Try new version field first, fall back to legacy field with zipfile.ZipFile(zip_path, "r") as zipf:
file_version = project_data.get('data_version', project_data.get('serialization_version', '1.0')) zipf.extractall(extract_to)
# Check if version is compatible # Load project.json
is_compatible, error_msg = check_version_compatibility(file_version, zip_path) project_json_path = os.path.join(extract_to, "project.json")
if not is_compatible: if not os.path.exists(project_json_path):
return None, error_msg raise ValueError("Invalid project file: project.json not found")
# Apply migrations if needed with open(project_json_path, "r") as f:
if VersionCompatibility.needs_migration(file_version): project_data = json.load(f)
print(f"Migrating project from version {file_version} to {CURRENT_DATA_VERSION}...")
try:
project_data = DataMigration.migrate(project_data, file_version, CURRENT_DATA_VERSION)
print(f"Migration completed successfully")
except Exception as e:
error_msg = f"Migration failed: {str(e)}"
print(error_msg)
return None, error_msg
elif file_version != CURRENT_DATA_VERSION:
print(f"Note: Loading project with version {file_version}, current version is {CURRENT_DATA_VERSION}")
# Create new project # Check version compatibility
project_name = project_data.get('name', 'Untitled Project') # Try new version field first, fall back to legacy field
project = Project(name=project_name, folder_path=extract_to) file_version = project_data.get("data_version", project_data.get("serialization_version", "1.0"))
# Deserialize project data # Check if version is compatible
project.deserialize(project_data) is_compatible, error_msg = check_version_compatibility(file_version, zip_path)
if not is_compatible:
raise ValueError(error_msg)
# Update folder path to extraction location # Apply migrations if needed
project.folder_path = extract_to if VersionCompatibility.needs_migration(file_version):
project.asset_manager.project_folder = extract_to print(f"Migrating project from version {file_version} to {CURRENT_DATA_VERSION}...")
project.asset_manager.assets_folder = os.path.join(extract_to, "assets") 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}")
# Attach temporary directory to project (if we created one) # Create new project
# The TemporaryDirectory will auto-cleanup when the project is deleted project_name = project_data.get("name", "Untitled Project")
if temp_dir_obj is not None: project = Project(name=project_name, folder_path=extract_to)
project._temp_dir = temp_dir_obj
print(f"Project loaded to temporary directory: {extract_to}")
# Normalize asset paths in all ImageData elements # Deserialize project data
# This fixes old projects that have absolute or wrong relative paths project.deserialize(project_data)
_normalize_asset_paths(project, extract_to)
# Set asset resolution context for ImageData rendering # Update folder path to extraction location
# Include the directory containing the .ppz file as a search path project.folder_path = extract_to
from pyPhotoAlbum.models import set_asset_resolution_context project.asset_manager.project_folder = extract_to
zip_directory = os.path.dirname(os.path.abspath(zip_path)) project.asset_manager.assets_folder = os.path.join(extract_to, "assets")
set_asset_resolution_context(extract_to, additional_search_paths=[zip_directory])
print(f"Project loaded from {zip_path} to {extract_to}") # Attach temporary directory to project (if we created one)
print(f"Additional search path: {zip_directory}") # The TemporaryDirectory will auto-cleanup when the project is deleted
return project, None if temp_dir_obj is not None:
project._temp_dir = temp_dir_obj
except Exception as e: print(f"Project loaded to temporary directory: {extract_to}")
error_msg = f"Error loading project: {str(e)}"
print(error_msg) # Normalize asset paths in all ImageData elements
return None, error_msg # 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]: def get_project_info(zip_path: str) -> Optional[dict]:
""" """
Get basic information about a project without fully loading it. Get basic information about a project without fully loading it.
Args: Args:
zip_path: Path to the ZIP file zip_path: Path to the ZIP file
Returns: Returns:
Dictionary with project info, or None if error Dictionary with project info, or None if error
""" """
try: try:
with zipfile.ZipFile(zip_path, 'r') as zipf: with zipfile.ZipFile(zip_path, "r") as zipf:
# Read project.json # Read project.json
project_json = zipf.read('project.json').decode('utf-8') project_json = zipf.read("project.json").decode("utf-8")
project_data = json.loads(project_json) project_data = json.loads(project_json)
return { return {
'name': project_data.get('name', 'Unknown'), "name": project_data.get("name", "Unknown"),
'version': project_data.get('serialization_version', 'Unknown'), "version": project_data.get("serialization_version", "Unknown"),
'page_count': len(project_data.get('pages', [])), "page_count": len(project_data.get("pages", [])),
'page_size_mm': project_data.get('page_size_mm', (0, 0)), "page_size_mm": project_data.get("page_size_mm", (0, 0)),
'working_dpi': project_data.get('working_dpi', 300), "working_dpi": project_data.get("working_dpi", 300),
} }
except Exception as e: except Exception as e:
print(f"Error reading project info: {e}") print(f"Error reading project info: {e}")
+77 -86
View File
@@ -12,14 +12,14 @@ from collections import defaultdict
def build_ribbon_config(window_class: Type) -> Dict[str, Any]: def build_ribbon_config(window_class: Type) -> Dict[str, Any]:
""" """
Extract decorated methods and build ribbon configuration. Extract decorated methods and build ribbon configuration.
This function scans all methods in the window class and its mixins This function scans all methods in the window class and its mixins
for methods decorated with @ribbon_action, then builds a nested for methods decorated with @ribbon_action, then builds a nested
configuration structure suitable for the RibbonWidget. configuration structure suitable for the RibbonWidget.
Args: Args:
window_class: The MainWindow class with decorated methods window_class: The MainWindow class with decorated methods
Returns: Returns:
Dictionary containing the ribbon configuration with structure: Dictionary containing the ribbon configuration with structure:
{ {
@@ -41,60 +41,62 @@ def build_ribbon_config(window_class: Type) -> Dict[str, Any]:
} }
""" """
# Structure to collect actions by tab and group # Structure to collect actions by tab and group
tabs = defaultdict(lambda: defaultdict(list)) tabs: Dict[str, Dict[str, List[Dict[str, Any]]]] = defaultdict(lambda: defaultdict(list))
# Scan all methods in the class and its bases (mixins) # Scan all methods in the class and its bases (mixins)
for attr_name in dir(window_class): for attr_name in dir(window_class):
try: try:
attr = getattr(window_class, attr_name) attr = getattr(window_class, attr_name)
# Check if this attribute has ribbon action metadata # Check if this attribute has ribbon action metadata
if hasattr(attr, '_ribbon_action'): if hasattr(attr, "_ribbon_action"):
action_data = attr._ribbon_action action_data = attr._ribbon_action
# Extract tab and group information # Extract tab and group information
tab_name = action_data['tab'] tab_name = action_data["tab"]
group_name = action_data['group'] group_name = action_data["group"]
# Add action to the appropriate tab and group # Add action to the appropriate tab and group
tabs[tab_name][group_name].append({ tabs[tab_name][group_name].append(
'label': action_data['label'], {
'action': action_data['action'], "label": action_data["label"],
'tooltip': action_data['tooltip'], "action": action_data["action"],
'icon': action_data.get('icon'), "tooltip": action_data["tooltip"],
'shortcut': action_data.get('shortcut'), "icon": action_data.get("icon"),
}) "shortcut": action_data.get("shortcut"),
}
)
except (AttributeError, TypeError): except (AttributeError, TypeError):
# Skip attributes that can't be inspected # Skip attributes that can't be inspected
continue continue
# Convert to the expected ribbon config format # Convert to the expected ribbon config format
ribbon_config = {} ribbon_config = {}
# Define tab order (tabs will appear in this order) # Define tab order (tabs will appear in this order)
tab_order = ['Home', 'Insert', 'Layout', 'Arrange', 'View', 'Export'] tab_order = ["Home", "Insert", "Layout", "Arrange", "Style", "View"]
# Add tabs in the defined order, then add any remaining tabs # Add tabs in the defined order, then add any remaining tabs
all_tabs = list(tabs.keys()) all_tabs = list(tabs.keys())
ordered_tabs = [t for t in tab_order if t in all_tabs] 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]) ordered_tabs.extend([t for t in all_tabs if t not in tab_order])
for tab_name in ordered_tabs: for tab_name in ordered_tabs:
groups_dict = tabs[tab_name] groups_dict = tabs[tab_name]
# Convert groups dictionary to list format # Convert groups dictionary to list format
groups_list = [] groups_list = []
# Define group order per tab (if needed) # Define group order per tab (if needed)
group_orders = { group_orders = {
'Home': ['File', 'Edit'], "Home": ["File", "Edit"],
'Insert': ['Media'], "Insert": ["Media", "Snapping"],
'Layout': ['Navigation', 'Page', 'Templates'], "Layout": ["Page", "Templates"],
'Arrange': ['Align', 'Size', 'Distribute'], "Arrange": ["Align", "Distribute", "Size", "Order", "Transform"],
'View': ['Zoom'], "Style": ["Corners", "Border", "Effects", "Frame", "Presets"],
'Export': ['Export'], "View": ["Zoom", "Guides"],
} }
# Get the group order for this tab, or use alphabetical # Get the group order for this tab, or use alphabetical
if tab_name in group_orders: if tab_name in group_orders:
group_order = group_orders[tab_name] group_order = group_orders[tab_name]
@@ -103,139 +105,128 @@ def build_ribbon_config(window_class: Type) -> Dict[str, Any]:
group_order.extend([g for g in all_groups if g not in group_order]) group_order.extend([g for g in all_groups if g not in group_order])
else: else:
group_order = sorted(groups_dict.keys()) group_order = sorted(groups_dict.keys())
for group_name in group_order: for group_name in group_order:
if group_name in groups_dict: if group_name in groups_dict:
actions = groups_dict[group_name] actions = groups_dict[group_name]
groups_list.append({ groups_list.append({"name": group_name, "actions": actions})
'name': group_name,
'actions': actions ribbon_config[tab_name] = {"groups": groups_list}
})
ribbon_config[tab_name] = {
'groups': groups_list
}
return ribbon_config return ribbon_config
def get_keyboard_shortcuts(window_class: Type) -> Dict[str, str]: def get_keyboard_shortcuts(window_class: Type) -> Dict[str, str]:
""" """
Extract keyboard shortcuts from decorated methods. Extract keyboard shortcuts from decorated methods.
Args: Args:
window_class: The MainWindow class with decorated methods window_class: The MainWindow class with decorated methods
Returns: Returns:
Dictionary mapping shortcut strings to method names Dictionary mapping shortcut strings to method names
Example: {'Ctrl+N': 'new_project', 'Ctrl+S': 'save_project'} Example: {'Ctrl+N': 'new_project', 'Ctrl+S': 'save_project'}
""" """
shortcuts = {} shortcuts = {}
for attr_name in dir(window_class): for attr_name in dir(window_class):
try: try:
attr = getattr(window_class, attr_name) attr = getattr(window_class, attr_name)
if hasattr(attr, '_ribbon_action'): if hasattr(attr, "_ribbon_action"):
action_data = attr._ribbon_action action_data = attr._ribbon_action
shortcut = action_data.get('shortcut') shortcut = action_data.get("shortcut")
if shortcut: if shortcut:
shortcuts[shortcut] = action_data['action'] shortcuts[shortcut] = action_data["action"]
except (AttributeError, TypeError): except (AttributeError, TypeError):
continue continue
return shortcuts return shortcuts
def validate_ribbon_config(config: Dict[str, Any]) -> List[str]: def validate_ribbon_config(config: Dict[str, Any]) -> List[str]:
""" """
Validate the ribbon configuration structure. Validate the ribbon configuration structure.
Args: Args:
config: The ribbon configuration dictionary config: The ribbon configuration dictionary
Returns: Returns:
List of validation error messages (empty if valid) List of validation error messages (empty if valid)
""" """
errors = [] errors = []
if not isinstance(config, dict): if not isinstance(config, dict):
errors.append("Config must be a dictionary") errors.append("Config must be a dictionary")
return errors return errors
for tab_name, tab_data in config.items(): for tab_name, tab_data in config.items():
if not isinstance(tab_data, dict): if not isinstance(tab_data, dict):
errors.append(f"Tab '{tab_name}' data must be a dictionary") errors.append(f"Tab '{tab_name}' data must be a dictionary")
continue continue
if 'groups' not in tab_data: if "groups" not in tab_data:
errors.append(f"Tab '{tab_name}' missing 'groups' key") errors.append(f"Tab '{tab_name}' missing 'groups' key")
continue continue
groups = tab_data['groups'] groups = tab_data["groups"]
if not isinstance(groups, list): if not isinstance(groups, list):
errors.append(f"Tab '{tab_name}' groups must be a list") errors.append(f"Tab '{tab_name}' groups must be a list")
continue continue
for i, group in enumerate(groups): for i, group in enumerate(groups):
if not isinstance(group, dict): if not isinstance(group, dict):
errors.append(f"Tab '{tab_name}' group {i} must be a dictionary") errors.append(f"Tab '{tab_name}' group {i} must be a dictionary")
continue continue
if 'name' not in group: if "name" not in group:
errors.append(f"Tab '{tab_name}' group {i} missing 'name'") errors.append(f"Tab '{tab_name}' group {i} missing 'name'")
if 'actions' not in group: if "actions" not in group:
errors.append(f"Tab '{tab_name}' group {i} missing 'actions'") errors.append(f"Tab '{tab_name}' group {i} missing 'actions'")
continue continue
actions = group['actions'] actions = group["actions"]
if not isinstance(actions, list): if not isinstance(actions, list):
errors.append(f"Tab '{tab_name}' group {i} actions must be a list") errors.append(f"Tab '{tab_name}' group {i} actions must be a list")
continue continue
for j, action in enumerate(actions): for j, action in enumerate(actions):
if not isinstance(action, dict): if not isinstance(action, dict):
errors.append(f"Tab '{tab_name}' group {i} action {j} must be a dictionary") errors.append(f"Tab '{tab_name}' group {i} action {j} must be a dictionary")
continue continue
required_keys = ['label', 'action', 'tooltip'] required_keys = ["label", "action", "tooltip"]
for key in required_keys: for key in required_keys:
if key not in action: if key not in action:
errors.append( errors.append(f"Tab '{tab_name}' group {i} action {j} missing '{key}'")
f"Tab '{tab_name}' group {i} action {j} missing '{key}'"
)
return errors return errors
def print_ribbon_summary(config: Dict[str, Any]): def print_ribbon_summary(config: Dict[str, Any]):
""" """
Print a summary of the ribbon configuration. Print a summary of the ribbon configuration.
Args: Args:
config: The ribbon configuration dictionary config: The ribbon configuration dictionary
""" """
print("\n=== Ribbon Configuration Summary ===\n") print("\n=== Ribbon Configuration Summary ===\n")
total_tabs = len(config) total_tabs = len(config)
total_groups = sum(len(tab_data['groups']) for tab_data in config.values()) total_groups = sum(len(tab_data["groups"]) for tab_data in config.values())
total_actions = sum( total_actions = sum(len(group["actions"]) for tab_data in config.values() for group in tab_data["groups"])
len(group['actions'])
for tab_data in config.values()
for group in tab_data['groups']
)
print(f"Total Tabs: {total_tabs}") print(f"Total Tabs: {total_tabs}")
print(f"Total Groups: {total_groups}") print(f"Total Groups: {total_groups}")
print(f"Total Actions: {total_actions}\n") print(f"Total Actions: {total_actions}\n")
for tab_name, tab_data in config.items(): for tab_name, tab_data in config.items():
print(f"📑 {tab_name}") print(f"📑 {tab_name}")
for group in tab_data['groups']: for group in tab_data["groups"]:
print(f" 📦 {group['name']} ({len(group['actions'])} actions)") print(f" 📦 {group['name']} ({len(group['actions'])} actions)")
for action in group['actions']: for action in group["actions"]:
shortcut = f" ({action['shortcut']})" if action.get('shortcut') else "" shortcut = f" ({action['shortcut']})" if action.get("shortcut") else ""
print(f"{action['label']}{shortcut}") print(f"{action['label']}{shortcut}")
print() print()
+19 -9
View File
@@ -2,24 +2,26 @@
Ribbon widget for pyPhotoAlbum Ribbon widget for pyPhotoAlbum
""" """
from PyQt6.QtWidgets import QWidget, QTabWidget, QHBoxLayout, QVBoxLayout, QPushButton, QLabel, QFrame from PyQt6.QtWidgets import QWidget, QTabWidget, QHBoxLayout, QVBoxLayout, QPushButton, QLabel, QFrame, QGridLayout
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
class RibbonWidget(QWidget): class RibbonWidget(QWidget):
"""A ribbon-style toolbar using QTabWidget""" """A ribbon-style toolbar using QTabWidget"""
def __init__(self, main_window, ribbon_config=None, parent=None): def __init__(self, main_window, ribbon_config=None, buttons_per_row=4, parent=None):
super().__init__(parent) super().__init__(parent)
self.main_window = main_window 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 # Use provided config or fall back to importing the old one
if ribbon_config is None: if ribbon_config is None:
from ribbon_config import RIBBON_CONFIG from ribbon_config import RIBBON_CONFIG
self.ribbon_config = RIBBON_CONFIG self.ribbon_config = RIBBON_CONFIG
else: else:
self.ribbon_config = ribbon_config self.ribbon_config = ribbon_config
# Main layout # Main layout
main_layout = QVBoxLayout() main_layout = QVBoxLayout()
main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setContentsMargins(0, 0, 0, 0)
@@ -66,13 +68,20 @@ class RibbonWidget(QWidget):
group_layout.setSpacing(5) group_layout.setSpacing(5)
group_widget.setLayout(group_layout) group_widget.setLayout(group_layout)
# Create actions layout # Create actions grid layout
actions_layout = QHBoxLayout() actions_layout = QGridLayout()
actions_layout.setSpacing(5) actions_layout.setSpacing(5)
for action_config in group_config.get("actions", []): # 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) button = self._create_action_button(action_config)
actions_layout.addWidget(button) row = i // buttons_per_row
col = i % buttons_per_row
actions_layout.addWidget(button, row, col)
group_layout.addLayout(actions_layout) group_layout.addLayout(actions_layout)
@@ -98,7 +107,8 @@ class RibbonWidget(QWidget):
# Connect to action # Connect to action
action_name = action_config.get("action") action_name = action_config.get("action")
if action_name: if action_name:
button.clicked.connect(lambda: self._execute_action(action_name)) # Use default argument to capture action_name by value, not by reference
button.clicked.connect(lambda checked, name=action_name: self._execute_action(name))
return button return button
+197 -237
View File
@@ -3,39 +3,49 @@ Snapping system for pyPhotoAlbum
Provides grid snapping, edge snapping, and custom guide snapping Provides grid snapping, edge snapping, and custom guide snapping
""" """
from typing import List, Tuple, Optional import math
from typing import Any, Dict, List, Tuple, Optional
from dataclasses import dataclass from dataclasses import dataclass
@dataclass @dataclass
class Guide: class Guide:
"""Represents a snapping guide (vertical or horizontal line)""" """Represents a snapping guide (vertical or horizontal line)"""
position: float # Position in mm position: float # Position in mm
orientation: str # 'vertical' or 'horizontal' orientation: str # 'vertical' or 'horizontal'
def serialize(self) -> dict: def serialize(self) -> dict:
"""Serialize guide to dictionary""" """Serialize guide to dictionary"""
return { return {"position": self.position, "orientation": self.orientation}
"position": self.position,
"orientation": self.orientation
}
@staticmethod @staticmethod
def deserialize(data: dict) -> 'Guide': def deserialize(data: dict) -> "Guide":
"""Deserialize guide from dictionary""" """Deserialize guide from dictionary"""
return Guide( return Guide(position=data.get("position", 0), orientation=data.get("orientation", "vertical"))
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: class SnappingSystem:
"""Manages snapping behavior for layout elements""" """Manages snapping behavior for layout elements"""
def __init__(self, snap_threshold_mm: float = 5.0): def __init__(self, snap_threshold_mm: float = 5.0):
""" """
Initialize snapping system Initialize snapping system
Args: Args:
snap_threshold_mm: Distance in mm within which snapping occurs snap_threshold_mm: Distance in mm within which snapping occurs
""" """
@@ -45,84 +55,104 @@ class SnappingSystem:
self.snap_to_edges = True self.snap_to_edges = True
self.snap_to_guides = True self.snap_to_guides = True
self.guides: List[Guide] = [] self.guides: List[Guide] = []
def add_guide(self, position: float, orientation: str): def add_guide(self, position: float, orientation: str):
"""Add a new guide""" """Add a new guide"""
guide = Guide(position=position, orientation=orientation) guide = Guide(position=position, orientation=orientation)
self.guides.append(guide) self.guides.append(guide)
return guide return guide
def remove_guide(self, guide: Guide): def remove_guide(self, guide: Guide):
"""Remove a guide""" """Remove a guide"""
if guide in self.guides: if guide in self.guides:
self.guides.remove(guide) self.guides.remove(guide)
def clear_guides(self): def clear_guides(self):
"""Remove all guides""" """Remove all guides"""
self.guides.clear() self.guides.clear()
def snap_position(self, def snap_position(
position: Tuple[float, float], self,
size: Tuple[float, float], position: Tuple[float, float],
page_size: Tuple[float, float], size: Tuple[float, float],
dpi: int = 300) -> 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 Apply snapping to a position using combined distance threshold
Args: Args:
position: Current position (x, y) in pixels position: Current position (x, y) in pixels
size: Element size (width, height) in pixels size: Element size (width, height) in pixels
page_size: Page size (width, height) in mm page_size: Page size (width, height) in mm
dpi: DPI for conversion dpi: DPI for conversion
project: Optional project for global snapping settings
Returns: Returns:
Snapped position (x, y) in pixels Snapped position (x, y) in pixels
""" """
import math
x, y = position x, y = position
width, height = size width, height = size
page_width_mm, page_height_mm = page_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 # Convert threshold from mm to pixels
snap_threshold_px = self.snap_threshold_mm * dpi / 25.4 snap_threshold_px = snap_threshold_mm * dpi / 25.4
# Collect all potential snap points for both edges of the element # Collect all potential snap points for both edges of the element
snap_points = [] snap_points = []
# 1. Page edge snap points # 1. Page edge snap points
if self.snap_to_edges: if snap_to_edges:
page_width_px = page_width_mm * dpi / 25.4 page_width_px = page_width_mm * dpi / 25.4
page_height_px = page_height_mm * dpi / 25.4 page_height_px = page_height_mm * dpi / 25.4
# Corners where element's top-left can snap # Corners where element's top-left can snap
snap_points.extend([ snap_points.extend(
(0, 0), # Top-left corner [
(page_width_px - width, 0), # Top-right corner (0, 0), # Top-left corner
(0, page_height_px - height), # Bottom-left corner (page_width_px - width, 0), # Top-right corner
(page_width_px - width, page_height_px - height), # Bottom-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) # Edge positions (element aligned to edge on one axis)
snap_points.extend([ snap_points.extend(
(0, y), # Left edge [
(page_width_px - width, y), # Right edge (0, y), # Left edge
(x, 0), # Top edge (page_width_px - width, y), # Right edge
(x, page_height_px - height), # Bottom edge (x, 0), # Top edge
]) (x, page_height_px - height), # Bottom edge
]
)
# 2. Grid snap points # 2. Grid snap points
if self.snap_to_grid: if snap_to_grid:
grid_size_px = self.grid_size_mm * dpi / 25.4 grid_size_px = grid_size_mm * dpi / 25.4
page_width_px = page_width_mm * dpi / 25.4 page_width_px = page_width_mm * dpi / 25.4
page_height_px = page_height_mm * dpi / 25.4 page_height_px = page_height_mm * dpi / 25.4
# Calculate grid intersection points within range # Calculate grid intersection points within range
x_start = max(0, int((x - snap_threshold_px) / grid_size_px)) * grid_size_px 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) 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_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) y_end = min(page_height_px, int((y + snap_threshold_px) / grid_size_px + 1) * grid_size_px)
grid_x = x_start grid_x = x_start
while grid_x <= x_end: while grid_x <= x_end:
grid_y = y_start grid_y = y_start
@@ -135,12 +165,12 @@ class SnappingSystem:
snap_points.append((grid_x, grid_y - height)) snap_points.append((grid_x, grid_y - height))
grid_y += grid_size_px grid_y += grid_size_px
grid_x += grid_size_px grid_x += grid_size_px
# 3. Guide snap points # 3. Guide snap points
if self.snap_to_guides: if snap_to_guides:
vertical_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == 'vertical'] 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'] 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) # Guide intersections (when both vertical and horizontal guides exist)
for vg in vertical_guides: for vg in vertical_guides:
for hg in horizontal_guides: for hg in horizontal_guides:
@@ -149,314 +179,244 @@ class SnappingSystem:
snap_points.append((vg - width, hg)) snap_points.append((vg - width, hg))
snap_points.append((vg, hg - height)) snap_points.append((vg, hg - height))
snap_points.append((vg - width, hg - height)) snap_points.append((vg - width, hg - height))
# Find the nearest snap point using Euclidean distance # Find the nearest snap point using Euclidean distance
best_snap_point = None best_snap_point = None
best_distance = snap_threshold_px best_distance = snap_threshold_px
for snap_x, snap_y in snap_points: for snap_x, snap_y in snap_points:
distance = math.sqrt((x - snap_x) ** 2 + (y - snap_y) ** 2) distance = math.sqrt((x - snap_x) ** 2 + (y - snap_y) ** 2)
if distance < best_distance: if distance < best_distance:
best_snap_point = (snap_x, snap_y) best_snap_point = (snap_x, snap_y)
best_distance = distance best_distance = distance
# Return snapped position or original position # Return snapped position or original position
if best_snap_point: if best_snap_point:
return best_snap_point return best_snap_point
else: else:
return (x, y) return (x, y)
def snap_resize(self, def snap_resize(self, params: SnapResizeParams) -> Tuple[Tuple[float, float], Tuple[float, float]]:
position: Tuple[float, float],
size: Tuple[float, float],
dx: float,
dy: float,
resize_handle: str,
page_size: Tuple[float, float],
dpi: int = 300) -> Tuple[Tuple[float, float], Tuple[float, float]]:
""" """
Apply snapping during resize operations Apply snapping during resize operations
Args: Args:
position: Current position (x, y) in pixels params: SnapResizeParams containing all resize parameters
size: Current size (width, height) in pixels
dx: Delta x movement in pixels
dy: Delta y movement in pixels
resize_handle: Which handle is being dragged ('nw', 'ne', 'sw', 'se', 'n', 's', 'e', 'w')
page_size: Page size (width, height) in mm
dpi: DPI for conversion
Returns: Returns:
Tuple of (snapped_position, snapped_size) in pixels Tuple of (snapped_position, snapped_size) in pixels
""" """
x, y = position x, y = params.position
width, height = size width, height = params.size
page_width_mm, page_height_mm = page_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 # Convert threshold from mm to pixels
snap_threshold_px = self.snap_threshold_mm * dpi / 25.4 snap_threshold_px = snap_threshold_mm * params.dpi / 25.4
# Calculate new position and size based on resize handle # Calculate new position and size based on resize handle
new_x, new_y = x, y new_x, new_y = x, y
new_width, new_height = width, height new_width, new_height = width, height
# Apply resize based on handle # Apply resize based on handle
if resize_handle in ['nw', 'n', 'ne']: if params.resize_handle in ["nw", "n", "ne"]:
# Top edge moving # Top edge moving
new_y = y + dy new_y = y + params.dy
new_height = height - dy new_height = height - params.dy
if resize_handle in ['sw', 's', 'se']: if params.resize_handle in ["sw", "s", "se"]:
# Bottom edge moving # Bottom edge moving
new_height = height + dy new_height = height + params.dy
if resize_handle in ['nw', 'w', 'sw']: if params.resize_handle in ["nw", "w", "sw"]:
# Left edge moving # Left edge moving
new_x = x + dx new_x = x + params.dx
new_width = width - dx new_width = width - params.dx
if resize_handle in ['ne', 'e', 'se']: if params.resize_handle in ["ne", "e", "se"]:
# Right edge moving # Right edge moving
new_width = width + dx new_width = width + params.dx
# Now apply snapping to the edges that are being moved # Now apply snapping to the edges that are being moved
# Use _snap_edge_to_targets consistently for all edges # Use _snap_edge_to_targets consistently for all edges
# Snap left edge (for nw, w, sw handles) # Snap left edge (for nw, w, sw handles)
if resize_handle in ['nw', 'w', 'sw']: if params.resize_handle in ["nw", "w", "sw"]:
# Try to snap the left edge # Try to snap the left edge
snapped_left = self._snap_edge_to_targets( snapped_left = self._snap_edge_to_targets(
new_x, page_width_mm, dpi, snap_threshold_px, 'vertical' new_x, page_width_mm, params.dpi, snap_threshold_px, "vertical", params.project
) )
if snapped_left is not None: if snapped_left is not None:
# Adjust width to compensate for position change # Adjust width to compensate for position change
width_adjustment = new_x - snapped_left width_adjustment = new_x - snapped_left
new_x = snapped_left new_x = snapped_left
new_width += width_adjustment new_width += width_adjustment
# Snap right edge (for ne, e, se handles) # Snap right edge (for ne, e, se handles)
if resize_handle in ['ne', 'e', 'se']: if params.resize_handle in ["ne", "e", "se"]:
# Calculate right edge position # Calculate right edge position
right_edge = new_x + new_width right_edge = new_x + new_width
# Try to snap the right edge # Try to snap the right edge
snapped_right = self._snap_edge_to_targets( snapped_right = self._snap_edge_to_targets(
right_edge, page_width_mm, dpi, snap_threshold_px, 'vertical' right_edge, page_width_mm, params.dpi, snap_threshold_px, "vertical", params.project
) )
if snapped_right is not None: if snapped_right is not None:
new_width = snapped_right - new_x new_width = snapped_right - new_x
# Snap top edge (for nw, n, ne handles) # Snap top edge (for nw, n, ne handles)
if resize_handle in ['nw', 'n', 'ne']: if params.resize_handle in ["nw", "n", "ne"]:
# Try to snap the top edge # Try to snap the top edge
snapped_top = self._snap_edge_to_targets( snapped_top = self._snap_edge_to_targets(
new_y, page_height_mm, dpi, snap_threshold_px, 'horizontal' new_y, page_height_mm, params.dpi, snap_threshold_px, "horizontal", params.project
) )
if snapped_top is not None: if snapped_top is not None:
# Adjust height to compensate for position change # Adjust height to compensate for position change
height_adjustment = new_y - snapped_top height_adjustment = new_y - snapped_top
new_y = snapped_top new_y = snapped_top
new_height += height_adjustment new_height += height_adjustment
# Snap bottom edge (for sw, s, se handles) # Snap bottom edge (for sw, s, se handles)
if resize_handle in ['sw', 's', 'se']: if params.resize_handle in ["sw", "s", "se"]:
# Calculate bottom edge position # Calculate bottom edge position
bottom_edge = new_y + new_height bottom_edge = new_y + new_height
# Try to snap the bottom edge # Try to snap the bottom edge
snapped_bottom = self._snap_edge_to_targets( snapped_bottom = self._snap_edge_to_targets(
bottom_edge, page_height_mm, dpi, snap_threshold_px, 'horizontal' bottom_edge, page_height_mm, params.dpi, snap_threshold_px, "horizontal", params.project
) )
if snapped_bottom is not None: if snapped_bottom is not None:
new_height = snapped_bottom - new_y new_height = snapped_bottom - new_y
# Ensure minimum size # Ensure minimum size
min_size = 10 # Minimum 10 pixels min_size = 10 # Minimum 10 pixels
new_width = max(new_width, min_size) new_width = max(new_width, min_size)
new_height = max(new_height, min_size) new_height = max(new_height, min_size)
return ((new_x, new_y), (new_width, new_height)) return ((new_x, new_y), (new_width, new_height))
def _snap_edge_to_targets(self, def _snap_edge_to_targets(
edge_position: float, self,
page_size_mm: float, edge_position: float,
dpi: int, page_size_mm: float,
snap_threshold_px: float, dpi: int,
orientation: str) -> Optional[float]: snap_threshold_px: float,
orientation: str,
project=None,
) -> Optional[float]:
""" """
Snap an edge position to available targets (grid, edges, guides) Snap an edge position to available targets (grid, edges, guides)
Args: Args:
edge_position: Current edge position in pixels edge_position: Current edge position in pixels
page_size_mm: Page size along axis in mm page_size_mm: Page size along axis in mm
dpi: DPI for conversion dpi: DPI for conversion
snap_threshold_px: Snap threshold in pixels snap_threshold_px: Snap threshold in pixels
orientation: 'vertical' for x-axis, 'horizontal' for y-axis orientation: 'vertical' for x-axis, 'horizontal' for y-axis
project: Optional project for global snapping settings
Returns: Returns:
Snapped edge position in pixels, or None if no snap Snapped edge position in pixels, or None if no snap
""" """
snap_candidates = [] # 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 # 1. Page edge snapping
if self.snap_to_edges: if snap_to_edges:
# Snap to start edge (0) # Snap to start edge (0)
snap_candidates.append((0, abs(edge_position - 0))) snap_candidates.append((0.0, abs(edge_position - 0)))
# Snap to end edge # Snap to end edge
page_size_px = page_size_mm * dpi / 25.4 page_size_px = page_size_mm * dpi / 25.4
snap_candidates.append((page_size_px, abs(edge_position - page_size_px))) snap_candidates.append((page_size_px, abs(edge_position - page_size_px)))
# 2. Grid snapping # 2. Grid snapping
if self.snap_to_grid: if snap_to_grid:
grid_size_px = self.grid_size_mm * dpi / 25.4 grid_size_px = grid_size_mm * dpi / 25.4
# Snap to nearest grid line # Snap to nearest grid line
nearest_grid = round(edge_position / grid_size_px) * grid_size_px nearest_grid = round(edge_position / grid_size_px) * grid_size_px
snap_candidates.append((nearest_grid, abs(edge_position - nearest_grid))) snap_candidates.append((nearest_grid, abs(edge_position - nearest_grid)))
# 3. Guide snapping # 3. Guide snapping
if self.snap_to_guides: if snap_to_guides:
for guide in self.guides: for guide in self.guides:
if guide.orientation == orientation: if guide.orientation == orientation:
guide_pos_px = guide.position * dpi / 25.4 guide_pos_px = guide.position * dpi / 25.4
snap_candidates.append((guide_pos_px, abs(edge_position - guide_pos_px))) snap_candidates.append((guide_pos_px, abs(edge_position - guide_pos_px)))
# Find the best snap candidate within threshold # Find the best snap candidate within threshold
best_snap = None best_snap = None
best_distance = snap_threshold_px best_distance = snap_threshold_px
for snap_pos, distance in snap_candidates: for snap_pos, distance in snap_candidates:
if distance < best_distance: if distance < best_distance:
best_snap = snap_pos best_snap = snap_pos
best_distance = distance best_distance = distance
return best_snap return best_snap
def _snap_axis(self,
position: float,
size: float,
page_size_mm: float,
dpi: int,
snap_threshold_px: float,
orientation: str) -> float:
"""
Snap along a single axis
Args:
position: Current position along axis in pixels
size: Element size along axis in pixels
page_size_mm: Page size along axis in mm
dpi: DPI for conversion
snap_threshold_px: Snap threshold in pixels
orientation: 'vertical' for x-axis, 'horizontal' for y-axis
Returns:
Snapped position in pixels
"""
snap_candidates = []
# 1. Page edge snapping
if self.snap_to_edges:
# Snap to start edge (0)
snap_candidates.append((0, abs(position - 0)))
# Snap to end edge
page_size_px = page_size_mm * dpi / 25.4
snap_candidates.append((page_size_px - size, abs(position - (page_size_px - size))))
# Also snap element's far edge to page edge
snap_candidates.append((page_size_px - size, abs((position + size) - page_size_px)))
# 2. Grid snapping
if self.snap_to_grid:
grid_size_px = self.grid_size_mm * dpi / 25.4
# Snap to nearest grid line
nearest_grid = round(position / grid_size_px) * grid_size_px
snap_candidates.append((nearest_grid, abs(position - nearest_grid)))
# Also try snapping element's far edge to grid
element_end = position + size
nearest_grid_end = round(element_end / grid_size_px) * grid_size_px
snap_candidates.append((nearest_grid_end - size, abs(element_end - nearest_grid_end)))
# 3. Guide snapping
if self.snap_to_guides:
for guide in self.guides:
if guide.orientation == orientation:
guide_pos_px = guide.position * dpi / 25.4
# Snap start edge to guide
snap_candidates.append((guide_pos_px, abs(position - guide_pos_px)))
# Snap end edge to guide
element_end = position + size
snap_candidates.append((guide_pos_px - size, abs(element_end - guide_pos_px)))
# Find the best snap candidate within threshold
best_snap = None
best_distance = snap_threshold_px
for snap_pos, distance in snap_candidates:
if distance < best_distance:
best_snap = snap_pos
best_distance = distance
return best_snap if best_snap is not None else position
def get_snap_lines(self, page_size: Tuple[float, float], dpi: int = 300) -> dict: def get_snap_lines(self, page_size: Tuple[float, float], dpi: int = 300) -> dict:
""" """
Get all snap lines for visualization Get all snap lines for visualization
Args: Args:
page_size: Page size (width, height) in mm page_size: Page size (width, height) in mm
dpi: DPI for conversion dpi: DPI for conversion
Returns: Returns:
Dictionary with 'grid', 'edges', and 'guides' lists Dictionary with 'grid', 'edges', and 'guides' lists
""" """
page_width_mm, page_height_mm = page_size page_width_mm, page_height_mm = page_size
page_width_px = page_width_mm * dpi / 25.4 page_width_px = page_width_mm * dpi / 25.4
page_height_px = page_height_mm * dpi / 25.4 page_height_px = page_height_mm * dpi / 25.4
result = { result: Dict[str, List[Tuple[str, float]]] = {"grid": [], "edges": [], "guides": []}
'grid': [],
'edges': [],
'guides': []
}
# Grid lines # Grid lines
if self.snap_to_grid: if self.snap_to_grid:
grid_size_px = self.grid_size_mm * dpi / 25.4 grid_size_px = self.grid_size_mm * dpi / 25.4
# Vertical grid lines # Vertical grid lines
x = 0 x: float = 0
while x <= page_width_px: while x <= page_width_px:
result['grid'].append(('vertical', x)) result["grid"].append(("vertical", x))
x += grid_size_px x += grid_size_px
# Horizontal grid lines # Horizontal grid lines
y = 0 y: float = 0
while y <= page_height_px: while y <= page_height_px:
result['grid'].append(('horizontal', y)) result["grid"].append(("horizontal", y))
y += grid_size_px y += grid_size_px
# Edge lines # Edge lines
if self.snap_to_edges: if self.snap_to_edges:
result['edges'].extend([ result["edges"].extend(
('vertical', 0), [("vertical", 0), ("vertical", page_width_px), ("horizontal", 0), ("horizontal", page_height_px)]
('vertical', page_width_px), )
('horizontal', 0),
('horizontal', page_height_px)
])
# Guide lines # Guide lines
if self.snap_to_guides: if self.snap_to_guides:
for guide in self.guides: for guide in self.guides:
guide_pos_px = guide.position * dpi / 25.4 guide_pos_px = guide.position * dpi / 25.4
result['guides'].append((guide.orientation, guide_pos_px)) result["guides"].append((guide.orientation, guide_pos_px))
return result return result
def serialize(self) -> dict: def serialize(self) -> dict:
"""Serialize snapping system to dictionary""" """Serialize snapping system to dictionary"""
return { return {
@@ -465,9 +425,9 @@ class SnappingSystem:
"snap_to_grid": self.snap_to_grid, "snap_to_grid": self.snap_to_grid,
"snap_to_edges": self.snap_to_edges, "snap_to_edges": self.snap_to_edges,
"snap_to_guides": self.snap_to_guides, "snap_to_guides": self.snap_to_guides,
"guides": [guide.serialize() for guide in self.guides] "guides": [guide.serialize() for guide in self.guides],
} }
def deserialize(self, data: dict): def deserialize(self, data: dict):
"""Deserialize from dictionary""" """Deserialize from dictionary"""
self.snap_threshold_mm = data.get("snap_threshold_mm", 5.0) self.snap_threshold_mm = data.get("snap_threshold_mm", 5.0)
@@ -475,7 +435,7 @@ class SnappingSystem:
self.snap_to_grid = data.get("snap_to_grid", False) self.snap_to_grid = data.get("snap_to_grid", False)
self.snap_to_edges = data.get("snap_to_edges", True) self.snap_to_edges = data.get("snap_to_edges", True)
self.snap_to_guides = data.get("snap_to_guides", True) self.snap_to_guides = data.get("snap_to_guides", True)
self.guides = [] self.guides = []
for guide_data in data.get("guides", []): for guide_data in data.get("guides", []):
self.guides.append(Guide.deserialize(guide_data)) self.guides.append(Guide.deserialize(guide_data))
+83 -85
View File
@@ -14,7 +14,9 @@ from pyPhotoAlbum.project import Page
class Template: class Template:
"""Class representing a page layout template""" """Class representing a page layout template"""
def __init__(self, name: str = "Untitled Template", description: str = "", page_size_mm: Tuple[float, float] = (210, 297)): def __init__(
self, name: str = "Untitled Template", description: str = "", page_size_mm: Tuple[float, float] = (210, 297)
):
self.name = name self.name = name
self.description = description self.description = description
self.page_size_mm = page_size_mm self.page_size_mm = page_size_mm
@@ -30,21 +32,22 @@ class Template:
"name": self.name, "name": self.name,
"description": self.description, "description": self.description,
"page_size_mm": self.page_size_mm, "page_size_mm": self.page_size_mm,
"elements": [elem.serialize() for elem in self.elements] "elements": [elem.serialize() for elem in self.elements],
} }
@classmethod @classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Template': def from_dict(cls, data: Dict[str, Any]) -> "Template":
"""Deserialize template from dictionary""" """Deserialize template from dictionary"""
template = cls( template = cls(
name=data.get("name", "Untitled Template"), name=data.get("name", "Untitled Template"),
description=data.get("description", ""), description=data.get("description", ""),
page_size_mm=tuple(data.get("page_size_mm", (210, 297))) page_size_mm=tuple(data.get("page_size_mm", (210, 297))),
) )
# Deserialize elements # Deserialize elements
for elem_data in data.get("elements", []): for elem_data in data.get("elements", []):
elem_type = elem_data.get("type") elem_type = elem_data.get("type")
elem: BaseLayoutElement
if elem_type == "placeholder": if elem_type == "placeholder":
elem = PlaceholderData() elem = PlaceholderData()
elif elem_type == "textbox": elif elem_type == "textbox":
@@ -59,13 +62,13 @@ class Template:
def save_to_file(self, file_path: str): def save_to_file(self, file_path: str):
"""Save template to JSON file""" """Save template to JSON file"""
with open(file_path, 'w') as f: with open(file_path, "w") as f:
json.dump(self.to_dict(), f, indent=2) json.dump(self.to_dict(), f, indent=2)
@classmethod @classmethod
def load_from_file(cls, file_path: str) -> 'Template': def load_from_file(cls, file_path: str) -> "Template":
"""Load template from JSON file""" """Load template from JSON file"""
with open(file_path, 'r') as f: with open(file_path, "r") as f:
data = json.load(f) data = json.load(f)
return cls.from_dict(data) return cls.from_dict(data)
@@ -94,7 +97,7 @@ class TemplateManager:
def _ensure_templates_directory(self): def _ensure_templates_directory(self):
"""Create templates directory if it doesn't exist""" """Create templates directory if it doesn't exist"""
self.templates_dir.mkdir(parents=True, exist_ok=True) self.templates_dir.mkdir(parents=True, exist_ok=True)
# Also ensure built-in templates directory exists # Also ensure built-in templates directory exists
builtin_dir = self._get_builtin_templates_directory() builtin_dir = self._get_builtin_templates_directory()
builtin_dir.mkdir(parents=True, exist_ok=True) builtin_dir.mkdir(parents=True, exist_ok=True)
@@ -102,33 +105,33 @@ class TemplateManager:
def list_templates(self) -> List[str]: def list_templates(self) -> List[str]:
"""List all available template names (embedded + user + built-in)""" """List all available template names (embedded + user + built-in)"""
templates = [] templates = []
# List embedded templates (priority) # List embedded templates (priority)
if self.project and self.project.embedded_templates: if self.project and self.project.embedded_templates:
for template_name in self.project.embedded_templates.keys(): for template_name in self.project.embedded_templates.keys():
templates.append(f"[Embedded] {template_name}") templates.append(f"[Embedded] {template_name}")
# List user templates # List user templates
if self.templates_dir.exists(): if self.templates_dir.exists():
for file in self.templates_dir.glob("*.json"): for file in self.templates_dir.glob("*.json"):
templates.append(file.stem) templates.append(file.stem)
# List built-in templates # List built-in templates
builtin_dir = self._get_builtin_templates_directory() builtin_dir = self._get_builtin_templates_directory()
if builtin_dir.exists(): if builtin_dir.exists():
for file in builtin_dir.glob("*.json"): for file in builtin_dir.glob("*.json"):
template_name = f"[Built-in] {file.stem}" template_name = f"[Built-in] {file.stem}"
templates.append(template_name) templates.append(template_name)
return sorted(templates) return sorted(templates)
def load_template(self, name: str) -> Template: def load_template(self, name: str) -> Template:
""" """
Load a template by name with priority: embedded > user > built-in. Load a template by name with priority: embedded > user > built-in.
Args: Args:
name: Template name (may include prefix like '[Embedded]' or '[Built-in]') name: Template name (may include prefix like '[Embedded]' or '[Built-in]')
Returns: Returns:
Template instance Template instance
""" """
@@ -139,12 +142,12 @@ class TemplateManager:
template_data = self.project.embedded_templates[actual_name] template_data = self.project.embedded_templates[actual_name]
return Template.from_dict(template_data) return Template.from_dict(template_data)
raise FileNotFoundError(f"Embedded template '{actual_name}' not found") raise FileNotFoundError(f"Embedded template '{actual_name}' not found")
# Check embedded templates even without prefix (for backward compatibility) # Check embedded templates even without prefix (for backward compatibility)
if self.project and name in self.project.embedded_templates: if self.project and name in self.project.embedded_templates:
template_data = self.project.embedded_templates[name] template_data = self.project.embedded_templates[name]
return Template.from_dict(template_data) return Template.from_dict(template_data)
# Check if it's a built-in template # Check if it's a built-in template
if name.startswith("[Built-in] "): if name.startswith("[Built-in] "):
actual_name = name.replace("[Built-in] ", "") actual_name = name.replace("[Built-in] ", "")
@@ -152,16 +155,16 @@ class TemplateManager:
else: else:
# User template # User template
template_path = self.templates_dir / f"{name}.json" template_path = self.templates_dir / f"{name}.json"
if not template_path.exists(): if not template_path.exists():
raise FileNotFoundError(f"Template '{name}' not found") raise FileNotFoundError(f"Template '{name}' not found")
return Template.load_from_file(str(template_path)) return Template.load_from_file(str(template_path))
def save_template(self, template: Template, embed_in_project: bool = False): def save_template(self, template: Template, embed_in_project: bool = False):
""" """
Save a template to filesystem or embed in project. Save a template to filesystem or embed in project.
Args: Args:
template: Template to save template: Template to save
embed_in_project: If True, embed in project instead of saving to filesystem embed_in_project: If True, embed in project instead of saving to filesystem
@@ -179,7 +182,7 @@ class TemplateManager:
"""Delete a template (embedded or user templates only)""" """Delete a template (embedded or user templates only)"""
if name.startswith("[Built-in] "): if name.startswith("[Built-in] "):
raise PermissionError("Cannot delete built-in templates") raise PermissionError("Cannot delete built-in templates")
# Check if it's an embedded template # Check if it's an embedded template
if name.startswith("[Embedded] "): if name.startswith("[Embedded] "):
actual_name = name.replace("[Embedded] ", "") actual_name = name.replace("[Embedded] ", "")
@@ -188,7 +191,7 @@ class TemplateManager:
print(f"Removed embedded template '{actual_name}'") print(f"Removed embedded template '{actual_name}'")
return return
raise FileNotFoundError(f"Embedded template '{actual_name}' not found") raise FileNotFoundError(f"Embedded template '{actual_name}' not found")
# User template from filesystem # User template from filesystem
template_path = self.templates_dir / f"{name}.json" template_path = self.templates_dir / f"{name}.json"
if template_path.exists(): if template_path.exists():
@@ -197,26 +200,22 @@ class TemplateManager:
def embed_template(self, template: Template): def embed_template(self, template: Template):
""" """
Embed a template in the project. Embed a template in the project.
Args: Args:
template: Template to embed template: Template to embed
""" """
if not self.project: if not self.project:
raise RuntimeError("No project associated with this TemplateManager") raise RuntimeError("No project associated with this TemplateManager")
self.project.embedded_templates[template.name] = template.to_dict() self.project.embedded_templates[template.name] = template.to_dict()
print(f"Embedded template '{template.name}' in project") print(f"Embedded template '{template.name}' in project")
def create_template_from_page(self, page: Page, name: str, description: str = "") -> Template: def create_template_from_page(self, page: Page, name: str, description: str = "") -> Template:
""" """
Create a template from an existing page. Create a template from an existing page.
Converts all ImageData elements to PlaceholderData. Converts all ImageData elements to PlaceholderData.
""" """
template = Template( template = Template(name=name, description=description, page_size_mm=page.layout.size)
name=name,
description=description,
page_size_mm=page.layout.size
)
# Convert elements # Convert elements
for element in page.layout.elements: for element in page.layout.elements:
@@ -229,7 +228,7 @@ class TemplateManager:
width=element.size[0], width=element.size[0],
height=element.size[1], height=element.size[1],
rotation=element.rotation, rotation=element.rotation,
z_index=element.z_index z_index=element.z_index,
) )
template.add_element(placeholder) template.add_element(placeholder)
elif isinstance(element, TextBoxData): elif isinstance(element, TextBoxData):
@@ -243,7 +242,7 @@ class TemplateManager:
width=element.size[0], width=element.size[0],
height=element.size[1], height=element.size[1],
rotation=element.rotation, rotation=element.rotation,
z_index=element.z_index z_index=element.z_index,
) )
template.add_element(text_box) template.add_element(text_box)
elif isinstance(element, PlaceholderData): elif isinstance(element, PlaceholderData):
@@ -256,7 +255,7 @@ class TemplateManager:
width=element.size[0], width=element.size[0],
height=element.size[1], height=element.size[1],
rotation=element.rotation, rotation=element.rotation,
z_index=element.z_index z_index=element.z_index,
) )
template.add_element(placeholder) template.add_element(placeholder)
@@ -268,32 +267,32 @@ class TemplateManager:
from_size: Tuple[float, float], from_size: Tuple[float, float],
to_size: Tuple[float, float], to_size: Tuple[float, float],
scale_mode: str = "proportional", scale_mode: str = "proportional",
margin_percent: float = 0.0 margin_percent: float = 0.0,
) -> List[BaseLayoutElement]: ) -> List[BaseLayoutElement]:
""" """
Scale template elements to fit target page size with adjustable margins. Scale template elements to fit target page size with adjustable margins.
Args: Args:
elements: List of elements to scale elements: List of elements to scale
from_size: Original template size (width, height) in mm from_size: Original template size (width, height) in mm
to_size: Target page size (width, height) in mm to_size: Target page size (width, height) in mm
scale_mode: "proportional", "stretch", or "center" scale_mode: "proportional", "stretch", or "center"
margin_percent: Percentage of page size to use for margins (0-10%) margin_percent: Percentage of page size to use for margins (0-10%)
Returns: Returns:
List of scaled elements List of scaled elements
""" """
from_width, from_height = from_size from_width, from_height = from_size
to_width, to_height = to_size to_width, to_height = to_size
# Calculate target margins from percentage # Calculate target margins from percentage
margin_x = to_width * (margin_percent / 100.0) margin_x = to_width * (margin_percent / 100.0)
margin_y = to_height * (margin_percent / 100.0) margin_y = to_height * (margin_percent / 100.0)
# Available content area after margins # Available content area after margins
content_width = to_width - (2 * margin_x) content_width = to_width - (2 * margin_x)
content_height = to_height - (2 * margin_y) content_height = to_height - (2 * margin_y)
# Calculate scale factors based on mode # Calculate scale factors based on mode
if scale_mode == "stretch": if scale_mode == "stretch":
# Stretch to fill content area independently in each dimension # Stretch to fill content area independently in each dimension
@@ -318,40 +317,48 @@ class TemplateManager:
offset_x = (to_width - from_width) / 2 offset_x = (to_width - from_width) / 2
offset_y = (to_height - from_height) / 2 offset_y = (to_height - from_height) / 2
scaled_elements = [] scaled_elements: List[BaseLayoutElement] = []
for element in elements: for element in elements:
# Create a new element of the same type # Create a new element of the same type
new_elem: BaseLayoutElement
if isinstance(element, PlaceholderData): if isinstance(element, PlaceholderData):
new_elem = PlaceholderData( new_elem = PlaceholderData(
placeholder_type=element.placeholder_type, placeholder_type=element.placeholder_type, default_content=element.default_content
default_content=element.default_content
) )
elif isinstance(element, TextBoxData): elif isinstance(element, TextBoxData):
new_elem = TextBoxData( new_elem = TextBoxData(
text_content=element.text_content, text_content=element.text_content,
font_settings=element.font_settings.copy() if element.font_settings else None, font_settings=element.font_settings.copy() if element.font_settings else None,
alignment=element.alignment alignment=element.alignment,
) )
else: else:
continue # Skip other types continue # Skip other types
# Scale position and size # Scale position and size (still in mm)
old_x, old_y = element.position old_x, old_y = element.position
old_w, old_h = element.size old_w, old_h = element.size
new_elem.position = ( new_elem.position = (old_x * scale_x + offset_x, old_y * scale_y + offset_y)
old_x * scale_x + offset_x, new_elem.size = (old_w * scale_x, old_h * scale_y)
old_y * scale_y + offset_y
)
new_elem.size = (
old_w * scale_x,
old_h * scale_y
)
new_elem.rotation = element.rotation new_elem.rotation = element.rotation
new_elem.z_index = element.z_index new_elem.z_index = element.z_index
scaled_elements.append(new_elem) 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 return scaled_elements
def apply_template_to_page( def apply_template_to_page(
@@ -361,15 +368,15 @@ class TemplateManager:
mode: str = "replace", mode: str = "replace",
scale_mode: str = "proportional", scale_mode: str = "proportional",
margin_percent: float = 2.5, margin_percent: float = 2.5,
auto_embed: bool = True auto_embed: bool = True,
): ):
""" """
Apply template to an existing page with adjustable margins. Apply template to an existing page with adjustable margins.
Args: Args:
template: Template to apply template: Template to apply
page: Target page page: Target page
mode: "replace" to clear page and add placeholders, mode: "replace" to clear page and add placeholders,
"reflow" to keep existing content and reposition "reflow" to keep existing content and reposition
scale_mode: "proportional", "stretch", or "center" scale_mode: "proportional", "stretch", or "center"
margin_percent: Percentage of page size to use for margins (0-10%) margin_percent: Percentage of page size to use for margins (0-10%)
@@ -379,44 +386,36 @@ class TemplateManager:
if auto_embed and self.project: if auto_embed and self.project:
if template.name not in self.project.embedded_templates: if template.name not in self.project.embedded_templates:
self.embed_template(template) self.embed_template(template)
if mode == "replace": if mode == "replace":
# Clear existing elements # Clear existing elements
page.layout.elements.clear() page.layout.elements.clear()
# Scale template elements to fit page # Scale template elements to fit page
scaled_elements = self.scale_template_elements( scaled_elements = self.scale_template_elements(
template.elements, template.elements, template.page_size_mm, page.layout.size, scale_mode, margin_percent
template.page_size_mm,
page.layout.size,
scale_mode,
margin_percent
) )
# Add scaled elements to page # Add scaled elements to page
for element in scaled_elements: for element in scaled_elements:
page.layout.add_element(element) page.layout.add_element(element)
elif mode == "reflow": elif mode == "reflow":
# Keep existing content but reposition to template slots # Keep existing content but reposition to template slots
existing_images = [e for e in page.layout.elements if isinstance(e, ImageData)] 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)] existing_text = [e for e in page.layout.elements if isinstance(e, TextBoxData)]
# Get template placeholders (scaled) # Get template placeholders (scaled)
scaled_elements = self.scale_template_elements( scaled_elements = self.scale_template_elements(
template.elements, template.elements, template.page_size_mm, page.layout.size, scale_mode, margin_percent
template.page_size_mm,
page.layout.size,
scale_mode,
margin_percent
) )
template_placeholders = [e for e in scaled_elements if isinstance(e, PlaceholderData)] template_placeholders = [e for e in scaled_elements if isinstance(e, PlaceholderData)]
template_text = [e for e in scaled_elements if isinstance(e, TextBoxData)] template_text = [e for e in scaled_elements if isinstance(e, TextBoxData)]
# Clear page # Clear page
page.layout.elements.clear() page.layout.elements.clear()
# Reflow images into placeholder slots # Reflow images into placeholder slots
for i, placeholder in enumerate(template_placeholders): for i, placeholder in enumerate(template_placeholders):
if i < len(existing_images): if i < len(existing_images):
@@ -429,11 +428,11 @@ class TemplateManager:
else: else:
# Add placeholder if no more images # Add placeholder if no more images
page.layout.add_element(placeholder) page.layout.add_element(placeholder)
# Add remaining images (if any) at their original positions # Add remaining images (if any) at their original positions
for img in existing_images[len(template_placeholders):]: for img in existing_images[len(template_placeholders) :]:
page.layout.add_element(img) page.layout.add_element(img)
# Add template text boxes # Add template text boxes
for text_elem in template_text: for text_elem in template_text:
page.layout.add_element(text_elem) page.layout.add_element(text_elem)
@@ -444,18 +443,20 @@ class TemplateManager:
page_number: int = 1, page_number: int = 1,
target_size_mm: Optional[Tuple[float, float]] = None, target_size_mm: Optional[Tuple[float, float]] = None,
scale_mode: str = "proportional", scale_mode: str = "proportional",
auto_embed: bool = True margin_percent: float = 2.5,
auto_embed: bool = True,
) -> Page: ) -> Page:
""" """
Create a new page from a template. Create a new page from a template.
Args: Args:
template: Template to use template: Template to use
page_number: Page number for the new page page_number: Page number for the new page
target_size_mm: Target page size (if different from template) target_size_mm: Target page size (if different from template)
scale_mode: Scaling mode if target_size_mm is provided 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 auto_embed: If True, automatically embed template in project
Returns: Returns:
New Page instance with template layout New Page instance with template layout
""" """
@@ -463,24 +464,21 @@ class TemplateManager:
if auto_embed and self.project: if auto_embed and self.project:
if template.name not in self.project.embedded_templates: if template.name not in self.project.embedded_templates:
self.embed_template(template) self.embed_template(template)
# Determine page size # Determine page size
if target_size_mm is None: if target_size_mm is None:
page_size = template.page_size_mm page_size = template.page_size_mm
elements = [e for e in template.elements] # Copy elements as-is elements = [e for e in template.elements] # Copy elements as-is
else: else:
page_size = target_size_mm page_size = target_size_mm
# Scale template elements # Scale template elements with margins
elements = self.scale_template_elements( elements = self.scale_template_elements(
template.elements, template.elements, template.page_size_mm, target_size_mm, scale_mode, margin_percent
template.page_size_mm,
target_size_mm,
scale_mode
) )
# Create new page layout # Create new page layout
layout = PageLayout(width=page_size[0], height=page_size[1]) layout = PageLayout(width=page_size[0], height=page_size[1])
# Add elements # Add elements
for element in elements: for element in elements:
layout.add_element(element) layout.add_element(element)
+70
View 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
View 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": ""
}
]
}
+19 -19
View File
@@ -1,20 +1,20 @@
{ {
"name": "Grid_2x2", "name": "Grid_2x2",
"description": "Simple 2x2 grid layout with equal-sized image placeholders (square page, margins applied at use time)", "description": "2x2 grid layout with 5mm spacing between placeholders and 5mm borders",
"page_size_mm": [ "page_size_mm": [
210, 200,
210 200
], ],
"elements": [ "elements": [
{ {
"type": "placeholder", "type": "placeholder",
"position": [ "position": [
0, 5,
0 5
], ],
"size": [ "size": [
105, 92.5,
105 92.5
], ],
"rotation": 0, "rotation": 0,
"z_index": 0, "z_index": 0,
@@ -24,12 +24,12 @@
{ {
"type": "placeholder", "type": "placeholder",
"position": [ "position": [
105, 102.5,
0 5
], ],
"size": [ "size": [
105, 92.5,
105 92.5
], ],
"rotation": 0, "rotation": 0,
"z_index": 0, "z_index": 0,
@@ -39,12 +39,12 @@
{ {
"type": "placeholder", "type": "placeholder",
"position": [ "position": [
0, 5,
105 102.5
], ],
"size": [ "size": [
105, 92.5,
105 92.5
], ],
"rotation": 0, "rotation": 0,
"z_index": 0, "z_index": 0,
@@ -54,12 +54,12 @@
{ {
"type": "placeholder", "type": "placeholder",
"position": [ "position": [
105, 102.5,
105 102.5
], ],
"size": [ "size": [
105, 92.5,
105 92.5
], ],
"rotation": 0, "rotation": 0,
"z_index": 0, "z_index": 0,
+55
View 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
View 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": ""
}
]
}
@@ -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": ""
}
]
}
+8 -8
View File
@@ -1,9 +1,9 @@
{ {
"name": "Single_Large", "name": "Single_Large",
"description": "Single large image placeholder with title text (square page, margins applied at use time)", "description": "Single large image placeholder with title text",
"page_size_mm": [ "page_size_mm": [
210, 200,
210 200
], ],
"elements": [ "elements": [
{ {
@@ -13,8 +13,8 @@
0 0
], ],
"size": [ "size": [
210, 200,
25 20
], ],
"rotation": 0, "rotation": 0,
"z_index": 1, "z_index": 1,
@@ -34,11 +34,11 @@
"type": "placeholder", "type": "placeholder",
"position": [ "position": [
0, 0,
25 20
], ],
"size": [ "size": [
210, 200,
185 180
], ],
"rotation": 0, "rotation": 0,
"z_index": 0, "z_index": 0,
+40
View File
@@ -0,0 +1,40 @@
{
"name": "Two_Column",
"description": "2 equal vertical columns with 5mm spacing and borders",
"page_size_mm": [
200,
200
],
"elements": [
{
"type": "placeholder",
"position": [
5,
5
],
"size": [
92.5,
190
],
"rotation": 0,
"z_index": 0,
"placeholder_type": "image",
"default_content": ""
},
{
"type": "placeholder",
"position": [
102.5,
5
],
"size": [
92.5,
190
],
"rotation": 0,
"z_index": 0,
"placeholder_type": "image",
"default_content": ""
}
]
}
+47 -49
View File
@@ -3,8 +3,15 @@ Text editing dialog for pyPhotoAlbum
""" """
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QDialog,
QTextEdit, QLabel, QComboBox, QSpinBox, QColorDialog QVBoxLayout,
QHBoxLayout,
QPushButton,
QTextEdit,
QLabel,
QComboBox,
QSpinBox,
QColorDialog,
) )
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
from PyQt6.QtGui import QFont, QColor from PyQt6.QtGui import QFont, QColor
@@ -12,144 +19,135 @@ from PyQt6.QtGui import QFont, QColor
class TextEditDialog(QDialog): class TextEditDialog(QDialog):
"""Dialog for editing text box content and properties""" """Dialog for editing text box content and properties"""
def __init__(self, text_element, parent=None): def __init__(self, text_element, parent=None):
super().__init__(parent) super().__init__(parent)
self.text_element = text_element self.text_element = text_element
self.setWindowTitle("Edit Text") self.setWindowTitle("Edit Text")
self.resize(500, 400) self.resize(500, 400)
# Create UI # Create UI
self._init_ui() self._init_ui()
# Load current values # Load current values
self._load_values() self._load_values()
def _init_ui(self): def _init_ui(self):
"""Initialize the user interface""" """Initialize the user interface"""
layout = QVBoxLayout() layout = QVBoxLayout()
# Text editor # Text editor
text_label = QLabel("Text:") text_label = QLabel("Text:")
self.text_edit = QTextEdit() self.text_edit = QTextEdit()
self.text_edit.setAcceptRichText(False) # Plain text only self.text_edit.setAcceptRichText(False) # Plain text only
layout.addWidget(text_label) layout.addWidget(text_label)
layout.addWidget(self.text_edit) layout.addWidget(self.text_edit)
# Font settings # Font settings
font_layout = QHBoxLayout() font_layout = QHBoxLayout()
# Font family # Font family
font_layout.addWidget(QLabel("Font:")) font_layout.addWidget(QLabel("Font:"))
self.font_combo = QComboBox() self.font_combo = QComboBox()
self.font_combo.addItems([ self.font_combo.addItems(
"Arial", "Times New Roman", "Courier New", ["Arial", "Times New Roman", "Courier New", "Helvetica", "Verdana", "Georgia", "Comic Sans MS"]
"Helvetica", "Verdana", "Georgia", "Comic Sans MS" )
])
font_layout.addWidget(self.font_combo) font_layout.addWidget(self.font_combo)
# Font size # Font size
font_layout.addWidget(QLabel("Size:")) font_layout.addWidget(QLabel("Size:"))
self.font_size_spin = QSpinBox() self.font_size_spin = QSpinBox()
self.font_size_spin.setRange(6, 72) self.font_size_spin.setRange(6, 72)
self.font_size_spin.setValue(12) self.font_size_spin.setValue(12)
font_layout.addWidget(self.font_size_spin) font_layout.addWidget(self.font_size_spin)
# Text color # Text color
self.color_button = QPushButton("Color") self.color_button = QPushButton("Color")
self.color_button.clicked.connect(self._choose_color) self.color_button.clicked.connect(self._choose_color)
self.current_color = QColor(0, 0, 0) # Default black self.current_color = QColor(0, 0, 0) # Default black
font_layout.addWidget(self.color_button) font_layout.addWidget(self.color_button)
font_layout.addStretch() font_layout.addStretch()
layout.addLayout(font_layout) layout.addLayout(font_layout)
# Alignment # Alignment
alignment_layout = QHBoxLayout() alignment_layout = QHBoxLayout()
alignment_layout.addWidget(QLabel("Alignment:")) alignment_layout.addWidget(QLabel("Alignment:"))
self.alignment_combo = QComboBox() self.alignment_combo = QComboBox()
self.alignment_combo.addItems(["left", "center", "right"]) self.alignment_combo.addItems(["left", "center", "right", "justify"])
alignment_layout.addWidget(self.alignment_combo) alignment_layout.addWidget(self.alignment_combo)
alignment_layout.addStretch() alignment_layout.addStretch()
layout.addLayout(alignment_layout) layout.addLayout(alignment_layout)
# Buttons # Buttons
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
button_layout.addStretch() button_layout.addStretch()
cancel_button = QPushButton("Cancel") cancel_button = QPushButton("Cancel")
cancel_button.clicked.connect(self.reject) cancel_button.clicked.connect(self.reject)
button_layout.addWidget(cancel_button) button_layout.addWidget(cancel_button)
ok_button = QPushButton("OK") ok_button = QPushButton("OK")
ok_button.clicked.connect(self.accept) ok_button.clicked.connect(self.accept)
ok_button.setDefault(True) ok_button.setDefault(True)
button_layout.addWidget(ok_button) button_layout.addWidget(ok_button)
layout.addLayout(button_layout) layout.addLayout(button_layout)
self.setLayout(layout) self.setLayout(layout)
def _load_values(self): def _load_values(self):
"""Load current values from text element""" """Load current values from text element"""
# Load text content # Load text content
self.text_edit.setPlainText(self.text_element.text_content) self.text_edit.setPlainText(self.text_element.text_content)
# Load font settings # Load font settings
font_family = self.text_element.font_settings.get('family', 'Arial') font_family = self.text_element.font_settings.get("family", "Arial")
index = self.font_combo.findText(font_family) index = self.font_combo.findText(font_family)
if index >= 0: if index >= 0:
self.font_combo.setCurrentIndex(index) self.font_combo.setCurrentIndex(index)
font_size = self.text_element.font_settings.get('size', 12) font_size = self.text_element.font_settings.get("size", 12)
self.font_size_spin.setValue(int(font_size)) self.font_size_spin.setValue(int(font_size))
# Load color # Load color
color = self.text_element.font_settings.get('color', (0, 0, 0)) color = self.text_element.font_settings.get("color", (0, 0, 0))
if all(isinstance(c, int) and c > 1 for c in color): if all(isinstance(c, int) and c > 1 for c in color):
# Color in 0-255 range # Color in 0-255 range
self.current_color = QColor(*color) self.current_color = QColor(*color)
else: else:
# Color in 0-1 range # Color in 0-1 range
self.current_color = QColor( self.current_color = QColor(int(color[0] * 255), int(color[1] * 255), int(color[2] * 255))
int(color[0] * 255),
int(color[1] * 255),
int(color[2] * 255)
)
self._update_color_button() self._update_color_button()
# Load alignment # Load alignment
alignment = self.text_element.alignment alignment = self.text_element.alignment
index = self.alignment_combo.findText(alignment) index = self.alignment_combo.findText(alignment)
if index >= 0: if index >= 0:
self.alignment_combo.setCurrentIndex(index) self.alignment_combo.setCurrentIndex(index)
def _choose_color(self): def _choose_color(self):
"""Open color picker dialog""" """Open color picker dialog"""
color = QColorDialog.getColor(self.current_color, self, "Choose Text Color") color = QColorDialog.getColor(self.current_color, self, "Choose Text Color")
if color.isValid(): if color.isValid():
self.current_color = color self.current_color = color
self._update_color_button() self._update_color_button()
def _update_color_button(self): def _update_color_button(self):
"""Update color button appearance""" """Update color button appearance"""
self.color_button.setStyleSheet( self.color_button.setStyleSheet(
f"background-color: {self.current_color.name()}; " f"background-color: {self.current_color.name()}; "
f"color: {'white' if self.current_color.lightness() < 128 else 'black'};" f"color: {'white' if self.current_color.lightness() < 128 else 'black'};"
) )
def get_values(self): def get_values(self):
"""Get the edited values""" """Get the edited values"""
return { return {
'text_content': self.text_edit.toPlainText(), "text_content": self.text_edit.toPlainText(),
'font_settings': { "font_settings": {
'family': self.font_combo.currentText(), "family": self.font_combo.currentText(),
'size': self.font_size_spin.value(), "size": self.font_size_spin.value(),
'color': ( "color": (self.current_color.red(), self.current_color.green(), self.current_color.blue()),
self.current_color.red(),
self.current_color.green(),
self.current_color.blue()
)
}, },
'alignment': self.alignment_combo.currentText() "alignment": self.alignment_combo.currentText(),
} }
+899
View File
@@ -0,0 +1,899 @@
"""
Thumbnail Browser Widget - displays thumbnails from a folder for drag-and-drop into album.
"""
import os
from pathlib import Path
from typing import Optional, List, Tuple
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QFileDialog, QDockWidget, QScrollBar
from PyQt6.QtCore import Qt, QSize, QMimeData, QUrl, QPoint
from PyQt6.QtGui import QDrag, QCursor, QPainter, QFont, QColor
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
from pyPhotoAlbum.gl_imports import *
from pyPhotoAlbum.mixins.viewport import ViewportMixin
IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp"]
class DateHeader:
"""Represents a date separator header in the thumbnail list."""
def __init__(self, date_text: str, y_position: float):
self.date_text = date_text
self.y = y_position
self.height = 30.0 # Height of the header bar
class ThumbnailItem:
"""Represents a thumbnail with position and path information."""
def __init__(self, image_path: str, grid_pos: Tuple[int, int], thumbnail_size: float = 100.0):
self.image_path = image_path
self.grid_row, self.grid_col = grid_pos
self.thumbnail_size = thumbnail_size
self.is_used_in_project = False # Will be updated when checking against project
# Position in mm (will be calculated based on grid)
spacing = 10.0 # mm spacing between thumbnails
self.x = self.grid_col * (self.thumbnail_size + spacing) + spacing
self.y = self.grid_row * (self.thumbnail_size + spacing) + spacing
# Texture info (loaded async)
self._texture_id = None
self._pending_pil_image = None
self._async_loading = False
self._img_width = None
self._img_height = None
def get_bounds(self) -> Tuple[float, float, float, float]:
"""Return (x, y, width, height) bounds."""
return (self.x, self.y, self.thumbnail_size, self.thumbnail_size)
def contains_point(self, x: float, y: float) -> bool:
"""Check if point is inside this thumbnail."""
return self.x <= x <= self.x + self.thumbnail_size and self.y <= y <= self.y + self.thumbnail_size
class ThumbnailGLWidget(QOpenGLWidget):
"""
OpenGL widget that displays thumbnails in a grid.
Uses the same async loading and texture system as the main canvas.
"""
def __init__(self, main_window=None):
super().__init__()
self.thumbnails: List[ThumbnailItem] = []
self.date_headers: List[DateHeader] = []
self.current_folder: Optional[Path] = None
# Store reference to main window
self._main_window = main_window
# Viewport state
self.zoom_level = 1.0
self.pan_offset = (0, 0)
# Dragging state
self.drag_start_pos = None
self.dragging_thumbnail = None
# Scrollbar (created but managed by parent)
self.scrollbar = None
self._updating_scrollbar = False # Flag to prevent circular updates
# Sort mode (set by parent dock)
self.sort_mode = "name"
self._get_image_date_func = None # Function to get date from parent
# Enable OpenGL
self.setMinimumSize(QSize(250, 300))
def window(self):
"""Override window() to return stored main_window reference."""
return self._main_window if self._main_window else super().window()
def update(self):
"""Override update to batch repaints for better performance."""
# Just schedule the update - Qt will automatically batch multiple
# update() calls into a single paintGL() invocation
super().update()
def initializeGL(self):
"""Initialize OpenGL context."""
glClearColor(0.95, 0.95, 0.95, 1.0) # Light gray background
glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
glEnable(GL_TEXTURE_2D)
def resizeGL(self, w, h):
"""Handle resize events."""
glViewport(0, 0, w, h)
glMatrixMode(GL_PROJECTION)
glLoadIdentity()
glOrtho(0, w, h, 0, -1, 1) # 2D orthographic projection
glMatrixMode(GL_MODELVIEW)
# Rearrange thumbnails to fit new width
if hasattr(self, "image_files") and self.image_files:
self._arrange_thumbnails()
else:
# Still update scrollbar even if no thumbnails
self._update_scrollbar_range()
def paintGL(self):
"""Render thumbnails."""
glClear(GL_COLOR_BUFFER_BIT)
glLoadIdentity()
if not self.thumbnails:
return
# Apply zoom and pan
glTranslatef(self.pan_offset[0], self.pan_offset[1], 0)
glScalef(self.zoom_level, self.zoom_level, 1.0)
# Render date headers first (so they appear behind thumbnails)
for header in self.date_headers:
self._render_date_header(header)
# Render each thumbnail (placeholders or textures)
for thumb in self.thumbnails:
self._render_thumbnail(thumb)
def paintEvent(self, event):
"""Override paintEvent to add text labels after OpenGL rendering."""
# Call the default OpenGL paint
super().paintEvent(event)
# Draw text labels for date headers using QPainter
if self.date_headers and self.sort_mode == "date":
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# Set font for date labels
font = QFont("Arial", 11, QFont.Weight.Bold)
painter.setFont(font)
painter.setPen(QColor(255, 255, 255)) # White text
for header in self.date_headers:
# Transform header position to screen coordinates
screen_y = header.y * self.zoom_level + self.pan_offset[1]
screen_h = header.height * self.zoom_level
# Only draw if header is visible
if screen_y + screen_h >= 0 and screen_y <= self.height():
# Draw text centered vertically in the header bar
text_y = int(screen_y + screen_h / 2)
painter.drawText(10, text_y + 5, header.date_text)
painter.end()
def _render_thumbnail(self, thumb: ThumbnailItem):
"""Render a single thumbnail using placeholder pattern."""
x, y, w, h = thumb.get_bounds()
# If we have a pending image, convert it to texture (happens once per image)
if hasattr(thumb, "_pending_pil_image") and thumb._pending_pil_image is not None:
self._create_texture_for_thumbnail(thumb)
# Render based on state: texture, loading placeholder, or empty placeholder
if thumb._texture_id:
# Calculate aspect-ratio-corrected dimensions
if hasattr(thumb, "_img_width") and hasattr(thumb, "_img_height"):
img_aspect = thumb._img_width / thumb._img_height
thumb_aspect = w / h
if img_aspect > thumb_aspect:
# Image is wider - fit to width
render_w = w
render_h = w / img_aspect
render_x = x
render_y = y + (h - render_h) / 2
else:
# Image is taller - fit to height
render_h = h
render_w = h * img_aspect
render_x = x + (w - render_w) / 2
render_y = y
else:
# No aspect ratio info, use full bounds
render_x, render_y, render_w, render_h = x, y, w, h
# Render actual texture
glEnable(GL_TEXTURE_2D)
glBindTexture(GL_TEXTURE_2D, thumb._texture_id)
# If used in project, desaturate by tinting grey
if thumb.is_used_in_project:
glColor4f(0.5, 0.5, 0.5, 0.6) # Grey tint + partial transparency
else:
glColor4f(1.0, 1.0, 1.0, 1.0)
glBegin(GL_QUADS)
glTexCoord2f(0.0, 0.0)
glVertex2f(render_x, render_y)
glTexCoord2f(1.0, 0.0)
glVertex2f(render_x + render_w, render_y)
glTexCoord2f(1.0, 1.0)
glVertex2f(render_x + render_w, render_y + render_h)
glTexCoord2f(0.0, 1.0)
glVertex2f(render_x, render_y + render_h)
glEnd()
glDisable(GL_TEXTURE_2D)
else:
# Render placeholder (grey box while loading or if load failed)
glColor3f(0.8, 0.8, 0.8)
glBegin(GL_QUADS)
glVertex2f(x, y)
glVertex2f(x + w, y)
glVertex2f(x + w, y + h)
glVertex2f(x, y + h)
glEnd()
# Border
glColor3f(0.5, 0.5, 0.5)
glLineWidth(1.0)
glBegin(GL_LINE_LOOP)
glVertex2f(x, y)
glVertex2f(x + w, y)
glVertex2f(x + w, y + h)
glVertex2f(x, y + h)
glEnd()
def _render_date_header(self, header: DateHeader):
"""Render a date separator header."""
# Calculate full width bar
widget_width = self.width() / self.zoom_level
x = 0
y = header.y
w = widget_width
h = header.height
# Draw background bar (dark blue-gray)
glColor3f(0.3, 0.4, 0.5)
glBegin(GL_QUADS)
glVertex2f(x, y)
glVertex2f(x + w, y)
glVertex2f(x + w, y + h)
glVertex2f(x, y + h)
glEnd()
# Draw bottom border
glColor3f(0.2, 0.3, 0.4)
glLineWidth(2.0)
glBegin(GL_LINES)
glVertex2f(x, y + h)
glVertex2f(x + w, y + h)
glEnd()
# Note: Text rendering would require QPainter overlay
# For now, the colored bar serves as a visual separator
# Text will be added using QPainter in a future enhancement
def _create_texture_for_thumbnail(self, thumb: ThumbnailItem):
"""Create OpenGL texture from pending PIL image."""
if not thumb._pending_pil_image:
return False
try:
pil_image = thumb._pending_pil_image
# Ensure RGBA
if pil_image.mode != "RGBA":
pil_image = pil_image.convert("RGBA")
# Delete old texture
if thumb._texture_id:
glDeleteTextures([thumb._texture_id])
# Create texture
img_data = pil_image.tobytes()
texture_id = glGenTextures(1)
glBindTexture(GL_TEXTURE_2D, texture_id)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
glTexImage2D(
GL_TEXTURE_2D, 0, GL_RGBA, pil_image.width, pil_image.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, img_data
)
thumb._texture_id = texture_id
thumb._img_width = pil_image.width
thumb._img_height = pil_image.height
thumb._pending_pil_image = None
return True
except Exception as e:
print(f"Error creating texture for thumbnail: {e}")
thumb._pending_pil_image = None
return False
def load_folder(self, folder_path: Path):
"""Load thumbnails from a folder."""
self.current_folder = folder_path
# Find all image files
self.image_files: list[Path] = []
for ext in IMAGE_EXTENSIONS:
self.image_files.extend(folder_path.glob(f"*{ext}"))
self.image_files.extend(folder_path.glob(f"*{ext.upper()}"))
self.image_files.sort()
# Arrange thumbnails based on current widget size and zoom
self._arrange_thumbnails()
# Update which images are already in use
self.update_used_images()
self.update()
def _arrange_thumbnails(self):
"""Arrange thumbnails in a grid based on widget width and zoom level."""
if not hasattr(self, "image_files") or not self.image_files:
self.thumbnails.clear()
return
# Calculate number of columns that fit
widget_width = self.width()
if widget_width <= 0:
widget_width = 250 # Default minimum width
# Thumbnail size in screen pixels (affected by zoom)
thumb_size_screen = 100.0 * self.zoom_level
spacing_screen = 10.0 * self.zoom_level
# Calculate columns
columns = max(1, int((widget_width - spacing_screen) / (thumb_size_screen + spacing_screen)))
# Calculate total grid width to center it
spacing = 10.0
grid_width = columns * (100.0 + spacing) - spacing # Total width in base units
# Horizontal offset to center the grid
h_offset = max(0, (widget_width / self.zoom_level - grid_width) / 2)
# Build a map of existing thumbnails by path to reuse them
existing_thumbs = {thumb.image_path: thumb for thumb in self.thumbnails}
# Clear lists but reuse thumbnail objects
self.thumbnails.clear()
self.date_headers.clear()
# For date mode: track current date and positioning
current_date_str = None
section_start_y = spacing
row_in_section = 0
col = 0
for idx, image_file in enumerate(self.image_files):
image_path = str(image_file)
# Check if we need a date header (only in date sort mode)
if self.sort_mode == "date" and self._get_image_date_func:
from datetime import datetime
timestamp = self._get_image_date_func(image_file)
date_obj = datetime.fromtimestamp(timestamp)
date_str = date_obj.strftime("%B %d, %Y") # e.g., "December 13, 2025"
if date_str != current_date_str:
# Starting a new date section
if current_date_str is not None:
# Not the first section - calculate where this section starts
# It should start after the last thumbnail of the previous section
if self.thumbnails:
last_thumb = self.thumbnails[-1]
# Start after the last row of previous section
last_row_y = last_thumb.y + last_thumb.thumbnail_size
section_start_y = last_row_y + spacing * 2 # Extra spacing between sections
# Add header at section start
header = DateHeader(date_str, section_start_y)
self.date_headers.append(header)
# Update section_start_y to after the header
section_start_y += header.height + spacing
current_date_str = date_str
row_in_section = 0
col = 0
# Calculate position
if self.sort_mode == "date":
# In date mode: position relative to section start
row = row_in_section
thumb_y = section_start_y + row * (100.0 + spacing)
else:
# In other modes: simple grid based on overall index
row = idx // columns
thumb_y = row * (100.0 + spacing) + spacing
# Calculate X position (always centered)
thumb_x = h_offset + col * (100.0 + spacing) + spacing
# Reuse existing thumbnail if available, otherwise create new
if image_path in existing_thumbs:
thumb = existing_thumbs[image_path]
thumb.grid_row = row
thumb.grid_col = col
thumb.x = thumb_x
thumb.y = thumb_y
else:
# Create new placeholder thumbnail
thumb = ThumbnailItem(image_path, (row, col))
thumb.x = thumb_x
thumb.y = thumb_y
# Request async load
self._request_thumbnail_load(thumb)
self.thumbnails.append(thumb)
# Update column and row counters
col += 1
if col >= columns:
col = 0
row_in_section += 1
# Update scrollbar range after arranging
self._update_scrollbar_range()
def _update_scrollbar_range(self):
"""Update scrollbar range based on content height."""
if not self.scrollbar or self._updating_scrollbar:
return
if not self.thumbnails:
self.scrollbar.setRange(0, 0)
self.scrollbar.setPageStep(self.height())
return
# Calculate total content height
if self.thumbnails:
# Find the maximum Y position
max_y = max(thumb.y + thumb.thumbnail_size for thumb in self.thumbnails)
content_height = max_y * self.zoom_level
else:
content_height = 0
# Visible height
visible_height = self.height()
# Scrollable range
scroll_range = max(0, int(content_height - visible_height))
self._updating_scrollbar = True
self.scrollbar.setRange(0, scroll_range)
self.scrollbar.setPageStep(visible_height)
self.scrollbar.setSingleStep(int(visible_height / 10)) # 10% of visible height per step
# Update scrollbar position based on current pan
scroll_pos = int(-self.pan_offset[1])
self.scrollbar.setValue(scroll_pos)
self._updating_scrollbar = False
def _on_scrollbar_changed(self, value):
"""Handle scrollbar value change."""
if self._updating_scrollbar:
return
# Update pan offset based on scrollbar value
self.pan_offset = (0, -value)
self.update()
def _update_scrollbar_position(self):
"""Update scrollbar position based on current pan offset."""
if not self.scrollbar or self._updating_scrollbar:
return
self._updating_scrollbar = True
scroll_pos = int(-self.pan_offset[1])
self.scrollbar.setValue(scroll_pos)
self._updating_scrollbar = False
def update_used_images(self):
"""Update which thumbnails are already used in the project."""
# Get reference to main window's project
main_window = self.window()
if not hasattr(main_window, "project") or not main_window.project:
return
project = main_window.project
# Collect all image paths used in the project
used_paths = set()
for page in project.pages:
from pyPhotoAlbum.models import ImageData
for element in page.layout.elements:
if isinstance(element, ImageData) and element.image_path:
# Resolve to absolute path for comparison
abs_path = element.resolve_image_path()
if abs_path:
used_paths.add(abs_path)
# Mark thumbnails as used
for thumb in self.thumbnails:
thumb.is_used_in_project = thumb.image_path in used_paths
def _request_thumbnail_load(self, thumb: ThumbnailItem):
"""Request async load for a thumbnail using main window's loader."""
# Skip if already loading or loaded
if thumb._async_loading or thumb._texture_id:
return
# Get main window's async loader
main_window = self.window()
if not main_window or not hasattr(main_window, "_gl_widget"):
return
gl_widget = main_window._gl_widget
if not hasattr(gl_widget, "async_image_loader"):
return
from pyPhotoAlbum.async_backend import LoadPriority
try:
# Mark as loading to prevent duplicate requests
thumb._async_loading = True
# Request load through main window's async loader
# Use LOW priority for thumbnails to not interfere with main canvas
gl_widget.async_image_loader.request_load(
Path(thumb.image_path),
priority=LoadPriority.LOW,
target_size=(200, 200), # Small thumbnails
user_data=thumb,
)
except RuntimeError:
thumb._async_loading = False # Reset on error
def _on_image_loaded(self, path: Path, image, user_data):
"""Handle async image loaded - sets pending image on the placeholder."""
if isinstance(user_data, ThumbnailItem):
# Store the loaded image in the placeholder
user_data._pending_pil_image = image
user_data._img_width = image.width
user_data._img_height = image.height
user_data._async_loading = False
# Schedule a repaint (will be batched if many images load quickly)
self.update()
def _on_image_load_failed(self, path: Path, error_msg: str, user_data):
"""Handle async image load failure."""
pass # Silently ignore load failures for thumbnails
def screen_to_viewport(self, screen_x: int, screen_y: int) -> Tuple[float, float]:
"""Convert screen coordinates to viewport coordinates (accounting for zoom/pan)."""
vp_x = (screen_x - self.pan_offset[0]) / self.zoom_level
vp_y = (screen_y - self.pan_offset[1]) / self.zoom_level
return vp_x, vp_y
def get_thumbnail_at(self, screen_x: int, screen_y: int) -> Optional[ThumbnailItem]:
"""Get thumbnail at screen position."""
vp_x, vp_y = self.screen_to_viewport(screen_x, screen_y)
for thumb in self.thumbnails:
if thumb.contains_point(vp_x, vp_y):
return thumb
return None
def mousePressEvent(self, event):
"""Handle mouse press for drag."""
if event.button() == Qt.MouseButton.LeftButton:
self.drag_start_pos = event.pos()
self.dragging_thumbnail = self.get_thumbnail_at(event.pos().x(), event.pos().y())
def mouseMoveEvent(self, event):
"""Handle mouse move for drag or pan."""
if not (event.buttons() & Qt.MouseButton.LeftButton):
return
if self.drag_start_pos is None:
return
# Check if we should start dragging a thumbnail
if self.dragging_thumbnail:
# Start drag operation
drag = QDrag(self)
mime_data = QMimeData()
# Set file URL for the drag
url = QUrl.fromLocalFile(self.dragging_thumbnail.image_path)
mime_data.setUrls([url])
drag.setMimeData(mime_data)
# Execute drag (this blocks until drop or cancel)
drag.exec(Qt.DropAction.CopyAction)
# Reset drag state
self.drag_start_pos = None
self.dragging_thumbnail = None
else:
# Pan the view (right-click or middle-click drag)
# Only allow vertical panning - grid is always horizontally centered
delta = event.pos() - self.drag_start_pos
self.pan_offset = (0, self.pan_offset[1] + delta.y()) # No horizontal pan - grid is centered
self.drag_start_pos = event.pos()
self._update_scrollbar_position()
self.update()
def mouseReleaseEvent(self, event):
"""Handle mouse release."""
self.drag_start_pos = None
self.dragging_thumbnail = None
def wheelEvent(self, event):
"""Handle mouse wheel for scrolling (or zooming with Ctrl)."""
delta = event.angleDelta().y()
# Check if Ctrl is pressed for zooming
if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
# Zoom mode
mouse_y = event.position().y()
zoom_factor = 1.1 if delta > 0 else 0.9
# Calculate vertical world position before zoom
world_y = (mouse_y - self.pan_offset[1]) / self.zoom_level
# Apply zoom
old_zoom = self.zoom_level
self.zoom_level *= zoom_factor
self.zoom_level = max(0.1, min(5.0, self.zoom_level)) # Clamp
# Rearrange thumbnails if zoom level changed significantly
# This recalculates horizontal centering
if abs(self.zoom_level - old_zoom) > 0.01:
self._arrange_thumbnails()
# Adjust vertical pan to keep mouse position fixed
# Keep horizontal pan at 0 (grid is always horizontally centered)
self.pan_offset = (
0, # No horizontal pan - grid is centered in _arrange_thumbnails
mouse_y - world_y * self.zoom_level,
)
else:
# Scroll mode - scroll vertically only
scroll_amount = delta * 0.5 # Adjust sensitivity
self.pan_offset = (0, self.pan_offset[1] + scroll_amount) # No horizontal pan
self._update_scrollbar_position()
self.update()
class ThumbnailBrowserDock(QDockWidget):
"""
Dockable widget containing the thumbnail browser.
"""
def __init__(self, parent=None):
super().__init__("Image Browser", parent)
# Create main widget
main_widget = QWidget()
layout = QVBoxLayout(main_widget)
layout.setContentsMargins(5, 5, 5, 5)
layout.setSpacing(5)
# Header with folder selection
header_layout = QHBoxLayout()
self.folder_label = QLabel("No folder selected")
self.folder_label.setStyleSheet("font-weight: bold; padding: 5px;")
header_layout.addWidget(self.folder_label)
self.select_folder_btn = QPushButton("Select Folder...")
self.select_folder_btn.clicked.connect(self._select_folder)
header_layout.addWidget(self.select_folder_btn)
layout.addLayout(header_layout)
# Sort toolbar
sort_layout = QHBoxLayout()
sort_layout.setContentsMargins(5, 0, 5, 5)
sort_label = QLabel("Sort by:")
sort_layout.addWidget(sort_label)
self.sort_name_btn = QPushButton("Name")
self.sort_name_btn.setCheckable(True)
self.sort_name_btn.setChecked(True) # Default sort
self.sort_name_btn.clicked.connect(lambda: self._sort_by("name"))
sort_layout.addWidget(self.sort_name_btn)
self.sort_date_btn = QPushButton("Date")
self.sort_date_btn.setCheckable(True)
self.sort_date_btn.clicked.connect(lambda: self._sort_by("date"))
sort_layout.addWidget(self.sort_date_btn)
self.sort_camera_btn = QPushButton("Camera")
self.sort_camera_btn.setCheckable(True)
self.sort_camera_btn.clicked.connect(lambda: self._sort_by("camera"))
sort_layout.addWidget(self.sort_camera_btn)
sort_layout.addStretch()
layout.addLayout(sort_layout)
# Track current sort mode
self.current_sort = "name"
# Create horizontal layout for GL widget and scrollbar
browser_layout = QHBoxLayout()
browser_layout.setContentsMargins(0, 0, 0, 0)
browser_layout.setSpacing(0)
# GL Widget for thumbnails
self.gl_widget = ThumbnailGLWidget(main_window=parent)
browser_layout.addWidget(self.gl_widget)
# Vertical scrollbar
self.scrollbar = QScrollBar(Qt.Orientation.Vertical)
self.scrollbar.valueChanged.connect(self.gl_widget._on_scrollbar_changed)
browser_layout.addWidget(self.scrollbar)
# Connect scrollbar to GL widget
self.gl_widget.scrollbar = self.scrollbar
layout.addLayout(browser_layout)
self.setWidget(main_widget)
# Dock settings
self.setAllowedAreas(Qt.DockWidgetArea.LeftDockWidgetArea | Qt.DockWidgetArea.RightDockWidgetArea)
self.setFeatures(
QDockWidget.DockWidgetFeature.DockWidgetClosable
| QDockWidget.DockWidgetFeature.DockWidgetMovable
| QDockWidget.DockWidgetFeature.DockWidgetFloatable
)
# Connect to main window's async loader when shown
self._connect_async_loader()
def _connect_async_loader(self):
"""Connect to main window's async image loader."""
main_window = self.window()
if not hasattr(main_window, "_gl_widget"):
return
gl_widget = main_window._gl_widget
if not hasattr(gl_widget, "async_image_loader"):
return
# Avoid duplicate connections
if hasattr(self, "_async_connected") and self._async_connected:
return
try:
# Connect signals
gl_widget.async_image_loader.image_loaded.connect(self.gl_widget._on_image_loaded)
gl_widget.async_image_loader.load_failed.connect(self.gl_widget._on_image_load_failed)
self._async_connected = True
except Exception:
pass # Silently handle connection errors
def showEvent(self, event):
"""Handle show event."""
super().showEvent(event)
# Ensure async loader is connected when shown
self._connect_async_loader()
def _select_folder(self):
"""Open dialog to select folder."""
folder_path = QFileDialog.getExistingDirectory(
self,
"Select Image Folder",
str(self.gl_widget.current_folder) if self.gl_widget.current_folder else str(Path.home()),
QFileDialog.Option.ShowDirsOnly,
)
if folder_path:
self.load_folder(Path(folder_path))
def load_folder(self, folder_path: Path):
"""Load thumbnails from folder."""
self.folder_label.setText(f"Folder: {folder_path.name}")
self.gl_widget.load_folder(folder_path)
# Apply current sort after loading
self._apply_sort()
self.gl_widget._arrange_thumbnails()
self.gl_widget.update_used_images()
self.gl_widget.update()
def _sort_by(self, sort_mode: str):
"""Sort thumbnails by the specified mode."""
# Update button states (only one can be checked)
self.sort_name_btn.setChecked(sort_mode == "name")
self.sort_date_btn.setChecked(sort_mode == "date")
self.sort_camera_btn.setChecked(sort_mode == "camera")
self.current_sort = sort_mode
# Re-sort the image files in the GL widget
if hasattr(self.gl_widget, "image_files") and self.gl_widget.image_files:
self._apply_sort()
# Re-arrange thumbnails with new order
self.gl_widget._arrange_thumbnails()
self.gl_widget.update_used_images()
self.gl_widget.update()
def _apply_sort(self):
"""Apply current sort mode to image files."""
if not hasattr(self.gl_widget, "image_files") or not self.gl_widget.image_files:
return
if self.current_sort == "name":
# Sort by filename only (not full path)
self.gl_widget.image_files.sort(key=lambda p: p.name.lower())
# Clear date headers for non-date sorts
self.gl_widget.date_headers.clear()
# Reset sort mode in GL widget
self.gl_widget.sort_mode = "name"
self.gl_widget._get_image_date_func = None
elif self.current_sort == "date":
# Sort by file modification time (or EXIF date if available)
self.gl_widget.image_files.sort(key=self._get_image_date)
# Date headers will be created during _arrange_thumbnails
self.gl_widget.sort_mode = "date"
self.gl_widget._get_image_date_func = self._get_image_date
elif self.current_sort == "camera":
# Sort by camera model from EXIF
self.gl_widget.image_files.sort(key=self._get_camera_model)
# Clear date headers for non-date sorts
self.gl_widget.date_headers.clear()
# Reset sort mode in GL widget
self.gl_widget.sort_mode = "camera"
self.gl_widget._get_image_date_func = None
def _get_image_date(self, image_path: Path) -> float:
"""Get image date from EXIF or file modification time."""
try:
from PIL import Image
from PIL.ExifTags import TAGS
with Image.open(image_path) as img:
exif = img.getexif()
if exif:
# Look for DateTimeOriginal (when photo was taken)
for tag_id, value in exif.items():
tag = TAGS.get(tag_id, tag_id)
if tag == "DateTimeOriginal":
# Convert EXIF date format "2023:12:13 14:30:00" to timestamp
from datetime import datetime
try:
dt = datetime.strptime(value, "%Y:%m:%d %H:%M:%S")
return dt.timestamp()
except:
pass
except Exception:
pass
# Fallback to file modification time
return image_path.stat().st_mtime
def _get_camera_model(self, image_path: Path) -> str:
"""Get camera model from EXIF metadata."""
try:
from PIL import Image
from PIL.ExifTags import TAGS
with Image.open(image_path) as img:
exif = img.getexif()
if exif:
# Look for camera model
for tag_id, value in exif.items():
tag = TAGS.get(tag_id, tag_id)
if tag == "Model":
return str(value).strip()
except Exception:
pass
# Fallback to filename if no EXIF data
return image_path.name.lower()
+95 -8
View File
@@ -2,12 +2,13 @@
Version management and migration system for pyPhotoAlbum projects Version management and migration system for pyPhotoAlbum projects
""" """
from typing import Dict, Any, Optional, Callable, List
import os import os
import uuid
from datetime import datetime, timezone
from typing import Dict, Any, Optional, Callable, List
# Current data version - increment when making breaking changes to data format # Current data version - increment when making breaking changes to data format
CURRENT_DATA_VERSION = "2.0" CURRENT_DATA_VERSION = "3.0"
# Version history and compatibility information # Version history and compatibility information
VERSION_HISTORY = { VERSION_HISTORY = {
@@ -22,10 +23,21 @@ VERSION_HISTORY = {
"released": "2025-01-11", "released": "2025-01-11",
"breaking_changes": [ "breaking_changes": [
"Asset paths changed from absolute/full-project-relative to project-relative", "Asset paths changed from absolute/full-project-relative to project-relative",
"Added automatic path normalization for legacy projects" "Added automatic path normalization for legacy projects",
], ],
"compatible_with": ["1.0", "2.0"], # 2.0 can read 1.0 with migration "compatible_with": ["1.0", "2.0"], # 2.0 can read 1.0 with migration
} },
"3.0": {
"description": "Added merge conflict resolution support with UUIDs, timestamps, and project IDs",
"released": "2025-01-22",
"breaking_changes": [
"Added required UUID fields to all pages and elements",
"Added created/last_modified timestamps to projects, pages, and elements",
"Added project_id for merge detection (same ID = merge, different ID = concatenate)",
"Added deletion tracking (deleted flag and deleted_at timestamp)",
],
"compatible_with": ["1.0", "2.0", "3.0"], # 3.0 can read older versions with migration
},
} }
@@ -107,9 +119,11 @@ class DataMigration:
@classmethod @classmethod
def register_migration(cls, from_version: str, to_version: str): def register_migration(cls, from_version: str, to_version: str):
"""Decorator to register a migration function""" """Decorator to register a migration function"""
def decorator(func): def decorator(func):
cls._migrations[(from_version, to_version)] = func cls._migrations[(from_version, to_version)] = func
return func return func
return decorator return decorator
@classmethod @classmethod
@@ -151,6 +165,7 @@ class DataMigration:
# Register migrations # Register migrations
@DataMigration.register_migration("1.0", "2.0") @DataMigration.register_migration("1.0", "2.0")
def migrate_1_0_to_2_0(data: Dict[str, Any]) -> Dict[str, Any]: def migrate_1_0_to_2_0(data: Dict[str, Any]) -> Dict[str, Any]:
""" """
@@ -163,7 +178,7 @@ def migrate_1_0_to_2_0(data: Dict[str, Any]) -> Dict[str, Any]:
print("Migration 1.0 → 2.0: Asset paths will be normalized during load") print("Migration 1.0 → 2.0: Asset paths will be normalized during load")
# Update version in data # Update version in data
data['data_version'] = "2.0" data["data_version"] = "2.0"
# Note: Actual path normalization is handled in load_from_zip # Note: Actual path normalization is handled in load_from_zip
# This migration mainly updates the version number # This migration mainly updates the version number
@@ -171,6 +186,78 @@ def migrate_1_0_to_2_0(data: Dict[str, Any]) -> Dict[str, Any]:
return data return data
@DataMigration.register_migration("2.0", "3.0")
def migrate_2_0_to_3_0(data: Dict[str, Any]) -> Dict[str, Any]:
"""
Migrate from version 2.0 to 3.0.
Main changes:
- Add UUIDs to all pages and elements
- Add timestamps (created, last_modified) to project, pages, and elements
- Add project_id to project
- Add deletion tracking (deleted, deleted_at) to pages and elements
"""
print("Migration 2.0 → 3.0: Adding UUIDs, timestamps, and project_id")
# Get current timestamp for migration
now = datetime.now(timezone.utc).isoformat()
# Add project-level fields
if "project_id" not in data:
data["project_id"] = str(uuid.uuid4())
print(f" Generated project_id: {data['project_id']}")
if "created" not in data:
data["created"] = now
if "last_modified" not in data:
data["last_modified"] = now
# Migrate pages
for page_data in data.get("pages", []):
# Add UUID
if "uuid" not in page_data:
page_data["uuid"] = str(uuid.uuid4())
# Add timestamps
if "created" not in page_data:
page_data["created"] = now
if "last_modified" not in page_data:
page_data["last_modified"] = now
# Add deletion tracking
if "deleted" not in page_data:
page_data["deleted"] = False
if "deleted_at" not in page_data:
page_data["deleted_at"] = None
# Migrate elements in page layout
layout_data = page_data.get("layout", {})
for element_data in layout_data.get("elements", []):
# Add UUID
if "uuid" not in element_data:
element_data["uuid"] = str(uuid.uuid4())
# Add timestamps
if "created" not in element_data:
element_data["created"] = now
if "last_modified" not in element_data:
element_data["last_modified"] = now
# Add deletion tracking
if "deleted" not in element_data:
element_data["deleted"] = False
if "deleted_at" not in element_data:
element_data["deleted_at"] = None
# Update version
data["data_version"] = "3.0"
print(f" Migrated {len(data.get('pages', []))} pages to v3.0")
return data
def check_version_compatibility(file_version: str, file_path: str = "") -> tuple[bool, Optional[str]]: def check_version_compatibility(file_version: str, file_path: str = "") -> tuple[bool, Optional[str]]:
""" """
Check version compatibility and provide user-friendly messages. Check version compatibility and provide user-friendly messages.
@@ -202,7 +289,7 @@ def check_version_compatibility(file_version: str, file_path: str = "") -> tuple
error_msg += f"File version info:\n" error_msg += f"File version info:\n"
error_msg += f" Description: {file_info.get('description', 'Unknown')}\n" error_msg += f" Description: {file_info.get('description', 'Unknown')}\n"
error_msg += f" Released: {file_info.get('released', 'Unknown')}\n" error_msg += f" Released: {file_info.get('released', 'Unknown')}\n"
breaking_changes = file_info.get('breaking_changes', []) breaking_changes = file_info.get("breaking_changes", [])
if breaking_changes: if breaking_changes:
error_msg += f" Breaking changes:\n" error_msg += f" Breaking changes:\n"
for change in breaking_changes: for change in breaking_changes:
@@ -227,7 +314,7 @@ def format_version_info() -> str:
info.append(f" Description: {version_info.get('description', 'Unknown')}") info.append(f" Description: {version_info.get('description', 'Unknown')}")
info.append(f" Released: {version_info.get('released', 'Unknown')}") info.append(f" Released: {version_info.get('released', 'Unknown')}")
breaking_changes = version_info.get('breaking_changes', []) breaking_changes = version_info.get("breaking_changes", [])
if breaking_changes: if breaking_changes:
info.append(f" Breaking changes:") info.append(f" Breaking changes:")
for change in breaking_changes: for change in breaking_changes:
+2
View File
@@ -31,6 +31,7 @@ dependencies = [
"Pillow>=8.0.0", "Pillow>=8.0.0",
"reportlab>=3.5.0", "reportlab>=3.5.0",
"lxml>=4.6.0", "lxml>=4.6.0",
"pypdf>=4.0.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
@@ -39,6 +40,7 @@ dev = [
"pytest-qt>=4.2.0", "pytest-qt>=4.2.0",
"pytest-cov>=4.0.0", "pytest-cov>=4.0.0",
"pytest-mock>=3.10.0", "pytest-mock>=3.10.0",
"pdfplumber>=0.10.0",
"flake8>=5.0.0", "flake8>=5.0.0",
"black>=22.0.0", "black>=22.0.0",
"mypy>=0.990", "mypy>=0.990",
-74
View File
@@ -1,74 +0,0 @@
#!/usr/bin/env python3
"""
Test script to verify asset loading fix and version handling
"""
import os
import sys
from pyPhotoAlbum.project_serializer import load_from_zip
from pyPhotoAlbum.models import ImageData
from pyPhotoAlbum.version_manager import format_version_info
# Path to test file
test_file = "/home/dtourolle/Nextcloud/Photo Gallery/gr58/Album_pytool.ppz"
print("=" * 70)
print("Testing asset loading fix and version handling")
print("=" * 70)
print()
print(format_version_info())
print()
print("=" * 70)
print(f"Loading: {test_file}")
print("=" * 70)
print()
# Load project
project, error = load_from_zip(test_file)
if error:
print(f"ERROR: {error}")
sys.exit(1)
print(f"Project loaded: {project.name}")
print(f"Project folder: {project.folder_path}")
print(f"Assets folder: {project.asset_manager.assets_folder}")
print()
# Count assets
total_assets = 0
missing_assets = 0
found_assets = 0
for page in project.pages:
for element in page.layout.elements:
if isinstance(element, ImageData) and element.image_path:
total_assets += 1
# Check if asset exists
if os.path.isabs(element.image_path):
full_path = element.image_path
else:
full_path = os.path.join(project.folder_path, element.image_path)
if os.path.exists(full_path):
found_assets += 1
print(f"✓ Found: {element.image_path}")
else:
missing_assets += 1
print(f"✗ Missing: {element.image_path}")
print()
print(f"Results:")
print(f" Total assets: {total_assets}")
print(f" Found: {found_assets}")
print(f" Missing: {missing_assets}")
if missing_assets == 0:
print()
print("SUCCESS! All assets loaded correctly.")
sys.exit(0)
else:
print()
print(f"PARTIAL: {missing_assets} assets still missing.")
sys.exit(1)
-134
View File
@@ -1,134 +0,0 @@
#!/usr/bin/env python
"""
Test to verify async loading doesn't block the main thread.
This test demonstrates that the UI remains responsive during image loading.
"""
import time
import sys
from pathlib import Path
from PyQt6.QtWidgets import QApplication
from PyQt6.QtCore import QTimer
from pyPhotoAlbum.async_backend import AsyncImageLoader, ImageCache, LoadPriority
def test_nonblocking_load():
"""Test that async image loading doesn't block the main thread"""
print("Testing non-blocking async image loading...")
# Track if main thread stays responsive
main_thread_ticks = []
def main_thread_tick():
"""This should continue running during async loads"""
main_thread_ticks.append(time.time())
print(f"✓ Main thread tick {len(main_thread_ticks)} (responsive!)")
# Create Qt application
app = QApplication(sys.argv)
# Create async loader
cache = ImageCache(max_memory_mb=128)
loader = AsyncImageLoader(cache=cache, max_workers=2)
# Track loaded images
loaded_images = []
def on_image_loaded(path, image, user_data):
loaded_images.append(path)
print(f"✓ Loaded: {path} (size: {image.size})")
def on_load_failed(path, error_msg, user_data):
print(f"✗ Failed: {path} - {error_msg}")
loader.image_loaded.connect(on_image_loaded)
loader.load_failed.connect(on_load_failed)
# Start the async loader
loader.start()
print("✓ Async loader started")
# Request some image loads (these would normally block for 50-500ms each)
test_images = [
Path("assets/sample1.jpg"),
Path("assets/sample2.jpg"),
Path("assets/sample3.jpg"),
]
print(f"\nRequesting {len(test_images)} image loads...")
for img_path in test_images:
loader.request_load(img_path, priority=LoadPriority.HIGH)
print(f" → Queued: {img_path}")
print("\nMain thread should remain responsive while images load in background...")
# Setup main thread ticker (should run continuously)
ticker = QTimer()
ticker.timeout.connect(main_thread_tick)
ticker.start(100) # Tick every 100ms
# Setup test timeout
def check_completion():
elapsed = time.time() - start_time
if len(loaded_images) >= len(test_images):
print(f"\n✓ All images loaded in {elapsed:.2f}s")
print(f"✓ Main thread ticked {len(main_thread_ticks)} times during loading")
if len(main_thread_ticks) >= 3:
print("✓ SUCCESS: Main thread remained responsive!")
else:
print("✗ FAIL: Main thread was blocked!")
# Cleanup
ticker.stop()
loader.stop()
app.quit()
elif elapsed > 10.0:
print(f"\n✗ Timeout: Only loaded {len(loaded_images)}/{len(test_images)} images")
ticker.stop()
loader.stop()
app.quit()
# Check completion every 200ms
completion_timer = QTimer()
completion_timer.timeout.connect(check_completion)
completion_timer.start(200)
start_time = time.time()
# Run Qt event loop (this should NOT block)
app.exec()
print("\nTest completed!")
# Report results
print(f"\nResults:")
print(f" Images loaded: {len(loaded_images)}/{len(test_images)}")
print(f" Main thread ticks: {len(main_thread_ticks)}")
print(f" Cache stats: {cache.get_stats()}")
return len(main_thread_ticks) >= 3 # Success if main thread ticked at least 3 times
if __name__ == "__main__":
print("=" * 60)
print("Async Non-Blocking Test")
print("=" * 60)
print()
success = test_nonblocking_load()
print()
print("=" * 60)
if success:
print("✓ TEST PASSED: Async loading is non-blocking")
else:
print("✗ TEST FAILED: Main thread was blocked")
print("=" * 60)
sys.exit(0 if success else 1)
-90
View File
@@ -1,90 +0,0 @@
#!/usr/bin/env python3
"""
Test script to verify Page Setup functionality
"""
import sys
from PyQt6.QtWidgets import QApplication
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
def test_page_setup_behavior():
"""Test that page setup works correctly with multiple pages"""
# Create a project with default size
project = Project("Test Project")
print(f"Initial project default page size: {project.page_size_mm}")
# Add first page - should use project default
page1_layout = PageLayout(width=project.page_size_mm[0], height=project.page_size_mm[1])
page1 = Page(layout=page1_layout, page_number=1)
page1.manually_sized = False
project.add_page(page1)
print(f"Page 1 size: {page1.layout.size}, manually_sized: {page1.manually_sized}")
# Add second page - should also use project default
page2_layout = PageLayout(width=project.page_size_mm[0], height=project.page_size_mm[1])
page2 = Page(layout=page2_layout, page_number=2)
page2.manually_sized = False
project.add_page(page2)
print(f"Page 2 size: {page2.layout.size}, manually_sized: {page2.manually_sized}")
# Simulate changing page 1 size without setting as default
print("\n--- Simulating Page Setup on Page 1 (without setting as default) ---")
page1.layout.size = (200, 200)
page1.layout.base_width = 200
page1.manually_sized = True
print(f"Page 1 size after change: {page1.layout.size}, manually_sized: {page1.manually_sized}")
print(f"Project default still: {project.page_size_mm}")
print(f"Page 2 unchanged: {page2.layout.size}, manually_sized: {page2.manually_sized}")
# Add third page - should use original project default
page3_layout = PageLayout(width=project.page_size_mm[0], height=project.page_size_mm[1])
page3 = Page(layout=page3_layout, page_number=3)
page3.manually_sized = False
project.add_page(page3)
print(f"Page 3 size: {page3.layout.size}, manually_sized: {page3.manually_sized}")
# Simulate changing page 2 size AND setting as default
print("\n--- Simulating Page Setup on Page 2 (with setting as default) ---")
page2.layout.size = (250, 250)
page2.layout.base_width = 250
page2.manually_sized = True
project.page_size_mm = (250, 250) # Set as default
print(f"Page 2 size after change: {page2.layout.size}, manually_sized: {page2.manually_sized}")
print(f"Project default updated to: {project.page_size_mm}")
# Add fourth page - should use NEW project default
page4_layout = PageLayout(width=project.page_size_mm[0], height=project.page_size_mm[1])
page4 = Page(layout=page4_layout, page_number=4)
page4.manually_sized = False
project.add_page(page4)
print(f"Page 4 size: {page4.layout.size}, manually_sized: {page4.manually_sized}")
# Test double spread
print("\n--- Testing Double Spread ---")
page5_layout = PageLayout(width=project.page_size_mm[0], height=project.page_size_mm[1], is_facing_page=True)
page5 = Page(layout=page5_layout, page_number=5, is_double_spread=True)
page5.manually_sized = False
project.add_page(page5)
print(f"Page 5 (double spread) size: {page5.layout.size}, base_width: {page5.layout.base_width}")
print(f"Expected: width should be 2x base_width = {project.page_size_mm[0] * 2}")
print("\n--- Summary ---")
for i, page in enumerate(project.pages, 1):
spread_info = " (double spread)" if page.is_double_spread else ""
manual_info = " *manually sized*" if page.manually_sized else ""
print(f"Page {i}: {page.layout.size}{spread_info}{manual_info}")
print(f"Project default: {project.page_size_mm}")
print("\n✓ All tests passed!")
if __name__ == "__main__":
# Initialize Qt application (needed for PyQt6 widgets even in tests)
app = QApplication(sys.argv)
test_page_setup_behavior()
print("\nTest completed successfully!")
-65
View File
@@ -1,65 +0,0 @@
#!/usr/bin/env python3
"""
Test version round-trip: save with v2.0, load with v2.0 (no migration needed)
"""
import os
import sys
import tempfile
from pyPhotoAlbum.project import Project
from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip
from pyPhotoAlbum.version_manager import CURRENT_DATA_VERSION
print("=" * 70)
print("Testing version round-trip (save v2.0, load v2.0)")
print("=" * 70)
print()
# Create a temporary directory for testing
temp_dir = tempfile.mkdtemp(prefix="pyphotos_test_")
test_ppz = os.path.join(temp_dir, "test_project.ppz")
try:
# Create a new project
print("Creating new project...")
project = Project("Test Project")
print(f" Project folder: {project.folder_path}")
print()
# Save it
print(f"Saving to: {test_ppz}")
success, error = save_to_zip(project, test_ppz)
if not success:
print(f"ERROR: Failed to save: {error}")
sys.exit(1)
print(" Saved successfully!")
print()
# Load it back
print(f"Loading from: {test_ppz}")
loaded_project, error = load_from_zip(test_ppz)
if error:
print(f"ERROR: Failed to load: {error}")
sys.exit(1)
print(f" Loaded successfully!")
print(f" Project name: {loaded_project.name}")
print(f" Project folder: {loaded_project.folder_path}")
print()
# Check that it's version 2.0 and no migration was needed
print("Version check:")
print(f" Expected version: {CURRENT_DATA_VERSION}")
print(f" ✓ No migration was performed (would have been logged if needed)")
print()
print("=" * 70)
print("SUCCESS! Version round-trip test passed.")
print("=" * 70)
finally:
# Cleanup
import shutil
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
print(f"\nCleaned up test directory: {temp_dir}")

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