Compare commits
41
Commits
3bfe2fa654
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15d8ca8c05 | ||
|
|
1fe44e7d8a | ||
|
|
fd0758dd6c | ||
|
|
5763fa629e | ||
|
|
3092388226 | ||
|
|
cdd5215eb4 | ||
|
|
e112437372 | ||
|
|
f96200c799 | ||
|
|
f0aa005d8c | ||
|
|
293b568772 | ||
|
|
3a2d8b05db | ||
|
|
54cc78783a | ||
|
|
cf27d9ebee | ||
|
|
6a791b1397 | ||
|
|
254a95d83c | ||
|
|
c1ee894e7b | ||
|
|
8f9f387848 | ||
|
|
c66724c190 | ||
|
|
a7f32e73b9 | ||
|
|
8f1e906884 | ||
|
|
80d7d291f3 | ||
|
|
2a087f0c9d | ||
|
|
b18a780a33 | ||
|
|
558b7be01a | ||
|
|
f6ed11b0bc | ||
|
|
3b2301b547 | ||
|
|
d911f00c93 | ||
|
|
40e709a29a | ||
|
|
35b740496d | ||
|
|
fae9e5bd2b | ||
|
|
d7786ede80 | ||
|
|
45268cdfe4 | ||
|
|
e8d9eaca51 | ||
|
|
fc672bcdff | ||
|
|
73c504abb7 | ||
|
|
56ea7d5ea9 | ||
|
|
e52cb7334a | ||
|
|
2fc99a3292 | ||
|
|
6755549dfd | ||
|
|
0d698a83b4 | ||
|
|
e93a789524 |
@@ -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
|
||||||
@@ -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
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||

|
|
||||||
```
|
|
||||||
|
|
||||||
### Documentation Coverage Badge
|
|
||||||
|
|
||||||
```markdown
|
|
||||||

|
|
||||||
```
|
|
||||||
|
|
||||||
## Example README Section
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# pyPhotoAlbum
|
|
||||||
|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
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/`.
|
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
# pyPhotoAlbum
|
# pyPhotoAlbum
|
||||||
|
|
||||||
A Python-based desktop application for designing photo albums with an intuitive interface and professional PDF export capabilities.
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
A desktop application for designing and creating professional photo albums with an intuitive drag-and-drop interface and high-quality PDF export.
|
||||||
|
|
||||||
## Overview
|
## 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:
|
||||||
|
|||||||
@@ -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%)
|
|
||||||
@@ -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.
|
||||||
Executable
+265
@@ -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 ""
|
||||||
+40
-10
@@ -81,18 +81,24 @@ in_virtualenv() {
|
|||||||
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 .
|
||||||
if in_virtualenv; then
|
;;
|
||||||
|
venv)
|
||||||
print_info "Installing pyPhotoAlbum in virtual environment..."
|
print_info "Installing pyPhotoAlbum in virtual environment..."
|
||||||
pip install .
|
pip install --upgrade .
|
||||||
else
|
;;
|
||||||
|
user-force)
|
||||||
|
print_info "Installing pyPhotoAlbum for current user (forcing --user)..."
|
||||||
|
pip install --user --upgrade .
|
||||||
|
;;
|
||||||
|
*)
|
||||||
print_info "Installing pyPhotoAlbum for current user..."
|
print_info "Installing pyPhotoAlbum for current user..."
|
||||||
pip install --user .
|
pip install --user --upgrade .
|
||||||
fi
|
;;
|
||||||
fi
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
# Install desktop integration
|
# Install desktop integration
|
||||||
@@ -202,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 ""
|
||||||
|
|
||||||
|
|||||||
Executable
+18
@@ -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" "$@"
|
||||||
@@ -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
|
||||||
|
|||||||
+285
-163
@@ -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,166 +743,15 @@ class AlignmentManager:
|
|||||||
if not elements:
|
if not elements:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
page_width, page_height = page_size
|
maximizer = ElementMaximizer(elements, page_size, min_gap)
|
||||||
changes = []
|
return maximizer.maximize(max_iterations, growth_rate)
|
||||||
|
|
||||||
# Record initial states
|
|
||||||
for elem in elements:
|
|
||||||
changes.append((elem, elem.position, elem.size))
|
|
||||||
|
|
||||||
# Helper function to check if element would collide with boundaries or other elements
|
|
||||||
def check_collision(elem_idx: int, new_size: Tuple[float, float]) -> bool:
|
|
||||||
elem = elements[elem_idx]
|
|
||||||
x, y = elem.position
|
|
||||||
w, h = new_size
|
|
||||||
|
|
||||||
# Check page boundaries
|
|
||||||
if x < min_gap or y < min_gap:
|
|
||||||
return True
|
|
||||||
if x + w > page_width - min_gap:
|
|
||||||
return True
|
|
||||||
if y + h > page_height - min_gap:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Check collision with other elements
|
|
||||||
for i, other in enumerate(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), # Other is to the right
|
|
||||||
x - (other_x + other_w) # Other is to the left
|
|
||||||
)
|
|
||||||
|
|
||||||
vertical_gap = max(
|
|
||||||
other_y - (y + h), # Other is below
|
|
||||||
y - (other_y + other_h) # Other is above
|
|
||||||
)
|
|
||||||
|
|
||||||
# If rectangles overlap or are too close in both dimensions
|
|
||||||
if horizontal_gap < min_gap and vertical_gap < min_gap:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Helper function to get the maximum scale factor for an element
|
|
||||||
def get_max_scale(elem_idx: int, current_scale: float) -> float:
|
|
||||||
elem = elements[elem_idx]
|
|
||||||
old_size = changes[elem_idx][2]
|
|
||||||
|
|
||||||
# Binary search for maximum scale
|
|
||||||
low, high = current_scale, current_scale * 3.0
|
|
||||||
best_scale = current_scale
|
|
||||||
|
|
||||||
for _ in range(20): # Binary search iterations
|
|
||||||
mid = (low + high) / 2.0
|
|
||||||
test_size = (old_size[0] * mid, old_size[1] * mid)
|
|
||||||
|
|
||||||
if check_collision(elem_idx, test_size):
|
|
||||||
high = mid
|
|
||||||
else:
|
|
||||||
best_scale = mid
|
|
||||||
low = mid
|
|
||||||
|
|
||||||
if high - low < 0.001:
|
|
||||||
break
|
|
||||||
|
|
||||||
return best_scale
|
|
||||||
|
|
||||||
# Growth algorithm - iterative expansion
|
|
||||||
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
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def expand_to_bounds(
|
def expand_to_bounds(
|
||||||
element: BaseLayoutElement,
|
element: BaseLayoutElement,
|
||||||
page_size: Tuple[float, float],
|
page_size: Tuple[float, float],
|
||||||
other_elements: List[BaseLayoutElement],
|
other_elements: List[BaseLayoutElement],
|
||||||
min_gap: float = 10.0
|
min_gap: float = 10.0,
|
||||||
) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
|
) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
|
||||||
"""
|
"""
|
||||||
Expand a single element until it is min_gap away from page edges or other elements.
|
Expand a single element until it is min_gap away from page edges or other elements.
|
||||||
|
|||||||
@@ -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,22 +147,14 @@ 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
|
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)
|
||||||
|
|
||||||
# Build mapping of missing paths to elements
|
# Build mapping of missing paths to elements
|
||||||
@@ -161,19 +172,34 @@ class AssetHealDialog(QDialog):
|
|||||||
found_path = None
|
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_path = candidate
|
found_path = resolved
|
||||||
break
|
print(f"Resolved relative path: {asset_path} → {resolved}")
|
||||||
|
|
||||||
# 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_path = candidate
|
found_path = asset_path
|
||||||
break
|
print(f"Found at absolute path: {asset_path}")
|
||||||
|
|
||||||
|
# THIRD: Search in user-provided search paths
|
||||||
|
if not found_path:
|
||||||
|
for search_path in self.search_paths:
|
||||||
|
# Try direct match by filename
|
||||||
|
candidate = os.path.join(search_path, filename)
|
||||||
|
if os.path.exists(candidate):
|
||||||
|
found_path = candidate
|
||||||
|
break
|
||||||
|
|
||||||
|
# Try with same relative path structure
|
||||||
|
candidate = os.path.join(search_path, asset_path)
|
||||||
|
if os.path.exists(candidate):
|
||||||
|
found_path = candidate
|
||||||
|
break
|
||||||
|
|
||||||
if found_path:
|
if found_path:
|
||||||
healed_count += 1
|
healed_count += 1
|
||||||
@@ -181,12 +207,11 @@ class AssetHealDialog(QDialog):
|
|||||||
# Check if the found file needs to be imported
|
# Check if the found file needs to be imported
|
||||||
# (i.e., it's not already in the assets folder)
|
# (i.e., it's not already in the assets folder)
|
||||||
needs_import = True
|
needs_import = True
|
||||||
if not os.path.isabs(asset_path) and asset_path.startswith('assets/'):
|
if not os.path.isabs(asset_path) and asset_path.startswith("assets/"):
|
||||||
# It's already a relative assets path, just missing from disk
|
# It's already a relative assets path, just missing from disk
|
||||||
# Copy it to the correct location
|
# Copy it to the correct location
|
||||||
dest_path = os.path.join(self.project.folder_path, asset_path)
|
dest_path = os.path.join(self.project.folder_path, asset_path)
|
||||||
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
|
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
|
||||||
import shutil
|
|
||||||
shutil.copy2(found_path, dest_path)
|
shutil.copy2(found_path, dest_path)
|
||||||
print(f"Restored: {asset_path} from {found_path}")
|
print(f"Restored: {asset_path} from {found_path}")
|
||||||
else:
|
else:
|
||||||
@@ -223,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
@@ -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)
|
||||||
|
|||||||
+186
-108
@@ -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,39 +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
|
|
||||||
if img.mode != 'RGBA':
|
|
||||||
img = img.convert('RGBA')
|
|
||||||
|
|
||||||
# Downsample if target size specified (preserving aspect ratio)
|
# Downsample if target size specified (preserving aspect ratio)
|
||||||
if target_size:
|
if target_size:
|
||||||
max_size = target_size[0] # Assume square target (2048, 2048)
|
max_size = target_size[0] # Assume square target (2048, 2048)
|
||||||
if img.width > max_size or img.height > max_size:
|
original_size = img.size
|
||||||
# Calculate scale to fit within max_size while preserving aspect ratio
|
img = resize_to_fit(img, max_size)
|
||||||
scale = min(max_size / img.width, max_size / img.height)
|
if img.size != original_size:
|
||||||
new_width = int(img.width * scale)
|
logger.debug(f"Downsampled {path}: {original_size} -> {img.size}")
|
||||||
new_height = int(img.height * scale)
|
|
||||||
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
|
||||||
logger.debug(f"Downsampled {path}: {img.size} -> ({new_width}, {new_height})")
|
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -410,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
|
||||||
|
|
||||||
@@ -418,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
|
||||||
@@ -458,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(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -481,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):
|
||||||
"""
|
"""
|
||||||
@@ -496,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
|
||||||
@@ -518,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")
|
||||||
|
|
||||||
@@ -585,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}")
|
||||||
@@ -618,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.
|
||||||
|
|
||||||
@@ -671,24 +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
|
||||||
# Note: We cache the unrotated image so rotation can be applied per-element
|
is_file_path = isinstance(path, (str, Path))
|
||||||
cached_img = self.image_cache.get(Path(path))
|
|
||||||
if cached_img:
|
|
||||||
logger.debug(f"PDF using cached image: {path}")
|
|
||||||
return cached_img
|
|
||||||
|
|
||||||
# Load and cache (unrotated - rotation is applied per-element)
|
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
|
||||||
@@ -696,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()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from pyPhotoAlbum.version_manager import (
|
|||||||
CURRENT_DATA_VERSION,
|
CURRENT_DATA_VERSION,
|
||||||
check_version_compatibility,
|
check_version_compatibility,
|
||||||
VersionCompatibility,
|
VersionCompatibility,
|
||||||
DataMigration
|
DataMigration,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ class AsyncProjectLoader(QThread):
|
|||||||
self.progress_updated.emit(10, 100, "Extracting project files...")
|
self.progress_updated.emit(10, 100, "Extracting project files...")
|
||||||
|
|
||||||
# Extract ZIP contents with progress
|
# Extract ZIP contents with progress
|
||||||
with zipfile.ZipFile(self.zip_path, 'r') as zipf:
|
with zipfile.ZipFile(self.zip_path, "r") as zipf:
|
||||||
file_list = zipf.namelist()
|
file_list = zipf.namelist()
|
||||||
total_files = len(file_list)
|
total_files = len(file_list)
|
||||||
|
|
||||||
@@ -91,10 +91,7 @@ class AsyncProjectLoader(QThread):
|
|||||||
# Update progress every 10 files or on last file
|
# Update progress every 10 files or on last file
|
||||||
if i % 10 == 0 or i == total_files - 1:
|
if i % 10 == 0 or i == total_files - 1:
|
||||||
progress = 10 + int((i / total_files) * 30) # 10-40%
|
progress = 10 + int((i / total_files) * 30) # 10-40%
|
||||||
self.progress_updated.emit(
|
self.progress_updated.emit(progress, 100, f"Extracting files... ({i + 1}/{total_files})")
|
||||||
progress, 100,
|
|
||||||
f"Extracting files... ({i + 1}/{total_files})"
|
|
||||||
)
|
|
||||||
|
|
||||||
if self._cancelled:
|
if self._cancelled:
|
||||||
return
|
return
|
||||||
@@ -103,12 +100,12 @@ class AsyncProjectLoader(QThread):
|
|||||||
self.progress_updated.emit(45, 100, "Loading project data...")
|
self.progress_updated.emit(45, 100, "Loading project data...")
|
||||||
|
|
||||||
# Load project.json
|
# Load project.json
|
||||||
project_json_path = os.path.join(extract_to, 'project.json')
|
project_json_path = os.path.join(extract_to, "project.json")
|
||||||
if not os.path.exists(project_json_path):
|
if not os.path.exists(project_json_path):
|
||||||
self.load_failed.emit("Invalid project file: project.json not found")
|
self.load_failed.emit("Invalid project file: project.json not found")
|
||||||
return
|
return
|
||||||
|
|
||||||
with open(project_json_path, 'r') as f:
|
with open(project_json_path, "r") as f:
|
||||||
project_data = json.load(f)
|
project_data = json.load(f)
|
||||||
|
|
||||||
if self._cancelled:
|
if self._cancelled:
|
||||||
@@ -118,7 +115,7 @@ class AsyncProjectLoader(QThread):
|
|||||||
self.progress_updated.emit(55, 100, "Checking version compatibility...")
|
self.progress_updated.emit(55, 100, "Checking version compatibility...")
|
||||||
|
|
||||||
# Check version compatibility
|
# Check version compatibility
|
||||||
file_version = project_data.get('data_version', project_data.get('serialization_version', '1.0'))
|
file_version = project_data.get("data_version", project_data.get("serialization_version", "1.0"))
|
||||||
|
|
||||||
is_compatible, error_msg = check_version_compatibility(file_version, self.zip_path)
|
is_compatible, error_msg = check_version_compatibility(file_version, self.zip_path)
|
||||||
if not is_compatible:
|
if not is_compatible:
|
||||||
@@ -141,7 +138,7 @@ class AsyncProjectLoader(QThread):
|
|||||||
self.progress_updated.emit(70, 100, "Creating project...")
|
self.progress_updated.emit(70, 100, "Creating project...")
|
||||||
|
|
||||||
# Create new project
|
# Create new project
|
||||||
project_name = project_data.get('name', 'Untitled Project')
|
project_name = project_data.get("name", "Untitled Project")
|
||||||
project = Project(name=project_name, folder_path=extract_to)
|
project = Project(name=project_name, folder_path=extract_to)
|
||||||
|
|
||||||
# Deserialize project data
|
# Deserialize project data
|
||||||
@@ -169,8 +166,8 @@ class AsyncProjectLoader(QThread):
|
|||||||
self.progress_updated.emit(95, 100, "Setting up asset resolution...")
|
self.progress_updated.emit(95, 100, "Setting up asset resolution...")
|
||||||
|
|
||||||
# Set asset resolution context
|
# Set asset resolution context
|
||||||
zip_directory = os.path.dirname(os.path.abspath(self.zip_path))
|
# Only set project folder - search paths are reserved for healing functionality
|
||||||
set_asset_resolution_context(extract_to, additional_search_paths=[zip_directory])
|
set_asset_resolution_context(extract_to)
|
||||||
|
|
||||||
if self._cancelled:
|
if self._cancelled:
|
||||||
return
|
return
|
||||||
@@ -197,14 +194,14 @@ class AsyncProjectLoader(QThread):
|
|||||||
original_path = element.image_path
|
original_path = element.image_path
|
||||||
|
|
||||||
# Skip if already a simple relative path
|
# Skip if already a simple relative path
|
||||||
if not os.path.isabs(original_path) and not original_path.startswith('./projects/'):
|
if not os.path.isabs(original_path) and not original_path.startswith("./projects/"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 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
|
||||||
continue
|
continue
|
||||||
@@ -222,9 +219,9 @@ class AsyncProjectLoader(QThread):
|
|||||||
print(f"Normalized {normalized_count} asset paths")
|
print(f"Normalized {normalized_count} asset paths")
|
||||||
|
|
||||||
|
|
||||||
def load_from_zip_async(zip_path: str, extract_to: Optional[str] = None,
|
def load_from_zip_async(
|
||||||
progress_callback=None, complete_callback=None,
|
zip_path: str, extract_to: Optional[str] = None, progress_callback=None, complete_callback=None, error_callback=None
|
||||||
error_callback=None) -> AsyncProjectLoader:
|
) -> AsyncProjectLoader:
|
||||||
"""
|
"""
|
||||||
Load a project from a ZIP file asynchronously.
|
Load a project from a ZIP file asynchronously.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
+238
-302
@@ -2,6 +2,7 @@
|
|||||||
Command pattern implementation for undo/redo functionality
|
Command pattern implementation for undo/redo functionality
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData
|
from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData
|
||||||
@@ -10,111 +11,120 @@ from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, T
|
|||||||
def _normalize_asset_path(image_path: str, asset_manager) -> str:
|
def _normalize_asset_path(image_path: str, asset_manager) -> str:
|
||||||
"""
|
"""
|
||||||
Convert absolute path to relative for asset manager.
|
Convert absolute path to relative for asset manager.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
image_path: Image path (absolute or relative)
|
image_path: Image path (absolute or relative)
|
||||||
asset_manager: AssetManager instance
|
asset_manager: AssetManager instance
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Relative path suitable for asset manager
|
Relative path suitable for asset manager
|
||||||
"""
|
"""
|
||||||
if not asset_manager or not image_path:
|
if not asset_manager or not image_path:
|
||||||
return image_path
|
return image_path
|
||||||
|
|
||||||
import os
|
|
||||||
if os.path.isabs(image_path):
|
if os.path.isabs(image_path):
|
||||||
return os.path.relpath(image_path, asset_manager.project_folder)
|
return os.path.relpath(image_path, asset_manager.project_folder)
|
||||||
return image_path
|
return image_path
|
||||||
|
|
||||||
|
|
||||||
|
def _deserialize_element(elem_data: Dict[str, Any]) -> BaseLayoutElement:
|
||||||
|
"""
|
||||||
|
Deserialize element data into the appropriate element type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
elem_data: Dictionary containing serialized element data with 'type' key
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Deserialized element instance (ImageData, PlaceholderData, or TextBoxData)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If element type is unknown
|
||||||
|
"""
|
||||||
|
elem_type = elem_data.get("type")
|
||||||
|
|
||||||
|
element: BaseLayoutElement
|
||||||
|
if elem_type == "image":
|
||||||
|
element = ImageData()
|
||||||
|
elif elem_type == "placeholder":
|
||||||
|
element = PlaceholderData()
|
||||||
|
elif elem_type == "textbox":
|
||||||
|
element = TextBoxData()
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown element type: {elem_type}")
|
||||||
|
|
||||||
|
element.deserialize(elem_data)
|
||||||
|
return element
|
||||||
|
|
||||||
|
|
||||||
class Command(ABC):
|
class Command(ABC):
|
||||||
"""Abstract base class for all commands"""
|
"""Abstract base class for all commands"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def execute(self):
|
def execute(self):
|
||||||
"""Execute the command"""
|
"""Execute the command"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def undo(self):
|
def undo(self):
|
||||||
"""Undo the command"""
|
"""Undo the command"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def redo(self):
|
def redo(self):
|
||||||
"""Redo the command (default implementation calls execute)"""
|
"""Redo the command (default implementation calls execute)"""
|
||||||
self.execute()
|
self.execute()
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def serialize(self) -> Dict[str, Any]:
|
def serialize(self) -> Dict[str, Any]:
|
||||||
"""Serialize command to dictionary for saving"""
|
"""Serialize command to dictionary for saving"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def deserialize(data: Dict[str, Any], project) -> 'Command':
|
def deserialize(data: Dict[str, Any], project) -> "Command":
|
||||||
"""Deserialize command from dictionary"""
|
"""Deserialize command from dictionary"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class AddElementCommand(Command):
|
class AddElementCommand(Command):
|
||||||
"""Command for adding an element to a page"""
|
"""Command for adding an element to a page"""
|
||||||
|
|
||||||
def __init__(self, page_layout, element: BaseLayoutElement, asset_manager=None):
|
def __init__(self, page_layout, element: BaseLayoutElement, asset_manager=None):
|
||||||
self.page_layout = page_layout
|
self.page_layout = page_layout
|
||||||
self.element = element
|
self.element = element
|
||||||
self.executed = False
|
self.executed = False
|
||||||
self.asset_manager = asset_manager
|
self.asset_manager = asset_manager
|
||||||
|
|
||||||
# Acquire reference to asset when command is created
|
# Acquire reference to asset when command is created
|
||||||
if self.asset_manager and isinstance(self.element, ImageData) and self.element.image_path:
|
if self.asset_manager and isinstance(self.element, ImageData) and self.element.image_path:
|
||||||
rel_path = _normalize_asset_path(self.element.image_path, self.asset_manager)
|
rel_path = _normalize_asset_path(self.element.image_path, self.asset_manager)
|
||||||
self.asset_manager.acquire_reference(rel_path)
|
self.asset_manager.acquire_reference(rel_path)
|
||||||
|
|
||||||
def execute(self):
|
def execute(self):
|
||||||
"""Add the element to the page"""
|
"""Add the element to the page"""
|
||||||
if not self.executed:
|
if not self.executed:
|
||||||
self.page_layout.add_element(self.element)
|
self.page_layout.add_element(self.element)
|
||||||
self.executed = True
|
self.executed = True
|
||||||
|
|
||||||
def undo(self):
|
def undo(self):
|
||||||
"""Remove the element from the page"""
|
"""Remove the element from the page"""
|
||||||
if self.executed:
|
if self.executed:
|
||||||
self.page_layout.remove_element(self.element)
|
self.page_layout.remove_element(self.element)
|
||||||
self.executed = False
|
self.executed = False
|
||||||
|
|
||||||
def redo(self):
|
def redo(self):
|
||||||
"""Re-add the element"""
|
"""Re-add the element"""
|
||||||
self.execute()
|
self.execute()
|
||||||
|
|
||||||
def serialize(self) -> Dict[str, Any]:
|
def serialize(self) -> Dict[str, Any]:
|
||||||
"""Serialize to dictionary"""
|
"""Serialize to dictionary"""
|
||||||
return {
|
return {"type": "add_element", "element": self.element.serialize(), "executed": self.executed}
|
||||||
"type": "add_element",
|
|
||||||
"element": self.element.serialize(),
|
|
||||||
"executed": self.executed
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def deserialize(data: Dict[str, Any], project) -> 'AddElementCommand':
|
def deserialize(data: Dict[str, Any], project) -> "AddElementCommand":
|
||||||
"""Deserialize from dictionary"""
|
"""Deserialize from dictionary"""
|
||||||
# Reconstruct element from serialized data
|
element = _deserialize_element(data["element"])
|
||||||
elem_data = data["element"]
|
# Note: page_layout will be handled by the CommandHistory deserializer
|
||||||
elem_type = elem_data.get("type")
|
|
||||||
|
|
||||||
if elem_type == "image":
|
|
||||||
element = ImageData()
|
|
||||||
elif elem_type == "placeholder":
|
|
||||||
element = PlaceholderData()
|
|
||||||
elif elem_type == "textbox":
|
|
||||||
element = TextBoxData()
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown element type: {elem_type}")
|
|
||||||
|
|
||||||
element.deserialize(elem_data)
|
|
||||||
|
|
||||||
# Note: We need to find the correct page_layout
|
|
||||||
# This will be handled by the CommandHistory deserializer
|
|
||||||
cmd = AddElementCommand(None, element)
|
cmd = AddElementCommand(None, element)
|
||||||
cmd.executed = data.get("executed", False)
|
cmd.executed = data.get("executed", False)
|
||||||
return cmd
|
return cmd
|
||||||
@@ -122,59 +132,42 @@ class AddElementCommand(Command):
|
|||||||
|
|
||||||
class DeleteElementCommand(Command):
|
class DeleteElementCommand(Command):
|
||||||
"""Command for deleting an element from a page"""
|
"""Command for deleting an element from a page"""
|
||||||
|
|
||||||
def __init__(self, page_layout, element: BaseLayoutElement, asset_manager=None):
|
def __init__(self, page_layout, element: BaseLayoutElement, asset_manager=None):
|
||||||
self.page_layout = page_layout
|
self.page_layout = page_layout
|
||||||
self.element = element
|
self.element = element
|
||||||
self.executed = False
|
self.executed = False
|
||||||
self.asset_manager = asset_manager
|
self.asset_manager = asset_manager
|
||||||
|
|
||||||
# Acquire reference to asset to keep it alive while in undo history
|
# Acquire reference to asset to keep it alive while in undo history
|
||||||
if self.asset_manager and isinstance(self.element, ImageData) and self.element.image_path:
|
if self.asset_manager and isinstance(self.element, ImageData) and self.element.image_path:
|
||||||
rel_path = _normalize_asset_path(self.element.image_path, self.asset_manager)
|
rel_path = _normalize_asset_path(self.element.image_path, self.asset_manager)
|
||||||
self.asset_manager.acquire_reference(rel_path)
|
self.asset_manager.acquire_reference(rel_path)
|
||||||
|
|
||||||
def execute(self):
|
def execute(self):
|
||||||
"""Remove the element from the page"""
|
"""Remove the element from the page"""
|
||||||
if not self.executed:
|
if not self.executed:
|
||||||
self.page_layout.remove_element(self.element)
|
self.page_layout.remove_element(self.element)
|
||||||
self.executed = True
|
self.executed = True
|
||||||
|
|
||||||
def undo(self):
|
def undo(self):
|
||||||
"""Re-add the element to the page"""
|
"""Re-add the element to the page"""
|
||||||
if self.executed:
|
if self.executed:
|
||||||
self.page_layout.add_element(self.element)
|
self.page_layout.add_element(self.element)
|
||||||
self.executed = False
|
self.executed = False
|
||||||
|
|
||||||
def redo(self):
|
def redo(self):
|
||||||
"""Re-remove the element"""
|
"""Re-remove the element"""
|
||||||
self.execute()
|
self.execute()
|
||||||
|
|
||||||
def serialize(self) -> Dict[str, Any]:
|
def serialize(self) -> Dict[str, Any]:
|
||||||
"""Serialize to dictionary"""
|
"""Serialize to dictionary"""
|
||||||
return {
|
return {"type": "delete_element", "element": self.element.serialize(), "executed": self.executed}
|
||||||
"type": "delete_element",
|
|
||||||
"element": self.element.serialize(),
|
|
||||||
"executed": self.executed
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def deserialize(data: Dict[str, Any], project) -> 'DeleteElementCommand':
|
def deserialize(data: Dict[str, Any], project) -> "DeleteElementCommand":
|
||||||
"""Deserialize from dictionary"""
|
"""Deserialize from dictionary"""
|
||||||
elem_data = data["element"]
|
element = _deserialize_element(data["element"])
|
||||||
elem_type = elem_data.get("type")
|
|
||||||
|
|
||||||
if elem_type == "image":
|
|
||||||
element = ImageData()
|
|
||||||
elif elem_type == "placeholder":
|
|
||||||
element = PlaceholderData()
|
|
||||||
elif elem_type == "textbox":
|
|
||||||
element = TextBoxData()
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown element type: {elem_type}")
|
|
||||||
|
|
||||||
element.deserialize(elem_data)
|
|
||||||
|
|
||||||
cmd = DeleteElementCommand(None, element)
|
cmd = DeleteElementCommand(None, element)
|
||||||
cmd.executed = data.get("executed", False)
|
cmd.executed = data.get("executed", False)
|
||||||
return cmd
|
return cmd
|
||||||
@@ -182,82 +175,66 @@ class DeleteElementCommand(Command):
|
|||||||
|
|
||||||
class MoveElementCommand(Command):
|
class MoveElementCommand(Command):
|
||||||
"""Command for moving an element"""
|
"""Command for moving an element"""
|
||||||
|
|
||||||
def __init__(self, element: BaseLayoutElement, old_position: tuple, new_position: tuple):
|
def __init__(self, element: BaseLayoutElement, old_position: tuple, new_position: tuple):
|
||||||
self.element = element
|
self.element = element
|
||||||
self.old_position = old_position
|
self.old_position = old_position
|
||||||
self.new_position = new_position
|
self.new_position = new_position
|
||||||
|
|
||||||
def execute(self):
|
def execute(self):
|
||||||
"""Move element to new position"""
|
"""Move element to new position"""
|
||||||
self.element.position = self.new_position
|
self.element.position = self.new_position
|
||||||
|
|
||||||
def undo(self):
|
def undo(self):
|
||||||
"""Move element back to old position"""
|
"""Move element back to old position"""
|
||||||
self.element.position = self.old_position
|
self.element.position = self.old_position
|
||||||
|
|
||||||
def redo(self):
|
def redo(self):
|
||||||
"""Move element to new position again"""
|
"""Move element to new position again"""
|
||||||
self.execute()
|
self.execute()
|
||||||
|
|
||||||
def serialize(self) -> Dict[str, Any]:
|
def serialize(self) -> Dict[str, Any]:
|
||||||
"""Serialize to dictionary"""
|
"""Serialize to dictionary"""
|
||||||
return {
|
return {
|
||||||
"type": "move_element",
|
"type": "move_element",
|
||||||
"element": self.element.serialize(),
|
"element": self.element.serialize(),
|
||||||
"old_position": self.old_position,
|
"old_position": self.old_position,
|
||||||
"new_position": self.new_position
|
"new_position": self.new_position,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def deserialize(data: Dict[str, Any], project) -> 'MoveElementCommand':
|
def deserialize(data: Dict[str, Any], project) -> "MoveElementCommand":
|
||||||
"""Deserialize from dictionary"""
|
"""Deserialize from dictionary"""
|
||||||
elem_data = data["element"]
|
element = _deserialize_element(data["element"])
|
||||||
elem_type = elem_data.get("type")
|
return MoveElementCommand(element, tuple(data["old_position"]), tuple(data["new_position"]))
|
||||||
|
|
||||||
if elem_type == "image":
|
|
||||||
element = ImageData()
|
|
||||||
elif elem_type == "placeholder":
|
|
||||||
element = PlaceholderData()
|
|
||||||
elif elem_type == "textbox":
|
|
||||||
element = TextBoxData()
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown element type: {elem_type}")
|
|
||||||
|
|
||||||
element.deserialize(elem_data)
|
|
||||||
|
|
||||||
return MoveElementCommand(
|
|
||||||
element,
|
|
||||||
tuple(data["old_position"]),
|
|
||||||
tuple(data["new_position"])
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ResizeElementCommand(Command):
|
class ResizeElementCommand(Command):
|
||||||
"""Command for resizing an element"""
|
"""Command for resizing an element"""
|
||||||
|
|
||||||
def __init__(self, element: BaseLayoutElement, old_position: tuple, old_size: tuple,
|
def __init__(
|
||||||
new_position: tuple, new_size: tuple):
|
self, element: BaseLayoutElement, old_position: tuple, old_size: tuple, new_position: tuple, new_size: tuple
|
||||||
|
):
|
||||||
self.element = element
|
self.element = element
|
||||||
self.old_position = old_position
|
self.old_position = old_position
|
||||||
self.old_size = old_size
|
self.old_size = old_size
|
||||||
self.new_position = new_position
|
self.new_position = new_position
|
||||||
self.new_size = new_size
|
self.new_size = new_size
|
||||||
|
|
||||||
def execute(self):
|
def execute(self):
|
||||||
"""Resize element to new size"""
|
"""Resize element to new size"""
|
||||||
self.element.position = self.new_position
|
self.element.position = self.new_position
|
||||||
self.element.size = self.new_size
|
self.element.size = self.new_size
|
||||||
|
|
||||||
def undo(self):
|
def undo(self):
|
||||||
"""Resize element back to old size"""
|
"""Resize element back to old size"""
|
||||||
self.element.position = self.old_position
|
self.element.position = self.old_position
|
||||||
self.element.size = self.old_size
|
self.element.size = self.old_size
|
||||||
|
|
||||||
def redo(self):
|
def redo(self):
|
||||||
"""Resize element to new size again"""
|
"""Resize element to new size again"""
|
||||||
self.execute()
|
self.execute()
|
||||||
|
|
||||||
def serialize(self) -> Dict[str, Any]:
|
def serialize(self) -> Dict[str, Any]:
|
||||||
"""Serialize to dictionary"""
|
"""Serialize to dictionary"""
|
||||||
return {
|
return {
|
||||||
@@ -266,32 +243,19 @@ class ResizeElementCommand(Command):
|
|||||||
"old_position": self.old_position,
|
"old_position": self.old_position,
|
||||||
"old_size": self.old_size,
|
"old_size": self.old_size,
|
||||||
"new_position": self.new_position,
|
"new_position": self.new_position,
|
||||||
"new_size": self.new_size
|
"new_size": self.new_size,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def deserialize(data: Dict[str, Any], project) -> 'ResizeElementCommand':
|
def deserialize(data: Dict[str, Any], project) -> "ResizeElementCommand":
|
||||||
"""Deserialize from dictionary"""
|
"""Deserialize from dictionary"""
|
||||||
elem_data = data["element"]
|
element = _deserialize_element(data["element"])
|
||||||
elem_type = elem_data.get("type")
|
|
||||||
|
|
||||||
if elem_type == "image":
|
|
||||||
element = ImageData()
|
|
||||||
elif elem_type == "placeholder":
|
|
||||||
element = PlaceholderData()
|
|
||||||
elif elem_type == "textbox":
|
|
||||||
element = TextBoxData()
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown element type: {elem_type}")
|
|
||||||
|
|
||||||
element.deserialize(elem_data)
|
|
||||||
|
|
||||||
return ResizeElementCommand(
|
return ResizeElementCommand(
|
||||||
element,
|
element,
|
||||||
tuple(data["old_position"]),
|
tuple(data["old_position"]),
|
||||||
tuple(data["old_size"]),
|
tuple(data["old_size"]),
|
||||||
tuple(data["new_position"]),
|
tuple(data["new_position"]),
|
||||||
tuple(data["new_size"])
|
tuple(data["new_size"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -308,7 +272,7 @@ class RotateElementCommand(Command):
|
|||||||
self.old_size = element.size
|
self.old_size = element.size
|
||||||
|
|
||||||
# For ImageData, store the old PIL rotation state
|
# For ImageData, store the old PIL rotation state
|
||||||
if hasattr(element, 'pil_rotation_90'):
|
if hasattr(element, "pil_rotation_90"):
|
||||||
self.old_pil_rotation = element.pil_rotation_90
|
self.old_pil_rotation = element.pil_rotation_90
|
||||||
else:
|
else:
|
||||||
self.old_pil_rotation = None
|
self.old_pil_rotation = None
|
||||||
@@ -344,9 +308,9 @@ class RotateElementCommand(Command):
|
|||||||
self.element.position = (center_x - h / 2, center_y - w / 2)
|
self.element.position = (center_x - h / 2, center_y - w / 2)
|
||||||
|
|
||||||
# Clear the texture so it will be reloaded with the new rotation
|
# Clear the texture so it will be reloaded with the new rotation
|
||||||
if hasattr(self.element, '_texture_id'):
|
if hasattr(self.element, "_texture_id"):
|
||||||
del self.element._texture_id
|
del self.element._texture_id
|
||||||
if hasattr(self.element, '_async_load_requested'):
|
if hasattr(self.element, "_async_load_requested"):
|
||||||
self.element._async_load_requested = False
|
self.element._async_load_requested = False
|
||||||
|
|
||||||
# Keep visual rotation at 0
|
# Keep visual rotation at 0
|
||||||
@@ -376,109 +340,88 @@ class RotateElementCommand(Command):
|
|||||||
# For ImageData, restore PIL rotation and clear texture
|
# For ImageData, restore PIL rotation and clear texture
|
||||||
if isinstance(self.element, ImageData) and self.old_pil_rotation is not None:
|
if isinstance(self.element, ImageData) and self.old_pil_rotation is not None:
|
||||||
self.element.pil_rotation_90 = self.old_pil_rotation
|
self.element.pil_rotation_90 = self.old_pil_rotation
|
||||||
if hasattr(self.element, '_texture_id'):
|
if hasattr(self.element, "_texture_id"):
|
||||||
self.element._texture_id = None
|
self.element._texture_id = None
|
||||||
self.element._async_load_requested = False
|
self.element._async_load_requested = False
|
||||||
|
|
||||||
def redo(self):
|
def redo(self):
|
||||||
"""Rotate element to new angle again"""
|
"""Rotate element to new angle again"""
|
||||||
self.execute()
|
self.execute()
|
||||||
|
|
||||||
def serialize(self) -> Dict[str, Any]:
|
def serialize(self) -> Dict[str, Any]:
|
||||||
"""Serialize to dictionary"""
|
"""Serialize to dictionary"""
|
||||||
return {
|
return {
|
||||||
"type": "rotate_element",
|
"type": "rotate_element",
|
||||||
"element": self.element.serialize(),
|
"element": self.element.serialize(),
|
||||||
"old_rotation": self.old_rotation,
|
"old_rotation": self.old_rotation,
|
||||||
"new_rotation": self.new_rotation
|
"new_rotation": self.new_rotation,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def deserialize(data: Dict[str, Any], project) -> 'RotateElementCommand':
|
def deserialize(data: Dict[str, Any], project) -> "RotateElementCommand":
|
||||||
"""Deserialize from dictionary"""
|
"""Deserialize from dictionary"""
|
||||||
elem_data = data["element"]
|
element = _deserialize_element(data["element"])
|
||||||
elem_type = elem_data.get("type")
|
return RotateElementCommand(element, data["old_rotation"], data["new_rotation"])
|
||||||
|
|
||||||
if elem_type == "image":
|
|
||||||
element = ImageData()
|
|
||||||
elif elem_type == "placeholder":
|
|
||||||
element = PlaceholderData()
|
|
||||||
elif elem_type == "textbox":
|
|
||||||
element = TextBoxData()
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown element type: {elem_type}")
|
|
||||||
|
|
||||||
element.deserialize(elem_data)
|
|
||||||
|
|
||||||
return RotateElementCommand(
|
|
||||||
element,
|
|
||||||
data["old_rotation"],
|
|
||||||
data["new_rotation"]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AdjustImageCropCommand(Command):
|
class AdjustImageCropCommand(Command):
|
||||||
"""Command for adjusting image crop/pan within frame"""
|
"""Command for adjusting image crop/pan within frame"""
|
||||||
|
|
||||||
def __init__(self, element: ImageData, old_crop_info: tuple, new_crop_info: tuple):
|
def __init__(self, element: ImageData, old_crop_info: tuple, new_crop_info: tuple):
|
||||||
self.element = element
|
self.element = element
|
||||||
self.old_crop_info = old_crop_info
|
self.old_crop_info = old_crop_info
|
||||||
self.new_crop_info = new_crop_info
|
self.new_crop_info = new_crop_info
|
||||||
|
|
||||||
def execute(self):
|
def execute(self):
|
||||||
"""Apply new crop info"""
|
"""Apply new crop info"""
|
||||||
self.element.crop_info = self.new_crop_info
|
self.element.crop_info = self.new_crop_info
|
||||||
|
|
||||||
def undo(self):
|
def undo(self):
|
||||||
"""Restore old crop info"""
|
"""Restore old crop info"""
|
||||||
self.element.crop_info = self.old_crop_info
|
self.element.crop_info = self.old_crop_info
|
||||||
|
|
||||||
def redo(self):
|
def redo(self):
|
||||||
"""Apply new crop info again"""
|
"""Apply new crop info again"""
|
||||||
self.execute()
|
self.execute()
|
||||||
|
|
||||||
def serialize(self) -> Dict[str, Any]:
|
def serialize(self) -> Dict[str, Any]:
|
||||||
"""Serialize to dictionary"""
|
"""Serialize to dictionary"""
|
||||||
return {
|
return {
|
||||||
"type": "adjust_image_crop",
|
"type": "adjust_image_crop",
|
||||||
"element": self.element.serialize(),
|
"element": self.element.serialize(),
|
||||||
"old_crop_info": self.old_crop_info,
|
"old_crop_info": self.old_crop_info,
|
||||||
"new_crop_info": self.new_crop_info
|
"new_crop_info": self.new_crop_info,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def deserialize(data: Dict[str, Any], project) -> 'AdjustImageCropCommand':
|
def deserialize(data: Dict[str, Any], project) -> "AdjustImageCropCommand":
|
||||||
"""Deserialize from dictionary"""
|
"""Deserialize from dictionary"""
|
||||||
elem_data = data["element"]
|
elem_data = data["element"]
|
||||||
element = ImageData()
|
element = ImageData()
|
||||||
element.deserialize(elem_data)
|
element.deserialize(elem_data)
|
||||||
|
|
||||||
return AdjustImageCropCommand(
|
return AdjustImageCropCommand(element, tuple(data["old_crop_info"]), tuple(data["new_crop_info"]))
|
||||||
element,
|
|
||||||
tuple(data["old_crop_info"]),
|
|
||||||
tuple(data["new_crop_info"])
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AlignElementsCommand(Command):
|
class AlignElementsCommand(Command):
|
||||||
"""Command for aligning multiple elements"""
|
"""Command for aligning multiple elements"""
|
||||||
|
|
||||||
def __init__(self, changes: List[tuple]):
|
def __init__(self, changes: List[tuple]):
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
changes: List of (element, old_position) tuples
|
changes: List of (element, old_position) tuples
|
||||||
"""
|
"""
|
||||||
self.changes = changes
|
self.changes = changes
|
||||||
|
|
||||||
def execute(self):
|
def execute(self):
|
||||||
"""Positions have already been set by AlignmentManager"""
|
"""Positions have already been set by AlignmentManager"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def undo(self):
|
def undo(self):
|
||||||
"""Restore old positions"""
|
"""Restore old positions"""
|
||||||
for element, old_position in self.changes:
|
for element, old_position in self.changes:
|
||||||
element.position = old_position
|
element.position = old_position
|
||||||
|
|
||||||
def redo(self):
|
def redo(self):
|
||||||
"""Re-apply alignment (positions are stored in current state)"""
|
"""Re-apply alignment (positions are stored in current state)"""
|
||||||
# Store current positions and restore them
|
# Store current positions and restore them
|
||||||
@@ -488,47 +431,31 @@ class AlignElementsCommand(Command):
|
|||||||
# Then re-apply new positions
|
# Then re-apply new positions
|
||||||
for element, new_position in new_positions:
|
for element, new_position in new_positions:
|
||||||
element.position = new_position
|
element.position = new_position
|
||||||
|
|
||||||
def serialize(self) -> Dict[str, Any]:
|
def serialize(self) -> Dict[str, Any]:
|
||||||
"""Serialize to dictionary"""
|
"""Serialize to dictionary"""
|
||||||
return {
|
return {
|
||||||
"type": "align_elements",
|
"type": "align_elements",
|
||||||
"changes": [
|
"changes": [{"element": elem.serialize(), "old_position": old_pos} for elem, old_pos in self.changes],
|
||||||
{
|
|
||||||
"element": elem.serialize(),
|
|
||||||
"old_position": old_pos
|
|
||||||
}
|
|
||||||
for elem, old_pos in self.changes
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def deserialize(data: Dict[str, Any], project) -> 'AlignElementsCommand':
|
def deserialize(data: Dict[str, Any], project) -> "AlignElementsCommand":
|
||||||
"""Deserialize from dictionary"""
|
"""Deserialize from dictionary"""
|
||||||
changes = []
|
changes = []
|
||||||
for change_data in data.get("changes", []):
|
for change_data in data.get("changes", []):
|
||||||
elem_data = change_data["element"]
|
try:
|
||||||
elem_type = elem_data.get("type")
|
element = _deserialize_element(change_data["element"])
|
||||||
|
old_position = tuple(change_data["old_position"])
|
||||||
if elem_type == "image":
|
changes.append((element, old_position))
|
||||||
element = ImageData()
|
except ValueError:
|
||||||
elif elem_type == "placeholder":
|
|
||||||
element = PlaceholderData()
|
|
||||||
elif elem_type == "textbox":
|
|
||||||
element = TextBoxData()
|
|
||||||
else:
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
element.deserialize(elem_data)
|
|
||||||
old_position = tuple(change_data["old_position"])
|
|
||||||
changes.append((element, old_position))
|
|
||||||
|
|
||||||
return AlignElementsCommand(changes)
|
return AlignElementsCommand(changes)
|
||||||
|
|
||||||
|
|
||||||
class ResizeElementsCommand(Command):
|
class ResizeElementsCommand(Command):
|
||||||
"""Command for resizing multiple elements"""
|
"""Command for resizing multiple elements"""
|
||||||
|
|
||||||
def __init__(self, changes: List[tuple]):
|
def __init__(self, changes: List[tuple]):
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
@@ -536,131 +463,101 @@ class ResizeElementsCommand(Command):
|
|||||||
"""
|
"""
|
||||||
self.changes = changes
|
self.changes = changes
|
||||||
self.new_states = [(elem, elem.position, elem.size) for elem, _, _ in changes]
|
self.new_states = [(elem, elem.position, elem.size) for elem, _, _ in changes]
|
||||||
|
|
||||||
def execute(self):
|
def execute(self):
|
||||||
"""Sizes have already been set by AlignmentManager"""
|
"""Sizes have already been set by AlignmentManager"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def undo(self):
|
def undo(self):
|
||||||
"""Restore old positions and sizes"""
|
"""Restore old positions and sizes"""
|
||||||
for element, old_position, old_size in self.changes:
|
for element, old_position, old_size in self.changes:
|
||||||
element.position = old_position
|
element.position = old_position
|
||||||
element.size = old_size
|
element.size = old_size
|
||||||
|
|
||||||
def redo(self):
|
def redo(self):
|
||||||
"""Re-apply new sizes"""
|
"""Re-apply new sizes"""
|
||||||
for element, new_position, new_size in self.new_states:
|
for element, new_position, new_size in self.new_states:
|
||||||
element.position = new_position
|
element.position = new_position
|
||||||
element.size = new_size
|
element.size = new_size
|
||||||
|
|
||||||
def serialize(self) -> Dict[str, Any]:
|
def serialize(self) -> Dict[str, Any]:
|
||||||
"""Serialize to dictionary"""
|
"""Serialize to dictionary"""
|
||||||
return {
|
return {
|
||||||
"type": "resize_elements",
|
"type": "resize_elements",
|
||||||
"changes": [
|
"changes": [
|
||||||
{
|
{"element": elem.serialize(), "old_position": old_pos, "old_size": old_size}
|
||||||
"element": elem.serialize(),
|
|
||||||
"old_position": old_pos,
|
|
||||||
"old_size": old_size
|
|
||||||
}
|
|
||||||
for elem, old_pos, old_size in self.changes
|
for elem, old_pos, old_size in self.changes
|
||||||
]
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def deserialize(data: Dict[str, Any], project) -> 'ResizeElementsCommand':
|
def deserialize(data: Dict[str, Any], project) -> "ResizeElementsCommand":
|
||||||
"""Deserialize from dictionary"""
|
"""Deserialize from dictionary"""
|
||||||
changes = []
|
changes = []
|
||||||
for change_data in data.get("changes", []):
|
for change_data in data.get("changes", []):
|
||||||
elem_data = change_data["element"]
|
try:
|
||||||
elem_type = elem_data.get("type")
|
element = _deserialize_element(change_data["element"])
|
||||||
|
old_position = tuple(change_data["old_position"])
|
||||||
if elem_type == "image":
|
old_size = tuple(change_data["old_size"])
|
||||||
element = ImageData()
|
changes.append((element, old_position, old_size))
|
||||||
elif elem_type == "placeholder":
|
except ValueError:
|
||||||
element = PlaceholderData()
|
|
||||||
elif elem_type == "textbox":
|
|
||||||
element = TextBoxData()
|
|
||||||
else:
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
element.deserialize(elem_data)
|
|
||||||
old_position = tuple(change_data["old_position"])
|
|
||||||
old_size = tuple(change_data["old_size"])
|
|
||||||
changes.append((element, old_position, old_size))
|
|
||||||
|
|
||||||
return ResizeElementsCommand(changes)
|
return ResizeElementsCommand(changes)
|
||||||
|
|
||||||
|
|
||||||
class ChangeZOrderCommand(Command):
|
class ChangeZOrderCommand(Command):
|
||||||
"""Command for changing element z-order (list position)"""
|
"""Command for changing element z-order (list position)"""
|
||||||
|
|
||||||
def __init__(self, page_layout, element: BaseLayoutElement, old_index: int, new_index: int):
|
def __init__(self, page_layout, element: BaseLayoutElement, old_index: int, new_index: int):
|
||||||
self.page_layout = page_layout
|
self.page_layout = page_layout
|
||||||
self.element = element
|
self.element = element
|
||||||
self.old_index = old_index
|
self.old_index = old_index
|
||||||
self.new_index = new_index
|
self.new_index = new_index
|
||||||
|
|
||||||
def execute(self):
|
def execute(self):
|
||||||
"""Move element to new position in list"""
|
"""Move element to new position in list"""
|
||||||
elements = self.page_layout.elements
|
elements = self.page_layout.elements
|
||||||
if self.element in elements:
|
if self.element in elements:
|
||||||
elements.remove(self.element)
|
elements.remove(self.element)
|
||||||
elements.insert(self.new_index, self.element)
|
elements.insert(self.new_index, self.element)
|
||||||
|
|
||||||
def undo(self):
|
def undo(self):
|
||||||
"""Move element back to old position in list"""
|
"""Move element back to old position in list"""
|
||||||
elements = self.page_layout.elements
|
elements = self.page_layout.elements
|
||||||
if self.element in elements:
|
if self.element in elements:
|
||||||
elements.remove(self.element)
|
elements.remove(self.element)
|
||||||
elements.insert(self.old_index, self.element)
|
elements.insert(self.old_index, self.element)
|
||||||
|
|
||||||
def redo(self):
|
def redo(self):
|
||||||
"""Move element to new position again"""
|
"""Move element to new position again"""
|
||||||
self.execute()
|
self.execute()
|
||||||
|
|
||||||
def serialize(self) -> Dict[str, Any]:
|
def serialize(self) -> Dict[str, Any]:
|
||||||
"""Serialize to dictionary"""
|
"""Serialize to dictionary"""
|
||||||
return {
|
return {
|
||||||
"type": "change_zorder",
|
"type": "change_zorder",
|
||||||
"element": self.element.serialize(),
|
"element": self.element.serialize(),
|
||||||
"old_index": self.old_index,
|
"old_index": self.old_index,
|
||||||
"new_index": self.new_index
|
"new_index": self.new_index,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def deserialize(data: Dict[str, Any], project) -> 'ChangeZOrderCommand':
|
def deserialize(data: Dict[str, Any], project) -> "ChangeZOrderCommand":
|
||||||
"""Deserialize from dictionary"""
|
"""Deserialize from dictionary"""
|
||||||
elem_data = data["element"]
|
element = _deserialize_element(data["element"])
|
||||||
elem_type = elem_data.get("type")
|
|
||||||
|
|
||||||
if elem_type == "image":
|
|
||||||
element = ImageData()
|
|
||||||
elif elem_type == "placeholder":
|
|
||||||
element = PlaceholderData()
|
|
||||||
elif elem_type == "textbox":
|
|
||||||
element = TextBoxData()
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown element type: {elem_type}")
|
|
||||||
|
|
||||||
element.deserialize(elem_data)
|
|
||||||
|
|
||||||
return ChangeZOrderCommand(
|
return ChangeZOrderCommand(
|
||||||
None, # page_layout will be set by CommandHistory
|
None, element, data["old_index"], data["new_index"] # page_layout will be set by CommandHistory
|
||||||
element,
|
|
||||||
data["old_index"],
|
|
||||||
data["new_index"]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class StateChangeCommand(Command):
|
class StateChangeCommand(Command):
|
||||||
"""
|
"""
|
||||||
Generic command for operations that change state.
|
Generic command for operations that change state.
|
||||||
|
|
||||||
This command captures before/after snapshots of state and can restore them.
|
This command captures before/after snapshots of state and can restore them.
|
||||||
Used by the @undoable_operation decorator.
|
Used by the @undoable_operation decorator.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, description: str, restore_func, before_state: Any, after_state: Any = None):
|
def __init__(self, description: str, restore_func, before_state: Any, after_state: Any = None):
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
@@ -673,31 +570,28 @@ class StateChangeCommand(Command):
|
|||||||
self.restore_func = restore_func
|
self.restore_func = restore_func
|
||||||
self.before_state = before_state
|
self.before_state = before_state
|
||||||
self.after_state = after_state
|
self.after_state = after_state
|
||||||
|
|
||||||
def execute(self):
|
def execute(self):
|
||||||
"""State is already applied, just store after_state if not set"""
|
"""State is already applied, just store after_state if not set"""
|
||||||
# After state is captured by decorator after operation runs
|
# After state is captured by decorator after operation runs
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def undo(self):
|
def undo(self):
|
||||||
"""Restore to before state"""
|
"""Restore to before state"""
|
||||||
self.restore_func(self.before_state)
|
self.restore_func(self.before_state)
|
||||||
|
|
||||||
def redo(self):
|
def redo(self):
|
||||||
"""Restore to after state"""
|
"""Restore to after state"""
|
||||||
self.restore_func(self.after_state)
|
self.restore_func(self.after_state)
|
||||||
|
|
||||||
def serialize(self) -> Dict[str, Any]:
|
def serialize(self) -> Dict[str, Any]:
|
||||||
"""Serialize to dictionary"""
|
"""Serialize to dictionary"""
|
||||||
# For now, state change commands are not serialized
|
# For now, state change commands are not serialized
|
||||||
# This could be enhanced later if needed
|
# This could be enhanced later if needed
|
||||||
return {
|
return {"type": "state_change", "description": self.description}
|
||||||
"type": "state_change",
|
|
||||||
"description": self.description
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def deserialize(data: Dict[str, Any], project) -> 'StateChangeCommand':
|
def deserialize(data: Dict[str, Any], project) -> "StateChangeCommand":
|
||||||
"""Deserialize from dictionary"""
|
"""Deserialize from dictionary"""
|
||||||
# Not implemented - would need to serialize state
|
# Not implemented - would need to serialize state
|
||||||
raise NotImplementedError("StateChangeCommand deserialization not yet supported")
|
raise NotImplementedError("StateChangeCommand deserialization not yet supported")
|
||||||
@@ -705,72 +599,86 @@ class StateChangeCommand(Command):
|
|||||||
|
|
||||||
class CommandHistory:
|
class CommandHistory:
|
||||||
"""Manages undo/redo command history"""
|
"""Manages undo/redo command history"""
|
||||||
|
|
||||||
def __init__(self, max_history: int = 100, asset_manager=None):
|
def __init__(self, max_history: int = 100, asset_manager=None, project=None):
|
||||||
self.undo_stack: List[Command] = []
|
self.undo_stack: List[Command] = []
|
||||||
self.redo_stack: List[Command] = []
|
self.redo_stack: List[Command] = []
|
||||||
self.max_history = max_history
|
self.max_history = max_history
|
||||||
self.asset_manager = asset_manager
|
self.asset_manager = asset_manager
|
||||||
|
self.project = project # Reference to project for dirty flag tracking
|
||||||
|
|
||||||
def execute(self, command: Command):
|
def execute(self, command: Command):
|
||||||
"""Execute a command and add it to history"""
|
"""Execute a command and add it to history"""
|
||||||
command.execute()
|
command.execute()
|
||||||
|
|
||||||
# When clearing redo stack, release asset references
|
# When clearing redo stack, release asset references
|
||||||
for cmd in self.redo_stack:
|
for cmd in self.redo_stack:
|
||||||
self._release_command_assets(cmd)
|
self._release_command_assets(cmd)
|
||||||
self.redo_stack.clear()
|
self.redo_stack.clear()
|
||||||
|
|
||||||
self.undo_stack.append(command)
|
self.undo_stack.append(command)
|
||||||
|
|
||||||
|
# Mark project as dirty
|
||||||
|
if self.project:
|
||||||
|
self.project.mark_dirty()
|
||||||
|
|
||||||
# Limit history size - release assets from old commands
|
# Limit history size - release assets from old commands
|
||||||
if len(self.undo_stack) > self.max_history:
|
if len(self.undo_stack) > self.max_history:
|
||||||
old_cmd = self.undo_stack.pop(0)
|
old_cmd = self.undo_stack.pop(0)
|
||||||
self._release_command_assets(old_cmd)
|
self._release_command_assets(old_cmd)
|
||||||
|
|
||||||
def _release_command_assets(self, command: Command):
|
def _release_command_assets(self, command: Command):
|
||||||
"""Release asset references held by a command"""
|
"""Release asset references held by a command"""
|
||||||
if not self.asset_manager:
|
if not self.asset_manager:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Release asset references for commands that hold them
|
# Release asset references for commands that hold them
|
||||||
if isinstance(command, (AddElementCommand, DeleteElementCommand)):
|
if isinstance(command, (AddElementCommand, DeleteElementCommand)):
|
||||||
if isinstance(command.element, ImageData) and command.element.image_path:
|
if isinstance(command.element, ImageData) and command.element.image_path:
|
||||||
# Convert absolute path to relative for asset manager
|
# Convert absolute path to relative for asset manager
|
||||||
import os
|
|
||||||
asset_path = command.element.image_path
|
asset_path = command.element.image_path
|
||||||
if os.path.isabs(asset_path):
|
if os.path.isabs(asset_path):
|
||||||
asset_path = os.path.relpath(asset_path, self.asset_manager.project_folder)
|
asset_path = os.path.relpath(asset_path, self.asset_manager.project_folder)
|
||||||
self.asset_manager.release_reference(asset_path)
|
self.asset_manager.release_reference(asset_path)
|
||||||
|
|
||||||
def undo(self) -> bool:
|
def undo(self) -> bool:
|
||||||
"""Undo the last command"""
|
"""Undo the last command"""
|
||||||
if not self.can_undo():
|
if not self.can_undo():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
command = self.undo_stack.pop()
|
command = self.undo_stack.pop()
|
||||||
command.undo()
|
command.undo()
|
||||||
self.redo_stack.append(command)
|
self.redo_stack.append(command)
|
||||||
|
|
||||||
|
# Mark project as dirty
|
||||||
|
if self.project:
|
||||||
|
self.project.mark_dirty()
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def redo(self) -> bool:
|
def redo(self) -> bool:
|
||||||
"""Redo the last undone command"""
|
"""Redo the last undone command"""
|
||||||
if not self.can_redo():
|
if not self.can_redo():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
command = self.redo_stack.pop()
|
command = self.redo_stack.pop()
|
||||||
command.redo()
|
command.redo()
|
||||||
self.undo_stack.append(command)
|
self.undo_stack.append(command)
|
||||||
|
|
||||||
|
# Mark project as dirty
|
||||||
|
if self.project:
|
||||||
|
self.project.mark_dirty()
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def can_undo(self) -> bool:
|
def can_undo(self) -> bool:
|
||||||
"""Check if undo is available"""
|
"""Check if undo is available"""
|
||||||
return len(self.undo_stack) > 0
|
return len(self.undo_stack) > 0
|
||||||
|
|
||||||
def can_redo(self) -> bool:
|
def can_redo(self) -> bool:
|
||||||
"""Check if redo is available"""
|
"""Check if redo is available"""
|
||||||
return len(self.redo_stack) > 0
|
return len(self.redo_stack) > 0
|
||||||
|
|
||||||
def clear(self):
|
def clear(self):
|
||||||
"""Clear all history and release asset references"""
|
"""Clear all history and release asset references"""
|
||||||
# Release all asset references
|
# Release all asset references
|
||||||
@@ -778,62 +686,90 @@ class CommandHistory:
|
|||||||
self._release_command_assets(cmd)
|
self._release_command_assets(cmd)
|
||||||
for cmd in self.redo_stack:
|
for cmd in self.redo_stack:
|
||||||
self._release_command_assets(cmd)
|
self._release_command_assets(cmd)
|
||||||
|
|
||||||
self.undo_stack.clear()
|
self.undo_stack.clear()
|
||||||
self.redo_stack.clear()
|
self.redo_stack.clear()
|
||||||
|
|
||||||
def serialize(self) -> Dict[str, Any]:
|
def serialize(self) -> Dict[str, Any]:
|
||||||
"""Serialize history to dictionary"""
|
"""Serialize history to dictionary"""
|
||||||
return {
|
return {
|
||||||
"undo_stack": [cmd.serialize() for cmd in self.undo_stack],
|
"undo_stack": [cmd.serialize() for cmd in self.undo_stack],
|
||||||
"redo_stack": [cmd.serialize() for cmd in self.redo_stack],
|
"redo_stack": [cmd.serialize() for cmd in self.redo_stack],
|
||||||
"max_history": self.max_history
|
"max_history": self.max_history,
|
||||||
}
|
}
|
||||||
|
|
||||||
def deserialize(self, data: Dict[str, Any], project):
|
def deserialize(self, data: Dict[str, Any], project):
|
||||||
"""Deserialize history from dictionary"""
|
"""Deserialize history from dictionary"""
|
||||||
self.max_history = data.get("max_history", 100)
|
self.max_history = data.get("max_history", 100)
|
||||||
|
|
||||||
# Deserialize undo stack
|
# Deserialize undo stack
|
||||||
self.undo_stack = []
|
self.undo_stack = []
|
||||||
for cmd_data in data.get("undo_stack", []):
|
for cmd_data in data.get("undo_stack", []):
|
||||||
cmd = self._deserialize_command(cmd_data, project)
|
cmd = self._deserialize_command(cmd_data, project)
|
||||||
if cmd:
|
if cmd:
|
||||||
|
# Fix up page_layout references for commands that need them
|
||||||
|
self._fixup_page_layout(cmd, project)
|
||||||
self.undo_stack.append(cmd)
|
self.undo_stack.append(cmd)
|
||||||
|
|
||||||
# Deserialize redo stack
|
# Deserialize redo stack
|
||||||
self.redo_stack = []
|
self.redo_stack = []
|
||||||
for cmd_data in data.get("redo_stack", []):
|
for cmd_data in data.get("redo_stack", []):
|
||||||
cmd = self._deserialize_command(cmd_data, project)
|
cmd = self._deserialize_command(cmd_data, project)
|
||||||
if cmd:
|
if cmd:
|
||||||
|
# Fix up page_layout references for commands that need them
|
||||||
|
self._fixup_page_layout(cmd, project)
|
||||||
self.redo_stack.append(cmd)
|
self.redo_stack.append(cmd)
|
||||||
|
|
||||||
|
def _fixup_page_layout(self, cmd: Command, project):
|
||||||
|
"""
|
||||||
|
Fix up page_layout references after deserialization.
|
||||||
|
|
||||||
|
Commands like AddElementCommand store page_layout as None during
|
||||||
|
deserialization because the page_layout object doesn't exist yet.
|
||||||
|
This method finds the correct page_layout based on the element.
|
||||||
|
"""
|
||||||
|
# Check if command has a page_layout attribute that's None
|
||||||
|
if not hasattr(cmd, "page_layout") or cmd.page_layout is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Try to find the page containing this element
|
||||||
|
if hasattr(cmd, "element") and cmd.element:
|
||||||
|
element = cmd.element
|
||||||
|
for page in project.pages:
|
||||||
|
if element in page.layout.elements:
|
||||||
|
cmd.page_layout = page.layout
|
||||||
|
return
|
||||||
|
# Element not found in any page - use first page as fallback
|
||||||
|
# This can happen for newly added elements not yet in a page
|
||||||
|
if project.pages:
|
||||||
|
cmd.page_layout = project.pages[0].layout
|
||||||
|
|
||||||
|
# Command type registry for deserialization
|
||||||
|
_COMMAND_DESERIALIZERS = {
|
||||||
|
"add_element": AddElementCommand.deserialize,
|
||||||
|
"delete_element": DeleteElementCommand.deserialize,
|
||||||
|
"move_element": MoveElementCommand.deserialize,
|
||||||
|
"resize_element": ResizeElementCommand.deserialize,
|
||||||
|
"rotate_element": RotateElementCommand.deserialize,
|
||||||
|
"align_elements": AlignElementsCommand.deserialize,
|
||||||
|
"resize_elements": ResizeElementsCommand.deserialize,
|
||||||
|
"change_zorder": ChangeZOrderCommand.deserialize,
|
||||||
|
"adjust_image_crop": AdjustImageCropCommand.deserialize,
|
||||||
|
}
|
||||||
|
|
||||||
def _deserialize_command(self, data: Dict[str, Any], project) -> Optional[Command]:
|
def _deserialize_command(self, data: Dict[str, Any], project) -> Optional[Command]:
|
||||||
"""Deserialize a single command"""
|
"""Deserialize a single command using registry pattern"""
|
||||||
cmd_type = data.get("type")
|
cmd_type = data.get("type")
|
||||||
|
if cmd_type is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
deserializer = self._COMMAND_DESERIALIZERS.get(cmd_type)
|
||||||
|
if not deserializer:
|
||||||
|
print(f"Warning: Unknown command type: {cmd_type}")
|
||||||
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if cmd_type == "add_element":
|
return deserializer(data, project)
|
||||||
return AddElementCommand.deserialize(data, project)
|
|
||||||
elif cmd_type == "delete_element":
|
|
||||||
return DeleteElementCommand.deserialize(data, project)
|
|
||||||
elif cmd_type == "move_element":
|
|
||||||
return MoveElementCommand.deserialize(data, project)
|
|
||||||
elif cmd_type == "resize_element":
|
|
||||||
return ResizeElementCommand.deserialize(data, project)
|
|
||||||
elif cmd_type == "rotate_element":
|
|
||||||
return RotateElementCommand.deserialize(data, project)
|
|
||||||
elif cmd_type == "align_elements":
|
|
||||||
return AlignElementsCommand.deserialize(data, project)
|
|
||||||
elif cmd_type == "resize_elements":
|
|
||||||
return ResizeElementsCommand.deserialize(data, project)
|
|
||||||
elif cmd_type == "change_zorder":
|
|
||||||
return ChangeZOrderCommand.deserialize(data, project)
|
|
||||||
elif cmd_type == "adjust_image_crop":
|
|
||||||
return AdjustImageCropCommand.deserialize(data, project)
|
|
||||||
else:
|
|
||||||
print(f"Warning: Unknown command type: {cmd_type}")
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error deserializing command: {e}")
|
print(f"Error deserializing command: {e}")
|
||||||
return None
|
return None
|
||||||
|
|||||||
+176
-83
@@ -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)
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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(),
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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 |
@@ -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
|
||||||
+238
-6
@@ -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
|
||||||
@@ -24,6 +25,7 @@ class GLWidget(
|
|||||||
AsyncLoadingMixin,
|
AsyncLoadingMixin,
|
||||||
ViewportMixin,
|
ViewportMixin,
|
||||||
RenderingMixin,
|
RenderingMixin,
|
||||||
|
AssetPathMixin,
|
||||||
AssetDropMixin,
|
AssetDropMixin,
|
||||||
PageNavigationMixin,
|
PageNavigationMixin,
|
||||||
ImagePanMixin,
|
ImagePanMixin,
|
||||||
@@ -32,7 +34,7 @@ class GLWidget(
|
|||||||
MouseInteractionMixin,
|
MouseInteractionMixin,
|
||||||
UndoableInteractionMixin,
|
UndoableInteractionMixin,
|
||||||
KeyboardNavigationMixin,
|
KeyboardNavigationMixin,
|
||||||
QOpenGLWidget
|
QOpenGLWidget,
|
||||||
):
|
):
|
||||||
"""OpenGL widget for pyPhotoAlbum rendering and user interaction
|
"""OpenGL widget for pyPhotoAlbum rendering and user interaction
|
||||||
|
|
||||||
@@ -51,11 +53,21 @@ 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
|
||||||
@@ -66,18 +78,45 @@ class GLWidget(
|
|||||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||||
self.setFocus()
|
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:
|
||||||
@@ -90,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}")
|
||||||
@@ -122,3 +161,196 @@ class GLWidget(
|
|||||||
|
|
||||||
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()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -5,7 +5,7 @@ Displays loading progress in the lower-right corner of the window.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QProgressBar
|
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QProgressBar
|
||||||
from PyQt6.QtCore import Qt, QPropertyAnimation, QEasingCurve, pyqtProperty
|
from PyQt6.QtCore import Qt, QPropertyAnimation, QEasingCurve, pyqtProperty # type: ignore[attr-defined]
|
||||||
from PyQt6.QtGui import QPalette, QColor
|
from PyQt6.QtGui import QPalette, QColor
|
||||||
|
|
||||||
|
|
||||||
@@ -96,8 +96,8 @@ class LoadingWidget(QWidget):
|
|||||||
"""Get opacity for animation"""
|
"""Get opacity for animation"""
|
||||||
return self._opacity
|
return self._opacity
|
||||||
|
|
||||||
@opacity.setter
|
@opacity.setter # type: ignore[no-redef]
|
||||||
def opacity(self, value):
|
def opacity(self, value: float) -> None:
|
||||||
"""Set opacity for animation"""
|
"""Set opacity for animation"""
|
||||||
self._opacity = value
|
self._opacity = value
|
||||||
self.setWindowOpacity(value)
|
self.setWindowOpacity(value)
|
||||||
|
|||||||
+184
-35
@@ -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, QScrollBar, QHBoxLayout
|
QMainWindow,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
QStatusBar,
|
||||||
|
QScrollBar,
|
||||||
|
QHBoxLayout,
|
||||||
|
QMessageBox,
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, QSize
|
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,20 +123,20 @@ 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)
|
||||||
@@ -147,18 +174,25 @@ class MainWindow(
|
|||||||
|
|
||||||
canvas_widget.setLayout(canvas_layout)
|
canvas_widget.setLayout(canvas_layout)
|
||||||
main_layout.addWidget(canvas_widget, 1)
|
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
|
# Track scrollbar updates to prevent feedback loops
|
||||||
self._updating_scrollbars = False
|
self._updating_scrollbars = False
|
||||||
|
# Track scrollbar visibility changes to prevent resize-triggered recentering
|
||||||
|
self._updating_scrollbar_visibility = False
|
||||||
|
|
||||||
def _on_vertical_scroll(self, value):
|
def _on_vertical_scroll(self, value):
|
||||||
"""Handle vertical scrollbar changes"""
|
"""Handle vertical scrollbar changes"""
|
||||||
@@ -178,13 +212,17 @@ class MainWindow(
|
|||||||
"""Update scrollbar positions and ranges based on current content and pan offset"""
|
"""Update scrollbar positions and ranges based on current content and pan offset"""
|
||||||
self._updating_scrollbars = True
|
self._updating_scrollbars = True
|
||||||
|
|
||||||
|
# Block signals to prevent feedback loop
|
||||||
|
self._v_scrollbar.blockSignals(True)
|
||||||
|
self._h_scrollbar.blockSignals(True)
|
||||||
|
|
||||||
# Get content bounds
|
# Get content bounds
|
||||||
bounds = self._gl_widget.get_content_bounds()
|
bounds = self._gl_widget.get_content_bounds()
|
||||||
viewport_width = self._gl_widget.width()
|
viewport_width = self._gl_widget.width()
|
||||||
viewport_height = self._gl_widget.height()
|
viewport_height = self._gl_widget.height()
|
||||||
|
|
||||||
content_height = bounds['height']
|
content_height = bounds["height"]
|
||||||
content_width = bounds['width']
|
content_width = bounds["width"]
|
||||||
|
|
||||||
# Vertical scrollbar
|
# Vertical scrollbar
|
||||||
# Scrollbar value 0 = top of content
|
# Scrollbar value 0 = top of content
|
||||||
@@ -198,6 +236,8 @@ class MainWindow(
|
|||||||
self._v_scrollbar.setValue(int(max(0, min(v_range, -self._gl_widget.pan_offset[1]))))
|
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
|
# 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)
|
self._v_scrollbar.setVisible(v_range > 0)
|
||||||
|
|
||||||
# Horizontal scrollbar
|
# Horizontal scrollbar
|
||||||
@@ -209,6 +249,11 @@ class MainWindow(
|
|||||||
|
|
||||||
# Show/hide horizontal scrollbar based on whether scrolling is needed
|
# Show/hide horizontal scrollbar based on whether scrolling is needed
|
||||||
self._h_scrollbar.setVisible(h_range > 0)
|
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
|
self._updating_scrollbars = False
|
||||||
|
|
||||||
@@ -227,7 +272,7 @@ class MainWindow(
|
|||||||
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")
|
||||||
@@ -235,47 +280,151 @@ class MainWindow(
|
|||||||
def resizeEvent(self, event):
|
def resizeEvent(self, event):
|
||||||
"""Handle window resize to reposition loading widget"""
|
"""Handle window resize to reposition loading widget"""
|
||||||
super().resizeEvent(event)
|
super().resizeEvent(event)
|
||||||
if hasattr(self, '_loading_widget'):
|
if hasattr(self, "_loading_widget"):
|
||||||
self._loading_widget.resizeParent()
|
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}")
|
||||||
@@ -284,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())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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,108 +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)):
|
||||||
main_window = self.window()
|
self._handle_drop_on_element(image_path, target_element)
|
||||||
if hasattr(main_window, 'project') and main_window.project:
|
|
||||||
try:
|
|
||||||
# Import the asset to the project's assets folder
|
|
||||||
asset_path = main_window.project.asset_manager.import_asset(image_path)
|
|
||||||
|
|
||||||
if isinstance(target_element, PlaceholderData):
|
|
||||||
new_image = ImageData(
|
|
||||||
image_path=asset_path, # Use imported asset 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
|
|
||||||
)
|
|
||||||
if 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:
|
|
||||||
# Update existing ImageData with imported asset
|
|
||||||
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}")
|
|
||||||
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}")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
+44
-47
@@ -2,57 +2,57 @@
|
|||||||
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):
|
def _get_most_visible_page_index(self):
|
||||||
@@ -62,7 +62,7 @@ class ApplicationStateMixin:
|
|||||||
Returns:
|
Returns:
|
||||||
int: Index of the most visible page
|
int: Index of the most visible page
|
||||||
"""
|
"""
|
||||||
if not hasattr(self.gl_widget, '_page_renderers') or not self.gl_widget._page_renderers:
|
if not hasattr(self.gl_widget, "_page_renderers") or not self.gl_widget._page_renderers:
|
||||||
return self.gl_widget.current_page_index
|
return self.gl_widget.current_page_index
|
||||||
|
|
||||||
# Get viewport dimensions
|
# Get viewport dimensions
|
||||||
@@ -70,7 +70,7 @@ class ApplicationStateMixin:
|
|||||||
viewport_center_y = viewport_height / 2
|
viewport_center_y = viewport_height / 2
|
||||||
|
|
||||||
# Find which page's center is closest to viewport center
|
# Find which page's center is closest to viewport center
|
||||||
min_distance = float('inf')
|
min_distance = float("inf")
|
||||||
best_page_index = self.gl_widget.current_page_index
|
best_page_index = self.gl_widget.current_page_index
|
||||||
|
|
||||||
for renderer, page in self.gl_widget._page_renderers:
|
for renderer, page in self.gl_widget._page_renderers:
|
||||||
@@ -107,109 +107,106 @@ class ApplicationStateMixin:
|
|||||||
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
|
# Update scrollbars to reflect new content
|
||||||
if hasattr(self, 'update_scrollbars'):
|
if hasattr(self, "update_scrollbars"):
|
||||||
self.update_scrollbars()
|
self.update_scrollbars()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,19 +58,21 @@ 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,
|
||||||
@@ -69,8 +80,9 @@ class ElementManipulationMixin:
|
|||||||
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
|
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
|
||||||
@@ -94,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
|
||||||
@@ -119,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.
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +65,7 @@ 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)
|
||||||
@@ -73,9 +84,9 @@ class ElementSelectionMixin:
|
|||||||
# Simple bounds check (no rotation transformation needed - images are already rotated)
|
# Simple bounds check (no rotation transformation needed - images are already rotated)
|
||||||
if ex <= page_x <= ex + ew and ey <= page_y <= ey + eh:
|
if ex <= page_x <= ex + ew and ey <= page_y <= ey + eh:
|
||||||
# Store the renderer with the element for later use
|
# Store the renderer with the element for later use
|
||||||
element._page_renderer = renderer
|
element._page_renderer = renderer # type: ignore[attr-defined]
|
||||||
element._parent_page = page
|
element._parent_page = page # type: ignore[attr-defined]
|
||||||
return element
|
return element # type: ignore[no-any-return]
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -96,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
|
||||||
@@ -117,10 +128,10 @@ class ElementSelectionMixin:
|
|||||||
|
|
||||||
# Check handles (no rotation transformation needed - images are already rotated)
|
# Check handles (no rotation transformation needed - images are already rotated)
|
||||||
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():
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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))}
|
||||||
@@ -16,7 +16,7 @@ class KeyboardNavigationMixin:
|
|||||||
def _navigate_to_next_page(self):
|
def _navigate_to_next_page(self):
|
||||||
"""Navigate to the next page using Page Down key"""
|
"""Navigate to the next page using Page Down key"""
|
||||||
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
|
||||||
|
|
||||||
current_index = main_window._get_most_visible_page_index()
|
current_index = main_window._get_most_visible_page_index()
|
||||||
@@ -24,14 +24,14 @@ class KeyboardNavigationMixin:
|
|||||||
next_page = main_window.project.pages[current_index + 1]
|
next_page = main_window.project.pages[current_index + 1]
|
||||||
self._scroll_to_page(next_page, current_index + 1)
|
self._scroll_to_page(next_page, current_index + 1)
|
||||||
|
|
||||||
if hasattr(main_window, 'show_status'):
|
if hasattr(main_window, "show_status"):
|
||||||
page_name = main_window.project.get_page_display_name(next_page)
|
page_name = main_window.project.get_page_display_name(next_page)
|
||||||
main_window.show_status(f"Navigated to {page_name}", 2000)
|
main_window.show_status(f"Navigated to {page_name}", 2000)
|
||||||
|
|
||||||
def _navigate_to_previous_page(self):
|
def _navigate_to_previous_page(self):
|
||||||
"""Navigate to the previous page using Page Up key"""
|
"""Navigate to the previous page using Page Up key"""
|
||||||
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
|
||||||
|
|
||||||
current_index = main_window._get_most_visible_page_index()
|
current_index = main_window._get_most_visible_page_index()
|
||||||
@@ -39,7 +39,7 @@ class KeyboardNavigationMixin:
|
|||||||
prev_page = main_window.project.pages[current_index - 1]
|
prev_page = main_window.project.pages[current_index - 1]
|
||||||
self._scroll_to_page(prev_page, current_index - 1)
|
self._scroll_to_page(prev_page, current_index - 1)
|
||||||
|
|
||||||
if hasattr(main_window, 'show_status'):
|
if hasattr(main_window, "show_status"):
|
||||||
page_name = main_window.project.get_page_display_name(prev_page)
|
page_name = main_window.project.get_page_display_name(prev_page)
|
||||||
main_window.show_status(f"Navigated to {page_name}", 2000)
|
main_window.show_status(f"Navigated to {page_name}", 2000)
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ class KeyboardNavigationMixin:
|
|||||||
page_index: The index of the page in the project
|
page_index: The index of the page in the project
|
||||||
"""
|
"""
|
||||||
main_window = self.window()
|
main_window = self.window()
|
||||||
if not hasattr(main_window, 'project'):
|
if not hasattr(main_window, "project"):
|
||||||
return
|
return
|
||||||
|
|
||||||
dpi = main_window.project.working_dpi
|
dpi = main_window.project.working_dpi
|
||||||
@@ -79,14 +79,14 @@ class KeyboardNavigationMixin:
|
|||||||
self.pan_offset[1] = target_pan_y
|
self.pan_offset[1] = target_pan_y
|
||||||
|
|
||||||
# Clamp pan offset to content bounds
|
# Clamp pan offset to content bounds
|
||||||
if hasattr(self, 'clamp_pan_offset'):
|
if hasattr(self, "clamp_pan_offset"):
|
||||||
self.clamp_pan_offset()
|
self.clamp_pan_offset()
|
||||||
|
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
# Update scrollbars if available
|
# Update scrollbars if available
|
||||||
main_window = self.window()
|
main_window = self.window()
|
||||||
if hasattr(main_window, 'update_scrollbars'):
|
if hasattr(main_window, "update_scrollbars"):
|
||||||
main_window.update_scrollbars()
|
main_window.update_scrollbars()
|
||||||
|
|
||||||
def _move_viewport_with_arrow_keys(self, key):
|
def _move_viewport_with_arrow_keys(self, key):
|
||||||
@@ -109,14 +109,14 @@ class KeyboardNavigationMixin:
|
|||||||
self.pan_offset[0] -= move_amount
|
self.pan_offset[0] -= move_amount
|
||||||
|
|
||||||
# Clamp pan offset to content bounds
|
# Clamp pan offset to content bounds
|
||||||
if hasattr(self, 'clamp_pan_offset'):
|
if hasattr(self, "clamp_pan_offset"):
|
||||||
self.clamp_pan_offset()
|
self.clamp_pan_offset()
|
||||||
|
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
# Update scrollbars if available
|
# Update scrollbars if available
|
||||||
main_window = self.window()
|
main_window = self.window()
|
||||||
if hasattr(main_window, 'update_scrollbars'):
|
if hasattr(main_window, "update_scrollbars"):
|
||||||
main_window.update_scrollbars()
|
main_window.update_scrollbars()
|
||||||
|
|
||||||
def _move_selected_elements_with_arrow_keys(self, key):
|
def _move_selected_elements_with_arrow_keys(self, key):
|
||||||
@@ -127,7 +127,7 @@ class KeyboardNavigationMixin:
|
|||||||
key: The Qt key code (Up, Down, Left, Right)
|
key: The Qt key code (Up, Down, Left, Right)
|
||||||
"""
|
"""
|
||||||
main_window = self.window()
|
main_window = self.window()
|
||||||
if not hasattr(main_window, 'project'):
|
if not hasattr(main_window, "project"):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Movement amount in mm
|
# Movement amount in mm
|
||||||
@@ -151,7 +151,7 @@ class KeyboardNavigationMixin:
|
|||||||
new_y = current_y + dy
|
new_y = current_y + dy
|
||||||
|
|
||||||
# Apply snapping if element has a parent page
|
# Apply snapping if element has a parent page
|
||||||
if hasattr(element, '_parent_page') and element._parent_page:
|
if hasattr(element, "_parent_page") and element._parent_page:
|
||||||
page = element._parent_page
|
page = element._parent_page
|
||||||
snap_sys = page.layout.snapping_system
|
snap_sys = page.layout.snapping_system
|
||||||
page_size = page.layout.size
|
page_size = page.layout.size
|
||||||
@@ -162,7 +162,7 @@ class KeyboardNavigationMixin:
|
|||||||
size=element.size,
|
size=element.size,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
dpi=dpi,
|
dpi=dpi,
|
||||||
project=main_window.project
|
project=main_window.project,
|
||||||
)
|
)
|
||||||
element.position = snapped_pos
|
element.position = snapped_pos
|
||||||
else:
|
else:
|
||||||
@@ -170,7 +170,7 @@ class KeyboardNavigationMixin:
|
|||||||
|
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
if hasattr(main_window, 'show_status'):
|
if hasattr(main_window, "show_status"):
|
||||||
count = len(self.selected_elements)
|
count = len(self.selected_elements)
|
||||||
elem_text = "element" if count == 1 else "elements"
|
elem_text = "element" if count == 1 else "elements"
|
||||||
main_window.show_status(f"Moved {count} {elem_text}", 1000)
|
main_window.show_status(f"Moved {count} {elem_text}", 1000)
|
||||||
|
|||||||
@@ -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,208 +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"""
|
||||||
# Ensure widget has focus for keyboard events
|
|
||||||
self.setFocus()
|
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 Ctrl is pressed and element is ImageData - enter image pan mode
|
|
||||||
if ctrl_pressed and isinstance(element, ImageData) and not self.rotation_mode:
|
|
||||||
# Enter image pan mode - pan image within frame
|
|
||||||
self.selected_elements = {element}
|
|
||||||
self.drag_start_pos = (x, y)
|
|
||||||
self.image_pan_mode = True
|
|
||||||
self.image_pan_start_crop = element.crop_info
|
|
||||||
self._begin_image_pan(element)
|
|
||||||
self.is_dragging = True
|
|
||||||
self.setCursor(Qt.CursorShape.SizeAllCursor)
|
|
||||||
print(f"Entered image pan mode for {element}")
|
|
||||||
elif ctrl_pressed:
|
|
||||||
# Multi-select mode (for non-ImageData elements or when Ctrl is pressed)
|
|
||||||
print(f"DEBUG: Multi-select mode triggered")
|
|
||||||
if element in self.selected_elements:
|
|
||||||
print(f"DEBUG: Removing element from selection")
|
|
||||||
self.selected_elements.remove(element)
|
|
||||||
else:
|
|
||||||
print(f"DEBUG: Adding element to selection. Current count: {len(self.selected_elements)}")
|
|
||||||
self.selected_elements.add(element)
|
|
||||||
print(f"DEBUG: Total selected elements: {len(self.selected_elements)}")
|
|
||||||
elif shift_pressed:
|
|
||||||
# Shift can be used for multi-select as well
|
|
||||||
if element in self.selected_elements:
|
|
||||||
self.selected_elements.remove(element)
|
|
||||||
else:
|
|
||||||
self.selected_elements.add(element)
|
|
||||||
else:
|
|
||||||
# Normal drag mode
|
|
||||||
print(f"DEBUG: Normal drag mode - single selection")
|
|
||||||
self.selected_elements = {element}
|
|
||||||
self.drag_start_pos = (x, y)
|
|
||||||
self.drag_start_element_pos = element.position
|
|
||||||
if not self.rotation_mode:
|
|
||||||
self._begin_move(element)
|
|
||||||
self.is_dragging = True
|
|
||||||
else:
|
|
||||||
if not ctrl_pressed:
|
|
||||||
self.selected_elements.clear()
|
|
||||||
|
|
||||||
self.update()
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
# Clamp pan offset to content bounds
|
|
||||||
if hasattr(self, 'clamp_pan_offset'):
|
|
||||||
self.clamp_pan_offset()
|
|
||||||
|
|
||||||
self.drag_start_pos = (x, y)
|
|
||||||
self.update()
|
|
||||||
|
|
||||||
# Update scrollbars if available
|
|
||||||
main_window = self.window()
|
|
||||||
if hasattr(main_window, 'update_scrollbars'):
|
|
||||||
main_window.update_scrollbars()
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
self._resize_element(total_dx, total_dy)
|
|
||||||
else:
|
|
||||||
# Move mode
|
|
||||||
current_page, current_page_index, current_renderer = self._get_page_at(x, y)
|
|
||||||
|
|
||||||
if current_page and hasattr(self.selected_element, '_parent_page'):
|
|
||||||
source_page = self.selected_element._parent_page
|
|
||||||
|
|
||||||
if current_page is not source_page:
|
|
||||||
self._transfer_element_to_page(self.selected_element, source_page, current_page, x, y, current_renderer)
|
|
||||||
else:
|
|
||||||
total_dx = (x - self.drag_start_pos[0]) / self.zoom_level
|
|
||||||
total_dy = (y - self.drag_start_pos[1]) / self.zoom_level
|
|
||||||
|
|
||||||
new_x = self.drag_start_element_pos[0] + total_dx
|
|
||||||
new_y = self.drag_start_element_pos[1] + total_dy
|
|
||||||
|
|
||||||
main_window = self.window()
|
|
||||||
snap_sys = source_page.layout.snapping_system
|
|
||||||
page_size = source_page.layout.size
|
|
||||||
dpi = main_window.project.working_dpi
|
|
||||||
|
|
||||||
snapped_pos = snap_sys.snap_position(
|
|
||||||
position=(new_x, new_y),
|
|
||||||
size=self.selected_element.size,
|
|
||||||
page_size=page_size,
|
|
||||||
dpi=dpi,
|
|
||||||
project=main_window.project
|
|
||||||
)
|
|
||||||
|
|
||||||
self.selected_element.position = snapped_pos
|
|
||||||
else:
|
|
||||||
total_dx = (x - self.drag_start_pos[0]) / self.zoom_level
|
|
||||||
total_dy = (y - self.drag_start_pos[1]) / self.zoom_level
|
|
||||||
|
|
||||||
new_x = self.drag_start_element_pos[0] + total_dx
|
|
||||||
new_y = self.drag_start_element_pos[1] + total_dy
|
|
||||||
|
|
||||||
self.selected_element.position = (new_x, new_y)
|
|
||||||
|
|
||||||
self.update()
|
|
||||||
|
|
||||||
def mouseReleaseEvent(self, event):
|
def mouseReleaseEvent(self, event):
|
||||||
"""Handle mouse release events"""
|
"""Handle mouse release events"""
|
||||||
@@ -238,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:
|
||||||
@@ -257,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
|
||||||
@@ -280,38 +338,63 @@ 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
|
# Clamp pan offset to content bounds
|
||||||
if hasattr(self, 'clamp_pan_offset'):
|
if hasattr(self, "clamp_pan_offset"):
|
||||||
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
|
# Update scrollbars if available
|
||||||
if hasattr(main_window, 'update_scrollbars'):
|
if hasattr(main_window, "update_scrollbars"):
|
||||||
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
|
# Clamp pan offset to content bounds
|
||||||
if hasattr(self, 'clamp_pan_offset'):
|
if hasattr(self, "clamp_pan_offset"):
|
||||||
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
|
# Update scrollbars if available
|
||||||
main_window = self.window()
|
main_window = self.window()
|
||||||
if hasattr(main_window, 'update_scrollbars'):
|
if hasattr(main_window, "update_scrollbars"):
|
||||||
main_window.update_scrollbars()
|
main_window.update_scrollbars()
|
||||||
|
|
||||||
def _edit_text_element(self, text_element):
|
def _edit_text_element(self, text_element):
|
||||||
@@ -322,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()
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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 left (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 right (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°")
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -2,40 +2,74 @@
|
|||||||
File operations mixin for pyPhotoAlbum
|
File operations mixin for pyPhotoAlbum
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import TYPE_CHECKING, Optional, cast
|
||||||
|
|
||||||
|
from PyQt6.QtCore import QObject, pyqtSignal
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QFileDialog, QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
QFileDialog,
|
||||||
QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox, QRadioButton,
|
QDialog,
|
||||||
QButtonGroup, QLineEdit, QTextEdit
|
QVBoxLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QDoubleSpinBox,
|
||||||
|
QSpinBox,
|
||||||
|
QPushButton,
|
||||||
|
QGroupBox,
|
||||||
|
QRadioButton,
|
||||||
|
QButtonGroup,
|
||||||
|
QLineEdit,
|
||||||
|
QTextEdit,
|
||||||
|
QWidget,
|
||||||
|
QMessageBox,
|
||||||
)
|
)
|
||||||
from pyPhotoAlbum.decorators import ribbon_action, numerical_input
|
from pyPhotoAlbum.decorators import ribbon_action, numerical_input
|
||||||
from pyPhotoAlbum.project import Project, Page
|
from pyPhotoAlbum.project import Project, Page
|
||||||
from pyPhotoAlbum.async_project_loader import AsyncProjectLoader
|
from pyPhotoAlbum.async_project_loader import AsyncProjectLoader
|
||||||
from pyPhotoAlbum.loading_widget import LoadingWidget
|
from pyPhotoAlbum.loading_widget import LoadingWidget
|
||||||
from pyPhotoAlbum.project_serializer import save_to_zip
|
from pyPhotoAlbum.project_serializer import save_to_zip, save_to_zip_async
|
||||||
from pyPhotoAlbum.models import set_asset_resolution_context
|
from pyPhotoAlbum.models import set_asset_resolution_context
|
||||||
from pyPhotoAlbum.version_manager import format_version_info, CURRENT_DATA_VERSION
|
from pyPhotoAlbum.version_manager import format_version_info, CURRENT_DATA_VERSION
|
||||||
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||||
|
|
||||||
|
|
||||||
|
class _SaveBridge(QObject):
|
||||||
|
"""Thread-safe signal bridge for async save callbacks.
|
||||||
|
|
||||||
|
Signals can be safely emitted from any thread; connected slots run on
|
||||||
|
the main (GUI) thread via Qt's queued connection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
progress = pyqtSignal(int, str)
|
||||||
|
finished = pyqtSignal(bool, str)
|
||||||
|
|
||||||
|
|
||||||
class FileOperationsMixin:
|
class FileOperationsMixin:
|
||||||
"""Mixin providing file-related operations"""
|
"""Mixin providing file-related operations"""
|
||||||
|
|
||||||
@ribbon_action(
|
# Type hints for expected attributes from mixing class
|
||||||
label="New",
|
def show_status(self, message: str, timeout: int = 0) -> None:
|
||||||
tooltip="Create a new project",
|
"""Expected from ApplicationStateMixin"""
|
||||||
tab="Home",
|
...
|
||||||
group="File",
|
|
||||||
shortcut="Ctrl+N"
|
def show_error(self, title: str, message: str) -> None:
|
||||||
)
|
"""Expected from ApplicationStateMixin"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def resolve_asset_path(self, path: str) -> Optional[str]:
|
||||||
|
"""Expected from asset path mixin"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@ribbon_action(label="New", tooltip="Create a new project", tab="Home", group="File", shortcut="Ctrl+N")
|
||||||
def new_project(self):
|
def new_project(self):
|
||||||
"""Create a new project with initial setup dialog"""
|
"""Create a new project with initial setup dialog"""
|
||||||
# Create new project setup dialog
|
# Create new project setup dialog
|
||||||
dialog = QDialog(self)
|
dialog = QDialog(self)
|
||||||
dialog.setWindowTitle("New Project Setup")
|
dialog.setWindowTitle("New Project Setup")
|
||||||
dialog.setMinimumWidth(450)
|
dialog.setMinimumWidth(450)
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
# Project name group
|
# Project name group
|
||||||
name_group = QGroupBox("Project Name")
|
name_group = QGroupBox("Project Name")
|
||||||
name_layout = QVBoxLayout()
|
name_layout = QVBoxLayout()
|
||||||
@@ -45,16 +79,16 @@ class FileOperationsMixin:
|
|||||||
name_layout.addWidget(name_input)
|
name_layout.addWidget(name_input)
|
||||||
name_group.setLayout(name_layout)
|
name_group.setLayout(name_layout)
|
||||||
layout.addWidget(name_group)
|
layout.addWidget(name_group)
|
||||||
|
|
||||||
# Default page size group
|
# Default page size group
|
||||||
size_group = QGroupBox("Default Page Size")
|
size_group = QGroupBox("Default Page Size")
|
||||||
size_layout = QVBoxLayout()
|
size_layout = QVBoxLayout()
|
||||||
|
|
||||||
info_label = QLabel("This will be the default size for all new pages in this project.")
|
info_label = QLabel("This will be the default size for all new pages in this project.")
|
||||||
info_label.setWordWrap(True)
|
info_label.setWordWrap(True)
|
||||||
info_label.setStyleSheet("font-size: 9pt; color: gray;")
|
info_label.setStyleSheet("font-size: 9pt; color: gray;")
|
||||||
size_layout.addWidget(info_label)
|
size_layout.addWidget(info_label)
|
||||||
|
|
||||||
# Width
|
# Width
|
||||||
width_layout = QHBoxLayout()
|
width_layout = QHBoxLayout()
|
||||||
width_layout.addWidget(QLabel("Width:"))
|
width_layout.addWidget(QLabel("Width:"))
|
||||||
@@ -64,7 +98,7 @@ class FileOperationsMixin:
|
|||||||
width_spinbox.setSuffix(" mm")
|
width_spinbox.setSuffix(" mm")
|
||||||
width_layout.addWidget(width_spinbox)
|
width_layout.addWidget(width_spinbox)
|
||||||
size_layout.addLayout(width_layout)
|
size_layout.addLayout(width_layout)
|
||||||
|
|
||||||
# Height
|
# Height
|
||||||
height_layout = QHBoxLayout()
|
height_layout = QHBoxLayout()
|
||||||
height_layout.addWidget(QLabel("Height:"))
|
height_layout.addWidget(QLabel("Height:"))
|
||||||
@@ -74,37 +108,37 @@ class FileOperationsMixin:
|
|||||||
height_spinbox.setSuffix(" mm")
|
height_spinbox.setSuffix(" mm")
|
||||||
height_layout.addWidget(height_spinbox)
|
height_layout.addWidget(height_spinbox)
|
||||||
size_layout.addLayout(height_layout)
|
size_layout.addLayout(height_layout)
|
||||||
|
|
||||||
# Add common size presets
|
# Add common size presets
|
||||||
presets_layout = QHBoxLayout()
|
presets_layout = QHBoxLayout()
|
||||||
presets_layout.addWidget(QLabel("Presets:"))
|
presets_layout.addWidget(QLabel("Presets:"))
|
||||||
|
|
||||||
def set_preset(w, h):
|
def set_preset(w, h):
|
||||||
width_spinbox.setValue(w)
|
width_spinbox.setValue(w)
|
||||||
height_spinbox.setValue(h)
|
height_spinbox.setValue(h)
|
||||||
|
|
||||||
preset_a4 = QPushButton("A4 (210×297)")
|
preset_a4 = QPushButton("A4 (210×297)")
|
||||||
preset_a4.clicked.connect(lambda: set_preset(210, 297))
|
preset_a4.clicked.connect(lambda: set_preset(210, 297))
|
||||||
presets_layout.addWidget(preset_a4)
|
presets_layout.addWidget(preset_a4)
|
||||||
|
|
||||||
preset_a5 = QPushButton("A5 (148×210)")
|
preset_a5 = QPushButton("A5 (148×210)")
|
||||||
preset_a5.clicked.connect(lambda: set_preset(148, 210))
|
preset_a5.clicked.connect(lambda: set_preset(148, 210))
|
||||||
presets_layout.addWidget(preset_a5)
|
presets_layout.addWidget(preset_a5)
|
||||||
|
|
||||||
preset_square = QPushButton("Square (200×200)")
|
preset_square = QPushButton("Square (200×200)")
|
||||||
preset_square.clicked.connect(lambda: set_preset(200, 200))
|
preset_square.clicked.connect(lambda: set_preset(200, 200))
|
||||||
presets_layout.addWidget(preset_square)
|
presets_layout.addWidget(preset_square)
|
||||||
|
|
||||||
presets_layout.addStretch()
|
presets_layout.addStretch()
|
||||||
size_layout.addLayout(presets_layout)
|
size_layout.addLayout(presets_layout)
|
||||||
|
|
||||||
size_group.setLayout(size_layout)
|
size_group.setLayout(size_layout)
|
||||||
layout.addWidget(size_group)
|
layout.addWidget(size_group)
|
||||||
|
|
||||||
# DPI settings group
|
# DPI settings group
|
||||||
dpi_group = QGroupBox("DPI Settings")
|
dpi_group = QGroupBox("DPI Settings")
|
||||||
dpi_layout = QVBoxLayout()
|
dpi_layout = QVBoxLayout()
|
||||||
|
|
||||||
# Working DPI
|
# Working DPI
|
||||||
working_dpi_layout = QHBoxLayout()
|
working_dpi_layout = QHBoxLayout()
|
||||||
working_dpi_layout.addWidget(QLabel("Working DPI:"))
|
working_dpi_layout.addWidget(QLabel("Working DPI:"))
|
||||||
@@ -113,7 +147,7 @@ class FileOperationsMixin:
|
|||||||
working_dpi_spinbox.setValue(300)
|
working_dpi_spinbox.setValue(300)
|
||||||
working_dpi_layout.addWidget(working_dpi_spinbox)
|
working_dpi_layout.addWidget(working_dpi_spinbox)
|
||||||
dpi_layout.addLayout(working_dpi_layout)
|
dpi_layout.addLayout(working_dpi_layout)
|
||||||
|
|
||||||
# Export DPI
|
# Export DPI
|
||||||
export_dpi_layout = QHBoxLayout()
|
export_dpi_layout = QHBoxLayout()
|
||||||
export_dpi_layout.addWidget(QLabel("Export DPI:"))
|
export_dpi_layout.addWidget(QLabel("Export DPI:"))
|
||||||
@@ -122,10 +156,10 @@ class FileOperationsMixin:
|
|||||||
export_dpi_spinbox.setValue(300)
|
export_dpi_spinbox.setValue(300)
|
||||||
export_dpi_layout.addWidget(export_dpi_spinbox)
|
export_dpi_layout.addWidget(export_dpi_spinbox)
|
||||||
dpi_layout.addLayout(export_dpi_layout)
|
dpi_layout.addLayout(export_dpi_layout)
|
||||||
|
|
||||||
dpi_group.setLayout(dpi_layout)
|
dpi_group.setLayout(dpi_layout)
|
||||||
layout.addWidget(dpi_group)
|
layout.addWidget(dpi_group)
|
||||||
|
|
||||||
# Buttons
|
# Buttons
|
||||||
button_layout = QHBoxLayout()
|
button_layout = QHBoxLayout()
|
||||||
cancel_btn = QPushButton("Cancel")
|
cancel_btn = QPushButton("Cancel")
|
||||||
@@ -133,14 +167,14 @@ class FileOperationsMixin:
|
|||||||
create_btn = QPushButton("Create Project")
|
create_btn = QPushButton("Create Project")
|
||||||
create_btn.clicked.connect(dialog.accept)
|
create_btn.clicked.connect(dialog.accept)
|
||||||
create_btn.setDefault(True)
|
create_btn.setDefault(True)
|
||||||
|
|
||||||
button_layout.addStretch()
|
button_layout.addStretch()
|
||||||
button_layout.addWidget(cancel_btn)
|
button_layout.addWidget(cancel_btn)
|
||||||
button_layout.addWidget(create_btn)
|
button_layout.addWidget(create_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:
|
||||||
# Get values
|
# Get values
|
||||||
@@ -151,7 +185,7 @@ class FileOperationsMixin:
|
|||||||
export_dpi = export_dpi_spinbox.value()
|
export_dpi = export_dpi_spinbox.value()
|
||||||
|
|
||||||
# Cleanup old project if it exists
|
# Cleanup old project if it exists
|
||||||
if hasattr(self, 'project') and self.project:
|
if hasattr(self, "project") and self.project:
|
||||||
self.project.cleanup()
|
self.project.cleanup()
|
||||||
|
|
||||||
# Create project with custom settings
|
# Create project with custom settings
|
||||||
@@ -165,34 +199,25 @@ class FileOperationsMixin:
|
|||||||
|
|
||||||
# Update view
|
# Update view
|
||||||
self.update_view()
|
self.update_view()
|
||||||
|
|
||||||
self.show_status(f"New project created: {project_name} ({width_mm}×{height_mm} mm)")
|
self.show_status(f"New project created: {project_name} ({width_mm}×{height_mm} mm)")
|
||||||
print(f"New project created: {project_name}, default page size: {width_mm}×{height_mm} mm")
|
print(f"New project created: {project_name}, default page size: {width_mm}×{height_mm} mm")
|
||||||
else:
|
else:
|
||||||
# User cancelled - keep current project
|
# User cancelled - keep current project
|
||||||
print("New project creation cancelled")
|
print("New project creation cancelled")
|
||||||
|
|
||||||
@ribbon_action(
|
@ribbon_action(label="Open", tooltip="Open an existing project", tab="Home", group="File", shortcut="Ctrl+O")
|
||||||
label="Open",
|
|
||||||
tooltip="Open an existing project",
|
|
||||||
tab="Home",
|
|
||||||
group="File",
|
|
||||||
shortcut="Ctrl+O"
|
|
||||||
)
|
|
||||||
def open_project(self):
|
def open_project(self):
|
||||||
"""Open an existing project with async loading and progress bar"""
|
"""Open an existing project with async loading and progress bar"""
|
||||||
file_path, _ = QFileDialog.getOpenFileName(
|
file_path, _ = QFileDialog.getOpenFileName(
|
||||||
self,
|
self, "Open Project", "", "pyPhotoAlbum Projects (*.ppz);;All Files (*)"
|
||||||
"Open Project",
|
|
||||||
"",
|
|
||||||
"pyPhotoAlbum Projects (*.ppz);;All Files (*)"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if file_path:
|
if file_path:
|
||||||
print(f"Opening project: {file_path}")
|
print(f"Opening project: {file_path}")
|
||||||
|
|
||||||
# Create loading widget if not exists
|
# Create loading widget if not exists
|
||||||
if not hasattr(self, '_loading_widget'):
|
if not hasattr(self, "_loading_widget"):
|
||||||
self._loading_widget = LoadingWidget(self)
|
self._loading_widget = LoadingWidget(self)
|
||||||
|
|
||||||
# Show loading widget
|
# Show loading widget
|
||||||
@@ -200,6 +225,7 @@ class FileOperationsMixin:
|
|||||||
|
|
||||||
# Create and configure async loader
|
# Create and configure async loader
|
||||||
self._project_loader = AsyncProjectLoader(file_path)
|
self._project_loader = AsyncProjectLoader(file_path)
|
||||||
|
self._opening_file_path = file_path # Store for later
|
||||||
|
|
||||||
# Connect signals
|
# Connect signals
|
||||||
self._project_loader.progress_updated.connect(self._on_load_progress)
|
self._project_loader.progress_updated.connect(self._on_load_progress)
|
||||||
@@ -211,77 +237,137 @@ class FileOperationsMixin:
|
|||||||
|
|
||||||
def _on_load_progress(self, current: int, total: int, message: str):
|
def _on_load_progress(self, current: int, total: int, message: str):
|
||||||
"""Handle loading progress updates"""
|
"""Handle loading progress updates"""
|
||||||
if hasattr(self, '_loading_widget'):
|
if hasattr(self, "_loading_widget"):
|
||||||
self._loading_widget.set_progress(current, total)
|
self._loading_widget.set_progress(current, total)
|
||||||
self._loading_widget.set_status(message)
|
self._loading_widget.set_status(message)
|
||||||
|
|
||||||
def _on_load_complete(self, project):
|
def _on_load_complete(self, project):
|
||||||
"""Handle successful project load"""
|
"""Handle successful project load"""
|
||||||
# Cleanup old project if it exists
|
# Cleanup old project if it exists
|
||||||
if hasattr(self, 'project') and self.project:
|
if hasattr(self, "project") and self.project:
|
||||||
self.project.cleanup()
|
self.project.cleanup()
|
||||||
|
|
||||||
# Set new project
|
# Set new project
|
||||||
self.project = project
|
self.project = project
|
||||||
|
|
||||||
|
# Set file path and mark as clean
|
||||||
|
if hasattr(self, "_opening_file_path"):
|
||||||
|
self.project.file_path = self._opening_file_path
|
||||||
|
delattr(self, "_opening_file_path")
|
||||||
|
self.project.mark_clean()
|
||||||
|
|
||||||
self.gl_widget.current_page_index = 0 # Reset to first page
|
self.gl_widget.current_page_index = 0 # Reset to first page
|
||||||
|
|
||||||
# Hide loading widget
|
# Hide loading widget
|
||||||
if hasattr(self, '_loading_widget'):
|
if hasattr(self, "_loading_widget"):
|
||||||
self._loading_widget.hide_loading()
|
self._loading_widget.hide_loading()
|
||||||
|
|
||||||
# Update view (this will trigger progressive image loading)
|
# Update view (this will trigger progressive image loading)
|
||||||
self.update_view()
|
self.update_view()
|
||||||
|
|
||||||
self.show_status(f"Project opened: {project.name}")
|
# Check for missing assets and inform user
|
||||||
|
missing_assets = self._check_missing_assets()
|
||||||
|
if missing_assets:
|
||||||
|
self._show_missing_assets_warning(missing_assets)
|
||||||
|
self.show_status(f"Project opened: {project.name} ({len(missing_assets)} missing images)")
|
||||||
|
else:
|
||||||
|
self.show_status(f"Project opened: {project.name}")
|
||||||
print(f"Successfully loaded project: {project.name}")
|
print(f"Successfully loaded project: {project.name}")
|
||||||
|
|
||||||
def _on_load_failed(self, error_msg: str):
|
def _on_load_failed(self, error_msg: str):
|
||||||
"""Handle project load failure"""
|
"""Handle project load failure"""
|
||||||
# Hide loading widget
|
# Hide loading widget
|
||||||
if hasattr(self, '_loading_widget'):
|
if hasattr(self, "_loading_widget"):
|
||||||
self._loading_widget.hide_loading()
|
self._loading_widget.hide_loading()
|
||||||
|
|
||||||
error_msg = f"Failed to open project: {error_msg}"
|
error_msg = f"Failed to open project: {error_msg}"
|
||||||
self.show_status(error_msg)
|
self.show_status(error_msg)
|
||||||
self.show_error("Load Failed", error_msg)
|
self.show_error("Load Failed", error_msg)
|
||||||
print(error_msg)
|
print(error_msg)
|
||||||
|
|
||||||
@ribbon_action(
|
@ribbon_action(label="Save", tooltip="Save the current project", tab="Home", group="File", shortcut="Ctrl+S")
|
||||||
label="Save",
|
def save_project(self) -> bool:
|
||||||
tooltip="Save the current project",
|
"""Save the current project asynchronously with progress feedback.
|
||||||
tab="Home",
|
|
||||||
group="File",
|
Returns True if an async save was started, False if the user cancelled.
|
||||||
shortcut="Ctrl+S"
|
"""
|
||||||
)
|
# Prevent concurrent saves — only one background save at a time
|
||||||
def save_project(self):
|
if getattr(self, "_save_in_progress", False):
|
||||||
"""Save the current project"""
|
self.show_status("Save already in progress...")
|
||||||
file_path, _ = QFileDialog.getSaveFileName(
|
return False
|
||||||
self,
|
|
||||||
"Save Project",
|
# If project has a file path, use it; otherwise prompt for location
|
||||||
"",
|
file_path = self.project.file_path if hasattr(self.project, "file_path") and self.project.file_path else None
|
||||||
"pyPhotoAlbum Projects (*.ppz);;All Files (*)"
|
|
||||||
)
|
if not file_path:
|
||||||
|
file_path, _ = QFileDialog.getSaveFileName(
|
||||||
if file_path:
|
self, "Save Project", "", "pyPhotoAlbum Projects (*.ppz);;All Files (*)" # type: ignore[arg-type]
|
||||||
print(f"Saving project to: {file_path}")
|
)
|
||||||
|
|
||||||
# Save project to ZIP
|
if not file_path:
|
||||||
success, error = save_to_zip(self.project, file_path)
|
return False
|
||||||
|
|
||||||
|
self._save_in_progress = True
|
||||||
|
print(f"Saving project to: {file_path}")
|
||||||
|
|
||||||
|
# Create loading widget if not exists
|
||||||
|
if not hasattr(self, "_loading_widget"):
|
||||||
|
self._loading_widget = LoadingWidget(self)
|
||||||
|
|
||||||
|
# Show loading widget
|
||||||
|
self._loading_widget.show_loading("Saving project...")
|
||||||
|
|
||||||
|
# Bridge object: signals are thread-safe so background thread can
|
||||||
|
# emit them and slots always run on the main (GUI) thread.
|
||||||
|
bridge = _SaveBridge(parent=self) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
def _on_progress(progress: int, message: str):
|
||||||
|
if hasattr(self, "_loading_widget"):
|
||||||
|
try:
|
||||||
|
self._loading_widget.set_progress(progress, 100)
|
||||||
|
self._loading_widget.set_status(message)
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _on_finished(success: bool, error: str):
|
||||||
|
self._save_in_progress = False
|
||||||
|
try:
|
||||||
|
if hasattr(self, "_loading_widget"):
|
||||||
|
self._loading_widget.hide_loading()
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
|
self.project.file_path = file_path
|
||||||
|
self.project.mark_clean()
|
||||||
self.show_status(f"Project saved: {file_path}")
|
self.show_status(f"Project saved: {file_path}")
|
||||||
print(f"Successfully saved project to: {file_path}")
|
print(f"Successfully saved project to: {file_path}")
|
||||||
|
if getattr(self, "_pending_close", False):
|
||||||
|
self._pending_close = False
|
||||||
|
self.close() # type: ignore[attr-defined]
|
||||||
else:
|
else:
|
||||||
|
self._pending_close = False
|
||||||
error_msg = f"Failed to save project: {error}"
|
error_msg = f"Failed to save project: {error}"
|
||||||
self.show_status(error_msg)
|
self.show_status(error_msg)
|
||||||
|
self.show_error("Save Failed", error_msg)
|
||||||
print(error_msg)
|
print(error_msg)
|
||||||
|
|
||||||
@ribbon_action(
|
bridge.progress.connect(_on_progress)
|
||||||
label="Heal Assets",
|
bridge.finished.connect(_on_finished)
|
||||||
tooltip="Reconnect missing image assets",
|
|
||||||
tab="Home",
|
# Start async save — callbacks emit signals (thread-safe)
|
||||||
group="File"
|
save_to_zip_async(
|
||||||
)
|
self.project,
|
||||||
|
file_path,
|
||||||
|
on_complete=lambda ok, err: bridge.finished.emit(ok, err or ""),
|
||||||
|
on_progress=lambda p, m: bridge.progress.emit(p, m),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Show immediate feedback
|
||||||
|
self.show_status("Saving project in background...", 2000)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@ribbon_action(label="Heal Assets", tooltip="Reconnect missing image assets", tab="Home", group="File")
|
||||||
def heal_assets(self):
|
def heal_assets(self):
|
||||||
"""Open the asset healing dialog to reconnect missing images"""
|
"""Open the asset healing dialog to reconnect missing images"""
|
||||||
dialog = AssetHealDialog(self.project, self)
|
dialog = AssetHealDialog(self.project, self)
|
||||||
@@ -290,31 +376,74 @@ class FileOperationsMixin:
|
|||||||
# Update the view to reflect any changes
|
# Update the view to reflect any changes
|
||||||
self.update_view()
|
self.update_view()
|
||||||
|
|
||||||
|
def _check_missing_assets(self) -> list:
|
||||||
|
"""Check for missing assets in the project - returns list of missing paths"""
|
||||||
|
from pyPhotoAlbum.models import ImageData
|
||||||
|
|
||||||
|
missing = []
|
||||||
|
for page in self.project.pages:
|
||||||
|
for element in page.layout.elements:
|
||||||
|
if isinstance(element, ImageData) and element.image_path:
|
||||||
|
# Absolute paths need healing
|
||||||
|
if os.path.isabs(element.image_path):
|
||||||
|
missing.append(element.image_path)
|
||||||
|
# Paths not in assets/ need healing
|
||||||
|
elif not element.image_path.startswith("assets/"):
|
||||||
|
missing.append(element.image_path)
|
||||||
|
else:
|
||||||
|
# Check if file exists in assets using mixin
|
||||||
|
if not self.resolve_asset_path(element.image_path):
|
||||||
|
missing.append(element.image_path)
|
||||||
|
return list(set(missing)) # Remove duplicates
|
||||||
|
|
||||||
|
def _show_missing_assets_warning(self, missing_assets: list):
|
||||||
|
"""Show a warning about missing assets"""
|
||||||
|
from PyQt6.QtWidgets import QMessageBox
|
||||||
|
|
||||||
|
# Build message with list of missing images
|
||||||
|
if len(missing_assets) <= 5:
|
||||||
|
asset_list = "\n".join(f" • {path}" for path in missing_assets)
|
||||||
|
else:
|
||||||
|
asset_list = "\n".join(f" • {path}" for path in missing_assets[:5])
|
||||||
|
asset_list += f"\n ... and {len(missing_assets) - 5} more"
|
||||||
|
|
||||||
|
msg = QMessageBox(cast(QWidget, self))
|
||||||
|
msg.setIcon(QMessageBox.Icon.Warning)
|
||||||
|
msg.setWindowTitle("Missing Assets")
|
||||||
|
msg.setText(f"{len(missing_assets)} image(s) could not be found in the assets folder:")
|
||||||
|
msg.setInformativeText(asset_list)
|
||||||
|
msg.setDetailedText(
|
||||||
|
"These images need to be reconnected using the 'Heal Assets' feature.\n\n"
|
||||||
|
"Go to: Home → Heal Assets\n\n"
|
||||||
|
"Add search paths where the original images might be located, "
|
||||||
|
"then click 'Attempt Healing' to find and import them."
|
||||||
|
)
|
||||||
|
msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Open)
|
||||||
|
btn = msg.button(QMessageBox.StandardButton.Open)
|
||||||
|
if btn is not None:
|
||||||
|
btn.setText("Open Heal Assets")
|
||||||
|
|
||||||
|
result = msg.exec()
|
||||||
|
if result == QMessageBox.StandardButton.Open:
|
||||||
|
self.heal_assets()
|
||||||
|
|
||||||
@ribbon_action(
|
@ribbon_action(
|
||||||
label="Project Settings",
|
label="Project Settings", tooltip="Configure project-wide page size and defaults", tab="Home", group="File"
|
||||||
tooltip="Configure project-wide page size and defaults",
|
|
||||||
tab="Home",
|
|
||||||
group="File"
|
|
||||||
)
|
|
||||||
@numerical_input(
|
|
||||||
fields=[
|
|
||||||
('width', 'Width', 'mm', 10, 1000),
|
|
||||||
('height', 'Height', 'mm', 10, 1000)
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
@numerical_input(fields=[("width", "Width", "mm", 10, 1000), ("height", "Height", "mm", 10, 1000)])
|
||||||
def project_settings(self):
|
def project_settings(self):
|
||||||
"""Configure project-wide settings including default page size"""
|
"""Configure project-wide settings including default page size"""
|
||||||
# Create dialog
|
# Create dialog
|
||||||
dialog = QDialog(self)
|
dialog = QDialog(self)
|
||||||
dialog.setWindowTitle("Project Settings")
|
dialog.setWindowTitle("Project Settings")
|
||||||
dialog.setMinimumWidth(500)
|
dialog.setMinimumWidth(500)
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
# Page size group
|
# Page size group
|
||||||
size_group = QGroupBox("Default Page Size")
|
size_group = QGroupBox("Default Page Size")
|
||||||
size_layout = QVBoxLayout()
|
size_layout = QVBoxLayout()
|
||||||
|
|
||||||
# Width
|
# Width
|
||||||
width_layout = QHBoxLayout()
|
width_layout = QHBoxLayout()
|
||||||
width_layout.addWidget(QLabel("Width:"))
|
width_layout.addWidget(QLabel("Width:"))
|
||||||
@@ -324,7 +453,7 @@ class FileOperationsMixin:
|
|||||||
width_spinbox.setSuffix(" mm")
|
width_spinbox.setSuffix(" mm")
|
||||||
width_layout.addWidget(width_spinbox)
|
width_layout.addWidget(width_spinbox)
|
||||||
size_layout.addLayout(width_layout)
|
size_layout.addLayout(width_layout)
|
||||||
|
|
||||||
# Height
|
# Height
|
||||||
height_layout = QHBoxLayout()
|
height_layout = QHBoxLayout()
|
||||||
height_layout.addWidget(QLabel("Height:"))
|
height_layout.addWidget(QLabel("Height:"))
|
||||||
@@ -334,14 +463,14 @@ class FileOperationsMixin:
|
|||||||
height_spinbox.setSuffix(" mm")
|
height_spinbox.setSuffix(" mm")
|
||||||
height_layout.addWidget(height_spinbox)
|
height_layout.addWidget(height_spinbox)
|
||||||
size_layout.addLayout(height_layout)
|
size_layout.addLayout(height_layout)
|
||||||
|
|
||||||
size_group.setLayout(size_layout)
|
size_group.setLayout(size_layout)
|
||||||
layout.addWidget(size_group)
|
layout.addWidget(size_group)
|
||||||
|
|
||||||
# DPI settings group
|
# DPI settings group
|
||||||
dpi_group = QGroupBox("DPI Settings")
|
dpi_group = QGroupBox("DPI Settings")
|
||||||
dpi_layout = QVBoxLayout()
|
dpi_layout = QVBoxLayout()
|
||||||
|
|
||||||
# Working DPI
|
# Working DPI
|
||||||
working_dpi_layout = QHBoxLayout()
|
working_dpi_layout = QHBoxLayout()
|
||||||
working_dpi_layout.addWidget(QLabel("Working DPI:"))
|
working_dpi_layout.addWidget(QLabel("Working DPI:"))
|
||||||
@@ -350,7 +479,7 @@ class FileOperationsMixin:
|
|||||||
working_dpi_spinbox.setValue(self.project.working_dpi)
|
working_dpi_spinbox.setValue(self.project.working_dpi)
|
||||||
working_dpi_layout.addWidget(working_dpi_spinbox)
|
working_dpi_layout.addWidget(working_dpi_spinbox)
|
||||||
dpi_layout.addLayout(working_dpi_layout)
|
dpi_layout.addLayout(working_dpi_layout)
|
||||||
|
|
||||||
# Export DPI
|
# Export DPI
|
||||||
export_dpi_layout = QHBoxLayout()
|
export_dpi_layout = QHBoxLayout()
|
||||||
export_dpi_layout.addWidget(QLabel("Export DPI:"))
|
export_dpi_layout.addWidget(QLabel("Export DPI:"))
|
||||||
@@ -359,48 +488,58 @@ class FileOperationsMixin:
|
|||||||
export_dpi_spinbox.setValue(self.project.export_dpi)
|
export_dpi_spinbox.setValue(self.project.export_dpi)
|
||||||
export_dpi_layout.addWidget(export_dpi_spinbox)
|
export_dpi_layout.addWidget(export_dpi_spinbox)
|
||||||
dpi_layout.addLayout(export_dpi_layout)
|
dpi_layout.addLayout(export_dpi_layout)
|
||||||
|
|
||||||
dpi_group.setLayout(dpi_layout)
|
dpi_group.setLayout(dpi_layout)
|
||||||
layout.addWidget(dpi_group)
|
layout.addWidget(dpi_group)
|
||||||
|
|
||||||
# Content scaling options (only if pages exist and size is changing)
|
# Content scaling options (only if pages exist and size is changing)
|
||||||
scaling_group = None
|
scaling_group = None
|
||||||
scaling_buttons = None
|
scaling_buttons = None
|
||||||
|
|
||||||
|
scope_buttons = None
|
||||||
if self.project.pages:
|
if self.project.pages:
|
||||||
scaling_group = QGroupBox("Apply to Existing Pages")
|
scaling_group = QGroupBox("Apply to Existing Pages")
|
||||||
scaling_layout = QVBoxLayout()
|
scaling_layout = QVBoxLayout()
|
||||||
|
|
||||||
info_label = QLabel("How should existing content be adjusted?\n(Pages with manual sizing will not be affected)")
|
# Scope: which pages to update
|
||||||
info_label.setWordWrap(True)
|
scaling_layout.addWidget(QLabel("Pages to update:"))
|
||||||
scaling_layout.addWidget(info_label)
|
scope_buttons = QButtonGroup()
|
||||||
|
scope_non_manual = QRadioButton("Non-manual pages only")
|
||||||
|
scope_non_manual.setChecked(True)
|
||||||
|
scope_all = QRadioButton("All pages (override manual sizing)")
|
||||||
|
scope_buttons.addButton(scope_non_manual, 0)
|
||||||
|
scope_buttons.addButton(scope_all, 1)
|
||||||
|
scaling_layout.addWidget(scope_non_manual)
|
||||||
|
scaling_layout.addWidget(scope_all)
|
||||||
|
|
||||||
|
# Content scaling
|
||||||
|
scaling_layout.addWidget(QLabel("Content adjustment:"))
|
||||||
scaling_buttons = QButtonGroup()
|
scaling_buttons = QButtonGroup()
|
||||||
|
|
||||||
proportional_radio = QRadioButton("Resize proportionally (fit to smallest axis)")
|
proportional_radio = QRadioButton("Resize proportionally (fit to smallest axis)")
|
||||||
proportional_radio.setToolTip("Scale content uniformly to fit the new page size")
|
proportional_radio.setToolTip("Scale content uniformly to fit the new page size")
|
||||||
scaling_buttons.addButton(proportional_radio, 0)
|
scaling_buttons.addButton(proportional_radio, 0)
|
||||||
scaling_layout.addWidget(proportional_radio)
|
scaling_layout.addWidget(proportional_radio)
|
||||||
|
|
||||||
stretch_radio = QRadioButton("Resize on both axes (stretch)")
|
stretch_radio = QRadioButton("Resize on both axes (stretch)")
|
||||||
stretch_radio.setToolTip("Scale width and height independently")
|
stretch_radio.setToolTip("Scale width and height independently")
|
||||||
scaling_buttons.addButton(stretch_radio, 1)
|
scaling_buttons.addButton(stretch_radio, 1)
|
||||||
scaling_layout.addWidget(stretch_radio)
|
scaling_layout.addWidget(stretch_radio)
|
||||||
|
|
||||||
reposition_radio = QRadioButton("Keep content size, reposition to center")
|
reposition_radio = QRadioButton("Keep content size, reposition to center")
|
||||||
reposition_radio.setToolTip("Maintain element sizes but center them on new page")
|
reposition_radio.setToolTip("Maintain element sizes but center them on new page")
|
||||||
scaling_buttons.addButton(reposition_radio, 2)
|
scaling_buttons.addButton(reposition_radio, 2)
|
||||||
scaling_layout.addWidget(reposition_radio)
|
scaling_layout.addWidget(reposition_radio)
|
||||||
|
|
||||||
none_radio = QRadioButton("Don't adjust content (page size only)")
|
none_radio = QRadioButton("Don't adjust content (page size only)")
|
||||||
none_radio.setToolTip("Only change page size, leave content as-is")
|
none_radio.setToolTip("Only change page size, leave content as-is")
|
||||||
none_radio.setChecked(True) # Default
|
none_radio.setChecked(True) # Default
|
||||||
scaling_buttons.addButton(none_radio, 3)
|
scaling_buttons.addButton(none_radio, 3)
|
||||||
scaling_layout.addWidget(none_radio)
|
scaling_layout.addWidget(none_radio)
|
||||||
|
|
||||||
scaling_group.setLayout(scaling_layout)
|
scaling_group.setLayout(scaling_layout)
|
||||||
layout.addWidget(scaling_group)
|
layout.addWidget(scaling_group)
|
||||||
|
|
||||||
# Buttons
|
# Buttons
|
||||||
button_layout = QHBoxLayout()
|
button_layout = QHBoxLayout()
|
||||||
cancel_btn = QPushButton("Cancel")
|
cancel_btn = QPushButton("Cancel")
|
||||||
@@ -408,14 +547,14 @@ class FileOperationsMixin:
|
|||||||
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
|
# Show dialog
|
||||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||||
# Get new values
|
# Get new values
|
||||||
@@ -423,74 +562,79 @@ class FileOperationsMixin:
|
|||||||
new_height = height_spinbox.value()
|
new_height = height_spinbox.value()
|
||||||
new_working_dpi = working_dpi_spinbox.value()
|
new_working_dpi = working_dpi_spinbox.value()
|
||||||
new_export_dpi = export_dpi_spinbox.value()
|
new_export_dpi = export_dpi_spinbox.value()
|
||||||
|
|
||||||
# Determine scaling mode
|
# Determine scaling mode and scope
|
||||||
scaling_mode = 'none'
|
scaling_mode = "none"
|
||||||
|
include_manual = False
|
||||||
if scaling_buttons:
|
if scaling_buttons:
|
||||||
selected_id = scaling_buttons.checkedId()
|
selected_id = scaling_buttons.checkedId()
|
||||||
modes = {0: 'proportional', 1: 'stretch', 2: 'reposition', 3: 'none'}
|
modes = {0: "proportional", 1: "stretch", 2: "reposition", 3: "none"}
|
||||||
scaling_mode = modes.get(selected_id, 'none')
|
scaling_mode = modes.get(selected_id, "none")
|
||||||
|
if scope_buttons:
|
||||||
|
include_manual = scope_buttons.checkedId() == 1
|
||||||
|
|
||||||
# Apply settings
|
# Apply settings
|
||||||
old_size = self.project.page_size_mm
|
old_size = self.project.page_size_mm
|
||||||
self.project.page_size_mm = (new_width, new_height)
|
self.project.page_size_mm = (new_width, new_height)
|
||||||
self.project.working_dpi = new_working_dpi
|
self.project.working_dpi = new_working_dpi
|
||||||
self.project.export_dpi = new_export_dpi
|
self.project.export_dpi = new_export_dpi
|
||||||
|
|
||||||
# Update existing pages (exclude manually sized ones)
|
# Update existing pages
|
||||||
if self.project.pages and old_size != (new_width, new_height):
|
if self.project.pages and old_size != (new_width, new_height):
|
||||||
self._apply_page_size_to_project(old_size, (new_width, new_height), scaling_mode)
|
self._apply_page_size_to_project(old_size, (new_width, new_height), scaling_mode, include_manual)
|
||||||
|
|
||||||
self.update_view()
|
self.update_view()
|
||||||
self.show_status(f"Project settings updated: {new_width}×{new_height} mm", 2000)
|
self.show_status(f"Project settings updated: {new_width}×{new_height} mm", 2000)
|
||||||
print(f"Project settings updated: {new_width}×{new_height} mm, scaling mode: {scaling_mode}")
|
print(f"Project settings updated: {new_width}×{new_height} mm, scaling mode: {scaling_mode}")
|
||||||
|
|
||||||
def _apply_page_size_to_project(self, old_size, new_size, scaling_mode):
|
def _apply_page_size_to_project(self, old_size, new_size, scaling_mode, include_manual=False):
|
||||||
"""
|
"""
|
||||||
Apply new page size to all non-manually-sized pages
|
Apply new page size to existing pages.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
old_size: Old page size (width, height) in mm
|
old_size: Old page size (width, height) in mm
|
||||||
new_size: New page size (width, height) in mm
|
new_size: New page size (width, height) in mm
|
||||||
scaling_mode: 'proportional', 'stretch', 'reposition', or 'none'
|
scaling_mode: 'proportional', 'stretch', 'reposition', or 'none'
|
||||||
|
include_manual: If True, also resize manually-sized pages
|
||||||
"""
|
"""
|
||||||
old_width, old_height = old_size
|
old_width, old_height = old_size
|
||||||
new_width, new_height = new_size
|
new_width, new_height = new_size
|
||||||
|
|
||||||
width_ratio = new_width / old_width if old_width > 0 else 1.0
|
width_ratio = new_width / old_width if old_width > 0 else 1.0
|
||||||
height_ratio = new_height / old_height if old_height > 0 else 1.0
|
height_ratio = new_height / old_height if old_height > 0 else 1.0
|
||||||
|
|
||||||
for page in self.project.pages:
|
for page in self.project.pages:
|
||||||
# Skip manually sized pages
|
if page.is_cover:
|
||||||
if page.manually_sized:
|
|
||||||
continue
|
continue
|
||||||
|
if page.manually_sized and not include_manual:
|
||||||
|
continue
|
||||||
|
|
||||||
# Update page size
|
# Update page size
|
||||||
old_page_width, old_page_height = page.layout.size
|
old_page_width, old_page_height = page.layout.size
|
||||||
|
|
||||||
# For double spreads, maintain the 2x multiplier
|
# For double spreads, maintain the 2x multiplier
|
||||||
if page.is_double_spread:
|
if page.is_double_spread:
|
||||||
page.layout.size = (new_width * 2, new_height)
|
page.layout.size = (new_width * 2, new_height)
|
||||||
else:
|
else:
|
||||||
page.layout.size = (new_width, new_height)
|
page.layout.size = (new_width, new_height)
|
||||||
|
|
||||||
# Apply content scaling based on mode
|
# Apply content scaling based on mode
|
||||||
if scaling_mode == 'proportional':
|
if scaling_mode == "proportional":
|
||||||
# Use smallest ratio to fit content
|
# Use smallest ratio to fit content
|
||||||
scale = min(width_ratio, height_ratio)
|
scale = min(width_ratio, height_ratio)
|
||||||
self._scale_page_elements(page, scale, scale)
|
self._scale_page_elements(page, scale, scale)
|
||||||
elif scaling_mode == 'stretch':
|
elif scaling_mode == "stretch":
|
||||||
# Scale independently on each axis
|
# Scale independently on each axis
|
||||||
self._scale_page_elements(page, width_ratio, height_ratio)
|
self._scale_page_elements(page, width_ratio, height_ratio)
|
||||||
elif scaling_mode == 'reposition':
|
elif scaling_mode == "reposition":
|
||||||
# Keep size, center content
|
# Keep size, center content
|
||||||
self._reposition_page_elements(page, old_size, new_size)
|
self._reposition_page_elements(page, old_size, new_size)
|
||||||
# 'none' - do nothing to elements
|
# 'none' - do nothing to elements
|
||||||
|
|
||||||
def _scale_page_elements(self, page, x_scale, y_scale):
|
def _scale_page_elements(self, page, x_scale, y_scale):
|
||||||
"""
|
"""
|
||||||
Scale all elements on a page
|
Scale all elements on a page
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
page: Page object
|
page: Page object
|
||||||
x_scale: Horizontal scale factor
|
x_scale: Horizontal scale factor
|
||||||
@@ -500,15 +644,15 @@ class FileOperationsMixin:
|
|||||||
# Scale position
|
# Scale position
|
||||||
x, y = element.position
|
x, y = element.position
|
||||||
element.position = (x * x_scale, y * y_scale)
|
element.position = (x * x_scale, y * y_scale)
|
||||||
|
|
||||||
# Scale size
|
# Scale size
|
||||||
width, height = element.size
|
width, height = element.size
|
||||||
element.size = (width * x_scale, height * y_scale)
|
element.size = (width * x_scale, height * y_scale)
|
||||||
|
|
||||||
def _reposition_page_elements(self, page, old_size, new_size):
|
def _reposition_page_elements(self, page, old_size, new_size):
|
||||||
"""
|
"""
|
||||||
Reposition elements to center them on the new page size
|
Reposition elements to center them on the new page size
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
page: Page object
|
page: Page object
|
||||||
old_size: Old page size (width, height) in mm
|
old_size: Old page size (width, height) in mm
|
||||||
@@ -516,20 +660,15 @@ class FileOperationsMixin:
|
|||||||
"""
|
"""
|
||||||
old_width, old_height = old_size
|
old_width, old_height = old_size
|
||||||
new_width, new_height = new_size
|
new_width, new_height = new_size
|
||||||
|
|
||||||
x_offset = (new_width - old_width) / 2.0
|
x_offset = (new_width - old_width) / 2.0
|
||||||
y_offset = (new_height - old_height) / 2.0
|
y_offset = (new_height - old_height) / 2.0
|
||||||
|
|
||||||
for element in page.layout.elements:
|
for element in page.layout.elements:
|
||||||
x, y = element.position
|
x, y = element.position
|
||||||
element.position = (x + x_offset, y + y_offset)
|
element.position = (x + x_offset, y + y_offset)
|
||||||
|
|
||||||
@ribbon_action(
|
@ribbon_action(label="Export PDF", tooltip="Export project to PDF", tab="Home", group="File")
|
||||||
label="Export PDF",
|
|
||||||
tooltip="Export project to PDF",
|
|
||||||
tab="Export",
|
|
||||||
group="Export"
|
|
||||||
)
|
|
||||||
def export_pdf(self):
|
def export_pdf(self):
|
||||||
"""Export project to PDF using async backend (non-blocking)"""
|
"""Export project to PDF using async backend (non-blocking)"""
|
||||||
# Check if we have pages to export
|
# Check if we have pages to export
|
||||||
@@ -538,33 +677,182 @@ class FileOperationsMixin:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Show file save dialog
|
# Show file save dialog
|
||||||
file_path, _ = QFileDialog.getSaveFileName(
|
file_path, _ = QFileDialog.getSaveFileName(self, "Export to PDF", "", "PDF Files (*.pdf);;All Files (*)")
|
||||||
self,
|
|
||||||
"Export to PDF",
|
|
||||||
"",
|
|
||||||
"PDF Files (*.pdf);;All Files (*)"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not file_path:
|
if not file_path:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Ensure .pdf extension
|
# Ensure .pdf extension
|
||||||
if not file_path.lower().endswith('.pdf'):
|
if not file_path.lower().endswith(".pdf"):
|
||||||
file_path += '.pdf'
|
file_path += ".pdf"
|
||||||
|
|
||||||
# Use async PDF export (non-blocking, UI stays responsive)
|
# Use async PDF export (non-blocking, UI stays responsive)
|
||||||
success = self.gl_widget.export_pdf_async(self.project, file_path, export_dpi=300)
|
success = self.gl_widget.export_pdf_async(self.project, file_path, export_dpi=self.project.export_dpi)
|
||||||
if success:
|
if success:
|
||||||
self.show_status("PDF export started...", 2000)
|
self.show_status("PDF export started...", 2000)
|
||||||
else:
|
else:
|
||||||
self.show_status("PDF export failed to start", 3000)
|
self.show_status("PDF export failed to start", 3000)
|
||||||
|
|
||||||
@ribbon_action(
|
@ribbon_action(
|
||||||
label="About",
|
label="Clean Assets", tooltip="Find and remove duplicate or unused image files", tab="Home", group="File"
|
||||||
tooltip="About pyPhotoAlbum and data format version",
|
|
||||||
tab="Home",
|
|
||||||
group="File"
|
|
||||||
)
|
)
|
||||||
|
def clean_assets(self):
|
||||||
|
"""Find and remove duplicate and unused asset files to save space"""
|
||||||
|
from PyQt6.QtWidgets import QProgressDialog, QCheckBox
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
|
||||||
|
# Helper to format bytes
|
||||||
|
def format_bytes(num_bytes):
|
||||||
|
if num_bytes >= 1024 * 1024:
|
||||||
|
return f"{num_bytes / (1024 * 1024):.1f} MB"
|
||||||
|
elif num_bytes >= 1024:
|
||||||
|
return f"{num_bytes / 1024:.1f} KB"
|
||||||
|
else:
|
||||||
|
return f"{num_bytes} bytes"
|
||||||
|
|
||||||
|
# Scan for issues with progress dialog
|
||||||
|
progress = QProgressDialog("Scanning assets...", "Cancel", 0, 100, self)
|
||||||
|
progress.setWindowTitle("Clean Assets")
|
||||||
|
progress.setWindowModality(Qt.WindowModality.WindowModal)
|
||||||
|
progress.setValue(10)
|
||||||
|
|
||||||
|
# Compute hashes for duplicate detection
|
||||||
|
self.project.asset_manager.compute_all_hashes()
|
||||||
|
progress.setValue(40)
|
||||||
|
|
||||||
|
if progress.wasCanceled():
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get duplicate stats
|
||||||
|
dup_groups, dup_files, dup_bytes = self.project.asset_manager.get_duplicate_stats()
|
||||||
|
progress.setValue(60)
|
||||||
|
|
||||||
|
# Get unused stats
|
||||||
|
unused_files, unused_bytes = self.project.asset_manager.get_unused_stats()
|
||||||
|
progress.setValue(80)
|
||||||
|
|
||||||
|
progress.close()
|
||||||
|
|
||||||
|
# Check if there's anything to clean
|
||||||
|
if dup_files == 0 and unused_files == 0:
|
||||||
|
QMessageBox.information(
|
||||||
|
self, "Assets Clean", "No duplicate or unused files were found in your project assets."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build dialog with checkboxes for each cleanup type
|
||||||
|
dialog = QDialog(self)
|
||||||
|
dialog.setWindowTitle("Clean Assets")
|
||||||
|
dialog.setMinimumWidth(450)
|
||||||
|
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
|
# Info label
|
||||||
|
info_label = QLabel("Select which cleanup operations to perform:")
|
||||||
|
layout.addWidget(info_label)
|
||||||
|
|
||||||
|
# Duplicates checkbox
|
||||||
|
dup_checkbox = None
|
||||||
|
if dup_files > 0:
|
||||||
|
dup_checkbox = QCheckBox(
|
||||||
|
f"Remove {dup_files} duplicate file(s) in {dup_groups} group(s) " f"(saves {format_bytes(dup_bytes)})"
|
||||||
|
)
|
||||||
|
dup_checkbox.setChecked(True)
|
||||||
|
dup_checkbox.setToolTip(
|
||||||
|
"Duplicate files have identical content but different names.\n"
|
||||||
|
"Image references will be automatically updated to use the kept file."
|
||||||
|
)
|
||||||
|
layout.addWidget(dup_checkbox)
|
||||||
|
|
||||||
|
# Unused checkbox
|
||||||
|
unused_checkbox = None
|
||||||
|
if unused_files > 0:
|
||||||
|
unused_checkbox = QCheckBox(f"Remove {unused_files} unused file(s) (saves {format_bytes(unused_bytes)})")
|
||||||
|
unused_checkbox.setChecked(True)
|
||||||
|
unused_checkbox.setToolTip(
|
||||||
|
"Unused files exist in the assets folder but are not referenced\n"
|
||||||
|
"by any image element in your project."
|
||||||
|
)
|
||||||
|
layout.addWidget(unused_checkbox)
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
total_files = dup_files + unused_files
|
||||||
|
total_bytes = dup_bytes + unused_bytes
|
||||||
|
summary_label = QLabel(f"\nTotal potential savings: {format_bytes(total_bytes)} from {total_files} file(s)")
|
||||||
|
summary_label.setStyleSheet("font-weight: bold;")
|
||||||
|
layout.addWidget(summary_label)
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
button_layout = QHBoxLayout()
|
||||||
|
cancel_btn = QPushButton("Cancel")
|
||||||
|
cancel_btn.clicked.connect(dialog.reject)
|
||||||
|
clean_btn = QPushButton("Clean Selected")
|
||||||
|
clean_btn.clicked.connect(dialog.accept)
|
||||||
|
clean_btn.setDefault(True)
|
||||||
|
|
||||||
|
button_layout.addStretch()
|
||||||
|
button_layout.addWidget(cancel_btn)
|
||||||
|
button_layout.addWidget(clean_btn)
|
||||||
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
|
dialog.setLayout(layout)
|
||||||
|
|
||||||
|
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Perform selected cleanups
|
||||||
|
total_removed = 0
|
||||||
|
total_saved = 0
|
||||||
|
|
||||||
|
# Remove duplicates if selected
|
||||||
|
if dup_checkbox and dup_checkbox.isChecked():
|
||||||
|
|
||||||
|
def update_image_references(old_path: str, new_path: str):
|
||||||
|
"""Update all ImageData elements that reference the old path"""
|
||||||
|
from pyPhotoAlbum.models import ImageData
|
||||||
|
|
||||||
|
for page in self.project.pages:
|
||||||
|
for element in page.layout.elements:
|
||||||
|
if isinstance(element, ImageData) and element.image_path == old_path:
|
||||||
|
element.image_path = new_path
|
||||||
|
element.mark_modified()
|
||||||
|
print(f"Updated image reference: {old_path} -> {new_path}")
|
||||||
|
|
||||||
|
removed, saved = self.project.asset_manager.deduplicate_assets(
|
||||||
|
update_references_callback=update_image_references
|
||||||
|
)
|
||||||
|
total_removed += removed
|
||||||
|
total_saved += saved
|
||||||
|
|
||||||
|
# Remove unused if selected
|
||||||
|
if unused_checkbox and unused_checkbox.isChecked():
|
||||||
|
removed, saved = self.project.asset_manager.remove_unused_assets()
|
||||||
|
total_removed += removed
|
||||||
|
total_saved += saved
|
||||||
|
|
||||||
|
if total_removed > 0:
|
||||||
|
# Mark project as dirty since we modified it
|
||||||
|
self.project.mark_dirty()
|
||||||
|
|
||||||
|
# Update view
|
||||||
|
self.update_view()
|
||||||
|
|
||||||
|
# Show result
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
"Cleanup Complete",
|
||||||
|
f"Removed {total_removed} file(s).\n\n"
|
||||||
|
f"Saved {format_bytes(total_saved)} of disk space.\n\n"
|
||||||
|
f"Remember to save your project to preserve these changes.",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.show_status(
|
||||||
|
f"Asset cleanup complete: removed {total_removed} files, saved {format_bytes(total_saved)}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.show_status("No files were removed")
|
||||||
|
|
||||||
|
@ribbon_action(label="About", tooltip="About pyPhotoAlbum and data format version", tab="Home", group="File")
|
||||||
def show_about(self):
|
def show_about(self):
|
||||||
"""Show about dialog with version information"""
|
"""Show about dialog with version information"""
|
||||||
dialog = QDialog(self)
|
dialog = QDialog(self)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -2,348 +2,185 @@
|
|||||||
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")
|
|
||||||
|
|
||||||
@ribbon_action(
|
|
||||||
label="Page Setup",
|
|
||||||
tooltip="Configure page size and settings",
|
|
||||||
tab="Layout",
|
|
||||||
group="Page"
|
|
||||||
)
|
|
||||||
def page_setup(self):
|
|
||||||
"""Open page setup dialog"""
|
|
||||||
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox, QComboBox, QCheckBox
|
|
||||||
|
|
||||||
# Check if we have pages
|
|
||||||
if not self.project.pages:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Create dialog
|
|
||||||
dialog = QDialog(self)
|
|
||||||
dialog.setWindowTitle("Page Setup")
|
|
||||||
dialog.setMinimumWidth(450)
|
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
|
||||||
|
|
||||||
# Page selection group
|
|
||||||
page_select_group = QGroupBox("Select Page")
|
|
||||||
page_select_layout = QVBoxLayout()
|
|
||||||
|
|
||||||
page_combo = QComboBox()
|
|
||||||
for i, page in enumerate(self.project.pages):
|
|
||||||
# Use display name helper
|
|
||||||
page_label = self.project.get_page_display_name(page)
|
|
||||||
if page.is_double_spread and not page.is_cover:
|
|
||||||
page_label += f" (Double Spread)"
|
|
||||||
if page.manually_sized:
|
|
||||||
page_label += " *"
|
|
||||||
page_combo.addItem(page_label, i)
|
|
||||||
page_select_layout.addWidget(page_combo)
|
|
||||||
|
|
||||||
# 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 most visible page
|
# Get display name for status message
|
||||||
initial_page_index = self._get_most_visible_page_index()
|
new_page_name = self.project.get_page_display_name(new_page)
|
||||||
if 0 <= initial_page_index < len(self.project.pages):
|
print(f"Added {new_page_name} at position {insert_index + 1} with size {width_mm}×{height_mm} mm")
|
||||||
page_combo.setCurrentIndex(initial_page_index)
|
|
||||||
on_page_changed(initial_page_index if 0 <= initial_page_index < len(self.project.pages) else 0)
|
@ribbon_action(label="Page Setup", tooltip="Configure page size and settings", tab="Layout", group="Page")
|
||||||
|
@dialog_action(dialog_class=PageSetupDialog, requires_pages=True)
|
||||||
# Buttons
|
def page_setup(self, values):
|
||||||
button_layout = QHBoxLayout()
|
"""
|
||||||
cancel_btn = QPushButton("Cancel")
|
Apply page setup configuration.
|
||||||
cancel_btn.clicked.connect(dialog.reject)
|
|
||||||
ok_btn = QPushButton("OK")
|
This method contains only business logic. UI presentation
|
||||||
ok_btn.clicked.connect(dialog.accept)
|
is handled by PageSetupDialog and the dialog_action decorator.
|
||||||
ok_btn.setDefault(True)
|
|
||||||
|
Args:
|
||||||
button_layout.addStretch()
|
values: Dictionary of values from the dialog
|
||||||
button_layout.addWidget(cancel_btn)
|
"""
|
||||||
button_layout.addWidget(ok_btn)
|
selected_page = values["selected_page"]
|
||||||
layout.addLayout(button_layout)
|
selected_index = values["selected_index"]
|
||||||
|
|
||||||
dialog.setLayout(layout)
|
# Update project cover settings
|
||||||
|
self.project.paper_thickness_mm = values["paper_thickness_mm"]
|
||||||
# Show dialog
|
self.project.cover_bleed_mm = values["cover_bleed_mm"]
|
||||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
|
||||||
# Get selected page
|
# Handle cover designation (only for first page)
|
||||||
selected_index = page_combo.currentData()
|
if selected_index == 0:
|
||||||
selected_page = self.project.pages[selected_index]
|
was_cover = selected_page.is_cover
|
||||||
|
is_cover = values["is_cover"]
|
||||||
# Update project cover settings
|
|
||||||
self.project.paper_thickness_mm = thickness_spinbox.value()
|
if was_cover != is_cover:
|
||||||
self.project.cover_bleed_mm = bleed_spinbox.value()
|
selected_page.is_cover = is_cover
|
||||||
|
self.project.has_cover = is_cover
|
||||||
# Handle cover designation (only for first page)
|
|
||||||
if selected_index == 0:
|
if is_cover:
|
||||||
was_cover = selected_page.is_cover
|
# Calculate and set cover dimensions
|
||||||
is_cover = cover_checkbox.isChecked()
|
self.project.update_cover_dimensions()
|
||||||
|
print(f"Page 1 designated as cover")
|
||||||
if was_cover != is_cover:
|
|
||||||
selected_page.is_cover = is_cover
|
|
||||||
self.project.has_cover = is_cover
|
|
||||||
|
|
||||||
if is_cover:
|
|
||||||
# Calculate and set cover dimensions
|
|
||||||
self.project.update_cover_dimensions()
|
|
||||||
print(f"Page 1 designated as cover")
|
|
||||||
else:
|
|
||||||
# Restore normal page size
|
|
||||||
selected_page.layout.size = self.project.page_size_mm
|
|
||||||
print(f"Cover removed from page 1")
|
|
||||||
|
|
||||||
# Get new values
|
|
||||||
width_mm = 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 current page",
|
|
||||||
tab="Layout",
|
|
||||||
group="Page"
|
|
||||||
)
|
)
|
||||||
def toggle_double_spread(self):
|
def toggle_double_spread(self):
|
||||||
"""Toggle double spread for the current page"""
|
"""Toggle double spread for the current page"""
|
||||||
@@ -358,31 +195,31 @@ class PageOperationsMixin:
|
|||||||
page_index = 0
|
page_index = 0
|
||||||
|
|
||||||
current_page = self.project.pages[page_index]
|
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()
|
||||||
|
|
||||||
@@ -390,13 +227,8 @@ class PageOperationsMixin:
|
|||||||
page_name = self.project.get_page_display_name(current_page)
|
page_name = self.project.get_page_display_name(current_page)
|
||||||
self.show_status(f"{page_name}: Double spread {status}, width = {new_width:.0f}mm", 2000)
|
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")
|
print(f"{page_name}: Double spread {status}, width = {new_width}mm")
|
||||||
|
|
||||||
@ribbon_action(
|
@ribbon_action(label="Remove Page", tooltip="Remove the currently selected page", tab="Layout", group="Page")
|
||||||
label="Remove Page",
|
|
||||||
tooltip="Remove the currently selected page",
|
|
||||||
tab="Layout",
|
|
||||||
group="Page"
|
|
||||||
)
|
|
||||||
def remove_page(self):
|
def remove_page(self):
|
||||||
"""Remove the currently selected page"""
|
"""Remove the currently selected page"""
|
||||||
if len(self.project.pages) <= 1:
|
if len(self.project.pages) <= 1:
|
||||||
@@ -417,9 +249,12 @@ class PageOperationsMixin:
|
|||||||
# Remove the selected page
|
# Remove the selected page
|
||||||
self.project.remove_page(page_to_remove)
|
self.project.remove_page(page_to_remove)
|
||||||
|
|
||||||
# Renumber remaining pages
|
# Renumber remaining pages to ensure consistent numbering
|
||||||
for i, page in enumerate(self.project.pages):
|
# Page numbers represent logical page numbers in the book
|
||||||
page.page_number = i + 1
|
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()
|
||||||
|
|||||||
@@ -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,30 +130,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_page(self):
|
def fit_to_page(self):
|
||||||
"""Fit selected element to page dimensions"""
|
"""Fit selected element to page dimensions"""
|
||||||
if not self.require_selection(min_count=1):
|
self._execute_fit_to_page(
|
||||||
return
|
lambda elem, page: AlignmentManager.fit_to_page(elem, page.layout.size[0], page.layout.size[1]),
|
||||||
|
"Fitted element to page",
|
||||||
page = self.get_current_page()
|
)
|
||||||
if not page:
|
|
||||||
self.show_warning("No Page", "Please create a page first.")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Get the first selected element
|
|
||||||
element = next(iter(self.gl_widget.selected_elements))
|
|
||||||
|
|
||||||
# Fit to page
|
|
||||||
page_width, page_height = page.layout.size
|
|
||||||
change = AlignmentManager.fit_to_page(element, page_width, page_height)
|
|
||||||
|
|
||||||
if change:
|
|
||||||
cmd = ResizeElementsCommand([change])
|
|
||||||
self.project.history.execute(cmd)
|
|
||||||
self.update_view()
|
|
||||||
self.show_status("Fitted element to page", 2000)
|
|
||||||
|
|
||||||
@ribbon_action(
|
@ribbon_action(
|
||||||
label="Expand Image",
|
label="Expand Image",
|
||||||
@@ -176,7 +145,7 @@ class SizeOperationsMixin:
|
|||||||
tab="Arrange",
|
tab="Arrange",
|
||||||
group="Size",
|
group="Size",
|
||||||
requires_selection=True,
|
requires_selection=True,
|
||||||
min_selection=1
|
min_selection=1,
|
||||||
)
|
)
|
||||||
def expand_image(self):
|
def expand_image(self):
|
||||||
"""Expand selected image to fill available space"""
|
"""Expand selected image to fill available space"""
|
||||||
@@ -195,16 +164,11 @@ class SizeOperationsMixin:
|
|||||||
other_elements = [e for e in page.layout.elements if e is not element]
|
other_elements = [e for e in page.layout.elements if e is not element]
|
||||||
|
|
||||||
# Use configurable min_gap (grid spacing from snapping system, default 10mm)
|
# Use configurable min_gap (grid spacing from snapping system, default 10mm)
|
||||||
min_gap = getattr(page.layout.snapping_system, 'grid_spacing', 10.0)
|
min_gap = getattr(page.layout.snapping_system, "grid_spacing", 10.0)
|
||||||
|
|
||||||
# Expand to bounds
|
# Expand to bounds
|
||||||
page_width, page_height = page.layout.size
|
page_width, page_height = page.layout.size
|
||||||
change = AlignmentManager.expand_to_bounds(
|
change = AlignmentManager.expand_to_bounds(element, (page_width, page_height), other_elements, min_gap)
|
||||||
element,
|
|
||||||
(page_width, page_height),
|
|
||||||
other_elements,
|
|
||||||
min_gap
|
|
||||||
)
|
|
||||||
|
|
||||||
if change:
|
if change:
|
||||||
cmd = ResizeElementsCommand([change])
|
cmd = ResizeElementsCommand([change])
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -3,82 +3,75 @@ 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"""
|
||||||
@@ -87,8 +80,7 @@ class TemplateOperationsMixin:
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -178,7 +170,7 @@ class TemplateOperationsMixin:
|
|||||||
page_number=new_page_number,
|
page_number=new_page_number,
|
||||||
target_size_mm=self.project.page_size_mm,
|
target_size_mm=self.project.page_size_mm,
|
||||||
scale_mode=scale_mode,
|
scale_mode=scale_mode,
|
||||||
margin_percent=margin_percent
|
margin_percent=margin_percent,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add to project
|
# Add to project
|
||||||
@@ -194,63 +186,62 @@ class TemplateOperationsMixin:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.show_error("Error", f"Failed to create page from template: {str(e)}")
|
self.show_error("Error", f"Failed to create page from template: {str(e)}")
|
||||||
print(f"Error creating page from template: {e}")
|
print(f"Error creating page from template: {e}")
|
||||||
|
|
||||||
@ribbon_action(
|
@ribbon_action(
|
||||||
label="Apply Template",
|
label="Apply Template",
|
||||||
tooltip="Apply a template layout to current page",
|
tooltip="Apply a template layout to current page",
|
||||||
tab="Layout",
|
tab="Layout",
|
||||||
group="Templates",
|
group="Templates",
|
||||||
requires_page=True
|
requires_page=True,
|
||||||
)
|
)
|
||||||
@undoable_operation(capture='page_elements', description='Apply Template')
|
@undoable_operation(capture="page_elements", description="Apply Template")
|
||||||
def apply_template_to_page(self):
|
def apply_template_to_page(self):
|
||||||
"""Apply a template to the current page"""
|
"""Apply a template to the current page"""
|
||||||
current_page = self.get_current_page()
|
current_page = self.get_current_page()
|
||||||
if not current_page:
|
if not current_page:
|
||||||
return
|
return
|
||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
# Create dialog for template application options
|
# Create dialog for template application options
|
||||||
dialog = QDialog(self)
|
dialog = QDialog(self)
|
||||||
dialog.setWindowTitle("Apply Template")
|
dialog.setWindowTitle("Apply 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
|
# Mode selection
|
||||||
layout.addWidget(QLabel("Mode:"))
|
layout.addWidget(QLabel("Mode:"))
|
||||||
mode_group = QButtonGroup(dialog)
|
mode_group = QButtonGroup(dialog)
|
||||||
|
|
||||||
replace_radio = QRadioButton("Replace with placeholders")
|
replace_radio = QRadioButton("Replace with placeholders")
|
||||||
replace_radio.setChecked(True)
|
replace_radio.setChecked(True)
|
||||||
replace_radio.setToolTip("Clear page and add template placeholders")
|
replace_radio.setToolTip("Clear page and add template placeholders")
|
||||||
mode_group.addButton(replace_radio, 0)
|
mode_group.addButton(replace_radio, 0)
|
||||||
layout.addWidget(replace_radio)
|
layout.addWidget(replace_radio)
|
||||||
|
|
||||||
reflow_radio = QRadioButton("Reflow existing content")
|
reflow_radio = QRadioButton("Reflow existing content")
|
||||||
reflow_radio.setToolTip("Keep existing images and reposition to template slots")
|
reflow_radio.setToolTip("Keep existing images and reposition to template slots")
|
||||||
mode_group.addButton(reflow_radio, 1)
|
mode_group.addButton(reflow_radio, 1)
|
||||||
layout.addWidget(reflow_radio)
|
layout.addWidget(reflow_radio)
|
||||||
|
|
||||||
layout.addSpacing(10)
|
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()
|
||||||
@@ -264,9 +255,9 @@ 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)
|
||||||
@@ -279,13 +270,13 @@ class TemplateOperationsMixin:
|
|||||||
stretch_radio.setChecked(True)
|
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
|
# Buttons
|
||||||
button_layout = QHBoxLayout()
|
button_layout = QHBoxLayout()
|
||||||
cancel_btn = QPushButton("Cancel")
|
cancel_btn = QPushButton("Cancel")
|
||||||
@@ -293,38 +284,34 @@ 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
|
||||||
@@ -332,7 +319,7 @@ class TemplateOperationsMixin:
|
|||||||
|
|
||||||
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}")
|
||||||
|
|||||||
@@ -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,46 +26,41 @@ 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"""
|
||||||
@@ -89,12 +73,13 @@ class ViewOperationsMixin:
|
|||||||
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"""
|
||||||
@@ -107,13 +92,8 @@ class ViewOperationsMixin:
|
|||||||
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"""
|
||||||
if not self.project:
|
if not self.project:
|
||||||
@@ -125,13 +105,8 @@ class ViewOperationsMixin:
|
|||||||
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="Show Grid",
|
|
||||||
tooltip="Toggle visibility of grid lines",
|
|
||||||
tab="View",
|
|
||||||
group="Snapping"
|
|
||||||
)
|
|
||||||
def toggle_show_grid(self):
|
def toggle_show_grid(self):
|
||||||
"""Toggle grid visibility"""
|
"""Toggle grid visibility"""
|
||||||
if not self.project:
|
if not self.project:
|
||||||
@@ -145,11 +120,38 @@ class ViewOperationsMixin:
|
|||||||
print(f"Grid {status}")
|
print(f"Grid {status}")
|
||||||
|
|
||||||
@ribbon_action(
|
@ribbon_action(
|
||||||
label="Show Guides",
|
label="Print Settings...", tooltip="Configure bleed and safe area for all pages", tab="View", group="Guides"
|
||||||
tooltip="Toggle visibility of guide lines",
|
|
||||||
tab="View",
|
|
||||||
group="Snapping"
|
|
||||||
)
|
)
|
||||||
|
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):
|
def toggle_snap_lines(self):
|
||||||
"""Toggle guide lines visibility"""
|
"""Toggle guide lines visibility"""
|
||||||
if not self.project:
|
if not self.project:
|
||||||
@@ -161,71 +163,70 @@ class ViewOperationsMixin:
|
|||||||
self.update_view()
|
self.update_view()
|
||||||
self.show_status(f"Guides {status}", 2000)
|
self.show_status(f"Guides {status}", 2000)
|
||||||
print(f"Guides {status}")
|
print(f"Guides {status}")
|
||||||
|
|
||||||
@ribbon_action(
|
@ribbon_action(label="Add H Guide", tooltip="Add horizontal guide at page center", tab="View", group="Guides")
|
||||||
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"""
|
||||||
@@ -295,68 +296,3 @@ class ViewOperationsMixin:
|
|||||||
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 grid lines",
|
|
||||||
tab="Layout",
|
|
||||||
group="Snapping"
|
|
||||||
)
|
|
||||||
def layout_toggle_show_grid(self):
|
|
||||||
"""Toggle grid visibility (Layout tab)"""
|
|
||||||
self.toggle_show_grid()
|
|
||||||
|
|
||||||
@ribbon_action(
|
|
||||||
label="Show Guides",
|
|
||||||
tooltip="Toggle visibility of guide lines",
|
|
||||||
tab="Layout",
|
|
||||||
group="Snapping"
|
|
||||||
)
|
|
||||||
def layout_toggle_snap_lines(self):
|
|
||||||
"""Toggle guide lines visibility (Layout tab)"""
|
|
||||||
self.toggle_snap_lines()
|
|
||||||
|
|
||||||
@ribbon_action(
|
|
||||||
label="Grid Settings...",
|
|
||||||
tooltip="Configure grid size and snap threshold",
|
|
||||||
tab="Layout",
|
|
||||||
group="Snapping"
|
|
||||||
)
|
|
||||||
def layout_set_grid_size(self):
|
|
||||||
"""Open grid settings dialog (Layout tab)"""
|
|
||||||
self.set_grid_size()
|
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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)}%")
|
||||||
|
|||||||
@@ -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,8 +25,23 @@ 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
|
||||||
|
|
||||||
|
try:
|
||||||
|
project = main_window.project
|
||||||
|
if not project:
|
||||||
|
return
|
||||||
|
if not project.pages:
|
||||||
|
return
|
||||||
|
except AttributeError:
|
||||||
|
# Project not yet initialized
|
||||||
return
|
return
|
||||||
|
|
||||||
# Set initial zoom and center the page if not done yet
|
# Set initial zoom and center the page if not done yet
|
||||||
@@ -34,11 +51,10 @@ class RenderingMixin:
|
|||||||
self.initial_zoom_set = True
|
self.initial_zoom_set = True
|
||||||
|
|
||||||
# Update scrollbars now that we have content bounds
|
# Update scrollbars now that we have content bounds
|
||||||
main_window = self.window()
|
if hasattr(self, "_main_window") and hasattr(self._main_window, "update_scrollbars"):
|
||||||
if hasattr(main_window, 'update_scrollbars'):
|
self._main_window.update_scrollbars()
|
||||||
main_window.update_scrollbars()
|
|
||||||
|
|
||||||
dpi = main_window.project.working_dpi
|
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()
|
||||||
@@ -50,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
|
||||||
|
|
||||||
@@ -66,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))
|
||||||
@@ -74,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, project=main_window.project)
|
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
|
||||||
|
|
||||||
@@ -90,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
|
||||||
@@ -107,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):
|
||||||
@@ -116,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
|
||||||
@@ -152,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)]
|
||||||
|
|
||||||
@@ -187,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)
|
||||||
@@ -213,7 +235,7 @@ class RenderingMixin:
|
|||||||
|
|
||||||
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)
|
||||||
@@ -235,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()
|
||||||
@@ -288,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
|
||||||
|
|||||||
+129
-30
@@ -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)
|
||||||
@@ -34,16 +37,31 @@ class ViewportMixin:
|
|||||||
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
|
# Recalculate centering if we have a project loaded
|
||||||
if self.initial_zoom_set:
|
# 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
|
# Maintain current zoom level, just recenter
|
||||||
self.pan_offset = self._calculate_center_pan_offset(self.zoom_level)
|
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
|
# Update scrollbars when viewport size changes
|
||||||
main_window = self.window()
|
main_window = self.window()
|
||||||
if hasattr(main_window, 'update_scrollbars'):
|
if hasattr(main_window, "update_scrollbars"):
|
||||||
main_window.update_scrollbars()
|
main_window.update_scrollbars()
|
||||||
|
|
||||||
def _calculate_fit_to_screen_zoom(self):
|
def _calculate_fit_to_screen_zoom(self):
|
||||||
@@ -54,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()
|
||||||
@@ -88,7 +106,7 @@ class ViewportMixin:
|
|||||||
list: [x_offset, y_offset] to center the page
|
list: [x_offset, y_offset] to center the page
|
||||||
"""
|
"""
|
||||||
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 [0, 0]
|
return [0, 0]
|
||||||
|
|
||||||
window_width = self.width()
|
window_width = self.width()
|
||||||
@@ -123,8 +141,8 @@ class ViewportMixin:
|
|||||||
dict: {'min_x', 'max_x', 'min_y', 'max_y', 'width', 'height'} in pixels
|
dict: {'min_x', 'max_x', 'min_y', 'max_y', 'width', 'height'} in pixels
|
||||||
"""
|
"""
|
||||||
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 {'min_x': 0, 'max_x': 800, 'min_y': 0, 'max_y': 600, 'width': 800, 'height': 600}
|
return {"min_x": 0, "max_x": 800, "min_y": 0, "max_y": 600, "width": 800, "height": 600}
|
||||||
|
|
||||||
dpi = main_window.project.working_dpi
|
dpi = main_window.project.working_dpi
|
||||||
PAGE_MARGIN = 50
|
PAGE_MARGIN = 50
|
||||||
@@ -149,14 +167,92 @@ class ViewportMixin:
|
|||||||
total_height += PAGE_MARGIN
|
total_height += PAGE_MARGIN
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'min_x': 0,
|
"min_x": 0,
|
||||||
'max_x': total_width,
|
"max_x": total_width,
|
||||||
'min_y': 0,
|
"min_y": 0,
|
||||||
'max_y': total_height,
|
"max_y": total_height,
|
||||||
'width': total_width,
|
"width": total_width,
|
||||||
'height': total_height
|
"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):
|
def clamp_pan_offset(self):
|
||||||
"""
|
"""
|
||||||
Clamp pan offset to prevent scrolling beyond content bounds.
|
Clamp pan offset to prevent scrolling beyond content bounds.
|
||||||
@@ -164,27 +260,30 @@ class ViewportMixin:
|
|||||||
Pan offset semantics:
|
Pan offset semantics:
|
||||||
- Positive pan_offset = content moved right/down (viewing top-left)
|
- Positive pan_offset = content moved right/down (viewing top-left)
|
||||||
- Negative pan_offset = content moved left/up (viewing bottom-right)
|
- 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.
|
||||||
"""
|
"""
|
||||||
bounds = self.get_content_bounds()
|
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_width = self.width()
|
||||||
viewport_height = self.height()
|
viewport_height = self.height()
|
||||||
|
|
||||||
content_width = bounds['width']
|
# Vertical clamping (returns original pan_y for page selection)
|
||||||
content_height = bounds['height']
|
original_pan_y = self._clamp_vertical_pan(viewport_height)
|
||||||
|
|
||||||
# Only clamp if content is larger than viewport
|
# Build page centerline data
|
||||||
# If content is smaller, allow any pan offset (for centering)
|
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
|
# Horizontal clamping
|
||||||
if content_width > viewport_width:
|
self._clamp_horizontal_pan(viewport_width, target_x, target_width)
|
||||||
# Content is wider than viewport - restrict panning
|
|
||||||
max_pan_left = 0 # Can't pan beyond left edge
|
|
||||||
min_pan_left = -(content_width - viewport_width) # Can't pan beyond right edge
|
|
||||||
self.pan_offset[0] = max(min_pan_left, min(max_pan_left, self.pan_offset[0]))
|
|
||||||
|
|
||||||
# Vertical clamping
|
|
||||||
if content_height > viewport_height:
|
|
||||||
# Content is taller than viewport - restrict panning
|
|
||||||
max_pan_up = 0 # Can't pan beyond top edge
|
|
||||||
min_pan_up = -(content_height - viewport_height) # Can't pan beyond bottom edge
|
|
||||||
self.pan_offset[1] = max(min_pan_up, min(max_pan_up, self.pan_offset[1]))
|
|
||||||
|
|||||||
+606
-205
File diff suppressed because it is too large
Load Diff
+67
-52
@@ -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,16 +49,18 @@ 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
|
||||||
|
|
||||||
@@ -50,19 +75,17 @@ class PageLayout:
|
|||||||
dpi: Working DPI for converting mm to pixels
|
dpi: Working DPI for converting mm to pixels
|
||||||
project: Optional project instance for global snapping settings
|
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)
|
||||||
@@ -72,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)
|
||||||
@@ -93,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)
|
||||||
@@ -123,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)
|
||||||
@@ -134,19 +158,15 @@ 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 show_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, project)
|
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, project=None):
|
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,
|
|
||||||
glVertex2f, GL_LINES, glEnable, glDisable, GL_BLEND,
|
|
||||||
glBlendFunc, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
|
|
||||||
|
|
||||||
# Use project settings if available, otherwise fall back to local snapping_system
|
# Use project settings if available, otherwise fall back to local snapping_system
|
||||||
if project:
|
if project:
|
||||||
# Use project-level global settings
|
# Use project-level global settings
|
||||||
@@ -169,6 +189,7 @@ class PageLayout:
|
|||||||
|
|
||||||
# Create a temporary snapping system with project settings to get snap lines
|
# Create a temporary snapping system with project settings to get snap lines
|
||||||
from pyPhotoAlbum.snapping import SnappingSystem
|
from pyPhotoAlbum.snapping import SnappingSystem
|
||||||
|
|
||||||
temp_snap_sys = SnappingSystem(snap_threshold_mm=snap_threshold_mm)
|
temp_snap_sys = SnappingSystem(snap_threshold_mm=snap_threshold_mm)
|
||||||
temp_snap_sys.grid_size_mm = grid_size_mm
|
temp_snap_sys.grid_size_mm = grid_size_mm
|
||||||
temp_snap_sys.snap_to_grid = snap_to_grid
|
temp_snap_sys.snap_to_grid = snap_to_grid
|
||||||
@@ -178,17 +199,13 @@ class PageLayout:
|
|||||||
|
|
||||||
snap_lines = temp_snap_sys.get_snap_lines(self.size, dpi)
|
snap_lines = temp_snap_sys.get_snap_lines(self.size, dpi)
|
||||||
|
|
||||||
# Enable alpha blending for transparency
|
# Draw grid lines (light gray, fully opaque) - visible when show_grid is enabled
|
||||||
glEnable(GL_BLEND)
|
if show_grid and snap_lines["grid"]:
|
||||||
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
|
glColor3f(0.8, 0.8, 0.8) # Light gray, fully opaque
|
||||||
|
|
||||||
# Draw grid lines (darker gray with transparency) - visible when show_grid is enabled
|
|
||||||
if show_grid and snap_lines['grid']:
|
|
||||||
glColor4f(0.6, 0.6, 0.6, 0.4) # Gray with 40% opacity
|
|
||||||
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
|
||||||
@@ -196,13 +213,13 @@ class PageLayout:
|
|||||||
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 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
|
||||||
@@ -211,7 +228,6 @@ class PageLayout:
|
|||||||
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"""
|
||||||
@@ -223,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]):
|
||||||
@@ -236,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":
|
||||||
@@ -250,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
|
||||||
@@ -260,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"""
|
||||||
|
|
||||||
@@ -281,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
|
||||||
@@ -300,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"""
|
||||||
|
|||||||
@@ -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)
|
||||||
"""
|
"""
|
||||||
|
|||||||
+981
-405
File diff suppressed because it is too large
Load Diff
+165
-67
@@ -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"""
|
||||||
|
|
||||||
@@ -110,18 +151,32 @@ class Project:
|
|||||||
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)
|
# Global snapping settings (apply to all pages)
|
||||||
self.snap_to_grid = False
|
self.snap_to_grid = False
|
||||||
@@ -135,16 +190,46 @@ class Project:
|
|||||||
# 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"""
|
||||||
@@ -152,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.
|
||||||
@@ -190,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',
|
||||||
@@ -257,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:
|
||||||
@@ -276,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):
|
||||||
@@ -325,6 +409,9 @@ 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_grid": self.snap_to_grid,
|
||||||
"snap_to_edges": self.snap_to_edges,
|
"snap_to_edges": self.snap_to_edges,
|
||||||
@@ -335,7 +422,11 @@ class Project:
|
|||||||
"show_snap_lines": self.show_snap_lines,
|
"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]):
|
||||||
@@ -353,6 +444,9 @@ 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", {})
|
||||||
@@ -365,7 +459,13 @@ class Project:
|
|||||||
self.snap_threshold_mm = data.get("snap_threshold_mm", 5.0)
|
self.snap_threshold_mm = data.get("snap_threshold_mm", 5.0)
|
||||||
self.show_grid = data.get("show_grid", False)
|
self.show_grid = data.get("show_grid", False)
|
||||||
self.show_snap_lines = data.get("show_snap_lines", True)
|
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
|
||||||
@@ -373,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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
+213
-104
@@ -7,17 +7,17 @@ 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
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ def _import_external_images(project: Project):
|
|||||||
# Absolute path - definitely external
|
# Absolute path - definitely external
|
||||||
is_external = True
|
is_external = True
|
||||||
external_path = element.image_path
|
external_path = element.image_path
|
||||||
elif not element.image_path.startswith('assets/'):
|
elif not element.image_path.startswith("assets/"):
|
||||||
# Relative path but not in assets folder
|
# Relative path but not in assets folder
|
||||||
# Check if it exists relative to project folder
|
# Check if it exists relative to project folder
|
||||||
full_path = os.path.join(project.folder_path, element.image_path)
|
full_path = os.path.join(project.folder_path, element.image_path)
|
||||||
@@ -91,15 +91,15 @@ def _normalize_asset_paths(project: Project, project_folder: str):
|
|||||||
original_path = element.image_path
|
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}")
|
||||||
@@ -133,8 +133,8 @@ def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, 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
|
# Check for and import any external images before saving
|
||||||
_import_external_images(project)
|
_import_external_images(project)
|
||||||
@@ -143,14 +143,14 @@ def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]:
|
|||||||
project_data = project.serialize()
|
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
|
||||||
@@ -171,7 +171,128 @@ def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]:
|
|||||||
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.
|
||||||
|
|
||||||
@@ -181,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}")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -17,10 +17,11 @@ class RibbonWidget(QWidget):
|
|||||||
# 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)
|
||||||
@@ -106,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
|
||||||
|
|
||||||
|
|||||||
+137
-215
@@ -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,28 +55,30 @@ 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,
|
page_size: Tuple[float, float],
|
||||||
project=None) -> 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
|
||||||
|
|
||||||
@@ -80,8 +92,6 @@ class SnappingSystem:
|
|||||||
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
|
||||||
@@ -102,43 +112,47 @@ class SnappingSystem:
|
|||||||
|
|
||||||
# Convert threshold from mm to pixels
|
# Convert threshold from mm to pixels
|
||||||
snap_threshold_px = 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 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 snap_to_grid:
|
if snap_to_grid:
|
||||||
grid_size_px = 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
|
||||||
@@ -151,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 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:
|
||||||
@@ -165,92 +179,77 @@ 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,
|
|
||||||
project=None) -> 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
|
|
||||||
project: Optional project for global snapping settings
|
|
||||||
|
|
||||||
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
|
# Use project settings if available, otherwise use local settings
|
||||||
if project:
|
if params.project:
|
||||||
snap_threshold_mm = project.snap_threshold_mm
|
snap_threshold_mm = params.project.snap_threshold_mm
|
||||||
else:
|
else:
|
||||||
snap_threshold_mm = self.snap_threshold_mm
|
snap_threshold_mm = self.snap_threshold_mm
|
||||||
|
|
||||||
# Convert threshold from mm to pixels
|
# Convert threshold from mm to pixels
|
||||||
snap_threshold_px = 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', project
|
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
|
||||||
@@ -259,21 +258,21 @@ class SnappingSystem:
|
|||||||
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', project
|
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', project
|
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
|
||||||
@@ -282,30 +281,32 @@ class SnappingSystem:
|
|||||||
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', project
|
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,
|
snap_threshold_px: float,
|
||||||
project=None) -> Optional[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)
|
||||||
|
|
||||||
@@ -332,12 +333,12 @@ class SnappingSystem:
|
|||||||
snap_to_guides = self.snap_to_guides
|
snap_to_guides = self.snap_to_guides
|
||||||
grid_size_mm = self.grid_size_mm
|
grid_size_mm = self.grid_size_mm
|
||||||
|
|
||||||
snap_candidates = []
|
snap_candidates: List[Tuple[float, float]] = []
|
||||||
|
|
||||||
# 1. Page edge snapping
|
# 1. Page edge snapping
|
||||||
if 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
|
||||||
@@ -357,144 +358,65 @@ class SnappingSystem:
|
|||||||
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 {
|
||||||
@@ -503,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)
|
||||||
@@ -513,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))
|
||||||
|
|||||||
@@ -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,19 +317,19 @@ 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
|
||||||
@@ -339,14 +338,8 @@ class TemplateManager:
|
|||||||
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
|
||||||
|
|
||||||
@@ -362,15 +355,9 @@ class TemplateManager:
|
|||||||
|
|
||||||
for elem in scaled_elements:
|
for elem in scaled_elements:
|
||||||
# Convert position from mm to pixels
|
# Convert position from mm to pixels
|
||||||
elem.position = (
|
elem.position = (elem.position[0] * mm_to_px, elem.position[1] * mm_to_px)
|
||||||
elem.position[0] * mm_to_px,
|
|
||||||
elem.position[1] * mm_to_px
|
|
||||||
)
|
|
||||||
# Convert size from mm to pixels
|
# Convert size from mm to pixels
|
||||||
elem.size = (
|
elem.size = (elem.size[0] * mm_to_px, elem.size[1] * mm_to_px)
|
||||||
elem.size[0] * mm_to_px,
|
|
||||||
elem.size[1] * mm_to_px
|
|
||||||
)
|
|
||||||
|
|
||||||
return scaled_elements
|
return scaled_elements
|
||||||
|
|
||||||
@@ -381,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%)
|
||||||
@@ -399,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):
|
||||||
@@ -449,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)
|
||||||
@@ -465,7 +444,7 @@ class TemplateManager:
|
|||||||
target_size_mm: Optional[Tuple[float, float]] = None,
|
target_size_mm: Optional[Tuple[float, float]] = None,
|
||||||
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,
|
||||||
) -> Page:
|
) -> Page:
|
||||||
"""
|
"""
|
||||||
Create a new page from a template.
|
Create a new page from a template.
|
||||||
@@ -494,11 +473,7 @@ class TemplateManager:
|
|||||||
page_size = target_size_mm
|
page_size = target_size_mm
|
||||||
# Scale template elements with margins
|
# 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,
|
|
||||||
margin_percent
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create new page layout
|
# Create new page layout
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Test to demonstrate the asset drop bug
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
|
|
||||||
from pyPhotoAlbum.project import Project, Page
|
|
||||||
from pyPhotoAlbum.models import ImageData
|
|
||||||
|
|
||||||
def test_direct_path_assignment():
|
|
||||||
"""Simulate what happens when you drop an image on existing element"""
|
|
||||||
|
|
||||||
project = Project("Test Direct Path")
|
|
||||||
page = Page()
|
|
||||||
project.add_page(page)
|
|
||||||
|
|
||||||
# Add an image element
|
|
||||||
img = ImageData()
|
|
||||||
img.position = (10, 10)
|
|
||||||
img.size = (50, 50)
|
|
||||||
page.layout.add_element(img)
|
|
||||||
|
|
||||||
# Simulate dropping a new image on existing element (line 77 in asset_drop.py)
|
|
||||||
external_image = "/home/dtourolle/Pictures/some_photo.jpg"
|
|
||||||
print(f"\nSimulating drop on existing image element...")
|
|
||||||
print(f"Setting image_path directly to: {external_image}")
|
|
||||||
img.image_path = external_image # BUG: Not imported!
|
|
||||||
|
|
||||||
# Check assets folder
|
|
||||||
assets = os.listdir(project.asset_manager.assets_folder) if os.path.exists(project.asset_manager.assets_folder) else []
|
|
||||||
print(f"\nAssets in folder: {len(assets)}")
|
|
||||||
print(f" {assets if assets else '(empty)'}")
|
|
||||||
|
|
||||||
# The image path in the element points to external file
|
|
||||||
print(f"\nImage path in element: {img.image_path}")
|
|
||||||
print(f" Is absolute path: {os.path.isabs(img.image_path)}")
|
|
||||||
|
|
||||||
if os.path.isabs(img.image_path):
|
|
||||||
print("\n❌ BUG CONFIRMED: Image path is absolute, not copied to assets!")
|
|
||||||
print(" When saved to .ppz, this external file will NOT be included.")
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
test_direct_path_assignment()
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Test to verify the heal function can fix old files with missing assets
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import shutil
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
|
|
||||||
from pyPhotoAlbum.project import Project, Page
|
|
||||||
from pyPhotoAlbum.models import ImageData
|
|
||||||
from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip
|
|
||||||
|
|
||||||
def test_heal_external_paths():
|
|
||||||
"""Test healing a project with external (absolute) image paths"""
|
|
||||||
|
|
||||||
print("=" * 70)
|
|
||||||
print("Test: Healing External Image Paths")
|
|
||||||
print("=" * 70)
|
|
||||||
|
|
||||||
# Create a test image in a temp location
|
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
|
||||||
# Create a test image file
|
|
||||||
test_image_source = "./projects/project_with_image/assets/test_image.jpg"
|
|
||||||
external_image_path = os.path.join(temp_dir, "external_photo.jpg")
|
|
||||||
shutil.copy2(test_image_source, external_image_path)
|
|
||||||
|
|
||||||
print(f"\n1. Created external image at: {external_image_path}")
|
|
||||||
|
|
||||||
# Create a project
|
|
||||||
project = Project("Test Heal")
|
|
||||||
page = Page()
|
|
||||||
project.add_page(page)
|
|
||||||
|
|
||||||
# Add an image element with ABSOLUTE path (simulating the bug)
|
|
||||||
img = ImageData()
|
|
||||||
img.image_path = external_image_path # BUG: Absolute path!
|
|
||||||
img.position = (10, 10)
|
|
||||||
img.size = (50, 50)
|
|
||||||
page.layout.add_element(img)
|
|
||||||
|
|
||||||
print(f"2. Created project with absolute path: {img.image_path}")
|
|
||||||
|
|
||||||
# Save to zip
|
|
||||||
with tempfile.NamedTemporaryFile(suffix='.ppz', delete=False) as tmp:
|
|
||||||
zip_path = tmp.name
|
|
||||||
|
|
||||||
save_to_zip(project, zip_path)
|
|
||||||
print(f"3. Saved to: {zip_path}")
|
|
||||||
|
|
||||||
# Check what was saved
|
|
||||||
import zipfile
|
|
||||||
with zipfile.ZipFile(zip_path, 'r') as zf:
|
|
||||||
files = zf.namelist()
|
|
||||||
asset_files = [f for f in files if f.startswith('assets/')]
|
|
||||||
print(f"\n4. Assets in zip: {len(asset_files)}")
|
|
||||||
if len(asset_files) == 0:
|
|
||||||
print(" ❌ No assets saved (expected - this is the bug!)")
|
|
||||||
else:
|
|
||||||
print(f" ✅ Assets: {asset_files}")
|
|
||||||
|
|
||||||
# Load the project
|
|
||||||
loaded_project, error = load_from_zip(zip_path)
|
|
||||||
if not loaded_project:
|
|
||||||
print(f" ❌ Failed to load: {error}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
print(f"\n5. Loaded project from zip")
|
|
||||||
|
|
||||||
# Check for missing assets
|
|
||||||
from pyPhotoAlbum.models import ImageData as ImageDataCheck
|
|
||||||
missing_count = 0
|
|
||||||
for page in loaded_project.pages:
|
|
||||||
for element in page.layout.elements:
|
|
||||||
if isinstance(element, ImageDataCheck) and element.image_path:
|
|
||||||
if os.path.isabs(element.image_path):
|
|
||||||
full_path = element.image_path
|
|
||||||
else:
|
|
||||||
full_path = os.path.join(loaded_project.folder_path, element.image_path)
|
|
||||||
|
|
||||||
if not os.path.exists(full_path):
|
|
||||||
missing_count += 1
|
|
||||||
print(f" ❌ Missing: {element.image_path}")
|
|
||||||
|
|
||||||
print(f"\n6. Missing assets detected: {missing_count}")
|
|
||||||
|
|
||||||
if missing_count == 0:
|
|
||||||
print(" ⚠️ No missing assets - test may not be accurate")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Now test the healing logic
|
|
||||||
print(f"\n7. Testing heal function...")
|
|
||||||
|
|
||||||
# Simulate what the heal dialog does
|
|
||||||
from pyPhotoAlbum.models import set_asset_resolution_context
|
|
||||||
|
|
||||||
search_paths = [temp_dir] # The directory where our external image is
|
|
||||||
set_asset_resolution_context(loaded_project.folder_path, search_paths)
|
|
||||||
|
|
||||||
healed_count = 0
|
|
||||||
for page in loaded_project.pages:
|
|
||||||
for element in page.layout.elements:
|
|
||||||
if isinstance(element, ImageDataCheck) and element.image_path:
|
|
||||||
# Check if missing
|
|
||||||
if os.path.isabs(element.image_path):
|
|
||||||
full_path = element.image_path
|
|
||||||
else:
|
|
||||||
full_path = os.path.join(loaded_project.folder_path, element.image_path)
|
|
||||||
|
|
||||||
if not os.path.exists(full_path):
|
|
||||||
# Try to find and import
|
|
||||||
filename = os.path.basename(element.image_path)
|
|
||||||
for search_path in search_paths:
|
|
||||||
candidate = os.path.join(search_path, filename)
|
|
||||||
if os.path.exists(candidate):
|
|
||||||
# Import it!
|
|
||||||
new_asset_path = loaded_project.asset_manager.import_asset(candidate)
|
|
||||||
element.image_path = new_asset_path
|
|
||||||
healed_count += 1
|
|
||||||
print(f" ✅ Healed: {filename} → {new_asset_path}")
|
|
||||||
break
|
|
||||||
|
|
||||||
print(f"\n8. Healed {healed_count} asset(s)")
|
|
||||||
|
|
||||||
# Save the healed project
|
|
||||||
healed_zip_path = zip_path.replace('.ppz', '_healed.ppz')
|
|
||||||
save_to_zip(loaded_project, healed_zip_path)
|
|
||||||
print(f"9. Saved healed project to: {healed_zip_path}")
|
|
||||||
|
|
||||||
# Verify the healed version has assets
|
|
||||||
with zipfile.ZipFile(healed_zip_path, 'r') as zf:
|
|
||||||
files = zf.namelist()
|
|
||||||
asset_files = [f for f in files if f.startswith('assets/')]
|
|
||||||
print(f"\n10. Assets in healed zip: {len(asset_files)}")
|
|
||||||
for asset in asset_files:
|
|
||||||
print(f" - {asset}")
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
loaded_project.cleanup()
|
|
||||||
os.unlink(zip_path)
|
|
||||||
os.unlink(healed_zip_path)
|
|
||||||
|
|
||||||
if healed_count > 0 and len(asset_files) > 0:
|
|
||||||
print("\n✅ HEAL FUNCTION WORKS - Old files can be fixed!")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print("\n❌ Healing did not work as expected")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
success = test_heal_external_paths()
|
|
||||||
sys.exit(0 if success else 1)
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Test script for the loading widget functionality
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
from PyQt6.QtWidgets import QApplication, QMainWindow, QPushButton, QVBoxLayout, QWidget
|
|
||||||
from PyQt6.QtCore import QTimer
|
|
||||||
|
|
||||||
# Add parent directory to path
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
|
||||||
|
|
||||||
from pyPhotoAlbum.loading_widget import LoadingWidget
|
|
||||||
|
|
||||||
|
|
||||||
class TestWindow(QMainWindow):
|
|
||||||
"""Test window for loading widget"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self.setWindowTitle("Loading Widget Test")
|
|
||||||
self.resize(800, 600)
|
|
||||||
|
|
||||||
# Central widget
|
|
||||||
central = QWidget()
|
|
||||||
layout = QVBoxLayout()
|
|
||||||
|
|
||||||
# Test buttons
|
|
||||||
btn1 = QPushButton("Show Loading (Determinate)")
|
|
||||||
btn1.clicked.connect(self.test_determinate)
|
|
||||||
layout.addWidget(btn1)
|
|
||||||
|
|
||||||
btn2 = QPushButton("Show Loading (Indeterminate)")
|
|
||||||
btn2.clicked.connect(self.test_indeterminate)
|
|
||||||
layout.addWidget(btn2)
|
|
||||||
|
|
||||||
btn3 = QPushButton("Simulate File Loading")
|
|
||||||
btn3.clicked.connect(self.simulate_file_loading)
|
|
||||||
layout.addWidget(btn3)
|
|
||||||
|
|
||||||
central.setLayout(layout)
|
|
||||||
self.setCentralWidget(central)
|
|
||||||
|
|
||||||
# Create loading widget
|
|
||||||
self.loading_widget = LoadingWidget(self)
|
|
||||||
|
|
||||||
# Timer for progress simulation
|
|
||||||
self.timer = QTimer()
|
|
||||||
self.timer.timeout.connect(self.update_progress)
|
|
||||||
self.progress = 0
|
|
||||||
|
|
||||||
def test_determinate(self):
|
|
||||||
"""Test determinate progress"""
|
|
||||||
self.loading_widget.show_loading("Loading...")
|
|
||||||
self.loading_widget.set_indeterminate(False)
|
|
||||||
self.loading_widget.set_progress(50, 100)
|
|
||||||
|
|
||||||
# Auto hide after 3 seconds
|
|
||||||
QTimer.singleShot(3000, self.loading_widget.hide_loading)
|
|
||||||
|
|
||||||
def test_indeterminate(self):
|
|
||||||
"""Test indeterminate progress"""
|
|
||||||
self.loading_widget.show_loading("Processing...")
|
|
||||||
self.loading_widget.set_indeterminate(True)
|
|
||||||
|
|
||||||
# Auto hide after 3 seconds
|
|
||||||
QTimer.singleShot(3000, self.loading_widget.hide_loading)
|
|
||||||
|
|
||||||
def simulate_file_loading(self):
|
|
||||||
"""Simulate file loading with progress"""
|
|
||||||
self.progress = 0
|
|
||||||
self.loading_widget.show_loading("Extracting files...")
|
|
||||||
self.loading_widget.set_indeterminate(False)
|
|
||||||
self.timer.start(100) # Update every 100ms
|
|
||||||
|
|
||||||
def update_progress(self):
|
|
||||||
"""Update progress during simulation"""
|
|
||||||
self.progress += 5
|
|
||||||
|
|
||||||
if self.progress <= 40:
|
|
||||||
self.loading_widget.set_status(f"Extracting files... ({self.progress}%)")
|
|
||||||
elif self.progress <= 70:
|
|
||||||
self.loading_widget.set_status("Loading project data...")
|
|
||||||
elif self.progress <= 95:
|
|
||||||
self.loading_widget.set_status("Normalizing asset paths...")
|
|
||||||
else:
|
|
||||||
self.loading_widget.set_status("Loading complete!")
|
|
||||||
|
|
||||||
self.loading_widget.set_progress(self.progress, 100)
|
|
||||||
|
|
||||||
if self.progress >= 100:
|
|
||||||
self.timer.stop()
|
|
||||||
QTimer.singleShot(500, self.loading_widget.hide_loading)
|
|
||||||
|
|
||||||
def resizeEvent(self, event):
|
|
||||||
"""Handle resize"""
|
|
||||||
super().resizeEvent(event)
|
|
||||||
self.loading_widget.resizeParent()
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Run test"""
|
|
||||||
app = QApplication(sys.argv)
|
|
||||||
window = TestWindow()
|
|
||||||
window.show()
|
|
||||||
|
|
||||||
print("Loading widget test window opened")
|
|
||||||
print("Click buttons to test different loading states")
|
|
||||||
|
|
||||||
sys.exit(app.exec())
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -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!")
|
|
||||||
@@ -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}")
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Test script to verify that images are being embedded in .ppz files
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import zipfile
|
|
||||||
import tempfile
|
|
||||||
import shutil
|
|
||||||
|
|
||||||
# Add project to path
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
|
|
||||||
from pyPhotoAlbum.project import Project, Page
|
|
||||||
from pyPhotoAlbum.models import ImageData
|
|
||||||
from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip
|
|
||||||
|
|
||||||
def test_zip_embedding():
|
|
||||||
"""Test that images are properly embedded in the zip file"""
|
|
||||||
|
|
||||||
# Create a test project with an image
|
|
||||||
project = Project("Test Embedding")
|
|
||||||
page = Page()
|
|
||||||
project.add_page(page)
|
|
||||||
|
|
||||||
# Use an existing test image
|
|
||||||
test_image = "./projects/project_with_image/assets/test_image.jpg"
|
|
||||||
if not os.path.exists(test_image):
|
|
||||||
print(f"ERROR: Test image not found: {test_image}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
print(f"Using test image: {test_image}")
|
|
||||||
print(f"Test image size: {os.path.getsize(test_image)} bytes")
|
|
||||||
|
|
||||||
# Import the image through the asset manager
|
|
||||||
print("\n1. Importing image through asset manager...")
|
|
||||||
asset_path = project.asset_manager.import_asset(test_image)
|
|
||||||
print(f" Asset path: {asset_path}")
|
|
||||||
|
|
||||||
# Check that the asset was copied
|
|
||||||
full_asset_path = project.asset_manager.get_absolute_path(asset_path)
|
|
||||||
if os.path.exists(full_asset_path):
|
|
||||||
print(f" ✓ Asset exists at: {full_asset_path}")
|
|
||||||
print(f" Asset size: {os.path.getsize(full_asset_path)} bytes")
|
|
||||||
else:
|
|
||||||
print(f" ✗ ERROR: Asset not found at {full_asset_path}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Add image to page
|
|
||||||
img = ImageData()
|
|
||||||
img.image_path = asset_path
|
|
||||||
img.position = (10, 10)
|
|
||||||
img.size = (50, 50)
|
|
||||||
page.layout.add_element(img)
|
|
||||||
|
|
||||||
# Save to a temporary zip file
|
|
||||||
print("\n2. Saving project to .ppz file...")
|
|
||||||
with tempfile.NamedTemporaryFile(suffix='.ppz', delete=False) as tmp:
|
|
||||||
zip_path = tmp.name
|
|
||||||
|
|
||||||
success, error = save_to_zip(project, zip_path)
|
|
||||||
if not success:
|
|
||||||
print(f" ✗ ERROR: Failed to save: {error}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
print(f" ✓ Saved to: {zip_path}")
|
|
||||||
zip_size = os.path.getsize(zip_path)
|
|
||||||
print(f" Zip file size: {zip_size:,} bytes")
|
|
||||||
|
|
||||||
# Inspect the zip file contents
|
|
||||||
print("\n3. Inspecting zip file contents...")
|
|
||||||
with zipfile.ZipFile(zip_path, 'r') as zf:
|
|
||||||
files = zf.namelist()
|
|
||||||
print(f" Files in zip: {len(files)}")
|
|
||||||
for fname in files:
|
|
||||||
info = zf.getinfo(fname)
|
|
||||||
print(f" - {fname} ({info.file_size:,} bytes)")
|
|
||||||
|
|
||||||
# Check if assets are included
|
|
||||||
asset_files = [f for f in files if f.startswith('assets/')]
|
|
||||||
print(f"\n4. Checking for embedded assets...")
|
|
||||||
print(f" Assets found: {len(asset_files)}")
|
|
||||||
|
|
||||||
if len(asset_files) == 0:
|
|
||||||
print(" ✗ ERROR: No assets embedded in zip file!")
|
|
||||||
print(f"\n DEBUG INFO:")
|
|
||||||
print(f" Project folder: {project.folder_path}")
|
|
||||||
print(f" Assets folder: {project.asset_manager.assets_folder}")
|
|
||||||
print(f" Assets folder exists: {os.path.exists(project.asset_manager.assets_folder)}")
|
|
||||||
|
|
||||||
if os.path.exists(project.asset_manager.assets_folder):
|
|
||||||
assets = os.listdir(project.asset_manager.assets_folder)
|
|
||||||
print(f" Files in assets folder: {assets}")
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
os.unlink(zip_path)
|
|
||||||
return False
|
|
||||||
|
|
||||||
print(f" ✓ Found {len(asset_files)} asset file(s) in zip")
|
|
||||||
|
|
||||||
# Load the project back
|
|
||||||
print("\n5. Loading project from zip...")
|
|
||||||
loaded_project, error = load_from_zip(zip_path)
|
|
||||||
if loaded_project is None:
|
|
||||||
print(f" ✗ ERROR: Failed to load: {error}")
|
|
||||||
os.unlink(zip_path)
|
|
||||||
return False
|
|
||||||
|
|
||||||
print(f" ✓ Loaded project: {loaded_project.name}")
|
|
||||||
|
|
||||||
# Check that the image is accessible
|
|
||||||
print("\n6. Verifying loaded image...")
|
|
||||||
if loaded_project.pages and loaded_project.pages[0].layout.elements:
|
|
||||||
img_elem = loaded_project.pages[0].layout.elements[0]
|
|
||||||
if isinstance(img_elem, ImageData):
|
|
||||||
loaded_img_path = loaded_project.asset_manager.get_absolute_path(img_elem.image_path)
|
|
||||||
if os.path.exists(loaded_img_path):
|
|
||||||
print(f" ✓ Image accessible at: {loaded_img_path}")
|
|
||||||
print(f" Image size: {os.path.getsize(loaded_img_path)} bytes")
|
|
||||||
else:
|
|
||||||
print(f" ✗ ERROR: Image not found at {loaded_img_path}")
|
|
||||||
os.unlink(zip_path)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
os.unlink(zip_path)
|
|
||||||
loaded_project.cleanup()
|
|
||||||
|
|
||||||
print("\n✅ ALL TESTS PASSED - Images are being embedded correctly!")
|
|
||||||
return True
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
print("=" * 70)
|
|
||||||
print("Testing .ppz file image embedding")
|
|
||||||
print("=" * 70)
|
|
||||||
|
|
||||||
success = test_zip_embedding()
|
|
||||||
sys.exit(0 if success else 1)
|
|
||||||
+151
-23
@@ -15,9 +15,9 @@ from pyPhotoAlbum.project import Project, Page
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def temp_image_file():
|
def temp_image_file():
|
||||||
"""Create a temporary test image file"""
|
"""Create a temporary test image file"""
|
||||||
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f:
|
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
|
||||||
# Create a simple test image
|
# Create a simple test image
|
||||||
img = Image.new('RGB', (100, 100), color='red')
|
img = Image.new("RGB", (100, 100), color="red")
|
||||||
img.save(f.name)
|
img.save(f.name)
|
||||||
yield f.name
|
yield f.name
|
||||||
# Cleanup
|
# Cleanup
|
||||||
@@ -37,37 +37,19 @@ def temp_dir():
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_image_data(temp_image_file):
|
def sample_image_data(temp_image_file):
|
||||||
"""Create a sample ImageData instance"""
|
"""Create a sample ImageData instance"""
|
||||||
return ImageData(
|
return ImageData(image_path=temp_image_file, x=10.0, y=20.0, width=100.0, height=150.0)
|
||||||
image_path=temp_image_file,
|
|
||||||
x=10.0,
|
|
||||||
y=20.0,
|
|
||||||
width=100.0,
|
|
||||||
height=150.0
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_placeholder_data():
|
def sample_placeholder_data():
|
||||||
"""Create a sample PlaceholderData instance"""
|
"""Create a sample PlaceholderData instance"""
|
||||||
return PlaceholderData(
|
return PlaceholderData(placeholder_type="image", x=50.0, y=60.0, width=200.0, height=150.0)
|
||||||
placeholder_type="image",
|
|
||||||
x=50.0,
|
|
||||||
y=60.0,
|
|
||||||
width=200.0,
|
|
||||||
height=150.0
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_textbox_data():
|
def sample_textbox_data():
|
||||||
"""Create a sample TextBoxData instance"""
|
"""Create a sample TextBoxData instance"""
|
||||||
return TextBoxData(
|
return TextBoxData(text_content="Sample Text", x=30.0, y=40.0, width=150.0, height=50.0)
|
||||||
text_content="Sample Text",
|
|
||||||
x=30.0,
|
|
||||||
y=40.0,
|
|
||||||
width=150.0,
|
|
||||||
height=50.0
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -103,3 +85,149 @@ def populated_page_layout(sample_image_data, sample_placeholder_data, sample_tex
|
|||||||
layout.add_element(sample_placeholder_data)
|
layout.add_element(sample_placeholder_data)
|
||||||
layout.add_element(sample_textbox_data)
|
layout.add_element(sample_textbox_data)
|
||||||
return layout
|
return layout
|
||||||
|
|
||||||
|
|
||||||
|
# GL Widget fixtures (moved from test_gl_widget_fixtures.py)
|
||||||
|
|
||||||
|
from unittest.mock import Mock, MagicMock
|
||||||
|
from PyQt6.QtCore import Qt, QPointF, QPoint
|
||||||
|
from PyQt6.QtGui import QMouseEvent, QWheelEvent
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_main_window():
|
||||||
|
"""Create a mock main window with a basic project"""
|
||||||
|
window = Mock()
|
||||||
|
window.project = Project(name="Test Project")
|
||||||
|
|
||||||
|
# Add a test page
|
||||||
|
page = Page(layout=PageLayout(width=210, height=297), page_number=1) # A4 size in mm
|
||||||
|
window.project.pages.append(page)
|
||||||
|
window.project.working_dpi = 96
|
||||||
|
window.project.page_size_mm = (210, 297)
|
||||||
|
window.project.page_spacing_mm = 10
|
||||||
|
|
||||||
|
# Mock status bar
|
||||||
|
window.status_bar = Mock()
|
||||||
|
window.status_bar.showMessage = Mock()
|
||||||
|
window.show_status = Mock()
|
||||||
|
|
||||||
|
return window
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_image_element():
|
||||||
|
"""Create a sample ImageData element for testing"""
|
||||||
|
return ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150, z_index=1)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_placeholder_element():
|
||||||
|
"""Create a sample PlaceholderData element for testing"""
|
||||||
|
return PlaceholderData(x=50, y=50, width=100, height=100, z_index=0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_textbox_element():
|
||||||
|
"""Create a sample TextBoxData element for testing"""
|
||||||
|
return TextBoxData(x=10, y=10, width=180, height=50, text_content="Test Text", z_index=2)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_page_renderer():
|
||||||
|
"""Create a mock PageRenderer
|
||||||
|
|
||||||
|
NOTE: This fixture contains simplified coordinate conversion logic for testing.
|
||||||
|
It is NOT a replacement for testing with the real PageRenderer in integration tests.
|
||||||
|
"""
|
||||||
|
renderer = Mock()
|
||||||
|
renderer.screen_x = 50
|
||||||
|
renderer.screen_y = 50
|
||||||
|
renderer.zoom = 1.0
|
||||||
|
renderer.dpi = 96
|
||||||
|
|
||||||
|
# Mock coordinate conversion methods
|
||||||
|
def page_to_screen(x, y):
|
||||||
|
return (renderer.screen_x + x * renderer.zoom, renderer.screen_y + y * renderer.zoom)
|
||||||
|
|
||||||
|
def screen_to_page(x, y):
|
||||||
|
return ((x - renderer.screen_x) / renderer.zoom, (y - renderer.screen_y) / renderer.zoom)
|
||||||
|
|
||||||
|
def is_point_in_page(x, y):
|
||||||
|
# Simple bounds check (assume 210mm x 297mm page at 96 DPI)
|
||||||
|
page_width_px = 210 * 96 / 25.4
|
||||||
|
page_height_px = 297 * 96 / 25.4
|
||||||
|
return (
|
||||||
|
renderer.screen_x <= x <= renderer.screen_x + page_width_px * renderer.zoom
|
||||||
|
and renderer.screen_y <= y <= renderer.screen_y + page_height_px * renderer.zoom
|
||||||
|
)
|
||||||
|
|
||||||
|
renderer.page_to_screen = page_to_screen
|
||||||
|
renderer.screen_to_page = screen_to_page
|
||||||
|
renderer.is_point_in_page = is_point_in_page
|
||||||
|
|
||||||
|
return renderer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def create_mouse_event():
|
||||||
|
"""Factory fixture for creating QMouseEvent objects"""
|
||||||
|
|
||||||
|
def _create_event(event_type, x, y, button=Qt.MouseButton.LeftButton, modifiers=Qt.KeyboardModifier.NoModifier):
|
||||||
|
"""Create a QMouseEvent for testing
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event_type: QEvent.Type (MouseButtonPress, MouseButtonRelease, MouseMove)
|
||||||
|
x, y: Position coordinates
|
||||||
|
button: Mouse button
|
||||||
|
modifiers: Keyboard modifiers
|
||||||
|
"""
|
||||||
|
pos = QPointF(x, y)
|
||||||
|
return QMouseEvent(event_type, pos, button, button, modifiers)
|
||||||
|
|
||||||
|
return _create_event
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def create_wheel_event():
|
||||||
|
"""Factory fixture for creating QWheelEvent objects"""
|
||||||
|
|
||||||
|
def _create_event(x, y, delta_y=120, modifiers=Qt.KeyboardModifier.NoModifier):
|
||||||
|
"""Create a QWheelEvent for testing
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x, y: Position coordinates
|
||||||
|
delta_y: Wheel delta (positive = scroll up, negative = scroll down)
|
||||||
|
modifiers: Keyboard modifiers (e.g., ControlModifier for zoom)
|
||||||
|
"""
|
||||||
|
pos = QPointF(x, y)
|
||||||
|
global_pos = QPoint(int(x), int(y))
|
||||||
|
angle_delta = QPoint(0, delta_y)
|
||||||
|
|
||||||
|
return QWheelEvent(
|
||||||
|
pos,
|
||||||
|
global_pos,
|
||||||
|
QPoint(0, 0),
|
||||||
|
angle_delta,
|
||||||
|
Qt.MouseButton.NoButton,
|
||||||
|
modifiers,
|
||||||
|
Qt.ScrollPhase.NoScrollPhase,
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _create_event
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def populated_page():
|
||||||
|
"""Create a page with multiple elements for testing"""
|
||||||
|
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||||
|
|
||||||
|
# Add various elements
|
||||||
|
page.layout.add_element(ImageData(image_path="img1.jpg", x=10, y=10, width=100, height=75, z_index=0))
|
||||||
|
|
||||||
|
page.layout.add_element(PlaceholderData(x=120, y=10, width=80, height=60, z_index=1))
|
||||||
|
|
||||||
|
page.layout.add_element(TextBoxData(x=10, y=100, width=190, height=40, text_content="Sample Text", z_index=2))
|
||||||
|
|
||||||
|
return page
|
||||||
|
|||||||
Regular → Executable
+87
-91
@@ -20,7 +20,7 @@ class TestAlignmentManager:
|
|||||||
"""Test get_bounds with single element"""
|
"""Test get_bounds with single element"""
|
||||||
elem = ImageData(x=10, y=20, width=100, height=50)
|
elem = ImageData(x=10, y=20, width=100, height=50)
|
||||||
bounds = AlignmentManager.get_bounds([elem])
|
bounds = AlignmentManager.get_bounds([elem])
|
||||||
|
|
||||||
# min_x, min_y, max_x, max_y
|
# min_x, min_y, max_x, max_y
|
||||||
assert bounds == (10, 20, 110, 70)
|
assert bounds == (10, 20, 110, 70)
|
||||||
|
|
||||||
@@ -29,9 +29,9 @@ class TestAlignmentManager:
|
|||||||
elem1 = ImageData(x=10, y=20, width=100, height=50)
|
elem1 = ImageData(x=10, y=20, width=100, height=50)
|
||||||
elem2 = ImageData(x=50, y=10, width=80, height=60)
|
elem2 = ImageData(x=50, y=10, width=80, height=60)
|
||||||
elem3 = ImageData(x=5, y=30, width=90, height=40)
|
elem3 = ImageData(x=5, y=30, width=90, height=40)
|
||||||
|
|
||||||
bounds = AlignmentManager.get_bounds([elem1, elem2, elem3])
|
bounds = AlignmentManager.get_bounds([elem1, elem2, elem3])
|
||||||
|
|
||||||
# min_x = 5, min_y = 10, max_x = 130 (50+80), max_y = 70 (10+60 or 20+50)
|
# min_x = 5, min_y = 10, max_x = 130 (50+80), max_y = 70 (10+60 or 20+50)
|
||||||
assert bounds[0] == 5 # min_x
|
assert bounds[0] == 5 # min_x
|
||||||
assert bounds[1] == 10 # min_y
|
assert bounds[1] == 10 # min_y
|
||||||
@@ -55,14 +55,14 @@ class TestAlignmentManager:
|
|||||||
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
||||||
elem2 = ImageData(x=30, y=40, width=80, height=60)
|
elem2 = ImageData(x=30, y=40, width=80, height=60)
|
||||||
elem3 = ImageData(x=70, y=60, width=90, height=40)
|
elem3 = ImageData(x=70, y=60, width=90, height=40)
|
||||||
|
|
||||||
changes = AlignmentManager.align_left([elem1, elem2, elem3])
|
changes = AlignmentManager.align_left([elem1, elem2, elem3])
|
||||||
|
|
||||||
# All should align to x=30 (leftmost)
|
# All should align to x=30 (leftmost)
|
||||||
assert elem1.position == (30, 20)
|
assert elem1.position == (30, 20)
|
||||||
assert elem2.position == (30, 40)
|
assert elem2.position == (30, 40)
|
||||||
assert elem3.position == (30, 60)
|
assert elem3.position == (30, 60)
|
||||||
|
|
||||||
# Check undo information
|
# Check undo information
|
||||||
assert len(changes) == 3
|
assert len(changes) == 3
|
||||||
assert changes[0] == (elem1, (50, 20))
|
assert changes[0] == (elem1, (50, 20))
|
||||||
@@ -72,16 +72,16 @@ class TestAlignmentManager:
|
|||||||
def test_align_right_multiple_elements(self):
|
def test_align_right_multiple_elements(self):
|
||||||
"""Test align_right with multiple elements"""
|
"""Test align_right with multiple elements"""
|
||||||
elem1 = ImageData(x=50, y=20, width=100, height=50) # right edge at 150
|
elem1 = ImageData(x=50, y=20, width=100, height=50) # right edge at 150
|
||||||
elem2 = ImageData(x=30, y=40, width=80, height=60) # right edge at 110
|
elem2 = ImageData(x=30, y=40, width=80, height=60) # right edge at 110
|
||||||
elem3 = ImageData(x=70, y=60, width=90, height=40) # right edge at 160
|
elem3 = ImageData(x=70, y=60, width=90, height=40) # right edge at 160
|
||||||
|
|
||||||
changes = AlignmentManager.align_right([elem1, elem2, elem3])
|
changes = AlignmentManager.align_right([elem1, elem2, elem3])
|
||||||
|
|
||||||
# All right edges should align to x=160 (rightmost)
|
# All right edges should align to x=160 (rightmost)
|
||||||
assert elem1.position[0] == 60 # 160 - 100
|
assert elem1.position[0] == 60 # 160 - 100
|
||||||
assert elem2.position[0] == 80 # 160 - 80
|
assert elem2.position[0] == 80 # 160 - 80
|
||||||
assert elem3.position[0] == 70 # 160 - 90
|
assert elem3.position[0] == 70 # 160 - 90
|
||||||
|
|
||||||
# Y positions should not change
|
# Y positions should not change
|
||||||
assert elem1.position[1] == 20
|
assert elem1.position[1] == 20
|
||||||
assert elem2.position[1] == 40
|
assert elem2.position[1] == 40
|
||||||
@@ -92,14 +92,14 @@ class TestAlignmentManager:
|
|||||||
elem1 = ImageData(x=50, y=30, width=100, height=50)
|
elem1 = ImageData(x=50, y=30, width=100, height=50)
|
||||||
elem2 = ImageData(x=30, y=20, width=80, height=60)
|
elem2 = ImageData(x=30, y=20, width=80, height=60)
|
||||||
elem3 = ImageData(x=70, y=40, width=90, height=40)
|
elem3 = ImageData(x=70, y=40, width=90, height=40)
|
||||||
|
|
||||||
changes = AlignmentManager.align_top([elem1, elem2, elem3])
|
changes = AlignmentManager.align_top([elem1, elem2, elem3])
|
||||||
|
|
||||||
# All should align to y=20 (topmost)
|
# All should align to y=20 (topmost)
|
||||||
assert elem1.position[1] == 20
|
assert elem1.position[1] == 20
|
||||||
assert elem2.position[1] == 20
|
assert elem2.position[1] == 20
|
||||||
assert elem3.position[1] == 20
|
assert elem3.position[1] == 20
|
||||||
|
|
||||||
# X positions should not change
|
# X positions should not change
|
||||||
assert elem1.position[0] == 50
|
assert elem1.position[0] == 50
|
||||||
assert elem2.position[0] == 30
|
assert elem2.position[0] == 30
|
||||||
@@ -108,16 +108,16 @@ class TestAlignmentManager:
|
|||||||
def test_align_bottom_multiple_elements(self):
|
def test_align_bottom_multiple_elements(self):
|
||||||
"""Test align_bottom with multiple elements"""
|
"""Test align_bottom with multiple elements"""
|
||||||
elem1 = ImageData(x=50, y=30, width=100, height=50) # bottom at 80
|
elem1 = ImageData(x=50, y=30, width=100, height=50) # bottom at 80
|
||||||
elem2 = ImageData(x=30, y=20, width=80, height=60) # bottom at 80
|
elem2 = ImageData(x=30, y=20, width=80, height=60) # bottom at 80
|
||||||
elem3 = ImageData(x=70, y=40, width=90, height=50) # bottom at 90
|
elem3 = ImageData(x=70, y=40, width=90, height=50) # bottom at 90
|
||||||
|
|
||||||
changes = AlignmentManager.align_bottom([elem1, elem2, elem3])
|
changes = AlignmentManager.align_bottom([elem1, elem2, elem3])
|
||||||
|
|
||||||
# All bottom edges should align to y=90 (bottommost)
|
# All bottom edges should align to y=90 (bottommost)
|
||||||
assert elem1.position[1] == 40 # 90 - 50
|
assert elem1.position[1] == 40 # 90 - 50
|
||||||
assert elem2.position[1] == 30 # 90 - 60
|
assert elem2.position[1] == 30 # 90 - 60
|
||||||
assert elem3.position[1] == 40 # 90 - 50
|
assert elem3.position[1] == 40 # 90 - 50
|
||||||
|
|
||||||
# X positions should not change
|
# X positions should not change
|
||||||
assert elem1.position[0] == 50
|
assert elem1.position[0] == 50
|
||||||
assert elem2.position[0] == 30
|
assert elem2.position[0] == 30
|
||||||
@@ -125,18 +125,18 @@ class TestAlignmentManager:
|
|||||||
|
|
||||||
def test_align_horizontal_center_multiple_elements(self):
|
def test_align_horizontal_center_multiple_elements(self):
|
||||||
"""Test align_horizontal_center with multiple elements"""
|
"""Test align_horizontal_center with multiple elements"""
|
||||||
elem1 = ImageData(x=50, y=20, width=100, height=50) # center at 100
|
elem1 = ImageData(x=50, y=20, width=100, height=50) # center at 100
|
||||||
elem2 = ImageData(x=30, y=40, width=80, height=60) # center at 70
|
elem2 = ImageData(x=30, y=40, width=80, height=60) # center at 70
|
||||||
elem3 = ImageData(x=70, y=60, width=60, height=40) # center at 100
|
elem3 = ImageData(x=70, y=60, width=60, height=40) # center at 100
|
||||||
|
|
||||||
changes = AlignmentManager.align_horizontal_center([elem1, elem2, elem3])
|
changes = AlignmentManager.align_horizontal_center([elem1, elem2, elem3])
|
||||||
|
|
||||||
# Average center = (100 + 70 + 100) / 3 = 90
|
# Average center = (100 + 70 + 100) / 3 = 90
|
||||||
# All elements should center at x=90
|
# All elements should center at x=90
|
||||||
assert abs(elem1.position[0] + elem1.size[0]/2 - 90) < 0.01
|
assert abs(elem1.position[0] + elem1.size[0] / 2 - 90) < 0.01
|
||||||
assert abs(elem2.position[0] + elem2.size[0]/2 - 90) < 0.01
|
assert abs(elem2.position[0] + elem2.size[0] / 2 - 90) < 0.01
|
||||||
assert abs(elem3.position[0] + elem3.size[0]/2 - 90) < 0.01
|
assert abs(elem3.position[0] + elem3.size[0] / 2 - 90) < 0.01
|
||||||
|
|
||||||
# Y positions should not change
|
# Y positions should not change
|
||||||
assert elem1.position[1] == 20
|
assert elem1.position[1] == 20
|
||||||
assert elem2.position[1] == 40
|
assert elem2.position[1] == 40
|
||||||
@@ -144,18 +144,18 @@ class TestAlignmentManager:
|
|||||||
|
|
||||||
def test_align_vertical_center_multiple_elements(self):
|
def test_align_vertical_center_multiple_elements(self):
|
||||||
"""Test align_vertical_center with multiple elements"""
|
"""Test align_vertical_center with multiple elements"""
|
||||||
elem1 = ImageData(x=50, y=20, width=100, height=50) # center at 45
|
elem1 = ImageData(x=50, y=20, width=100, height=50) # center at 45
|
||||||
elem2 = ImageData(x=30, y=40, width=80, height=60) # center at 70
|
elem2 = ImageData(x=30, y=40, width=80, height=60) # center at 70
|
||||||
elem3 = ImageData(x=70, y=30, width=60, height=40) # center at 50
|
elem3 = ImageData(x=70, y=30, width=60, height=40) # center at 50
|
||||||
|
|
||||||
changes = AlignmentManager.align_vertical_center([elem1, elem2, elem3])
|
changes = AlignmentManager.align_vertical_center([elem1, elem2, elem3])
|
||||||
|
|
||||||
# Average center = (45 + 70 + 50) / 3 = 55
|
# Average center = (45 + 70 + 50) / 3 = 55
|
||||||
# All elements should center at y=55
|
# All elements should center at y=55
|
||||||
assert abs(elem1.position[1] + elem1.size[1]/2 - 55) < 0.01
|
assert abs(elem1.position[1] + elem1.size[1] / 2 - 55) < 0.01
|
||||||
assert abs(elem2.position[1] + elem2.size[1]/2 - 55) < 0.01
|
assert abs(elem2.position[1] + elem2.size[1] / 2 - 55) < 0.01
|
||||||
assert abs(elem3.position[1] + elem3.size[1]/2 - 55) < 0.01
|
assert abs(elem3.position[1] + elem3.size[1] / 2 - 55) < 0.01
|
||||||
|
|
||||||
# X positions should not change
|
# X positions should not change
|
||||||
assert elem1.position[0] == 50
|
assert elem1.position[0] == 50
|
||||||
assert elem2.position[0] == 30
|
assert elem2.position[0] == 30
|
||||||
@@ -178,14 +178,14 @@ class TestAlignmentManager:
|
|||||||
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
||||||
elem2 = ImageData(x=30, y=40, width=80, height=60)
|
elem2 = ImageData(x=30, y=40, width=80, height=60)
|
||||||
elem3 = ImageData(x=70, y=60, width=90, height=40)
|
elem3 = ImageData(x=70, y=60, width=90, height=40)
|
||||||
|
|
||||||
changes = AlignmentManager.make_same_size([elem1, elem2, elem3])
|
changes = AlignmentManager.make_same_size([elem1, elem2, elem3])
|
||||||
|
|
||||||
# All should match elem1's size
|
# All should match elem1's size
|
||||||
assert elem1.size == (100, 50)
|
assert elem1.size == (100, 50)
|
||||||
assert elem2.size == (100, 50)
|
assert elem2.size == (100, 50)
|
||||||
assert elem3.size == (100, 50)
|
assert elem3.size == (100, 50)
|
||||||
|
|
||||||
# Check undo information (only elem2 and elem3 change)
|
# Check undo information (only elem2 and elem3 change)
|
||||||
assert len(changes) == 2
|
assert len(changes) == 2
|
||||||
assert changes[0][0] == elem2
|
assert changes[0][0] == elem2
|
||||||
@@ -198,14 +198,14 @@ class TestAlignmentManager:
|
|||||||
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
||||||
elem2 = ImageData(x=30, y=40, width=80, height=60)
|
elem2 = ImageData(x=30, y=40, width=80, height=60)
|
||||||
elem3 = ImageData(x=70, y=60, width=90, height=40)
|
elem3 = ImageData(x=70, y=60, width=90, height=40)
|
||||||
|
|
||||||
changes = AlignmentManager.make_same_width([elem1, elem2, elem3])
|
changes = AlignmentManager.make_same_width([elem1, elem2, elem3])
|
||||||
|
|
||||||
# All widths should match elem1
|
# All widths should match elem1
|
||||||
assert elem1.size[0] == 100
|
assert elem1.size[0] == 100
|
||||||
assert elem2.size[0] == 100
|
assert elem2.size[0] == 100
|
||||||
assert elem3.size[0] == 100
|
assert elem3.size[0] == 100
|
||||||
|
|
||||||
# Heights should not change
|
# Heights should not change
|
||||||
assert elem1.size[1] == 50
|
assert elem1.size[1] == 50
|
||||||
assert elem2.size[1] == 60
|
assert elem2.size[1] == 60
|
||||||
@@ -216,14 +216,14 @@ class TestAlignmentManager:
|
|||||||
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
||||||
elem2 = ImageData(x=30, y=40, width=80, height=60)
|
elem2 = ImageData(x=30, y=40, width=80, height=60)
|
||||||
elem3 = ImageData(x=70, y=60, width=90, height=40)
|
elem3 = ImageData(x=70, y=60, width=90, height=40)
|
||||||
|
|
||||||
changes = AlignmentManager.make_same_height([elem1, elem2, elem3])
|
changes = AlignmentManager.make_same_height([elem1, elem2, elem3])
|
||||||
|
|
||||||
# All heights should match elem1
|
# All heights should match elem1
|
||||||
assert elem1.size[1] == 50
|
assert elem1.size[1] == 50
|
||||||
assert elem2.size[1] == 50
|
assert elem2.size[1] == 50
|
||||||
assert elem3.size[1] == 50
|
assert elem3.size[1] == 50
|
||||||
|
|
||||||
# Widths should not change
|
# Widths should not change
|
||||||
assert elem1.size[0] == 100
|
assert elem1.size[0] == 100
|
||||||
assert elem2.size[0] == 80
|
assert elem2.size[0] == 80
|
||||||
@@ -233,7 +233,7 @@ class TestAlignmentManager:
|
|||||||
"""Test distribute_horizontally with less than 3 elements"""
|
"""Test distribute_horizontally with less than 3 elements"""
|
||||||
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
||||||
elem2 = ImageData(x=30, y=40, width=80, height=60)
|
elem2 = ImageData(x=30, y=40, width=80, height=60)
|
||||||
|
|
||||||
changes = AlignmentManager.distribute_horizontally([elem1, elem2])
|
changes = AlignmentManager.distribute_horizontally([elem1, elem2])
|
||||||
assert changes == []
|
assert changes == []
|
||||||
|
|
||||||
@@ -242,15 +242,15 @@ class TestAlignmentManager:
|
|||||||
elem1 = ImageData(x=0, y=20, width=100, height=50)
|
elem1 = ImageData(x=0, y=20, width=100, height=50)
|
||||||
elem2 = ImageData(x=50, y=40, width=80, height=60)
|
elem2 = ImageData(x=50, y=40, width=80, height=60)
|
||||||
elem3 = ImageData(x=200, y=60, width=90, height=40)
|
elem3 = ImageData(x=200, y=60, width=90, height=40)
|
||||||
|
|
||||||
changes = AlignmentManager.distribute_horizontally([elem1, elem2, elem3])
|
changes = AlignmentManager.distribute_horizontally([elem1, elem2, elem3])
|
||||||
|
|
||||||
# Elements should be distributed evenly by their left edges
|
# Elements should be distributed evenly by their left edges
|
||||||
# min_x = 0, max_x = 200, span = 200
|
# min_x = 0, max_x = 200, span = 200
|
||||||
# spacing = 200 / (3-1) = 100
|
# spacing = 200 / (3-1) = 100
|
||||||
positions = [elem.position[0] for elem in [elem1, elem2, elem3]]
|
positions = [elem.position[0] for elem in [elem1, elem2, elem3]]
|
||||||
sorted_positions = sorted(positions)
|
sorted_positions = sorted(positions)
|
||||||
|
|
||||||
assert sorted_positions[0] == 0
|
assert sorted_positions[0] == 0
|
||||||
assert sorted_positions[1] == 100
|
assert sorted_positions[1] == 100
|
||||||
assert sorted_positions[2] == 200
|
assert sorted_positions[2] == 200
|
||||||
@@ -260,15 +260,15 @@ class TestAlignmentManager:
|
|||||||
elem1 = ImageData(x=20, y=0, width=100, height=50)
|
elem1 = ImageData(x=20, y=0, width=100, height=50)
|
||||||
elem2 = ImageData(x=40, y=50, width=80, height=60)
|
elem2 = ImageData(x=40, y=50, width=80, height=60)
|
||||||
elem3 = ImageData(x=60, y=300, width=90, height=40)
|
elem3 = ImageData(x=60, y=300, width=90, height=40)
|
||||||
|
|
||||||
changes = AlignmentManager.distribute_vertically([elem1, elem2, elem3])
|
changes = AlignmentManager.distribute_vertically([elem1, elem2, elem3])
|
||||||
|
|
||||||
# Elements should be distributed evenly by their top edges
|
# Elements should be distributed evenly by their top edges
|
||||||
# min_y = 0, max_y = 300, span = 300
|
# min_y = 0, max_y = 300, span = 300
|
||||||
# spacing = 300 / (3-1) = 150
|
# spacing = 300 / (3-1) = 150
|
||||||
positions = [elem.position[1] for elem in [elem1, elem2, elem3]]
|
positions = [elem.position[1] for elem in [elem1, elem2, elem3]]
|
||||||
sorted_positions = sorted(positions)
|
sorted_positions = sorted(positions)
|
||||||
|
|
||||||
assert sorted_positions[0] == 0
|
assert sorted_positions[0] == 0
|
||||||
assert sorted_positions[1] == 150
|
assert sorted_positions[1] == 150
|
||||||
assert sorted_positions[2] == 300
|
assert sorted_positions[2] == 300
|
||||||
@@ -277,7 +277,7 @@ class TestAlignmentManager:
|
|||||||
"""Test space_horizontally with less than 3 elements"""
|
"""Test space_horizontally with less than 3 elements"""
|
||||||
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
||||||
elem2 = ImageData(x=200, y=40, width=80, height=60)
|
elem2 = ImageData(x=200, y=40, width=80, height=60)
|
||||||
|
|
||||||
changes = AlignmentManager.space_horizontally([elem1, elem2])
|
changes = AlignmentManager.space_horizontally([elem1, elem2])
|
||||||
assert changes == []
|
assert changes == []
|
||||||
|
|
||||||
@@ -286,17 +286,17 @@ class TestAlignmentManager:
|
|||||||
elem1 = ImageData(x=0, y=20, width=100, height=50)
|
elem1 = ImageData(x=0, y=20, width=100, height=50)
|
||||||
elem2 = ImageData(x=150, y=40, width=50, height=60)
|
elem2 = ImageData(x=150, y=40, width=50, height=60)
|
||||||
elem3 = ImageData(x=250, y=60, width=100, height=40)
|
elem3 = ImageData(x=250, y=60, width=100, height=40)
|
||||||
|
|
||||||
changes = AlignmentManager.space_horizontally([elem1, elem2, elem3])
|
changes = AlignmentManager.space_horizontally([elem1, elem2, elem3])
|
||||||
|
|
||||||
# Total width = 100 + 50 + 100 = 250
|
# Total width = 100 + 50 + 100 = 250
|
||||||
# Span = 0 to 350 (250 + 100 from elem3)
|
# Span = 0 to 350 (250 + 100 from elem3)
|
||||||
# Available space = 350 - 0 - 250 = 100
|
# Available space = 350 - 0 - 250 = 100
|
||||||
# Spacing = 100 / (3-1) = 50
|
# Spacing = 100 / (3-1) = 50
|
||||||
|
|
||||||
# After sorting by x: elem1 at 0, elem2 after 100+50=150, elem3 after 150+50+50=250
|
# After sorting by x: elem1 at 0, elem2 after 100+50=150, elem3 after 150+50+50=250
|
||||||
sorted_elements = sorted([elem1, elem2, elem3], key=lambda e: e.position[0])
|
sorted_elements = sorted([elem1, elem2, elem3], key=lambda e: e.position[0])
|
||||||
|
|
||||||
assert sorted_elements[0].position[0] == 0
|
assert sorted_elements[0].position[0] == 0
|
||||||
assert sorted_elements[1].position[0] == 150 # 0 + 100 + 50
|
assert sorted_elements[1].position[0] == 150 # 0 + 100 + 50
|
||||||
assert sorted_elements[2].position[0] == 250 # 150 + 50 + 50
|
assert sorted_elements[2].position[0] == 250 # 150 + 50 + 50
|
||||||
@@ -306,17 +306,17 @@ class TestAlignmentManager:
|
|||||||
elem1 = ImageData(x=20, y=0, width=100, height=50)
|
elem1 = ImageData(x=20, y=0, width=100, height=50)
|
||||||
elem2 = ImageData(x=40, y=100, width=80, height=30)
|
elem2 = ImageData(x=40, y=100, width=80, height=30)
|
||||||
elem3 = ImageData(x=60, y=200, width=90, height=50)
|
elem3 = ImageData(x=60, y=200, width=90, height=50)
|
||||||
|
|
||||||
changes = AlignmentManager.space_vertically([elem1, elem2, elem3])
|
changes = AlignmentManager.space_vertically([elem1, elem2, elem3])
|
||||||
|
|
||||||
# Total height = 50 + 30 + 50 = 130
|
# Total height = 50 + 30 + 50 = 130
|
||||||
# Span = 0 to 250 (200 + 50 from elem3)
|
# Span = 0 to 250 (200 + 50 from elem3)
|
||||||
# Available space = 250 - 0 - 130 = 120
|
# Available space = 250 - 0 - 130 = 120
|
||||||
# Spacing = 120 / (3-1) = 60
|
# Spacing = 120 / (3-1) = 60
|
||||||
|
|
||||||
# After sorting by y: elem1 at 0, elem2 after 50+60=110, elem3 after 110+30+60=200
|
# After sorting by y: elem1 at 0, elem2 after 50+60=110, elem3 after 110+30+60=200
|
||||||
sorted_elements = sorted([elem1, elem2, elem3], key=lambda e: e.position[1])
|
sorted_elements = sorted([elem1, elem2, elem3], key=lambda e: e.position[1])
|
||||||
|
|
||||||
assert sorted_elements[0].position[1] == 0
|
assert sorted_elements[0].position[1] == 0
|
||||||
assert sorted_elements[1].position[1] == 110 # 0 + 50 + 60
|
assert sorted_elements[1].position[1] == 110 # 0 + 50 + 60
|
||||||
assert sorted_elements[2].position[1] == 200 # 110 + 30 + 60
|
assert sorted_elements[2].position[1] == 200 # 110 + 30 + 60
|
||||||
@@ -326,10 +326,10 @@ class TestAlignmentManager:
|
|||||||
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
||||||
elem2 = PlaceholderData(placeholder_type="image", x=30, y=40, width=80, height=60)
|
elem2 = PlaceholderData(placeholder_type="image", x=30, y=40, width=80, height=60)
|
||||||
elem3 = TextBoxData(text_content="Test", x=70, y=60, width=90, height=40)
|
elem3 = TextBoxData(text_content="Test", x=70, y=60, width=90, height=40)
|
||||||
|
|
||||||
# Test align_left
|
# Test align_left
|
||||||
changes = AlignmentManager.align_left([elem1, elem2, elem3])
|
changes = AlignmentManager.align_left([elem1, elem2, elem3])
|
||||||
|
|
||||||
assert elem1.position[0] == 30
|
assert elem1.position[0] == 30
|
||||||
assert elem2.position[0] == 30
|
assert elem2.position[0] == 30
|
||||||
assert elem3.position[0] == 30
|
assert elem3.position[0] == 30
|
||||||
@@ -339,23 +339,23 @@ class TestAlignmentManager:
|
|||||||
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
||||||
elem2 = ImageData(x=30, y=40, width=80, height=60)
|
elem2 = ImageData(x=30, y=40, width=80, height=60)
|
||||||
elem3 = ImageData(x=70, y=60, width=90, height=40)
|
elem3 = ImageData(x=70, y=60, width=90, height=40)
|
||||||
|
|
||||||
# Test position changes
|
# Test position changes
|
||||||
changes = AlignmentManager.align_left([elem1, elem2, elem3])
|
changes = AlignmentManager.align_left([elem1, elem2, elem3])
|
||||||
|
|
||||||
for change in changes:
|
for change in changes:
|
||||||
assert len(change) == 2 # (element, old_position)
|
assert len(change) == 2 # (element, old_position)
|
||||||
assert isinstance(change[0], ImageData)
|
assert isinstance(change[0], ImageData)
|
||||||
assert isinstance(change[1], tuple)
|
assert isinstance(change[1], tuple)
|
||||||
assert len(change[1]) == 2 # (x, y)
|
assert len(change[1]) == 2 # (x, y)
|
||||||
|
|
||||||
# Test size changes
|
# Test size changes
|
||||||
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
||||||
elem2 = ImageData(x=30, y=40, width=80, height=60)
|
elem2 = ImageData(x=30, y=40, width=80, height=60)
|
||||||
elem3 = ImageData(x=70, y=60, width=90, height=40)
|
elem3 = ImageData(x=70, y=60, width=90, height=40)
|
||||||
|
|
||||||
changes = AlignmentManager.make_same_size([elem1, elem2, elem3])
|
changes = AlignmentManager.make_same_size([elem1, elem2, elem3])
|
||||||
|
|
||||||
for change in changes:
|
for change in changes:
|
||||||
assert len(change) == 3 # (element, old_position, old_size)
|
assert len(change) == 3 # (element, old_position, old_size)
|
||||||
assert isinstance(change[0], ImageData)
|
assert isinstance(change[0], ImageData)
|
||||||
@@ -368,15 +368,15 @@ class TestAlignmentManager:
|
|||||||
"""Test that alignment operations only change intended properties"""
|
"""Test that alignment operations only change intended properties"""
|
||||||
elem1 = ImageData(x=50, y=20, width=100, height=50, rotation=45, z_index=5)
|
elem1 = ImageData(x=50, y=20, width=100, height=50, rotation=45, z_index=5)
|
||||||
elem2 = ImageData(x=30, y=40, width=80, height=60, rotation=90, z_index=3)
|
elem2 = ImageData(x=30, y=40, width=80, height=60, rotation=90, z_index=3)
|
||||||
|
|
||||||
AlignmentManager.align_left([elem1, elem2])
|
AlignmentManager.align_left([elem1, elem2])
|
||||||
|
|
||||||
# Rotation and z_index should not change
|
# Rotation and z_index should not change
|
||||||
assert elem1.rotation == 45
|
assert elem1.rotation == 45
|
||||||
assert elem1.z_index == 5
|
assert elem1.z_index == 5
|
||||||
assert elem2.rotation == 90
|
assert elem2.rotation == 90
|
||||||
assert elem2.z_index == 3
|
assert elem2.z_index == 3
|
||||||
|
|
||||||
# Heights should not change
|
# Heights should not change
|
||||||
assert elem1.size[1] == 50
|
assert elem1.size[1] == 50
|
||||||
assert elem2.size[1] == 60
|
assert elem2.size[1] == 60
|
||||||
@@ -387,10 +387,10 @@ class TestAlignmentManager:
|
|||||||
elem3 = ImageData(x=200, y=60, width=90, height=40)
|
elem3 = ImageData(x=200, y=60, width=90, height=40)
|
||||||
elem1 = ImageData(x=0, y=20, width=100, height=50)
|
elem1 = ImageData(x=0, y=20, width=100, height=50)
|
||||||
elem2 = ImageData(x=100, y=40, width=80, height=60)
|
elem2 = ImageData(x=100, y=40, width=80, height=60)
|
||||||
|
|
||||||
# Pass in random order
|
# Pass in random order
|
||||||
changes = AlignmentManager.distribute_horizontally([elem3, elem1, elem2])
|
changes = AlignmentManager.distribute_horizontally([elem3, elem1, elem2])
|
||||||
|
|
||||||
# Should still distribute correctly
|
# Should still distribute correctly
|
||||||
positions = sorted([elem1.position[0], elem2.position[0], elem3.position[0]])
|
positions = sorted([elem1.position[0], elem2.position[0], elem3.position[0]])
|
||||||
assert positions[0] == 0
|
assert positions[0] == 0
|
||||||
@@ -402,21 +402,21 @@ class TestAlignmentManager:
|
|||||||
elem1 = ImageData(x=0, y=0, width=50, height=50)
|
elem1 = ImageData(x=0, y=0, width=50, height=50)
|
||||||
elem2 = ImageData(x=100, y=0, width=100, height=50)
|
elem2 = ImageData(x=100, y=0, width=100, height=50)
|
||||||
elem3 = ImageData(x=250, y=0, width=75, height=50)
|
elem3 = ImageData(x=250, y=0, width=75, height=50)
|
||||||
|
|
||||||
changes = AlignmentManager.space_horizontally([elem1, elem2, elem3])
|
changes = AlignmentManager.space_horizontally([elem1, elem2, elem3])
|
||||||
|
|
||||||
# Calculate expected spacing
|
# Calculate expected spacing
|
||||||
# Total width = 50 + 100 + 75 = 225
|
# Total width = 50 + 100 + 75 = 225
|
||||||
# rightmost edge = 250 + 75 = 325
|
# rightmost edge = 250 + 75 = 325
|
||||||
# Available space = 325 - 0 - 225 = 100
|
# Available space = 325 - 0 - 225 = 100
|
||||||
# Spacing = 100 / 2 = 50
|
# Spacing = 100 / 2 = 50
|
||||||
|
|
||||||
sorted_elements = sorted([elem1, elem2, elem3], key=lambda e: e.position[0])
|
sorted_elements = sorted([elem1, elem2, elem3], key=lambda e: e.position[0])
|
||||||
|
|
||||||
# Verify spacing between elements is equal
|
# Verify spacing between elements is equal
|
||||||
gap1 = sorted_elements[1].position[0] - (sorted_elements[0].position[0] + sorted_elements[0].size[0])
|
gap1 = sorted_elements[1].position[0] - (sorted_elements[0].position[0] + sorted_elements[0].size[0])
|
||||||
gap2 = sorted_elements[2].position[0] - (sorted_elements[1].position[0] + sorted_elements[1].size[0])
|
gap2 = sorted_elements[2].position[0] - (sorted_elements[1].position[0] + sorted_elements[1].size[0])
|
||||||
|
|
||||||
assert abs(gap1 - 50) < 0.01
|
assert abs(gap1 - 50) < 0.01
|
||||||
assert abs(gap2 - 50) < 0.01
|
assert abs(gap2 - 50) < 0.01
|
||||||
|
|
||||||
@@ -452,7 +452,7 @@ class TestAlignmentManager:
|
|||||||
assert len(changes) == 1
|
assert len(changes) == 1
|
||||||
assert changes[0][0] == elem
|
assert changes[0][0] == elem
|
||||||
assert changes[0][1] == (100, 80) # old position
|
assert changes[0][1] == (100, 80) # old position
|
||||||
assert changes[0][2] == (20, 15) # old size
|
assert changes[0][2] == (20, 15) # old size
|
||||||
|
|
||||||
def test_maximize_pattern_two_elements_horizontal(self):
|
def test_maximize_pattern_two_elements_horizontal(self):
|
||||||
"""Test maximize_pattern with two elements side by side"""
|
"""Test maximize_pattern with two elements side by side"""
|
||||||
@@ -469,11 +469,11 @@ class TestAlignmentManager:
|
|||||||
# Elements should not overlap (min_gap = 2.0)
|
# Elements should not overlap (min_gap = 2.0)
|
||||||
gap_x = max(
|
gap_x = max(
|
||||||
elem2.position[0] - (elem1.position[0] + elem1.size[0]),
|
elem2.position[0] - (elem1.position[0] + elem1.size[0]),
|
||||||
elem1.position[0] - (elem2.position[0] + elem2.size[0])
|
elem1.position[0] - (elem2.position[0] + elem2.size[0]),
|
||||||
)
|
)
|
||||||
gap_y = max(
|
gap_y = max(
|
||||||
elem2.position[1] - (elem1.position[1] + elem1.size[1]),
|
elem2.position[1] - (elem1.position[1] + elem1.size[1]),
|
||||||
elem1.position[1] - (elem2.position[1] + elem2.size[1])
|
elem1.position[1] - (elem2.position[1] + elem2.size[1]),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Either horizontal or vertical gap should be >= min_gap
|
# Either horizontal or vertical gap should be >= min_gap
|
||||||
@@ -510,11 +510,11 @@ class TestAlignmentManager:
|
|||||||
# Calculate gaps between rectangles
|
# Calculate gaps between rectangles
|
||||||
gap_x = max(
|
gap_x = max(
|
||||||
elem_b.position[0] - (elem_a.position[0] + elem_a.size[0]),
|
elem_b.position[0] - (elem_a.position[0] + elem_a.size[0]),
|
||||||
elem_a.position[0] - (elem_b.position[0] + elem_b.size[0])
|
elem_a.position[0] - (elem_b.position[0] + elem_b.size[0]),
|
||||||
)
|
)
|
||||||
gap_y = max(
|
gap_y = max(
|
||||||
elem_b.position[1] - (elem_a.position[1] + elem_a.size[1]),
|
elem_b.position[1] - (elem_a.position[1] + elem_a.size[1]),
|
||||||
elem_a.position[1] - (elem_b.position[1] + elem_b.size[1])
|
elem_a.position[1] - (elem_b.position[1] + elem_b.size[1]),
|
||||||
)
|
)
|
||||||
|
|
||||||
# At least one gap should be >= min_gap
|
# At least one gap should be >= min_gap
|
||||||
@@ -564,11 +564,7 @@ class TestAlignmentManager:
|
|||||||
elem4 = ImageData(x=140, y=90, width=10, height=10)
|
elem4 = ImageData(x=140, y=90, width=10, height=10)
|
||||||
page_size = (160, 110)
|
page_size = (160, 110)
|
||||||
|
|
||||||
changes = AlignmentManager.maximize_pattern(
|
changes = AlignmentManager.maximize_pattern([elem1, elem2, elem3, elem4], page_size, min_gap=2.0)
|
||||||
[elem1, elem2, elem3, elem4],
|
|
||||||
page_size,
|
|
||||||
min_gap=2.0
|
|
||||||
)
|
|
||||||
|
|
||||||
# All elements should grow
|
# All elements should grow
|
||||||
for elem in [elem1, elem2, elem3, elem4]:
|
for elem in [elem1, elem2, elem3, elem4]:
|
||||||
@@ -584,11 +580,11 @@ class TestAlignmentManager:
|
|||||||
|
|
||||||
gap_x = max(
|
gap_x = max(
|
||||||
elem_b.position[0] - (elem_a.position[0] + elem_a.size[0]),
|
elem_b.position[0] - (elem_a.position[0] + elem_a.size[0]),
|
||||||
elem_a.position[0] - (elem_b.position[0] + elem_b.size[0])
|
elem_a.position[0] - (elem_b.position[0] + elem_b.size[0]),
|
||||||
)
|
)
|
||||||
gap_y = max(
|
gap_y = max(
|
||||||
elem_b.position[1] - (elem_a.position[1] + elem_a.size[1]),
|
elem_b.position[1] - (elem_a.position[1] + elem_a.size[1]),
|
||||||
elem_a.position[1] - (elem_b.position[1] + elem_b.size[1])
|
elem_a.position[1] - (elem_b.position[1] + elem_b.size[1]),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert gap_x >= 2.0 or gap_y >= 2.0
|
assert gap_x >= 2.0 or gap_y >= 2.0
|
||||||
|
|||||||
Regular → Executable
+17
-41
@@ -81,7 +81,7 @@ class TestGetSelectedElementsList:
|
|||||||
class TestAlignLeft:
|
class TestAlignLeft:
|
||||||
"""Test align_left method"""
|
"""Test align_left method"""
|
||||||
|
|
||||||
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
|
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
|
||||||
def test_align_left_success(self, mock_manager, qtbot):
|
def test_align_left_success(self, mock_manager, qtbot):
|
||||||
"""Test aligning elements to the left"""
|
"""Test aligning elements to the left"""
|
||||||
window = TestAlignmentWindow()
|
window = TestAlignmentWindow()
|
||||||
@@ -93,10 +93,7 @@ class TestAlignLeft:
|
|||||||
window.gl_widget.selected_elements = {element1, element2}
|
window.gl_widget.selected_elements = {element1, element2}
|
||||||
|
|
||||||
# Mock AlignmentManager to return changes
|
# Mock AlignmentManager to return changes
|
||||||
mock_manager.align_left.return_value = [
|
mock_manager.align_left.return_value = [(element1, (100, 0)), (element2, (200, 100))]
|
||||||
(element1, (100, 0)),
|
|
||||||
(element2, (200, 100))
|
|
||||||
]
|
|
||||||
|
|
||||||
window.align_left()
|
window.align_left()
|
||||||
|
|
||||||
@@ -106,7 +103,7 @@ class TestAlignLeft:
|
|||||||
assert "aligned" in window._status_message.lower()
|
assert "aligned" in window._status_message.lower()
|
||||||
assert "left" in window._status_message.lower()
|
assert "left" in window._status_message.lower()
|
||||||
|
|
||||||
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
|
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
|
||||||
def test_align_left_no_changes(self, mock_manager, qtbot):
|
def test_align_left_no_changes(self, mock_manager, qtbot):
|
||||||
"""Test align left when no changes needed"""
|
"""Test align left when no changes needed"""
|
||||||
window = TestAlignmentWindow()
|
window = TestAlignmentWindow()
|
||||||
@@ -143,7 +140,7 @@ class TestAlignLeft:
|
|||||||
class TestAlignRight:
|
class TestAlignRight:
|
||||||
"""Test align_right method"""
|
"""Test align_right method"""
|
||||||
|
|
||||||
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
|
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
|
||||||
def test_align_right_success(self, mock_manager, qtbot):
|
def test_align_right_success(self, mock_manager, qtbot):
|
||||||
"""Test aligning elements to the right"""
|
"""Test aligning elements to the right"""
|
||||||
window = TestAlignmentWindow()
|
window = TestAlignmentWindow()
|
||||||
@@ -154,10 +151,7 @@ class TestAlignRight:
|
|||||||
|
|
||||||
window.gl_widget.selected_elements = {element1, element2}
|
window.gl_widget.selected_elements = {element1, element2}
|
||||||
|
|
||||||
mock_manager.align_right.return_value = [
|
mock_manager.align_right.return_value = [(element1, (100, 0)), (element2, (200, 100))]
|
||||||
(element1, (100, 0)),
|
|
||||||
(element2, (200, 100))
|
|
||||||
]
|
|
||||||
|
|
||||||
window.align_right()
|
window.align_right()
|
||||||
|
|
||||||
@@ -169,7 +163,7 @@ class TestAlignRight:
|
|||||||
class TestAlignTop:
|
class TestAlignTop:
|
||||||
"""Test align_top method"""
|
"""Test align_top method"""
|
||||||
|
|
||||||
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
|
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
|
||||||
def test_align_top_success(self, mock_manager, qtbot):
|
def test_align_top_success(self, mock_manager, qtbot):
|
||||||
"""Test aligning elements to the top"""
|
"""Test aligning elements to the top"""
|
||||||
window = TestAlignmentWindow()
|
window = TestAlignmentWindow()
|
||||||
@@ -180,10 +174,7 @@ class TestAlignTop:
|
|||||||
|
|
||||||
window.gl_widget.selected_elements = {element1, element2}
|
window.gl_widget.selected_elements = {element1, element2}
|
||||||
|
|
||||||
mock_manager.align_top.return_value = [
|
mock_manager.align_top.return_value = [(element1, (0, 50)), (element2, (100, 100))]
|
||||||
(element1, (0, 50)),
|
|
||||||
(element2, (100, 100))
|
|
||||||
]
|
|
||||||
|
|
||||||
window.align_top()
|
window.align_top()
|
||||||
|
|
||||||
@@ -195,7 +186,7 @@ class TestAlignTop:
|
|||||||
class TestAlignBottom:
|
class TestAlignBottom:
|
||||||
"""Test align_bottom method"""
|
"""Test align_bottom method"""
|
||||||
|
|
||||||
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
|
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
|
||||||
def test_align_bottom_success(self, mock_manager, qtbot):
|
def test_align_bottom_success(self, mock_manager, qtbot):
|
||||||
"""Test aligning elements to the bottom"""
|
"""Test aligning elements to the bottom"""
|
||||||
window = TestAlignmentWindow()
|
window = TestAlignmentWindow()
|
||||||
@@ -206,10 +197,7 @@ class TestAlignBottom:
|
|||||||
|
|
||||||
window.gl_widget.selected_elements = {element1, element2}
|
window.gl_widget.selected_elements = {element1, element2}
|
||||||
|
|
||||||
mock_manager.align_bottom.return_value = [
|
mock_manager.align_bottom.return_value = [(element1, (0, 50)), (element2, (100, 100))]
|
||||||
(element1, (0, 50)),
|
|
||||||
(element2, (100, 100))
|
|
||||||
]
|
|
||||||
|
|
||||||
window.align_bottom()
|
window.align_bottom()
|
||||||
|
|
||||||
@@ -221,7 +209,7 @@ class TestAlignBottom:
|
|||||||
class TestAlignHorizontalCenter:
|
class TestAlignHorizontalCenter:
|
||||||
"""Test align_horizontal_center method"""
|
"""Test align_horizontal_center method"""
|
||||||
|
|
||||||
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
|
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
|
||||||
def test_align_horizontal_center_success(self, mock_manager, qtbot):
|
def test_align_horizontal_center_success(self, mock_manager, qtbot):
|
||||||
"""Test aligning elements to horizontal center"""
|
"""Test aligning elements to horizontal center"""
|
||||||
window = TestAlignmentWindow()
|
window = TestAlignmentWindow()
|
||||||
@@ -232,10 +220,7 @@ class TestAlignHorizontalCenter:
|
|||||||
|
|
||||||
window.gl_widget.selected_elements = {element1, element2}
|
window.gl_widget.selected_elements = {element1, element2}
|
||||||
|
|
||||||
mock_manager.align_horizontal_center.return_value = [
|
mock_manager.align_horizontal_center.return_value = [(element1, (0, 0)), (element2, (200, 100))]
|
||||||
(element1, (0, 0)),
|
|
||||||
(element2, (200, 100))
|
|
||||||
]
|
|
||||||
|
|
||||||
window.align_horizontal_center()
|
window.align_horizontal_center()
|
||||||
|
|
||||||
@@ -247,7 +232,7 @@ class TestAlignHorizontalCenter:
|
|||||||
class TestAlignVerticalCenter:
|
class TestAlignVerticalCenter:
|
||||||
"""Test align_vertical_center method"""
|
"""Test align_vertical_center method"""
|
||||||
|
|
||||||
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
|
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
|
||||||
def test_align_vertical_center_success(self, mock_manager, qtbot):
|
def test_align_vertical_center_success(self, mock_manager, qtbot):
|
||||||
"""Test aligning elements to vertical center"""
|
"""Test aligning elements to vertical center"""
|
||||||
window = TestAlignmentWindow()
|
window = TestAlignmentWindow()
|
||||||
@@ -258,10 +243,7 @@ class TestAlignVerticalCenter:
|
|||||||
|
|
||||||
window.gl_widget.selected_elements = {element1, element2}
|
window.gl_widget.selected_elements = {element1, element2}
|
||||||
|
|
||||||
mock_manager.align_vertical_center.return_value = [
|
mock_manager.align_vertical_center.return_value = [(element1, (0, 0)), (element2, (100, 200))]
|
||||||
(element1, (0, 0)),
|
|
||||||
(element2, (100, 200))
|
|
||||||
]
|
|
||||||
|
|
||||||
window.align_vertical_center()
|
window.align_vertical_center()
|
||||||
|
|
||||||
@@ -273,7 +255,7 @@ class TestAlignVerticalCenter:
|
|||||||
class TestAlignmentCommandPattern:
|
class TestAlignmentCommandPattern:
|
||||||
"""Test alignment operations with command pattern for undo/redo"""
|
"""Test alignment operations with command pattern for undo/redo"""
|
||||||
|
|
||||||
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
|
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
|
||||||
def test_alignment_creates_command(self, mock_manager, qtbot):
|
def test_alignment_creates_command(self, mock_manager, qtbot):
|
||||||
"""Test that alignment creates a command for undo"""
|
"""Test that alignment creates a command for undo"""
|
||||||
window = TestAlignmentWindow()
|
window = TestAlignmentWindow()
|
||||||
@@ -284,10 +266,7 @@ class TestAlignmentCommandPattern:
|
|||||||
|
|
||||||
window.gl_widget.selected_elements = {element1, element2}
|
window.gl_widget.selected_elements = {element1, element2}
|
||||||
|
|
||||||
mock_manager.align_left.return_value = [
|
mock_manager.align_left.return_value = [(element1, (100, 0)), (element2, (200, 100))]
|
||||||
(element1, (100, 0)),
|
|
||||||
(element2, (200, 100))
|
|
||||||
]
|
|
||||||
|
|
||||||
# Should have no commands initially
|
# Should have no commands initially
|
||||||
assert not window.project.history.can_undo()
|
assert not window.project.history.can_undo()
|
||||||
@@ -297,7 +276,7 @@ class TestAlignmentCommandPattern:
|
|||||||
# Should have created a command
|
# Should have created a command
|
||||||
assert window.project.history.can_undo()
|
assert window.project.history.can_undo()
|
||||||
|
|
||||||
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
|
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
|
||||||
def test_alignment_undo_redo(self, mock_manager, qtbot):
|
def test_alignment_undo_redo(self, mock_manager, qtbot):
|
||||||
"""Test that alignment can be undone and redone"""
|
"""Test that alignment can be undone and redone"""
|
||||||
window = TestAlignmentWindow()
|
window = TestAlignmentWindow()
|
||||||
@@ -309,10 +288,7 @@ class TestAlignmentCommandPattern:
|
|||||||
window.gl_widget.selected_elements = {element1, element2}
|
window.gl_widget.selected_elements = {element1, element2}
|
||||||
|
|
||||||
# Mock alignment to return changes (command will handle actual moves)
|
# Mock alignment to return changes (command will handle actual moves)
|
||||||
mock_manager.align_top.return_value = [
|
mock_manager.align_top.return_value = [(element1, (100, 0)), (element2, (200, 100))]
|
||||||
(element1, (100, 0)),
|
|
||||||
(element2, (200, 100))
|
|
||||||
]
|
|
||||||
|
|
||||||
# Execute alignment - command created
|
# Execute alignment - command created
|
||||||
window.align_top()
|
window.align_top()
|
||||||
|
|||||||
Regular → Executable
+264
-11
@@ -8,6 +8,7 @@ from PyQt6.QtCore import QMimeData, QUrl, QPoint
|
|||||||
from PyQt6.QtGui import QDragEnterEvent, QDragMoveEvent, QDropEvent
|
from PyQt6.QtGui import QDragEnterEvent, QDragMoveEvent, QDropEvent
|
||||||
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
|
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
|
||||||
from pyPhotoAlbum.mixins.asset_drop import AssetDropMixin
|
from pyPhotoAlbum.mixins.asset_drop import AssetDropMixin
|
||||||
|
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
|
||||||
from pyPhotoAlbum.mixins.viewport import ViewportMixin
|
from pyPhotoAlbum.mixins.viewport import ViewportMixin
|
||||||
from pyPhotoAlbum.mixins.page_navigation import PageNavigationMixin
|
from pyPhotoAlbum.mixins.page_navigation import PageNavigationMixin
|
||||||
from pyPhotoAlbum.project import Project, Page
|
from pyPhotoAlbum.project import Project, Page
|
||||||
@@ -16,14 +17,21 @@ from pyPhotoAlbum.models import ImageData
|
|||||||
|
|
||||||
|
|
||||||
# Create test widget combining necessary mixins
|
# Create test widget combining necessary mixins
|
||||||
class TestAssetDropWidget(AssetDropMixin, PageNavigationMixin, ViewportMixin, QOpenGLWidget):
|
class TestAssetDropWidget(AssetDropMixin, AssetPathMixin, PageNavigationMixin, ViewportMixin, QOpenGLWidget):
|
||||||
"""Test widget combining asset drop, page navigation, and viewport mixins"""
|
"""Test widget combining asset drop, asset path, page navigation, and viewport mixins"""
|
||||||
|
|
||||||
def _get_element_at(self, x, y):
|
def _get_element_at(self, x, y):
|
||||||
"""Mock implementation for testing"""
|
"""Mock implementation for testing"""
|
||||||
# Will be overridden in tests that need it
|
# Will be overridden in tests that need it
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _get_project_folder(self):
|
||||||
|
"""Override to access project via window mock"""
|
||||||
|
main_window = self.window()
|
||||||
|
if hasattr(main_window, "project") and main_window.project:
|
||||||
|
return getattr(main_window.project, "folder_path", None)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class TestAssetDropInitialization:
|
class TestAssetDropInitialization:
|
||||||
"""Test AssetDropMixin initialization"""
|
"""Test AssetDropMixin initialization"""
|
||||||
@@ -35,7 +43,7 @@ class TestAssetDropInitialization:
|
|||||||
|
|
||||||
# Should accept drops (set in GLWidget.__init__)
|
# Should accept drops (set in GLWidget.__init__)
|
||||||
# This is a property of the widget, not the mixin
|
# This is a property of the widget, not the mixin
|
||||||
assert hasattr(widget, 'acceptDrops')
|
assert hasattr(widget, "acceptDrops")
|
||||||
|
|
||||||
|
|
||||||
class TestDragEnterEvent:
|
class TestDragEnterEvent:
|
||||||
@@ -133,7 +141,7 @@ class TestDragMoveEvent:
|
|||||||
class TestDropEvent:
|
class TestDropEvent:
|
||||||
"""Test dropEvent method"""
|
"""Test dropEvent method"""
|
||||||
|
|
||||||
@patch('pyPhotoAlbum.mixins.asset_drop.AddElementCommand')
|
@patch("pyPhotoAlbum.mixins.asset_drop.AddElementCommand")
|
||||||
def test_drop_creates_image_element(self, mock_cmd_class, qtbot):
|
def test_drop_creates_image_element(self, mock_cmd_class, qtbot):
|
||||||
"""Test dropping image file creates ImageData element"""
|
"""Test dropping image file creates ImageData element"""
|
||||||
widget = TestAssetDropWidget()
|
widget = TestAssetDropWidget()
|
||||||
@@ -231,7 +239,7 @@ class TestDropEvent:
|
|||||||
|
|
||||||
# Create a real test image file
|
# Create a real test image file
|
||||||
test_image = tmp_path / "test_image.jpg"
|
test_image = tmp_path / "test_image.jpg"
|
||||||
test_image.write_bytes(b'\xFF\xD8\xFF\xE0' + b'\x00' * 100) # Minimal JPEG header
|
test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100) # Minimal JPEG header
|
||||||
|
|
||||||
# Setup project with page containing placeholder
|
# Setup project with page containing placeholder
|
||||||
mock_window = Mock()
|
mock_window = Mock()
|
||||||
@@ -240,6 +248,7 @@ class TestDropEvent:
|
|||||||
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||||
|
|
||||||
from pyPhotoAlbum.models import PlaceholderData
|
from pyPhotoAlbum.models import PlaceholderData
|
||||||
|
|
||||||
placeholder = PlaceholderData(x=100, y=100, width=200, height=150)
|
placeholder = PlaceholderData(x=100, y=100, width=200, height=150)
|
||||||
page.layout.elements.append(placeholder)
|
page.layout.elements.append(placeholder)
|
||||||
|
|
||||||
@@ -272,7 +281,7 @@ class TestDropEvent:
|
|||||||
# Image path should now be in assets folder (imported)
|
# Image path should now be in assets folder (imported)
|
||||||
assert page.layout.elements[0].image_path.startswith("assets/")
|
assert page.layout.elements[0].image_path.startswith("assets/")
|
||||||
|
|
||||||
@patch('pyPhotoAlbum.mixins.asset_drop.AddElementCommand')
|
@patch("pyPhotoAlbum.mixins.asset_drop.AddElementCommand")
|
||||||
def test_drop_multiple_files(self, mock_cmd_class, qtbot):
|
def test_drop_multiple_files(self, mock_cmd_class, qtbot):
|
||||||
"""Test dropping first image from multiple files"""
|
"""Test dropping first image from multiple files"""
|
||||||
widget = TestAssetDropWidget()
|
widget = TestAssetDropWidget()
|
||||||
@@ -303,11 +312,13 @@ class TestDropEvent:
|
|||||||
|
|
||||||
# Create drop event with multiple files (only first is used)
|
# Create drop event with multiple files (only first is used)
|
||||||
mime_data = QMimeData()
|
mime_data = QMimeData()
|
||||||
mime_data.setUrls([
|
mime_data.setUrls(
|
||||||
QUrl.fromLocalFile("/path/to/image1.jpg"),
|
[
|
||||||
QUrl.fromLocalFile("/path/to/image2.png"),
|
QUrl.fromLocalFile("/path/to/image1.jpg"),
|
||||||
QUrl.fromLocalFile("/path/to/image3.jpg")
|
QUrl.fromLocalFile("/path/to/image2.png"),
|
||||||
])
|
QUrl.fromLocalFile("/path/to/image3.jpg"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
event = Mock()
|
event = Mock()
|
||||||
event.mimeData = Mock(return_value=mime_data)
|
event.mimeData = Mock(return_value=mime_data)
|
||||||
@@ -347,3 +358,245 @@ class TestDropEvent:
|
|||||||
# Should still accept event and call update
|
# Should still accept event and call update
|
||||||
assert event.acceptProposedAction.called
|
assert event.acceptProposedAction.called
|
||||||
assert widget.update.called
|
assert widget.update.called
|
||||||
|
|
||||||
|
def test_drop_on_existing_image_updates_it(self, qtbot, tmp_path):
|
||||||
|
"""Test dropping on existing ImageData updates its image path"""
|
||||||
|
widget = TestAssetDropWidget()
|
||||||
|
qtbot.addWidget(widget)
|
||||||
|
widget.update = Mock()
|
||||||
|
|
||||||
|
# Create a real test image file
|
||||||
|
test_image = tmp_path / "new_image.jpg"
|
||||||
|
test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
|
||||||
|
|
||||||
|
# Setup project with page containing existing ImageData
|
||||||
|
mock_window = Mock()
|
||||||
|
mock_window.project = Project(name="Test")
|
||||||
|
mock_window.project.working_dpi = 96
|
||||||
|
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||||
|
|
||||||
|
existing_image = ImageData(image_path="assets/old_image.jpg", x=100, y=100, width=200, height=150)
|
||||||
|
page.layout.elements.append(existing_image)
|
||||||
|
mock_window.project.pages = [page]
|
||||||
|
|
||||||
|
# Mock asset manager
|
||||||
|
mock_window.project.asset_manager = Mock()
|
||||||
|
mock_window.project.asset_manager.import_asset = Mock(return_value="assets/new_image.jpg")
|
||||||
|
|
||||||
|
widget.window = Mock(return_value=mock_window)
|
||||||
|
widget._get_element_at = Mock(return_value=existing_image)
|
||||||
|
|
||||||
|
mime_data = QMimeData()
|
||||||
|
mime_data.setUrls([QUrl.fromLocalFile(str(test_image))])
|
||||||
|
|
||||||
|
event = Mock()
|
||||||
|
event.mimeData = Mock(return_value=mime_data)
|
||||||
|
event.position = Mock(return_value=QPoint(150, 150))
|
||||||
|
event.acceptProposedAction = Mock()
|
||||||
|
|
||||||
|
widget.dropEvent(event)
|
||||||
|
|
||||||
|
# Should update existing ImageData's path
|
||||||
|
assert existing_image.image_path == "assets/new_image.jpg"
|
||||||
|
assert mock_window.project.asset_manager.import_asset.called
|
||||||
|
|
||||||
|
def test_drop_with_asset_import_failure(self, qtbot, tmp_path):
|
||||||
|
"""Test dropping handles asset import errors gracefully"""
|
||||||
|
widget = TestAssetDropWidget()
|
||||||
|
qtbot.addWidget(widget)
|
||||||
|
widget.update = Mock()
|
||||||
|
|
||||||
|
test_image = tmp_path / "test.jpg"
|
||||||
|
test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
|
||||||
|
|
||||||
|
mock_window = Mock()
|
||||||
|
mock_window.project = Project(name="Test")
|
||||||
|
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||||
|
|
||||||
|
existing_image = ImageData(image_path="assets/old.jpg", x=100, y=100, width=200, height=150)
|
||||||
|
page.layout.elements.append(existing_image)
|
||||||
|
mock_window.project.pages = [page]
|
||||||
|
|
||||||
|
# Mock asset manager to raise exception
|
||||||
|
mock_window.project.asset_manager = Mock()
|
||||||
|
mock_window.project.asset_manager.import_asset = Mock(side_effect=Exception("Import failed"))
|
||||||
|
|
||||||
|
widget.window = Mock(return_value=mock_window)
|
||||||
|
widget._get_element_at = Mock(return_value=existing_image)
|
||||||
|
|
||||||
|
mime_data = QMimeData()
|
||||||
|
mime_data.setUrls([QUrl.fromLocalFile(str(test_image))])
|
||||||
|
|
||||||
|
event = Mock()
|
||||||
|
event.mimeData = Mock(return_value=mime_data)
|
||||||
|
event.position = Mock(return_value=QPoint(150, 150))
|
||||||
|
event.acceptProposedAction = Mock()
|
||||||
|
|
||||||
|
# Should not crash, should handle error gracefully
|
||||||
|
widget.dropEvent(event)
|
||||||
|
|
||||||
|
# Original path should remain unchanged
|
||||||
|
assert existing_image.image_path == "assets/old.jpg"
|
||||||
|
assert event.acceptProposedAction.called
|
||||||
|
|
||||||
|
def test_drop_with_corrupted_image_uses_defaults(self, qtbot, tmp_path):
|
||||||
|
"""Test dropping corrupted image uses default dimensions"""
|
||||||
|
widget = TestAssetDropWidget()
|
||||||
|
qtbot.addWidget(widget)
|
||||||
|
widget.zoom_level = 1.0
|
||||||
|
widget.pan_offset = [0, 0]
|
||||||
|
widget.update = Mock()
|
||||||
|
|
||||||
|
# Create a corrupted/invalid image file
|
||||||
|
corrupted_image = tmp_path / "corrupted.jpg"
|
||||||
|
corrupted_image.write_bytes(b"not a valid image")
|
||||||
|
|
||||||
|
mock_window = Mock()
|
||||||
|
mock_window.project = Project(name="Test")
|
||||||
|
mock_window.project.working_dpi = 96
|
||||||
|
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||||
|
mock_window.project.pages = [page]
|
||||||
|
|
||||||
|
mock_window.project.asset_manager = Mock()
|
||||||
|
mock_window.project.asset_manager.import_asset = Mock(return_value="assets/corrupted.jpg")
|
||||||
|
mock_window.project.history = Mock()
|
||||||
|
|
||||||
|
mock_renderer = Mock()
|
||||||
|
mock_renderer.is_point_in_page = Mock(return_value=True)
|
||||||
|
mock_renderer.screen_to_page = Mock(return_value=(100, 100))
|
||||||
|
|
||||||
|
widget._get_page_at = Mock(return_value=(page, 0, mock_renderer))
|
||||||
|
widget._page_renderers = [(mock_renderer, page)]
|
||||||
|
widget.window = Mock(return_value=mock_window)
|
||||||
|
widget._get_element_at = Mock(return_value=None)
|
||||||
|
|
||||||
|
mime_data = QMimeData()
|
||||||
|
mime_data.setUrls([QUrl.fromLocalFile(str(corrupted_image))])
|
||||||
|
|
||||||
|
event = Mock()
|
||||||
|
event.mimeData = Mock(return_value=mime_data)
|
||||||
|
event.position = Mock(return_value=QPoint(150, 150))
|
||||||
|
event.acceptProposedAction = Mock()
|
||||||
|
|
||||||
|
widget.dropEvent(event)
|
||||||
|
|
||||||
|
# Should use default dimensions (200, 150) from _calculate_image_dimensions
|
||||||
|
# Check that AddElementCommand was called with an ImageData
|
||||||
|
from pyPhotoAlbum.commands import AddElementCommand
|
||||||
|
|
||||||
|
with patch("pyPhotoAlbum.mixins.asset_drop.AddElementCommand") as mock_cmd:
|
||||||
|
# Re-run to check the call
|
||||||
|
widget.dropEvent(event)
|
||||||
|
assert mock_cmd.called
|
||||||
|
|
||||||
|
|
||||||
|
class TestDragMoveEventEdgeCases:
|
||||||
|
"""Test edge cases for dragMoveEvent"""
|
||||||
|
|
||||||
|
def test_drag_move_rejects_no_urls(self, qtbot):
|
||||||
|
"""Test dragMoveEvent rejects events without URLs"""
|
||||||
|
widget = TestAssetDropWidget()
|
||||||
|
qtbot.addWidget(widget)
|
||||||
|
|
||||||
|
mime_data = QMimeData()
|
||||||
|
# No URLs set
|
||||||
|
|
||||||
|
event = Mock()
|
||||||
|
event.mimeData = Mock(return_value=mime_data)
|
||||||
|
event.acceptProposedAction = Mock()
|
||||||
|
event.ignore = Mock()
|
||||||
|
|
||||||
|
widget.dragMoveEvent(event)
|
||||||
|
|
||||||
|
# Should ignore the event
|
||||||
|
assert event.ignore.called
|
||||||
|
assert not event.acceptProposedAction.called
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractImagePathEdgeCases:
|
||||||
|
"""Test edge cases for _extract_image_path"""
|
||||||
|
|
||||||
|
def test_drop_ignores_non_image_urls(self, qtbot):
|
||||||
|
"""Test dropping non-image files is ignored"""
|
||||||
|
widget = TestAssetDropWidget()
|
||||||
|
qtbot.addWidget(widget)
|
||||||
|
widget.update = Mock()
|
||||||
|
|
||||||
|
mime_data = QMimeData()
|
||||||
|
mime_data.setUrls([QUrl.fromLocalFile("/path/to/document.pdf"), QUrl.fromLocalFile("/path/to/file.txt")])
|
||||||
|
|
||||||
|
event = Mock()
|
||||||
|
event.mimeData = Mock(return_value=mime_data)
|
||||||
|
event.position = Mock(return_value=QPoint(150, 150))
|
||||||
|
event.ignore = Mock()
|
||||||
|
event.acceptProposedAction = Mock()
|
||||||
|
|
||||||
|
widget.dropEvent(event)
|
||||||
|
|
||||||
|
# Should ignore event since no valid image files
|
||||||
|
assert event.ignore.called
|
||||||
|
assert not event.acceptProposedAction.called
|
||||||
|
|
||||||
|
def test_drop_ignores_empty_urls(self, qtbot):
|
||||||
|
"""Test dropping with no URLs is ignored"""
|
||||||
|
widget = TestAssetDropWidget()
|
||||||
|
qtbot.addWidget(widget)
|
||||||
|
widget.update = Mock()
|
||||||
|
|
||||||
|
mime_data = QMimeData()
|
||||||
|
# No URLs at all
|
||||||
|
|
||||||
|
event = Mock()
|
||||||
|
event.mimeData = Mock(return_value=mime_data)
|
||||||
|
event.position = Mock(return_value=QPoint(150, 150))
|
||||||
|
event.ignore = Mock()
|
||||||
|
event.acceptProposedAction = Mock()
|
||||||
|
|
||||||
|
widget.dropEvent(event)
|
||||||
|
|
||||||
|
# Should ignore event
|
||||||
|
assert event.ignore.called
|
||||||
|
assert not event.acceptProposedAction.called
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlaceholderReplacementEdgeCases:
|
||||||
|
"""Test edge cases for placeholder replacement"""
|
||||||
|
|
||||||
|
def test_replace_placeholder_with_no_pages(self, qtbot, tmp_path):
|
||||||
|
"""Test replacing placeholder when project has no pages"""
|
||||||
|
widget = TestAssetDropWidget()
|
||||||
|
qtbot.addWidget(widget)
|
||||||
|
widget.update = Mock()
|
||||||
|
|
||||||
|
test_image = tmp_path / "test.jpg"
|
||||||
|
test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
|
||||||
|
|
||||||
|
# Setup project WITHOUT pages
|
||||||
|
mock_window = Mock()
|
||||||
|
mock_window.project = Project(name="Test")
|
||||||
|
mock_window.project.working_dpi = 96
|
||||||
|
mock_window.project.pages = [] # Empty pages list
|
||||||
|
|
||||||
|
from pyPhotoAlbum.models import PlaceholderData
|
||||||
|
|
||||||
|
placeholder = PlaceholderData(x=100, y=100, width=200, height=150)
|
||||||
|
|
||||||
|
mock_window.project.asset_manager = Mock()
|
||||||
|
mock_window.project.asset_manager.import_asset = Mock(return_value="assets/test.jpg")
|
||||||
|
|
||||||
|
widget.window = Mock(return_value=mock_window)
|
||||||
|
widget._get_element_at = Mock(return_value=placeholder)
|
||||||
|
|
||||||
|
mime_data = QMimeData()
|
||||||
|
mime_data.setUrls([QUrl.fromLocalFile(str(test_image))])
|
||||||
|
|
||||||
|
event = Mock()
|
||||||
|
event.mimeData = Mock(return_value=mime_data)
|
||||||
|
event.position = Mock(return_value=QPoint(150, 150))
|
||||||
|
event.acceptProposedAction = Mock()
|
||||||
|
|
||||||
|
# Should not crash when trying to replace placeholder
|
||||||
|
widget.dropEvent(event)
|
||||||
|
|
||||||
|
# Event should still be accepted
|
||||||
|
assert event.acceptProposedAction.called
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user