Compare commits
28
Commits
35b740496d
...
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 |
@@ -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,22 +4,14 @@ 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
|
||||||
|
|||||||
+89
-18
@@ -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"]
|
||||||
@@ -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
|
|
||||||
try:
|
|
||||||
loaded_project = load_from_zip("album.ppz")
|
|
||||||
print(f"Loaded: {loaded_project.name}")
|
|
||||||
except Exception as error:
|
|
||||||
print(f"Error loading: {error}")
|
|
||||||
|
|
||||||
# Get project info without loading
|
|
||||||
from pyPhotoAlbum.project_serializer import get_project_info
|
|
||||||
info = get_project_info("album.ppz")
|
|
||||||
print(f"Name: {info['name']}, Pages: {info['page_count']}")
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Project Structure
|
|
||||||
|
|
||||||
```python
|
|
||||||
{
|
|
||||||
"name": "My Album",
|
|
||||||
"serialization_version": "1.0",
|
|
||||||
"page_size_mm": [210, 297],
|
|
||||||
"working_dpi": 300,
|
|
||||||
"export_dpi": 300,
|
|
||||||
"pages": [
|
|
||||||
{
|
|
||||||
"page_number": 1,
|
|
||||||
"is_double_spread": false,
|
|
||||||
"layout": {
|
|
||||||
"width": 210,
|
|
||||||
"height": 297,
|
|
||||||
"elements": [...]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### PDF Export
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pyPhotoAlbum.pdf_exporter import PDFExporter
|
|
||||||
|
|
||||||
# Create exporter
|
|
||||||
exporter = PDFExporter(project, export_dpi=300)
|
|
||||||
|
|
||||||
# Export with progress callback
|
|
||||||
def progress_callback(current, total):
|
|
||||||
print(f"Exporting page {current}/{total}")
|
|
||||||
|
|
||||||
success, errors = exporter.export(
|
|
||||||
output_path="album.pdf",
|
|
||||||
progress_callback=progress_callback
|
|
||||||
)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
print("PDF exported successfully")
|
|
||||||
else:
|
|
||||||
print(f"Errors: {errors}")
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### Run Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run all tests
|
|
||||||
pytest
|
|
||||||
|
|
||||||
# Run with coverage
|
|
||||||
pytest --cov=pyPhotoAlbum --cov-report=html
|
|
||||||
|
|
||||||
# Run specific test file
|
|
||||||
pytest tests/test_models.py
|
|
||||||
|
|
||||||
# Run with verbose output
|
|
||||||
pytest -v
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
tests/
|
|
||||||
├── __init__.py
|
|
||||||
├── conftest.py # Shared fixtures
|
|
||||||
├── test_models.py # Model serialization tests
|
|
||||||
├── test_project.py # Project and page tests
|
|
||||||
├── test_project_serialization.py # Save/load tests
|
|
||||||
├── test_page_renderer.py # Rendering tests
|
|
||||||
├── test_pdf_export.py # PDF export tests
|
|
||||||
├── test_gl_widget_fixtures.py # Shared GL widget test fixtures
|
|
||||||
├── test_viewport_mixin.py # Viewport mixin tests
|
|
||||||
├── test_element_selection_mixin.py # Selection mixin tests
|
|
||||||
├── test_element_manipulation_mixin.py # Manipulation mixin tests
|
|
||||||
├── test_image_pan_mixin.py # Image pan mixin tests
|
|
||||||
├── test_page_navigation_mixin.py # Page navigation mixin tests
|
|
||||||
└── test_asset_drop_mixin.py # Asset drop mixin tests
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example Test Cases
|
|
||||||
|
|
||||||
From `tests/test_models.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def test_image_serialization():
|
|
||||||
"""Test ImageData serialization"""
|
|
||||||
img = ImageData(
|
|
||||||
image_path="test.jpg",
|
|
||||||
x=15.0, y=25.0,
|
|
||||||
width=180.0, height=120.0,
|
|
||||||
rotation=30.0,
|
|
||||||
z_index=3
|
|
||||||
)
|
|
||||||
|
|
||||||
# Serialize
|
|
||||||
data = img.serialize()
|
|
||||||
assert data["type"] == "image"
|
|
||||||
assert data["position"] == (15.0, 25.0)
|
|
||||||
|
|
||||||
# Deserialize
|
|
||||||
img2 = ImageData()
|
|
||||||
img2.deserialize(data)
|
|
||||||
assert img2.position == img.position
|
|
||||||
```
|
|
||||||
|
|
||||||
From `tests/test_project_serialization.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def test_save_and_load_project(tmp_path):
|
|
||||||
"""Test complete save/load cycle"""
|
|
||||||
# Create project with pages
|
|
||||||
project = Project(name="Test")
|
|
||||||
page = Page(layout=PageLayout(), page_number=1)
|
|
||||||
project.add_page(page)
|
|
||||||
|
|
||||||
# Save
|
|
||||||
zip_path = tmp_path / "project.ppz"
|
|
||||||
success, error = save_to_zip(project, str(zip_path))
|
|
||||||
assert success is True
|
|
||||||
|
|
||||||
# Load
|
|
||||||
loaded = load_from_zip(str(zip_path))
|
|
||||||
assert loaded.name == "Test"
|
|
||||||
assert len(loaded.pages) == 1
|
|
||||||
```
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
pyPhotoAlbum/
|
|
||||||
├── __init__.py
|
|
||||||
├── main.py # Application entry point
|
|
||||||
├── models.py # Data models (ImageData, TextBoxData, etc.)
|
|
||||||
├── project.py # Project and Page classes
|
|
||||||
├── page_layout.py # Page layout management
|
|
||||||
├── page_renderer.py # OpenGL rendering
|
|
||||||
├── gl_widget.py # Main OpenGL widget (mixin orchestration)
|
|
||||||
├── project_serializer.py # Save/load functionality
|
|
||||||
├── asset_manager.py # Asset handling
|
|
||||||
├── commands.py # Undo/redo system
|
|
||||||
├── template_manager.py # Template system
|
|
||||||
├── pdf_exporter.py # PDF export
|
|
||||||
├── alignment.py # Alignment tools
|
|
||||||
├── snapping.py # Snapping system
|
|
||||||
├── decorators.py # UI decorators
|
|
||||||
├── ribbon_widget.py # Ribbon interface
|
|
||||||
├── ribbon_builder.py # Ribbon configuration
|
|
||||||
├── mixins/ # Mixin architecture
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── base.py # Base mixin class
|
|
||||||
│ ├── viewport.py # Zoom and pan management
|
|
||||||
│ ├── rendering.py # OpenGL rendering pipeline
|
|
||||||
│ ├── asset_drop.py # Drag-and-drop functionality
|
|
||||||
│ ├── page_navigation.py # Page detection and ghost pages
|
|
||||||
│ ├── image_pan.py # Image cropping within frames
|
|
||||||
│ ├── element_manipulation.py # Resize and rotate
|
|
||||||
│ ├── element_selection.py # Hit detection and selection
|
|
||||||
│ ├── mouse_interaction.py # Mouse event coordination
|
|
||||||
│ ├── interaction_undo.py # Undo/redo integration
|
|
||||||
│ └── operations/ # Operation mixins
|
|
||||||
│ ├── element_ops.py
|
|
||||||
│ ├── page_ops.py
|
|
||||||
│ ├── file_ops.py
|
|
||||||
│ ├── view_ops.py
|
|
||||||
│ ├── edit_ops.py
|
|
||||||
│ ├── template_ops.py
|
|
||||||
│ ├── alignment_ops.py
|
|
||||||
│ ├── distribution_ops.py
|
|
||||||
│ └── size_ops.py
|
|
||||||
└── templates/ # Built-in templates
|
|
||||||
├── Grid_2x2.json
|
|
||||||
└── Single_Large.json
|
|
||||||
|
|
||||||
tests/ # Unit tests (312 tests, 29% coverage)
|
|
||||||
examples/ # Usage examples
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Code Style
|
|
||||||
|
|
||||||
The project uses:
|
|
||||||
- **Black** for code formatting (line length: 120)
|
|
||||||
- **Flake8** for linting
|
|
||||||
- **MyPy** for type checking
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Format code
|
|
||||||
black pyPhotoAlbum tests
|
|
||||||
|
|
||||||
# Run linter
|
|
||||||
flake8 pyPhotoAlbum tests
|
|
||||||
|
|
||||||
# Type checking
|
|
||||||
mypy pyPhotoAlbum
|
|
||||||
```
|
|
||||||
|
|
||||||
### Continuous Integration
|
|
||||||
|
|
||||||
GitHub Actions / Gitea Actions workflows:
|
|
||||||
- Run tests on Python 3.9, 3.10, 3.11
|
|
||||||
- Check code quality with linters
|
|
||||||
- Generate coverage reports
|
|
||||||
|
|
||||||
### Contributing
|
|
||||||
|
|
||||||
1. Fork the repository
|
|
||||||
2. Create a feature branch
|
|
||||||
3. Write tests for new features
|
|
||||||
4. Ensure all tests pass
|
|
||||||
5. Submit a pull request
|
|
||||||
|
|
||||||
## Usage Examples
|
|
||||||
|
|
||||||
See the `examples/` directory for complete working examples:
|
|
||||||
|
|
||||||
- `basic_usage.py` - Creating projects and adding images
|
|
||||||
- `template_example.py` - Working with templates
|
|
||||||
- `generate_screenshots.py` - Creating documentation screenshots
|
|
||||||
|
|
||||||
Run examples:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd examples
|
|
||||||
python basic_usage.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Reference
|
|
||||||
|
|
||||||
### Key Classes
|
|
||||||
|
|
||||||
#### BaseLayoutElement (Abstract)
|
|
||||||
Base class for all layout elements.
|
|
||||||
|
|
||||||
**Methods:**
|
|
||||||
- `render()` - Render element using OpenGL
|
|
||||||
- `serialize() -> Dict` - Convert to dictionary
|
|
||||||
- `deserialize(data: Dict)` - Load from dictionary
|
|
||||||
|
|
||||||
**Attributes:**
|
|
||||||
- `position: Tuple[float, float]` - (x, y) in mm
|
|
||||||
- `size: Tuple[float, float]` - (width, height) in mm
|
|
||||||
- `rotation: float` - Rotation angle in degrees
|
|
||||||
- `z_index: int` - Layer order
|
|
||||||
|
|
||||||
#### ImageData
|
|
||||||
Image element with crop support.
|
|
||||||
|
|
||||||
**Constructor:**
|
|
||||||
```python
|
|
||||||
ImageData(
|
|
||||||
image_path: str = "",
|
|
||||||
crop_info: Tuple = (0, 0, 1, 1),
|
|
||||||
x: float = 0,
|
|
||||||
y: float = 0,
|
|
||||||
width: float = 100,
|
|
||||||
height: float = 100,
|
|
||||||
rotation: float = 0,
|
|
||||||
z_index: int = 0
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### TextBoxData
|
|
||||||
Text element with formatting.
|
|
||||||
|
|
||||||
**Constructor:**
|
|
||||||
```python
|
|
||||||
TextBoxData(
|
|
||||||
text_content: str = "",
|
|
||||||
font_settings: Dict = None,
|
|
||||||
alignment: str = "left",
|
|
||||||
x: float = 0,
|
|
||||||
y: float = 0,
|
|
||||||
width: float = 100,
|
|
||||||
height: float = 100
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Font Settings:**
|
|
||||||
```python
|
|
||||||
{
|
|
||||||
"family": "Arial",
|
|
||||||
"size": 12,
|
|
||||||
"color": (0, 0, 0) # RGB tuple
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Project
|
|
||||||
Main project container.
|
|
||||||
|
|
||||||
**Methods:**
|
|
||||||
- `add_page(page: Page)` - Add page to project
|
|
||||||
- `remove_page(page: Page)` - Remove page
|
|
||||||
- `serialize() -> Dict` - Save to dictionary
|
|
||||||
- `deserialize(data: Dict)` - Load from dictionary
|
|
||||||
|
|
||||||
**Attributes:**
|
|
||||||
- `name: str` - Project name
|
|
||||||
- `pages: List[Page]` - List of pages
|
|
||||||
- `page_size_mm: Tuple[float, float]` - Page dimensions
|
|
||||||
- `working_dpi: int` - Display DPI
|
|
||||||
- `export_dpi: int` - Export DPI
|
|
||||||
- `asset_manager: AssetManager` - Asset handler
|
|
||||||
- `history: CommandHistory` - Undo/redo history
|
|
||||||
|
|
||||||
#### Page
|
|
||||||
Single page in project.
|
|
||||||
|
|
||||||
**Constructor:**
|
|
||||||
```python
|
|
||||||
Page(
|
|
||||||
layout: PageLayout = None,
|
|
||||||
page_number: int = 1,
|
|
||||||
is_double_spread: bool = False
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### PageLayout
|
|
||||||
Element container for a page.
|
|
||||||
|
|
||||||
**Methods:**
|
|
||||||
- `add_element(element: BaseLayoutElement)` - Add element
|
|
||||||
- `remove_element(element: BaseLayoutElement)` - Remove element
|
|
||||||
- `render(dpi: int)` - Render all elements
|
|
||||||
|
|
||||||
**Attributes:**
|
|
||||||
- `elements: List[BaseLayoutElement]` - Page elements
|
|
||||||
- `width: float` - Page width in mm
|
|
||||||
- `height: float` - Page height in mm
|
|
||||||
|
|
||||||
## 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:
|
||||||
|
|||||||
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 ""
|
||||||
+4
-4
@@ -84,19 +84,19 @@ install_package() {
|
|||||||
case "$install_mode" in
|
case "$install_mode" in
|
||||||
system)
|
system)
|
||||||
print_info "Installing pyPhotoAlbum system-wide..."
|
print_info "Installing pyPhotoAlbum system-wide..."
|
||||||
sudo pip install .
|
sudo pip install --upgrade .
|
||||||
;;
|
;;
|
||||||
venv)
|
venv)
|
||||||
print_info "Installing pyPhotoAlbum in virtual environment..."
|
print_info "Installing pyPhotoAlbum in virtual environment..."
|
||||||
pip install .
|
pip install --upgrade .
|
||||||
;;
|
;;
|
||||||
user-force)
|
user-force)
|
||||||
print_info "Installing pyPhotoAlbum for current user (forcing --user)..."
|
print_info "Installing pyPhotoAlbum for current user (forcing --user)..."
|
||||||
pip install --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 .
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|||||||
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" "$@"
|
||||||
+43
-21
@@ -65,14 +65,10 @@ class ElementMaximizer:
|
|||||||
|
|
||||||
# Calculate distances between rectangles
|
# Calculate distances between rectangles
|
||||||
horizontal_gap = max(
|
horizontal_gap = max(
|
||||||
other_x - (x + w), # Other is to the right
|
other_x - (x + w), x - (other_x + other_w) # Other is to the right # Other is to the left
|
||||||
x - (other_x + other_w) # Other is to the left
|
|
||||||
)
|
)
|
||||||
|
|
||||||
vertical_gap = max(
|
vertical_gap = max(other_y - (y + h), y - (other_y + other_h)) # Other is below # Other is above
|
||||||
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 rectangles overlap or are too close in both dimensions
|
||||||
if horizontal_gap < self.min_gap and vertical_gap < self.min_gap:
|
if horizontal_gap < self.min_gap and vertical_gap < self.min_gap:
|
||||||
@@ -80,8 +76,14 @@ class ElementMaximizer:
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def find_max_scale(self, elem_idx: int, current_scale: float, max_search_scale: float = 3.0,
|
def find_max_scale(
|
||||||
tolerance: float = 0.001, max_iterations: int = 20) -> float:
|
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.
|
Use binary search to find the maximum scale factor for an element.
|
||||||
|
|
||||||
@@ -171,8 +173,10 @@ class ElementMaximizer:
|
|||||||
ow, oh = other.size
|
ow, oh = other.size
|
||||||
|
|
||||||
# Check if rectangles overlap (with min_gap consideration)
|
# Check if rectangles overlap (with min_gap consideration)
|
||||||
if (abs((x + w/2) - (ox + ow/2)) < (w + ow)/2 + self.min_gap and
|
if (
|
||||||
abs((y + h/2) - (oy + oh/2)) < (h + oh)/2 + self.min_gap):
|
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 True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
@@ -233,7 +237,9 @@ class ElementMaximizer:
|
|||||||
self.center_element_horizontally(elem)
|
self.center_element_horizontally(elem)
|
||||||
self.center_element_vertically(elem)
|
self.center_element_vertically(elem)
|
||||||
|
|
||||||
def maximize(self, max_iterations: int = 100, growth_rate: float = 0.05) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
|
def maximize(
|
||||||
|
self, max_iterations: int = 100, growth_rate: float = 0.05
|
||||||
|
) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
|
||||||
"""
|
"""
|
||||||
Execute the maximization algorithm.
|
Execute the maximization algorithm.
|
||||||
|
|
||||||
@@ -365,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.
|
||||||
|
|
||||||
@@ -413,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.
|
||||||
|
|
||||||
@@ -435,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.
|
||||||
|
|
||||||
@@ -457,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.
|
||||||
|
|
||||||
@@ -479,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.
|
||||||
|
|
||||||
@@ -613,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.
|
||||||
|
|
||||||
@@ -638,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.
|
||||||
|
|
||||||
@@ -663,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.
|
||||||
|
|
||||||
@@ -702,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.
|
||||||
@@ -729,7 +751,7 @@ class AlignmentManager:
|
|||||||
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.
|
||||||
|
|||||||
@@ -6,9 +6,16 @@ import os
|
|||||||
import shutil
|
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
|
||||||
|
|
||||||
@@ -124,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:
|
||||||
@@ -203,7 +207,7 @@ 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)
|
||||||
|
|||||||
@@ -2,12 +2,37 @@
|
|||||||
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"""
|
||||||
|
|
||||||
@@ -21,6 +46,7 @@ class AssetManager:
|
|||||||
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)
|
||||||
@@ -144,10 +170,261 @@ class AssetManager:
|
|||||||
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)
|
||||||
|
|||||||
+142
-99
@@ -12,9 +12,10 @@ 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
|
||||||
|
|
||||||
@@ -26,18 +27,16 @@ 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(
|
def get_image_dimensions(image_path: str, max_size: Optional[int] = None) -> Optional[Tuple[int, int]]:
|
||||||
image_path: str,
|
|
||||||
max_size: Optional[int] = None
|
|
||||||
) -> Optional[Tuple[int, int]]:
|
|
||||||
"""
|
"""
|
||||||
Extract image dimensions without loading the full image.
|
Extract image dimensions without loading the full image.
|
||||||
|
|
||||||
@@ -78,6 +77,7 @@ def get_image_dimensions(
|
|||||||
@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)
|
||||||
@@ -111,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
|
||||||
|
|
||||||
@@ -164,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)")
|
||||||
@@ -174,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."""
|
||||||
@@ -190,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -213,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):
|
||||||
"""
|
"""
|
||||||
@@ -227,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
|
||||||
@@ -251,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")
|
||||||
|
|
||||||
@@ -265,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)
|
||||||
@@ -345,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:
|
||||||
@@ -354,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)
|
||||||
@@ -369,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
|
||||||
@@ -379,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).
|
||||||
|
|
||||||
@@ -390,7 +405,7 @@ class AsyncImageLoader(QObject):
|
|||||||
Returns:
|
Returns:
|
||||||
Processed PIL Image
|
Processed PIL Image
|
||||||
"""
|
"""
|
||||||
img = Image.open(path)
|
img: Image.Image = Image.open(path)
|
||||||
img = convert_to_rgba(img)
|
img = convert_to_rgba(img)
|
||||||
|
|
||||||
# Downsample if target size specified (preserving aspect ratio)
|
# Downsample if target size specified (preserving aspect ratio)
|
||||||
@@ -405,17 +420,33 @@ class AsyncImageLoader(QObject):
|
|||||||
|
|
||||||
def _emit_loaded(self, path: Path, img: Image.Image, user_data: Any):
|
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.
|
||||||
|
|
||||||
@@ -446,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
|
||||||
|
|
||||||
@@ -454,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
|
||||||
@@ -494,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(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -517,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):
|
||||||
"""
|
"""
|
||||||
@@ -532,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
|
||||||
@@ -554,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")
|
||||||
|
|
||||||
@@ -621,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}")
|
||||||
@@ -654,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.
|
||||||
|
|
||||||
@@ -707,23 +746,31 @@ class AsyncPDFGenerator(QObject):
|
|||||||
|
|
||||||
# Patch Image.open to use cache
|
# Patch Image.open to use cache
|
||||||
def cached_open(path, *args, **kwargs):
|
def cached_open(path, *args, **kwargs):
|
||||||
# Try cache first
|
# Only use cache for file paths, not BytesIO or other file-like objects
|
||||||
# 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
|
||||||
img = convert_to_rgba(img)
|
# Note: We cache the unrotated image so rotation can be applied per-element
|
||||||
self.image_cache.put(Path(path), img)
|
path_obj = Path(path) if isinstance(path, str) else path
|
||||||
return img
|
cached_img = self.image_cache.get(path_obj)
|
||||||
|
if cached_img:
|
||||||
|
logger.debug(f"PDF using cached image: {path}")
|
||||||
|
return cached_img
|
||||||
|
|
||||||
|
# Load and cache (unrotated - rotation is applied per-element)
|
||||||
|
img = original_open(path, *args, **kwargs)
|
||||||
|
img = convert_to_rgba(img)
|
||||||
|
self.image_cache.put(path_obj, img)
|
||||||
|
return img
|
||||||
|
else:
|
||||||
|
# For BytesIO and other file-like objects, just use original open
|
||||||
|
return original_open(path, *args, **kwargs)
|
||||||
|
|
||||||
# Temporarily patch Image.open
|
# 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
|
||||||
@@ -731,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
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import json
|
|||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional, List, Tuple
|
from typing import Dict, Optional, List, Tuple
|
||||||
from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip
|
from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip
|
||||||
|
|
||||||
|
|
||||||
@@ -86,11 +86,11 @@ class AutosaveManager:
|
|||||||
"project_name": project.name,
|
"project_name": project.name,
|
||||||
"timestamp": datetime.now().isoformat(),
|
"timestamp": datetime.now().isoformat(),
|
||||||
"checkpoint_path": str(checkpoint_path),
|
"checkpoint_path": str(checkpoint_path),
|
||||||
"original_path": getattr(project, 'file_path', None),
|
"original_path": getattr(project, "file_path", None),
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata_path = checkpoint_path.with_suffix('.json')
|
metadata_path = checkpoint_path.with_suffix(".json")
|
||||||
with open(metadata_path, 'w') as f:
|
with open(metadata_path, "w") as f:
|
||||||
json.dump(metadata, f, indent=2)
|
json.dump(metadata, f, indent=2)
|
||||||
|
|
||||||
def list_checkpoints(self, project_name: Optional[str] = None) -> List[Tuple[Path, dict]]:
|
def list_checkpoints(self, project_name: Optional[str] = None) -> List[Tuple[Path, dict]]:
|
||||||
@@ -106,23 +106,23 @@ class AutosaveManager:
|
|||||||
checkpoints = []
|
checkpoints = []
|
||||||
|
|
||||||
for checkpoint_file in self.CHECKPOINT_DIR.glob(f"{self.CHECKPOINT_PREFIX}*{self.CHECKPOINT_EXTENSION}"):
|
for checkpoint_file in self.CHECKPOINT_DIR.glob(f"{self.CHECKPOINT_PREFIX}*{self.CHECKPOINT_EXTENSION}"):
|
||||||
metadata_file = checkpoint_file.with_suffix('.json')
|
metadata_file = checkpoint_file.with_suffix(".json")
|
||||||
|
|
||||||
# Try to load metadata
|
# Try to load metadata
|
||||||
metadata = {}
|
metadata = {}
|
||||||
if metadata_file.exists():
|
if metadata_file.exists():
|
||||||
try:
|
try:
|
||||||
with open(metadata_file, 'r') as f:
|
with open(metadata_file, "r") as f:
|
||||||
metadata = json.load(f)
|
metadata = json.load(f)
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Filter by project name if specified
|
# Filter by project name if specified
|
||||||
if project_name is None or metadata.get('project_name') == project_name:
|
if project_name is None or metadata.get("project_name") == project_name:
|
||||||
checkpoints.append((checkpoint_file, metadata))
|
checkpoints.append((checkpoint_file, metadata))
|
||||||
|
|
||||||
# Sort by timestamp (newest first)
|
# Sort by timestamp (newest first)
|
||||||
checkpoints.sort(key=lambda x: x[1].get('timestamp', ''), reverse=True)
|
checkpoints.sort(key=lambda x: x[1].get("timestamp", ""), reverse=True)
|
||||||
return checkpoints
|
return checkpoints
|
||||||
|
|
||||||
def load_checkpoint(self, checkpoint_path: Path):
|
def load_checkpoint(self, checkpoint_path: Path):
|
||||||
@@ -157,7 +157,7 @@ class AutosaveManager:
|
|||||||
checkpoint_path.unlink()
|
checkpoint_path.unlink()
|
||||||
|
|
||||||
# Delete metadata file
|
# Delete metadata file
|
||||||
metadata_path = checkpoint_path.with_suffix('.json')
|
metadata_path = checkpoint_path.with_suffix(".json")
|
||||||
if metadata_path.exists():
|
if metadata_path.exists():
|
||||||
metadata_path.unlink()
|
metadata_path.unlink()
|
||||||
|
|
||||||
@@ -186,11 +186,11 @@ class AutosaveManager:
|
|||||||
max_count: Maximum number of checkpoints to keep per project
|
max_count: Maximum number of checkpoints to keep per project
|
||||||
"""
|
"""
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
checkpoints_by_project = {}
|
checkpoints_by_project: Dict[str, List[Tuple[Path, dict]]] = {}
|
||||||
|
|
||||||
# Group checkpoints by project
|
# Group checkpoints by project
|
||||||
for checkpoint_path, metadata in self.list_checkpoints():
|
for checkpoint_path, metadata in self.list_checkpoints():
|
||||||
project_name = metadata.get('project_name', 'unknown')
|
project_name = metadata.get("project_name", "unknown")
|
||||||
if project_name not in checkpoints_by_project:
|
if project_name not in checkpoints_by_project:
|
||||||
checkpoints_by_project[project_name] = []
|
checkpoints_by_project[project_name] = []
|
||||||
checkpoints_by_project[project_name].append((checkpoint_path, metadata))
|
checkpoints_by_project[project_name].append((checkpoint_path, metadata))
|
||||||
@@ -198,11 +198,11 @@ class AutosaveManager:
|
|||||||
# Clean up each project's checkpoints
|
# Clean up each project's checkpoints
|
||||||
for project_name, checkpoints in checkpoints_by_project.items():
|
for project_name, checkpoints in checkpoints_by_project.items():
|
||||||
# Sort by timestamp (newest first)
|
# Sort by timestamp (newest first)
|
||||||
checkpoints.sort(key=lambda x: x[1].get('timestamp', ''), reverse=True)
|
checkpoints.sort(key=lambda x: x[1].get("timestamp", ""), reverse=True)
|
||||||
|
|
||||||
for idx, (checkpoint_path, metadata) in enumerate(checkpoints):
|
for idx, (checkpoint_path, metadata) in enumerate(checkpoints):
|
||||||
# Delete if too old
|
# Delete if too old
|
||||||
timestamp_str = metadata.get('timestamp')
|
timestamp_str = metadata.get("timestamp")
|
||||||
if timestamp_str:
|
if timestamp_str:
|
||||||
try:
|
try:
|
||||||
timestamp = datetime.fromisoformat(timestamp_str)
|
timestamp = datetime.fromisoformat(timestamp_str)
|
||||||
|
|||||||
+112
-188
@@ -27,6 +27,35 @@ def _normalize_asset_path(image_path: str, asset_manager) -> str:
|
|||||||
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"""
|
||||||
|
|
||||||
@@ -52,7 +81,7 @@ class Command(ABC):
|
|||||||
|
|
||||||
@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
|
||||||
|
|
||||||
@@ -89,32 +118,13 @@ class AddElementCommand(Command):
|
|||||||
|
|
||||||
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
|
||||||
@@ -152,29 +162,12 @@ class DeleteElementCommand(Command):
|
|||||||
|
|
||||||
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
|
||||||
@@ -206,38 +199,22 @@ class MoveElementCommand(Command):
|
|||||||
"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
|
||||||
@@ -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,7 +340,7 @@ 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
|
||||||
|
|
||||||
@@ -390,31 +354,14 @@ class RotateElementCommand(Command):
|
|||||||
"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):
|
||||||
@@ -443,21 +390,17 @@ class AdjustImageCropCommand(Command):
|
|||||||
"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):
|
||||||
@@ -493,36 +436,20 @@ class AlignElementsCommand(Command):
|
|||||||
"""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)
|
||||||
|
|
||||||
|
|
||||||
@@ -558,37 +485,23 @@ class ResizeElementsCommand(Command):
|
|||||||
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)
|
||||||
|
|
||||||
|
|
||||||
@@ -625,31 +538,15 @@ class ChangeZOrderCommand(Command):
|
|||||||
"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"]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -691,13 +588,10 @@ class StateChangeCommand(Command):
|
|||||||
"""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")
|
||||||
@@ -801,7 +695,7 @@ class CommandHistory:
|
|||||||
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):
|
||||||
@@ -813,6 +707,8 @@ class CommandHistory:
|
|||||||
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
|
||||||
@@ -820,8 +716,34 @@ class CommandHistory:
|
|||||||
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 type registry for deserialization
|
||||||
_COMMAND_DESERIALIZERS = {
|
_COMMAND_DESERIALIZERS = {
|
||||||
"add_element": AddElementCommand.deserialize,
|
"add_element": AddElementCommand.deserialize,
|
||||||
@@ -838,6 +760,8 @@ class CommandHistory:
|
|||||||
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 using registry pattern"""
|
"""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)
|
deserializer = self._COMMAND_DESERIALIZERS.get(cmd_type)
|
||||||
if not deserializer:
|
if not deserializer:
|
||||||
|
|||||||
+45
-49
@@ -4,7 +4,7 @@ Decorator system for pyPhotoAlbum ribbon UI
|
|||||||
|
|
||||||
import copy
|
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:
|
||||||
@@ -37,7 +37,7 @@ 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.
|
||||||
@@ -73,22 +73,23 @@ class RibbonAction:
|
|||||||
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
|
||||||
@@ -103,7 +104,7 @@ 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.
|
||||||
@@ -133,7 +134,7 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -175,14 +176,13 @@ class NumericalInput:
|
|||||||
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
|
||||||
|
|
||||||
@@ -218,7 +218,7 @@ class UndoableOperation:
|
|||||||
# 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.
|
||||||
|
|
||||||
@@ -241,10 +241,11 @@ class UndoableOperation:
|
|||||||
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)
|
||||||
@@ -259,14 +260,15 @@ class UndoableOperation:
|
|||||||
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")
|
||||||
|
|
||||||
@@ -276,9 +278,9 @@ class UndoableOperation:
|
|||||||
|
|
||||||
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
|
||||||
return [copy.deepcopy(elem.serialize()) for elem in current_page.layout.elements]
|
return [copy.deepcopy(elem.serialize()) for elem in current_page.layout.elements]
|
||||||
@@ -288,22 +290,24 @@ class UndoableOperation:
|
|||||||
|
|
||||||
def _restore_state(self, instance, capture_type: str, state):
|
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
|
||||||
@@ -312,7 +316,7 @@ class UndoableOperation:
|
|||||||
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.
|
||||||
|
|
||||||
@@ -343,11 +347,7 @@ class DialogAction:
|
|||||||
self.apply_page_setup(values)
|
self.apply_page_setup(values)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, dialog_class: type, requires_pages: bool = True):
|
||||||
self,
|
|
||||||
dialog_class: type,
|
|
||||||
requires_pages: bool = True
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
Initialize the dialog action decorator.
|
Initialize the dialog action decorator.
|
||||||
|
|
||||||
@@ -368,6 +368,7 @@ class DialogAction:
|
|||||||
Returns:
|
Returns:
|
||||||
The decorated function
|
The decorated function
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
def wrapper(self_instance, *args, **kwargs):
|
def wrapper(self_instance, *args, **kwargs):
|
||||||
# Check preconditions
|
# Check preconditions
|
||||||
@@ -376,7 +377,7 @@ class DialogAction:
|
|||||||
|
|
||||||
# Get initial page index if available
|
# Get initial page index if available
|
||||||
initial_page_index = 0
|
initial_page_index = 0
|
||||||
if hasattr(self_instance, '_get_most_visible_page_index'):
|
if hasattr(self_instance, "_get_most_visible_page_index"):
|
||||||
initial_page_index = self_instance._get_most_visible_page_index()
|
initial_page_index = self_instance._get_most_visible_page_index()
|
||||||
|
|
||||||
# Create and show dialog
|
# Create and show dialog
|
||||||
@@ -384,17 +385,15 @@ class DialogAction:
|
|||||||
|
|
||||||
# Create dialog
|
# Create dialog
|
||||||
dialog = self.dialog_class(
|
dialog = self.dialog_class(
|
||||||
parent=self_instance,
|
parent=self_instance, project=self_instance.project, initial_page_index=initial_page_index, **kwargs
|
||||||
project=self_instance.project,
|
|
||||||
initial_page_index=initial_page_index,
|
|
||||||
**kwargs
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Show dialog and get result
|
# Show dialog and get result
|
||||||
from PyQt6.QtWidgets import QDialog
|
from PyQt6.QtWidgets import QDialog
|
||||||
|
|
||||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||||
# Get values from dialog
|
# Get values from dialog
|
||||||
if hasattr(dialog, 'get_values'):
|
if hasattr(dialog, "get_values"):
|
||||||
values = dialog.get_values()
|
values = dialog.get_values()
|
||||||
# Call the decorated function with values
|
# Call the decorated function with values
|
||||||
return func(self_instance, values, *args, **kwargs)
|
return func(self_instance, values, *args, **kwargs)
|
||||||
@@ -406,10 +405,7 @@ class DialogAction:
|
|||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
def dialog_action(
|
def dialog_action(dialog_class: type, requires_pages: bool = True) -> Callable:
|
||||||
dialog_class: type,
|
|
||||||
requires_pages: bool = True
|
|
||||||
) -> Callable:
|
|
||||||
"""
|
"""
|
||||||
Convenience function for the DialogAction decorator.
|
Convenience function for the DialogAction decorator.
|
||||||
|
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ UI presentation logic separately from business logic.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from .page_setup_dialog import PageSetupDialog
|
from .page_setup_dialog import PageSetupDialog
|
||||||
|
from .print_settings_dialog import PrintSettingsDialog
|
||||||
|
|
||||||
__all__ = ['PageSetupDialog']
|
__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
|
||||||
@@ -8,9 +8,18 @@ separating presentation from business logic.
|
|||||||
import math
|
import math
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
QDialog,
|
||||||
QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox,
|
QVBoxLayout,
|
||||||
QComboBox, QCheckBox
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QDoubleSpinBox,
|
||||||
|
QSpinBox,
|
||||||
|
QPushButton,
|
||||||
|
QGroupBox,
|
||||||
|
QComboBox,
|
||||||
|
QCheckBox,
|
||||||
|
QRadioButton,
|
||||||
|
QButtonGroup,
|
||||||
)
|
)
|
||||||
from pyPhotoAlbum.project import Project
|
from pyPhotoAlbum.project import Project
|
||||||
|
|
||||||
@@ -23,12 +32,7 @@ class PageSetupDialog(QDialog):
|
|||||||
including page size, DPI settings, and cover configuration.
|
including page size, DPI settings, and cover configuration.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, parent, project: Project, initial_page_index: int = 0):
|
||||||
self,
|
|
||||||
parent,
|
|
||||||
project: Project,
|
|
||||||
initial_page_index: int = 0
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
Initialize the page setup dialog.
|
Initialize the page setup dialog.
|
||||||
|
|
||||||
@@ -105,9 +109,7 @@ class PageSetupDialog(QDialog):
|
|||||||
|
|
||||||
# Cover checkbox
|
# Cover checkbox
|
||||||
self.cover_checkbox = QCheckBox("Designate as Cover")
|
self.cover_checkbox = QCheckBox("Designate as Cover")
|
||||||
self.cover_checkbox.setToolTip(
|
self.cover_checkbox.setToolTip("Mark this page as the book cover with wrap-around front/spine/back")
|
||||||
"Mark this page as the book cover with wrap-around front/spine/back"
|
|
||||||
)
|
|
||||||
layout.addWidget(self.cover_checkbox)
|
layout.addWidget(self.cover_checkbox)
|
||||||
|
|
||||||
# Paper thickness
|
# Paper thickness
|
||||||
@@ -136,9 +138,7 @@ class PageSetupDialog(QDialog):
|
|||||||
|
|
||||||
# Calculated spine width display
|
# Calculated spine width display
|
||||||
self.spine_info_label = QLabel()
|
self.spine_info_label = QLabel()
|
||||||
self.spine_info_label.setStyleSheet(
|
self.spine_info_label.setStyleSheet("font-size: 9pt; color: #0066cc; padding: 5px;")
|
||||||
"font-size: 9pt; color: #0066cc; padding: 5px;"
|
|
||||||
)
|
|
||||||
self.spine_info_label.setWordWrap(True)
|
self.spine_info_label.setWordWrap(True)
|
||||||
layout.addWidget(self.spine_info_label)
|
layout.addWidget(self.spine_info_label)
|
||||||
|
|
||||||
@@ -168,12 +168,23 @@ class PageSetupDialog(QDialog):
|
|||||||
height_layout.addWidget(self.height_spinbox)
|
height_layout.addWidget(self.height_spinbox)
|
||||||
layout.addLayout(height_layout)
|
layout.addLayout(height_layout)
|
||||||
|
|
||||||
# Set as default checkbox
|
# Apply scope radio buttons
|
||||||
self.set_default_checkbox = QCheckBox("Set as default for new pages")
|
scope_label = QLabel("Apply to:")
|
||||||
self.set_default_checkbox.setToolTip(
|
layout.addWidget(scope_label)
|
||||||
"Update project default page size for future pages"
|
|
||||||
)
|
self._apply_scope_group = QButtonGroup(self)
|
||||||
layout.addWidget(self.set_default_checkbox)
|
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)
|
group.setLayout(layout)
|
||||||
return group
|
return group
|
||||||
@@ -248,7 +259,7 @@ class PageSetupDialog(QDialog):
|
|||||||
return
|
return
|
||||||
|
|
||||||
selected_page = self.project.pages[index]
|
selected_page = self.project.pages[index]
|
||||||
is_first_page = (index == 0)
|
is_first_page = index == 0
|
||||||
|
|
||||||
# Show/hide cover settings based on page selection
|
# Show/hide cover settings based on page selection
|
||||||
self._cover_group.setVisible(is_first_page)
|
self._cover_group.setVisible(is_first_page)
|
||||||
@@ -265,7 +276,7 @@ class PageSetupDialog(QDialog):
|
|||||||
elif selected_page.is_double_spread:
|
elif selected_page.is_double_spread:
|
||||||
display_width = (
|
display_width = (
|
||||||
selected_page.layout.base_width
|
selected_page.layout.base_width
|
||||||
if hasattr(selected_page.layout, 'base_width')
|
if hasattr(selected_page.layout, "base_width")
|
||||||
else selected_page.layout.size[0] / 2
|
else selected_page.layout.size[0] / 2
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -278,22 +289,21 @@ class PageSetupDialog(QDialog):
|
|||||||
is_cover = selected_page.is_cover
|
is_cover = selected_page.is_cover
|
||||||
self.width_spinbox.setEnabled(not is_cover)
|
self.width_spinbox.setEnabled(not is_cover)
|
||||||
self.height_spinbox.setEnabled(not is_cover)
|
self.height_spinbox.setEnabled(not is_cover)
|
||||||
self.set_default_checkbox.setEnabled(not is_cover)
|
self.scope_non_manual.setEnabled(not is_cover)
|
||||||
|
self.scope_all_pages.setEnabled(not is_cover)
|
||||||
|
if is_cover:
|
||||||
|
self.scope_page_only.setChecked(True)
|
||||||
|
|
||||||
def _update_spine_info(self):
|
def _update_spine_info(self):
|
||||||
"""Update the spine information display."""
|
"""Update the spine information display."""
|
||||||
if self.cover_checkbox.isChecked():
|
if self.cover_checkbox.isChecked():
|
||||||
# Calculate spine width with current settings
|
# Calculate spine width with current settings
|
||||||
content_pages = sum(
|
content_pages = sum(p.get_page_count() for p in self.project.pages if not p.is_cover)
|
||||||
p.get_page_count() for p in self.project.pages if not p.is_cover
|
|
||||||
)
|
|
||||||
sheets = math.ceil(content_pages / 4)
|
sheets = math.ceil(content_pages / 4)
|
||||||
spine_width = sheets * self.thickness_spinbox.value() * 2
|
spine_width = sheets * self.thickness_spinbox.value() * 2
|
||||||
|
|
||||||
page_width = self.project.page_size_mm[0]
|
page_width = self.project.page_size_mm[0]
|
||||||
total_width = (
|
total_width = (page_width * 2) + spine_width + (self.bleed_spinbox.value() * 2)
|
||||||
(page_width * 2) + spine_width + (self.bleed_spinbox.value() * 2)
|
|
||||||
)
|
|
||||||
|
|
||||||
self.spine_info_label.setText(
|
self.spine_info_label.setText(
|
||||||
f"Cover Layout: Front ({page_width:.0f}mm) + "
|
f"Cover Layout: Front ({page_width:.0f}mm) + "
|
||||||
@@ -317,14 +327,14 @@ class PageSetupDialog(QDialog):
|
|||||||
selected_page = self.project.pages[selected_index]
|
selected_page = self.project.pages[selected_index]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'selected_index': selected_index,
|
"selected_index": selected_index,
|
||||||
'selected_page': selected_page,
|
"selected_page": selected_page,
|
||||||
'is_cover': self.cover_checkbox.isChecked(),
|
"is_cover": self.cover_checkbox.isChecked(),
|
||||||
'paper_thickness_mm': self.thickness_spinbox.value(),
|
"paper_thickness_mm": self.thickness_spinbox.value(),
|
||||||
'cover_bleed_mm': self.bleed_spinbox.value(),
|
"cover_bleed_mm": self.bleed_spinbox.value(),
|
||||||
'width_mm': self.width_spinbox.value(),
|
"width_mm": self.width_spinbox.value(),
|
||||||
'height_mm': self.height_spinbox.value(),
|
"height_mm": self.height_spinbox.value(),
|
||||||
'working_dpi': self.working_dpi_spinbox.value(),
|
"working_dpi": self.working_dpi_spinbox.value(),
|
||||||
'export_dpi': self.export_dpi_spinbox.value(),
|
"export_dpi": self.export_dpi_spinbox.value(),
|
||||||
'set_as_default': self.set_default_checkbox.isChecked()
|
"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 |
@@ -22,18 +22,16 @@ try:
|
|||||||
glVertex2f,
|
glVertex2f,
|
||||||
GL_QUADS,
|
GL_QUADS,
|
||||||
GL_LINE_LOOP,
|
GL_LINE_LOOP,
|
||||||
|
GL_LINE_STRIP,
|
||||||
GL_LINES,
|
GL_LINES,
|
||||||
GL_TRIANGLE_FAN,
|
GL_TRIANGLE_FAN,
|
||||||
|
|
||||||
# Colors
|
# Colors
|
||||||
glColor3f,
|
glColor3f,
|
||||||
glColor4f,
|
glColor4f,
|
||||||
|
|
||||||
# Line state
|
# Line state
|
||||||
glLineWidth,
|
glLineWidth,
|
||||||
glLineStipple,
|
glLineStipple,
|
||||||
GL_LINE_STIPPLE,
|
GL_LINE_STIPPLE,
|
||||||
|
|
||||||
# General state
|
# General state
|
||||||
glEnable,
|
glEnable,
|
||||||
glDisable,
|
glDisable,
|
||||||
@@ -42,7 +40,6 @@ try:
|
|||||||
GL_SRC_ALPHA,
|
GL_SRC_ALPHA,
|
||||||
GL_ONE_MINUS_SRC_ALPHA,
|
GL_ONE_MINUS_SRC_ALPHA,
|
||||||
glBlendFunc,
|
glBlendFunc,
|
||||||
|
|
||||||
# Textures
|
# Textures
|
||||||
glGenTextures,
|
glGenTextures,
|
||||||
glBindTexture,
|
glBindTexture,
|
||||||
@@ -56,7 +53,6 @@ try:
|
|||||||
GL_TEXTURE_MAG_FILTER,
|
GL_TEXTURE_MAG_FILTER,
|
||||||
GL_LINEAR,
|
GL_LINEAR,
|
||||||
glTexCoord2f,
|
glTexCoord2f,
|
||||||
|
|
||||||
# Matrix operations
|
# Matrix operations
|
||||||
glPushMatrix,
|
glPushMatrix,
|
||||||
glPopMatrix,
|
glPopMatrix,
|
||||||
@@ -64,24 +60,23 @@ try:
|
|||||||
glTranslatef,
|
glTranslatef,
|
||||||
glLoadIdentity,
|
glLoadIdentity,
|
||||||
glRotatef,
|
glRotatef,
|
||||||
|
|
||||||
# Clear operations
|
# Clear operations
|
||||||
glClear,
|
glClear,
|
||||||
glClearColor,
|
glClearColor,
|
||||||
|
glFlush,
|
||||||
GL_COLOR_BUFFER_BIT,
|
GL_COLOR_BUFFER_BIT,
|
||||||
GL_DEPTH_BUFFER_BIT,
|
GL_DEPTH_BUFFER_BIT,
|
||||||
|
|
||||||
# Viewport
|
# Viewport
|
||||||
glViewport,
|
glViewport,
|
||||||
glMatrixMode,
|
glMatrixMode,
|
||||||
glOrtho,
|
glOrtho,
|
||||||
GL_PROJECTION,
|
GL_PROJECTION,
|
||||||
GL_MODELVIEW,
|
GL_MODELVIEW,
|
||||||
|
|
||||||
# Info/debug
|
# Info/debug
|
||||||
glGetString,
|
glGetString,
|
||||||
GL_VERSION,
|
GL_VERSION,
|
||||||
)
|
)
|
||||||
|
|
||||||
GL_AVAILABLE = True
|
GL_AVAILABLE = True
|
||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -100,12 +95,12 @@ except ImportError:
|
|||||||
glTexParameteri = glDeleteTextures = glTexCoord2f = _gl_stub
|
glTexParameteri = glDeleteTextures = glTexCoord2f = _gl_stub
|
||||||
glPushMatrix = glPopMatrix = glScalef = glTranslatef = _gl_stub
|
glPushMatrix = glPopMatrix = glScalef = glTranslatef = _gl_stub
|
||||||
glLoadIdentity = glRotatef = _gl_stub
|
glLoadIdentity = glRotatef = _gl_stub
|
||||||
glClear = glClearColor = _gl_stub
|
glClear = glClearColor = glFlush = _gl_stub
|
||||||
glViewport = glMatrixMode = glOrtho = _gl_stub
|
glViewport = glMatrixMode = glOrtho = _gl_stub
|
||||||
glGetString = _gl_stub
|
glGetString = _gl_stub
|
||||||
|
|
||||||
# Constants
|
# Constants
|
||||||
GL_QUADS = GL_LINE_LOOP = GL_LINES = GL_TRIANGLE_FAN = 0
|
GL_QUADS = GL_LINE_LOOP = GL_LINE_STRIP = GL_LINES = GL_TRIANGLE_FAN = 0
|
||||||
GL_LINE_STIPPLE = GL_DEPTH_TEST = GL_BLEND = 0
|
GL_LINE_STIPPLE = GL_DEPTH_TEST = GL_BLEND = 0
|
||||||
GL_SRC_ALPHA = GL_ONE_MINUS_SRC_ALPHA = 0
|
GL_SRC_ALPHA = GL_ONE_MINUS_SRC_ALPHA = 0
|
||||||
GL_TEXTURE_2D = GL_RGBA = GL_UNSIGNED_BYTE = 0
|
GL_TEXTURE_2D = GL_RGBA = GL_UNSIGNED_BYTE = 0
|
||||||
|
|||||||
+230
-7
@@ -34,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
|
||||||
|
|
||||||
@@ -53,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
|
||||||
@@ -68,6 +78,26 @@ 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
|
||||||
@@ -77,8 +107,8 @@ class GLWidget(
|
|||||||
def _get_project_folder(self):
|
def _get_project_folder(self):
|
||||||
"""Override AssetPathMixin to access project via main window."""
|
"""Override AssetPathMixin to access project via main window."""
|
||||||
main_window = self.window()
|
main_window = self.window()
|
||||||
if hasattr(main_window, 'project') and main_window.project:
|
if hasattr(main_window, "project") and main_window.project:
|
||||||
return getattr(main_window.project, 'folder_path', None)
|
return getattr(main_window.project, "folder_path", None)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def keyPressEvent(self, event):
|
def keyPressEvent(self, event):
|
||||||
@@ -86,7 +116,7 @@ class GLWidget(
|
|||||||
if event.key() == Qt.Key.Key_Delete or event.key() == Qt.Key.Key_Backspace:
|
if 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:
|
||||||
@@ -99,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}")
|
||||||
@@ -131,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()
|
||||||
|
|||||||
+282
-14
@@ -8,11 +8,11 @@ across models.py, pdf_exporter.py, and async_backend.py.
|
|||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Image Processing Utilities
|
# Image Processing Utilities
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
def apply_pil_rotation(image: Image.Image, pil_rotation_90: int) -> Image.Image:
|
def apply_pil_rotation(image: Image.Image, pil_rotation_90: int) -> Image.Image:
|
||||||
"""
|
"""
|
||||||
Apply 90-degree rotation increments to a PIL image.
|
Apply 90-degree rotation increments to a PIL image.
|
||||||
@@ -29,11 +29,11 @@ def apply_pil_rotation(image: Image.Image, pil_rotation_90: int) -> Image.Image:
|
|||||||
|
|
||||||
angle = pil_rotation_90 * 90
|
angle = pil_rotation_90 * 90
|
||||||
if angle == 90:
|
if angle == 90:
|
||||||
return image.transpose(Image.ROTATE_270) # CCW 90 = rotate right
|
return image.transpose(Image.Transpose.ROTATE_270) # CCW 90 = rotate right
|
||||||
elif angle == 180:
|
elif angle == 180:
|
||||||
return image.transpose(Image.ROTATE_180)
|
return image.transpose(Image.Transpose.ROTATE_180)
|
||||||
elif angle == 270:
|
elif angle == 270:
|
||||||
return image.transpose(Image.ROTATE_90) # CCW 270 = rotate left
|
return image.transpose(Image.Transpose.ROTATE_90) # CCW 270 = rotate left
|
||||||
|
|
||||||
return image
|
return image
|
||||||
|
|
||||||
@@ -48,8 +48,8 @@ def convert_to_rgba(image: Image.Image) -> Image.Image:
|
|||||||
Returns:
|
Returns:
|
||||||
PIL Image in RGBA mode
|
PIL Image in RGBA mode
|
||||||
"""
|
"""
|
||||||
if image.mode != 'RGBA':
|
if image.mode != "RGBA":
|
||||||
return image.convert('RGBA')
|
return image.convert("RGBA")
|
||||||
return image
|
return image
|
||||||
|
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ def calculate_center_crop_coords(
|
|||||||
img_height: int,
|
img_height: int,
|
||||||
target_width: float,
|
target_width: float,
|
||||||
target_height: float,
|
target_height: float,
|
||||||
crop_info: Tuple[float, float, float, float] = (0, 0, 1, 1)
|
crop_info: Tuple[float, float, float, float] = (0, 0, 1, 1),
|
||||||
) -> Tuple[float, float, float, float]:
|
) -> Tuple[float, float, float, float]:
|
||||||
"""
|
"""
|
||||||
Calculate texture/crop coordinates for center-crop fitting an image to a target aspect ratio.
|
Calculate texture/crop coordinates for center-crop fitting an image to a target aspect ratio.
|
||||||
@@ -113,10 +113,7 @@ def calculate_center_crop_coords(
|
|||||||
return (tx_min, ty_min, tx_max, ty_max)
|
return (tx_min, ty_min, tx_max, ty_max)
|
||||||
|
|
||||||
|
|
||||||
def crop_image_to_coords(
|
def crop_image_to_coords(image: Image.Image, coords: Tuple[float, float, float, float]) -> Image.Image:
|
||||||
image: Image.Image,
|
|
||||||
coords: Tuple[float, float, float, float]
|
|
||||||
) -> Image.Image:
|
|
||||||
"""
|
"""
|
||||||
Crop an image using normalized texture coordinates.
|
Crop an image using normalized texture coordinates.
|
||||||
|
|
||||||
@@ -139,9 +136,7 @@ def crop_image_to_coords(
|
|||||||
|
|
||||||
|
|
||||||
def resize_to_fit(
|
def resize_to_fit(
|
||||||
image: Image.Image,
|
image: Image.Image, max_size: int, resample: Image.Resampling = Image.Resampling.LANCZOS
|
||||||
max_size: int,
|
|
||||||
resample: Image.Resampling = Image.Resampling.LANCZOS
|
|
||||||
) -> Image.Image:
|
) -> Image.Image:
|
||||||
"""
|
"""
|
||||||
Resize image to fit within max_size while preserving aspect ratio.
|
Resize image to fit within max_size while preserving aspect ratio.
|
||||||
@@ -162,3 +157,276 @@ def resize_to_fit(
|
|||||||
new_height = int(image.height * scale)
|
new_height = int(image.height * scale)
|
||||||
|
|
||||||
return image.resize((new_width, new_height), resample)
|
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)
|
||||||
|
|||||||
+38
-21
@@ -9,8 +9,14 @@ import sys
|
|||||||
from datetime import datetime
|
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, QMessageBox
|
QMainWindow,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
QStatusBar,
|
||||||
|
QScrollBar,
|
||||||
|
QHBoxLayout,
|
||||||
|
QMessageBox,
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, QSize, QTimer
|
from PyQt6.QtCore import Qt, QSize, QTimer
|
||||||
from PyQt6.QtGui import QIcon
|
from PyQt6.QtGui import QIcon
|
||||||
@@ -21,6 +27,7 @@ from pyPhotoAlbum.ribbon_widget import RibbonWidget
|
|||||||
from pyPhotoAlbum.ribbon_builder import build_ribbon_config, print_ribbon_summary
|
from pyPhotoAlbum.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.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
|
||||||
@@ -37,6 +44,7 @@ from pyPhotoAlbum.mixins.operations import (
|
|||||||
SizeOperationsMixin,
|
SizeOperationsMixin,
|
||||||
ZOrderOperationsMixin,
|
ZOrderOperationsMixin,
|
||||||
MergeOperationsMixin,
|
MergeOperationsMixin,
|
||||||
|
StyleOperationsMixin,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -55,6 +63,7 @@ class MainWindow(
|
|||||||
SizeOperationsMixin,
|
SizeOperationsMixin,
|
||||||
ZOrderOperationsMixin,
|
ZOrderOperationsMixin,
|
||||||
MergeOperationsMixin,
|
MergeOperationsMixin,
|
||||||
|
StyleOperationsMixin,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Main application window using mixin architecture.
|
Main application window using mixin architecture.
|
||||||
@@ -85,7 +94,7 @@ class MainWindow(
|
|||||||
self._autosave_timer.start(5 * 60 * 1000) # 5 minutes in milliseconds
|
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"""
|
||||||
@@ -94,6 +103,7 @@ 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
|
||||||
@@ -167,6 +177,11 @@ class MainWindow(
|
|||||||
|
|
||||||
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)
|
||||||
@@ -206,8 +221,8 @@ class MainWindow(
|
|||||||
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
|
||||||
@@ -257,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")
|
||||||
@@ -265,7 +280,7 @@ 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):
|
||||||
@@ -315,8 +330,8 @@ class MainWindow(
|
|||||||
return
|
return
|
||||||
|
|
||||||
checkpoint_path, metadata = checkpoint_info
|
checkpoint_path, metadata = checkpoint_info
|
||||||
project_name = metadata.get('project_name', 'Unknown')
|
project_name = metadata.get("project_name", "Unknown")
|
||||||
timestamp_str = metadata.get('timestamp', 'Unknown time')
|
timestamp_str = metadata.get("timestamp", "Unknown time")
|
||||||
|
|
||||||
# Parse timestamp for better display
|
# Parse timestamp for better display
|
||||||
try:
|
try:
|
||||||
@@ -334,7 +349,7 @@ class MainWindow(
|
|||||||
f"Time: {time_display}\n\n"
|
f"Time: {time_display}\n\n"
|
||||||
f"Would you like to recover this checkpoint?",
|
f"Would you like to recover this checkpoint?",
|
||||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
QMessageBox.StandardButton.Yes
|
QMessageBox.StandardButton.Yes,
|
||||||
)
|
)
|
||||||
|
|
||||||
if reply == QMessageBox.StandardButton.Yes:
|
if reply == QMessageBox.StandardButton.Yes:
|
||||||
@@ -343,7 +358,7 @@ class MainWindow(
|
|||||||
|
|
||||||
if success:
|
if success:
|
||||||
# Replace current project with recovered one
|
# Replace current project with recovered one
|
||||||
if hasattr(self, '_project') and self._project:
|
if hasattr(self, "_project") and self._project:
|
||||||
self._project.cleanup()
|
self._project.cleanup()
|
||||||
|
|
||||||
self._project = result
|
self._project = result
|
||||||
@@ -365,19 +380,21 @@ class MainWindow(
|
|||||||
self,
|
self,
|
||||||
"Unsaved Changes",
|
"Unsaved Changes",
|
||||||
"You have unsaved changes. Would you like to save before exiting?",
|
"You have unsaved changes. Would you like to save before exiting?",
|
||||||
QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel,
|
|
||||||
QMessageBox.StandardButton.Save
|
QMessageBox.StandardButton.Save
|
||||||
|
| QMessageBox.StandardButton.Discard
|
||||||
|
| QMessageBox.StandardButton.Cancel,
|
||||||
|
QMessageBox.StandardButton.Save,
|
||||||
)
|
)
|
||||||
|
|
||||||
if reply == QMessageBox.StandardButton.Save:
|
if reply == QMessageBox.StandardButton.Save:
|
||||||
# Trigger save
|
# Save is async — ignore event and let on_complete trigger close
|
||||||
self.save_project()
|
self._pending_close = True
|
||||||
|
save_started = self.save_project()
|
||||||
# Check if save was successful (project should be clean now)
|
if not save_started:
|
||||||
if self.project.is_dirty():
|
# User cancelled the file dialog
|
||||||
# User cancelled save dialog or save failed
|
self._pending_close = False
|
||||||
event.ignore()
|
event.ignore()
|
||||||
return
|
return
|
||||||
elif reply == QMessageBox.StandardButton.Cancel:
|
elif reply == QMessageBox.StandardButton.Cancel:
|
||||||
# User cancelled exit
|
# User cancelled exit
|
||||||
event.ignore()
|
event.ignore()
|
||||||
@@ -390,7 +407,7 @@ class MainWindow(
|
|||||||
self.project.cleanup()
|
self.project.cleanup()
|
||||||
|
|
||||||
# Stop autosave timer
|
# Stop autosave timer
|
||||||
if hasattr(self, '_autosave_timer'):
|
if hasattr(self, "_autosave_timer"):
|
||||||
self._autosave_timer.stop()
|
self._autosave_timer.stop()
|
||||||
|
|
||||||
# Cleanup old checkpoints
|
# Cleanup old checkpoints
|
||||||
|
|||||||
@@ -3,9 +3,21 @@ Merge dialog for resolving project conflicts visually
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
|
QDialog,
|
||||||
QListWidget, QListWidgetItem, QSplitter, QWidget, QScrollArea,
|
QVBoxLayout,
|
||||||
QRadioButton, QButtonGroup, QTextEdit, QComboBox, QGroupBox
|
QHBoxLayout,
|
||||||
|
QPushButton,
|
||||||
|
QLabel,
|
||||||
|
QListWidget,
|
||||||
|
QListWidgetItem,
|
||||||
|
QSplitter,
|
||||||
|
QWidget,
|
||||||
|
QScrollArea,
|
||||||
|
QRadioButton,
|
||||||
|
QButtonGroup,
|
||||||
|
QTextEdit,
|
||||||
|
QComboBox,
|
||||||
|
QGroupBox,
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, QSize, pyqtSignal
|
from PyQt6.QtCore import Qt, QSize, pyqtSignal
|
||||||
from PyQt6.QtGui import QPixmap, QPainter, QColor, QFont, QPen
|
from PyQt6.QtGui import QPixmap, QPainter, QColor, QFont, QPen
|
||||||
@@ -22,10 +34,7 @@ class PagePreviewWidget(QWidget):
|
|||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.page_data = page_data
|
self.page_data = page_data
|
||||||
self.setMinimumSize(200, 280)
|
self.setMinimumSize(200, 280)
|
||||||
self.setSizePolicy(
|
self.setSizePolicy(self.sizePolicy().Policy.Expanding, self.sizePolicy().Policy.Expanding)
|
||||||
self.sizePolicy().Policy.Expanding,
|
|
||||||
self.sizePolicy().Policy.Expanding
|
|
||||||
)
|
|
||||||
|
|
||||||
def paintEvent(self, event):
|
def paintEvent(self, event):
|
||||||
"""Render the page preview"""
|
"""Render the page preview"""
|
||||||
@@ -356,8 +365,4 @@ class MergeDialog(QDialog):
|
|||||||
Returns:
|
Returns:
|
||||||
Merged project data dictionary
|
Merged project data dictionary
|
||||||
"""
|
"""
|
||||||
return self.merge_manager.apply_resolutions(
|
return self.merge_manager.apply_resolutions(self.our_project_data, self.their_project_data, self.resolutions)
|
||||||
self.our_project_data,
|
|
||||||
self.their_project_data,
|
|
||||||
self.resolutions
|
|
||||||
)
|
|
||||||
|
|||||||
+83
-126
@@ -16,6 +16,7 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
class ConflictType(Enum):
|
class ConflictType(Enum):
|
||||||
"""Types of merge conflicts"""
|
"""Types of merge conflicts"""
|
||||||
|
|
||||||
# Page-level conflicts
|
# Page-level conflicts
|
||||||
PAGE_MODIFIED_BOTH = "page_modified_both" # Page modified in both versions
|
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_DELETED_ONE = "page_deleted_one" # Page deleted in one version, modified in other
|
||||||
@@ -31,6 +32,7 @@ class ConflictType(Enum):
|
|||||||
|
|
||||||
class MergeStrategy(Enum):
|
class MergeStrategy(Enum):
|
||||||
"""Automatic merge resolution strategies"""
|
"""Automatic merge resolution strategies"""
|
||||||
|
|
||||||
LATEST_WINS = "latest_wins" # Most recent last_modified wins
|
LATEST_WINS = "latest_wins" # Most recent last_modified wins
|
||||||
OURS = "ours" # Always use our version
|
OURS = "ours" # Always use our version
|
||||||
THEIRS = "theirs" # Always use their version
|
THEIRS = "theirs" # Always use their version
|
||||||
@@ -40,6 +42,7 @@ class MergeStrategy(Enum):
|
|||||||
@dataclass
|
@dataclass
|
||||||
class ConflictInfo:
|
class ConflictInfo:
|
||||||
"""Information about a single merge conflict"""
|
"""Information about a single merge conflict"""
|
||||||
|
|
||||||
conflict_type: ConflictType
|
conflict_type: ConflictType
|
||||||
page_uuid: Optional[str] # UUID of the page (if page-level conflict)
|
page_uuid: Optional[str] # UUID of the page (if page-level conflict)
|
||||||
element_uuid: Optional[str] # UUID of the element (if element-level conflict)
|
element_uuid: Optional[str] # UUID of the element (if element-level conflict)
|
||||||
@@ -76,12 +79,10 @@ class MergeManager:
|
|||||||
print("MergeManager: One or both projects lack project_id, assuming concatenation")
|
print("MergeManager: One or both projects lack project_id, assuming concatenation")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return project_a_id == project_b_id
|
return bool(project_a_id == project_b_id)
|
||||||
|
|
||||||
def detect_conflicts(
|
def detect_conflicts(
|
||||||
self,
|
self, our_project_data: Dict[str, Any], their_project_data: Dict[str, Any]
|
||||||
our_project_data: Dict[str, Any],
|
|
||||||
their_project_data: Dict[str, Any]
|
|
||||||
) -> List[ConflictInfo]:
|
) -> List[ConflictInfo]:
|
||||||
"""
|
"""
|
||||||
Detect conflicts between two versions of the same project.
|
Detect conflicts between two versions of the same project.
|
||||||
@@ -103,16 +104,18 @@ class MergeManager:
|
|||||||
|
|
||||||
return self.conflicts
|
return self.conflicts
|
||||||
|
|
||||||
def _detect_project_settings_conflicts(
|
def _detect_project_settings_conflicts(self, our_data: Dict[str, Any], their_data: Dict[str, Any]):
|
||||||
self,
|
|
||||||
our_data: Dict[str, Any],
|
|
||||||
their_data: Dict[str, Any]
|
|
||||||
):
|
|
||||||
"""Detect conflicts in project-level settings."""
|
"""Detect conflicts in project-level settings."""
|
||||||
# Settings that can conflict
|
# Settings that can conflict
|
||||||
settings_keys = [
|
settings_keys = [
|
||||||
"name", "page_size_mm", "working_dpi", "export_dpi",
|
"name",
|
||||||
"has_cover", "paper_thickness_mm", "cover_bleed_mm", "binding_type"
|
"page_size_mm",
|
||||||
|
"working_dpi",
|
||||||
|
"export_dpi",
|
||||||
|
"has_cover",
|
||||||
|
"paper_thickness_mm",
|
||||||
|
"cover_bleed_mm",
|
||||||
|
"binding_type",
|
||||||
]
|
]
|
||||||
|
|
||||||
our_modified = our_data.get("last_modified")
|
our_modified = our_data.get("last_modified")
|
||||||
@@ -124,20 +127,18 @@ class MergeManager:
|
|||||||
|
|
||||||
# If values differ, it's a conflict
|
# If values differ, it's a conflict
|
||||||
if our_value != their_value:
|
if our_value != their_value:
|
||||||
self.conflicts.append(ConflictInfo(
|
self.conflicts.append(
|
||||||
conflict_type=ConflictType.SETTINGS_MODIFIED_BOTH,
|
ConflictInfo(
|
||||||
page_uuid=None,
|
conflict_type=ConflictType.SETTINGS_MODIFIED_BOTH,
|
||||||
element_uuid=None,
|
page_uuid=None,
|
||||||
our_version={key: our_value, "last_modified": our_modified},
|
element_uuid=None,
|
||||||
their_version={key: their_value, "last_modified": their_modified},
|
our_version={key: our_value, "last_modified": our_modified},
|
||||||
description=f"Project setting '{key}' modified in both versions"
|
their_version={key: their_value, "last_modified": their_modified},
|
||||||
))
|
description=f"Project setting '{key}' modified in both versions",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def _detect_page_conflicts(
|
def _detect_page_conflicts(self, our_data: Dict[str, Any], their_data: Dict[str, Any]):
|
||||||
self,
|
|
||||||
our_data: Dict[str, Any],
|
|
||||||
their_data: Dict[str, Any]
|
|
||||||
):
|
|
||||||
"""Detect conflicts at page level."""
|
"""Detect conflicts at page level."""
|
||||||
our_pages = {page["uuid"]: page for page in our_data.get("pages", [])}
|
our_pages = {page["uuid"]: page for page in our_data.get("pages", [])}
|
||||||
their_pages = {page["uuid"]: page for page in their_data.get("pages", [])}
|
their_pages = {page["uuid"]: page for page in their_data.get("pages", [])}
|
||||||
@@ -164,12 +165,7 @@ class MergeManager:
|
|||||||
# Unless we deleted it
|
# Unless we deleted it
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _detect_page_modification_conflicts(
|
def _detect_page_modification_conflicts(self, page_uuid: str, our_page: Dict[str, Any], their_page: Dict[str, Any]):
|
||||||
self,
|
|
||||||
page_uuid: str,
|
|
||||||
our_page: Dict[str, Any],
|
|
||||||
their_page: Dict[str, Any]
|
|
||||||
):
|
|
||||||
"""Detect conflicts in a specific page."""
|
"""Detect conflicts in a specific page."""
|
||||||
our_modified = our_page.get("last_modified")
|
our_modified = our_page.get("last_modified")
|
||||||
their_modified = their_page.get("last_modified")
|
their_modified = their_page.get("last_modified")
|
||||||
@@ -180,14 +176,16 @@ class MergeManager:
|
|||||||
|
|
||||||
# Check if one deleted, one modified
|
# Check if one deleted, one modified
|
||||||
if our_page.get("deleted") != their_page.get("deleted"):
|
if our_page.get("deleted") != their_page.get("deleted"):
|
||||||
self.conflicts.append(ConflictInfo(
|
self.conflicts.append(
|
||||||
conflict_type=ConflictType.PAGE_DELETED_ONE,
|
ConflictInfo(
|
||||||
page_uuid=page_uuid,
|
conflict_type=ConflictType.PAGE_DELETED_ONE,
|
||||||
element_uuid=None,
|
page_uuid=page_uuid,
|
||||||
our_version=our_page,
|
element_uuid=None,
|
||||||
their_version=their_page,
|
our_version=our_page,
|
||||||
description=f"Page deleted in one version but modified in the other"
|
their_version=their_page,
|
||||||
))
|
description=f"Page deleted in one version but modified in the other",
|
||||||
|
)
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check page-level properties
|
# Check page-level properties
|
||||||
@@ -201,24 +199,21 @@ class MergeManager:
|
|||||||
# Only flag as conflict if properties differ AND timestamps are identical
|
# Only flag as conflict if properties differ AND timestamps are identical
|
||||||
# (See element conflict detection for detailed explanation of this strategy)
|
# (See element conflict detection for detailed explanation of this strategy)
|
||||||
if page_modified and our_modified == their_modified:
|
if page_modified and our_modified == their_modified:
|
||||||
self.conflicts.append(ConflictInfo(
|
self.conflicts.append(
|
||||||
conflict_type=ConflictType.PAGE_MODIFIED_BOTH,
|
ConflictInfo(
|
||||||
page_uuid=page_uuid,
|
conflict_type=ConflictType.PAGE_MODIFIED_BOTH,
|
||||||
element_uuid=None,
|
page_uuid=page_uuid,
|
||||||
our_version=our_page,
|
element_uuid=None,
|
||||||
their_version=their_page,
|
our_version=our_page,
|
||||||
description=f"Page properties modified with same timestamp (possible conflict)"
|
their_version=their_page,
|
||||||
))
|
description=f"Page properties modified with same timestamp (possible conflict)",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Check element-level conflicts
|
# Check element-level conflicts
|
||||||
self._detect_element_conflicts(page_uuid, our_page, their_page)
|
self._detect_element_conflicts(page_uuid, our_page, their_page)
|
||||||
|
|
||||||
def _detect_element_conflicts(
|
def _detect_element_conflicts(self, page_uuid: str, our_page: Dict[str, Any], their_page: Dict[str, Any]):
|
||||||
self,
|
|
||||||
page_uuid: str,
|
|
||||||
our_page: Dict[str, Any],
|
|
||||||
their_page: Dict[str, Any]
|
|
||||||
):
|
|
||||||
"""Detect conflicts in elements within a page."""
|
"""Detect conflicts in elements within a page."""
|
||||||
our_layout = our_page.get("layout", {})
|
our_layout = our_page.get("layout", {})
|
||||||
their_layout = their_page.get("layout", {})
|
their_layout = their_page.get("layout", {})
|
||||||
@@ -238,16 +233,10 @@ class MergeManager:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Element exists in both - check for modifications
|
# Element exists in both - check for modifications
|
||||||
self._detect_element_modification_conflicts(
|
self._detect_element_modification_conflicts(page_uuid, elem_uuid, our_elem, their_elem)
|
||||||
page_uuid, elem_uuid, our_elem, their_elem
|
|
||||||
)
|
|
||||||
|
|
||||||
def _detect_element_modification_conflicts(
|
def _detect_element_modification_conflicts(
|
||||||
self,
|
self, page_uuid: str, elem_uuid: str, our_elem: Dict[str, Any], their_elem: Dict[str, Any]
|
||||||
page_uuid: str,
|
|
||||||
elem_uuid: str,
|
|
||||||
our_elem: Dict[str, Any],
|
|
||||||
their_elem: Dict[str, Any]
|
|
||||||
):
|
):
|
||||||
"""Detect conflicts in a specific element."""
|
"""Detect conflicts in a specific element."""
|
||||||
our_modified = our_elem.get("last_modified")
|
our_modified = our_elem.get("last_modified")
|
||||||
@@ -259,14 +248,16 @@ class MergeManager:
|
|||||||
|
|
||||||
# Check if one deleted, one modified
|
# Check if one deleted, one modified
|
||||||
if our_elem.get("deleted") != their_elem.get("deleted"):
|
if our_elem.get("deleted") != their_elem.get("deleted"):
|
||||||
self.conflicts.append(ConflictInfo(
|
self.conflicts.append(
|
||||||
conflict_type=ConflictType.ELEMENT_DELETED_ONE,
|
ConflictInfo(
|
||||||
page_uuid=page_uuid,
|
conflict_type=ConflictType.ELEMENT_DELETED_ONE,
|
||||||
element_uuid=elem_uuid,
|
page_uuid=page_uuid,
|
||||||
our_version=our_elem,
|
element_uuid=elem_uuid,
|
||||||
their_version=their_elem,
|
our_version=our_elem,
|
||||||
description=f"Element deleted in one version but modified in the other"
|
their_version=their_elem,
|
||||||
))
|
description=f"Element deleted in one version but modified in the other",
|
||||||
|
)
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check element properties
|
# Check element properties
|
||||||
@@ -298,22 +289,21 @@ class MergeManager:
|
|||||||
# Properties differ but timestamps match - this is unusual and might indicate
|
# 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.
|
# that both versions modified it at exactly the same time, or there's data corruption.
|
||||||
# Flag as conflict to be safe.
|
# Flag as conflict to be safe.
|
||||||
self.conflicts.append(ConflictInfo(
|
self.conflicts.append(
|
||||||
conflict_type=ConflictType.ELEMENT_MODIFIED_BOTH,
|
ConflictInfo(
|
||||||
page_uuid=page_uuid,
|
conflict_type=ConflictType.ELEMENT_MODIFIED_BOTH,
|
||||||
element_uuid=elem_uuid,
|
page_uuid=page_uuid,
|
||||||
our_version=our_elem,
|
element_uuid=elem_uuid,
|
||||||
their_version=their_elem,
|
our_version=our_elem,
|
||||||
description=f"Element modified with same timestamp (possible conflict)"
|
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.
|
# 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.
|
# The _merge_non_conflicting_changes method will automatically use the newer version.
|
||||||
|
|
||||||
def auto_resolve_conflicts(
|
def auto_resolve_conflicts(self, strategy: MergeStrategy = MergeStrategy.LATEST_WINS) -> Dict[int, str]:
|
||||||
self,
|
|
||||||
strategy: MergeStrategy = MergeStrategy.LATEST_WINS
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
"""
|
||||||
Automatically resolve conflicts based on a strategy.
|
Automatically resolve conflicts based on a strategy.
|
||||||
|
|
||||||
@@ -353,10 +343,7 @@ class MergeManager:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def apply_resolutions(
|
def apply_resolutions(
|
||||||
self,
|
self, our_project_data: Dict[str, Any], their_project_data: Dict[str, Any], resolutions: Dict[int, str]
|
||||||
our_project_data: Dict[str, Any],
|
|
||||||
their_project_data: Dict[str, Any],
|
|
||||||
resolutions: Dict[int, str]
|
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Apply conflict resolutions to create merged project.
|
Apply conflict resolutions to create merged project.
|
||||||
@@ -415,20 +402,12 @@ class MergeManager:
|
|||||||
break
|
break
|
||||||
break
|
break
|
||||||
|
|
||||||
def _merge_non_conflicting_changes(
|
def _merge_non_conflicting_changes(self, merged_data: Dict[str, Any], their_data: Dict[str, Any]):
|
||||||
self,
|
|
||||||
merged_data: Dict[str, Any],
|
|
||||||
their_data: Dict[str, Any]
|
|
||||||
):
|
|
||||||
"""Add non-conflicting pages and elements from their version."""
|
"""Add non-conflicting pages and elements from their version."""
|
||||||
self._add_missing_pages(merged_data, their_data)
|
self._add_missing_pages(merged_data, their_data)
|
||||||
self._merge_page_elements(merged_data, their_data)
|
self._merge_page_elements(merged_data, their_data)
|
||||||
|
|
||||||
def _add_missing_pages(
|
def _add_missing_pages(self, merged_data: Dict[str, Any], their_data: Dict[str, Any]):
|
||||||
self,
|
|
||||||
merged_data: Dict[str, Any],
|
|
||||||
their_data: Dict[str, Any]
|
|
||||||
):
|
|
||||||
"""Add pages that exist only in their version."""
|
"""Add pages that exist only in their version."""
|
||||||
our_page_uuids = {page["uuid"] for page in merged_data.get("pages", [])}
|
our_page_uuids = {page["uuid"] for page in merged_data.get("pages", [])}
|
||||||
|
|
||||||
@@ -436,11 +415,7 @@ class MergeManager:
|
|||||||
if their_page["uuid"] not in our_page_uuids:
|
if their_page["uuid"] not in our_page_uuids:
|
||||||
merged_data["pages"].append(their_page)
|
merged_data["pages"].append(their_page)
|
||||||
|
|
||||||
def _merge_page_elements(
|
def _merge_page_elements(self, merged_data: Dict[str, Any], their_data: Dict[str, Any]):
|
||||||
self,
|
|
||||||
merged_data: Dict[str, Any],
|
|
||||||
their_data: Dict[str, Any]
|
|
||||||
):
|
|
||||||
"""For pages that exist in both versions, merge their elements."""
|
"""For pages that exist in both versions, merge their elements."""
|
||||||
their_pages = {page["uuid"]: page for page in their_data.get("pages", [])}
|
their_pages = {page["uuid"]: page for page in their_data.get("pages", [])}
|
||||||
|
|
||||||
@@ -449,25 +424,15 @@ class MergeManager:
|
|||||||
if not their_page:
|
if not their_page:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
our_elements = {
|
our_elements = {elem["uuid"]: elem for elem in our_page.get("layout", {}).get("elements", [])}
|
||||||
elem["uuid"]: elem
|
|
||||||
for elem in our_page.get("layout", {}).get("elements", [])
|
|
||||||
}
|
|
||||||
|
|
||||||
for their_elem in their_page.get("layout", {}).get("elements", []):
|
for their_elem in their_page.get("layout", {}).get("elements", []):
|
||||||
self._merge_element(
|
self._merge_element(
|
||||||
our_page=our_page,
|
our_page=our_page, page_uuid=our_page["uuid"], their_elem=their_elem, our_elements=our_elements
|
||||||
page_uuid=our_page["uuid"],
|
|
||||||
their_elem=their_elem,
|
|
||||||
our_elements=our_elements
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _merge_element(
|
def _merge_element(
|
||||||
self,
|
self, our_page: Dict[str, Any], page_uuid: str, their_elem: Dict[str, Any], our_elements: Dict[str, Any]
|
||||||
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."""
|
"""Merge a single element from their version into our page."""
|
||||||
elem_uuid = their_elem["uuid"]
|
elem_uuid = their_elem["uuid"]
|
||||||
@@ -486,17 +451,10 @@ class MergeManager:
|
|||||||
|
|
||||||
def _is_element_in_conflict(self, elem_uuid: str, page_uuid: str) -> bool:
|
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."""
|
"""Check if element was part of a conflict that was already resolved."""
|
||||||
return any(
|
return any(c.element_uuid == elem_uuid and c.page_uuid == page_uuid for c in self.conflicts)
|
||||||
c.element_uuid == elem_uuid and c.page_uuid == page_uuid
|
|
||||||
for c in self.conflicts
|
|
||||||
)
|
|
||||||
|
|
||||||
def _merge_by_timestamp(
|
def _merge_by_timestamp(
|
||||||
self,
|
self, our_page: Dict[str, Any], elem_uuid: str, their_elem: Dict[str, Any], our_elem: Dict[str, Any]
|
||||||
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."""
|
"""Use the more recently modified version of an element."""
|
||||||
our_modified = our_elem.get("last_modified")
|
our_modified = our_elem.get("last_modified")
|
||||||
@@ -513,10 +471,7 @@ class MergeManager:
|
|||||||
break
|
break
|
||||||
|
|
||||||
|
|
||||||
def concatenate_projects(
|
def concatenate_projects(project_a_data: Dict[str, Any], project_b_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
project_a_data: Dict[str, Any],
|
|
||||||
project_b_data: Dict[str, Any]
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
"""
|
||||||
Concatenate two projects with different project_ids.
|
Concatenate two projects with different project_ids.
|
||||||
|
|
||||||
@@ -542,6 +497,8 @@ def concatenate_projects(
|
|||||||
# Update last_modified to now
|
# Update last_modified to now
|
||||||
merged_data["last_modified"] = datetime.now(timezone.utc).isoformat()
|
merged_data["last_modified"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
print(f"Concatenated projects: {len(project_a_data.get('pages', []))} + {len(project_b_data.get('pages', []))} = {len(merged_data['pages'])} pages")
|
print(
|
||||||
|
f"Concatenated projects: {len(project_a_data.get('pages', []))} + {len(project_b_data.get('pages', []))} = {len(merged_data['pages'])} pages"
|
||||||
|
)
|
||||||
|
|
||||||
return merged_data
|
return merged_data
|
||||||
|
|||||||
@@ -5,4 +5,4 @@ Mixin modules for pyPhotoAlbum
|
|||||||
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
|
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
|
||||||
from pyPhotoAlbum.mixins.dialog_mixin import DialogMixin
|
from pyPhotoAlbum.mixins.dialog_mixin import DialogMixin
|
||||||
|
|
||||||
__all__ = ['ApplicationStateMixin', 'DialogMixin']
|
__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"""
|
||||||
@@ -66,7 +66,7 @@ class AssetDropMixin:
|
|||||||
def _handle_drop_on_element(self, image_path, target_element):
|
def _handle_drop_on_element(self, image_path, target_element):
|
||||||
"""Handle dropping an image onto an existing element"""
|
"""Handle dropping an image onto an existing element"""
|
||||||
main_window = self.window()
|
main_window = self.window()
|
||||||
if not (hasattr(main_window, 'project') and main_window.project):
|
if not (hasattr(main_window, "project") and main_window.project):
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -89,7 +89,9 @@ class AssetDropMixin:
|
|||||||
y=placeholder.position[1],
|
y=placeholder.position[1],
|
||||||
width=placeholder.size[0],
|
width=placeholder.size[0],
|
||||||
height=placeholder.size[1],
|
height=placeholder.size[1],
|
||||||
z_index=placeholder.z_index
|
z_index=placeholder.z_index,
|
||||||
|
# Inherit styling from placeholder (for templatable styles)
|
||||||
|
style=placeholder.style.copy(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if not main_window.project.pages:
|
if not main_window.project.pages:
|
||||||
@@ -104,7 +106,7 @@ class AssetDropMixin:
|
|||||||
def _handle_drop_on_empty_space(self, image_path, x, y):
|
def _handle_drop_on_empty_space(self, image_path, x, y):
|
||||||
"""Handle dropping an image onto empty space"""
|
"""Handle dropping an image onto empty space"""
|
||||||
main_window = self.window()
|
main_window = self.window()
|
||||||
if not (hasattr(main_window, 'project') and main_window.project and main_window.project.pages):
|
if not (hasattr(main_window, "project") and main_window.project and main_window.project.pages):
|
||||||
return
|
return
|
||||||
|
|
||||||
target_page, page_index, page_renderer = self._get_page_at(x, y)
|
target_page, page_index, page_renderer = self._get_page_at(x, y)
|
||||||
@@ -120,8 +122,7 @@ class AssetDropMixin:
|
|||||||
img_width, img_height = self._calculate_image_dimensions(full_asset_path)
|
img_width, img_height = self._calculate_image_dimensions(full_asset_path)
|
||||||
|
|
||||||
self._add_new_image_to_page(
|
self._add_new_image_to_page(
|
||||||
asset_path, target_page, page_index, page_renderer,
|
asset_path, target_page, page_index, page_renderer, x, y, img_width, img_height, main_window
|
||||||
x, y, img_width, img_height, main_window
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error importing dropped image: {e}")
|
print(f"Error importing dropped image: {e}")
|
||||||
@@ -138,27 +139,18 @@ class AssetDropMixin:
|
|||||||
# Fallback dimensions if image cannot be read
|
# Fallback dimensions if image cannot be read
|
||||||
return 200, 150
|
return 200, 150
|
||||||
|
|
||||||
def _add_new_image_to_page(self, asset_path, target_page, page_index,
|
def _add_new_image_to_page(
|
||||||
page_renderer, x, y, img_width, img_height, main_window):
|
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)"""
|
"""Add a new image element to the target page (asset already imported)"""
|
||||||
if page_index >= 0:
|
if page_index >= 0:
|
||||||
self.current_page_index = page_index
|
self.current_page_index = page_index
|
||||||
|
|
||||||
page_local_x, page_local_y = page_renderer.screen_to_page(x, y)
|
page_local_x, page_local_y = page_renderer.screen_to_page(x, y)
|
||||||
|
|
||||||
new_image = ImageData(
|
new_image = ImageData(image_path=asset_path, x=page_local_x, y=page_local_y, width=img_width, height=img_height)
|
||||||
image_path=asset_path,
|
|
||||||
x=page_local_x,
|
|
||||||
y=page_local_y,
|
|
||||||
width=img_width,
|
|
||||||
height=img_height
|
|
||||||
)
|
|
||||||
|
|
||||||
cmd = AddElementCommand(
|
cmd = AddElementCommand(target_page.layout, new_image, asset_manager=main_window.project.asset_manager)
|
||||||
target_page.layout,
|
|
||||||
new_image,
|
|
||||||
asset_manager=main_window.project.asset_manager
|
|
||||||
)
|
|
||||||
main_window.project.history.execute(cmd)
|
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}")
|
print(f"Added new image to page {page_index + 1} at ({page_local_x:.1f}, {page_local_y:.1f}): {asset_path}")
|
||||||
|
|||||||
@@ -63,6 +63,6 @@ class AssetPathMixin:
|
|||||||
Override this method if the project is accessed differently.
|
Override this method if the project is accessed differently.
|
||||||
Default implementation uses self.project.folder_path.
|
Default implementation uses self.project.folder_path.
|
||||||
"""
|
"""
|
||||||
if hasattr(self, 'project') and self.project:
|
if hasattr(self, "project") and self.project:
|
||||||
return getattr(self.project, 'folder_path', None)
|
return getattr(self.project, "folder_path", None)
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -4,17 +4,32 @@ Async loading mixin for non-blocking image loading and PDF generation.
|
|||||||
|
|
||||||
import os
|
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.
|
||||||
|
|
||||||
@@ -48,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")
|
||||||
@@ -70,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
|
||||||
@@ -87,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):
|
||||||
@@ -102,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):
|
||||||
"""
|
"""
|
||||||
@@ -117,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:
|
||||||
@@ -145,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):
|
||||||
@@ -162,7 +176,7 @@ 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
|
||||||
|
|
||||||
@@ -188,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):
|
||||||
@@ -200,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
|
||||||
|
|
||||||
@@ -208,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)
|
||||||
@@ -232,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
|
||||||
|
|||||||
+15
-18
@@ -2,8 +2,8 @@
|
|||||||
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:
|
||||||
@@ -23,7 +23,7 @@ class ApplicationStateMixin:
|
|||||||
@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
|
||||||
|
|
||||||
@@ -35,21 +35,21 @@ class ApplicationStateMixin:
|
|||||||
@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
|
||||||
|
|
||||||
@@ -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:
|
||||||
@@ -117,7 +117,7 @@ class ApplicationStateMixin:
|
|||||||
"""
|
"""
|
||||||
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):
|
||||||
"""
|
"""
|
||||||
@@ -138,7 +138,7 @@ class ApplicationStateMixin:
|
|||||||
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):
|
||||||
"""
|
"""
|
||||||
@@ -148,7 +148,7 @@ class ApplicationStateMixin:
|
|||||||
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):
|
||||||
"""
|
"""
|
||||||
@@ -158,7 +158,7 @@ class ApplicationStateMixin:
|
|||||||
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:
|
||||||
"""
|
"""
|
||||||
@@ -197,10 +197,7 @@ class ApplicationStateMixin:
|
|||||||
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
|
||||||
@@ -211,5 +208,5 @@ class ApplicationStateMixin:
|
|||||||
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()
|
||||||
|
|||||||
@@ -16,12 +16,7 @@ class DialogMixin:
|
|||||||
making it easier to create, test, and maintain complex dialogs.
|
making it easier to create, test, and maintain complex dialogs.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def create_dialog(
|
def create_dialog(self, dialog_class: type, title: Optional[str] = None, **kwargs) -> Optional[Any]:
|
||||||
self,
|
|
||||||
dialog_class: type,
|
|
||||||
title: Optional[str] = None,
|
|
||||||
**kwargs
|
|
||||||
) -> Optional[Any]:
|
|
||||||
"""
|
"""
|
||||||
Create and show a dialog, handling the result.
|
Create and show a dialog, handling the result.
|
||||||
|
|
||||||
@@ -43,18 +38,13 @@ class DialogMixin:
|
|||||||
# Show dialog and handle result
|
# Show dialog and handle result
|
||||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||||
# Check if dialog has a get_values method
|
# Check if dialog has a get_values method
|
||||||
if hasattr(dialog, 'get_values'):
|
if hasattr(dialog, "get_values"):
|
||||||
return dialog.get_values()
|
return dialog.get_values()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def show_dialog(
|
def show_dialog(self, dialog_class: type, on_accept: Optional[Callable] = None, **kwargs) -> bool:
|
||||||
self,
|
|
||||||
dialog_class: type,
|
|
||||||
on_accept: Optional[Callable] = None,
|
|
||||||
**kwargs
|
|
||||||
) -> bool:
|
|
||||||
"""
|
"""
|
||||||
Show a dialog and execute callback on acceptance.
|
Show a dialog and execute callback on acceptance.
|
||||||
|
|
||||||
|
|||||||
@@ -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,20 @@ 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
|
||||||
from pyPhotoAlbum.snapping import SnapResizeParams
|
from pyPhotoAlbum.snapping import SnapResizeParams
|
||||||
|
|
||||||
params = SnapResizeParams(
|
params = SnapResizeParams(
|
||||||
position=self.resize_start_pos,
|
position=self.resize_start_pos,
|
||||||
size=self.resize_start_size,
|
size=self.resize_start_size,
|
||||||
@@ -70,7 +80,7 @@ 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)
|
new_pos, new_size = snap_sys.snap_resize(params)
|
||||||
|
|
||||||
@@ -96,20 +106,22 @@ class ElementManipulationMixin:
|
|||||||
"""
|
"""
|
||||||
if not self.resize_start_pos or not self.resize_start_size:
|
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
|
||||||
@@ -121,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.
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ class MoveCommandBuilder(CommandBuilder):
|
|||||||
|
|
||||||
def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
|
def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
|
||||||
"""Check if position changed significantly."""
|
"""Check if position changed significantly."""
|
||||||
old_pos = start_state.get('position')
|
old_pos = start_state.get("position")
|
||||||
if old_pos is None:
|
if old_pos is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ class MoveCommandBuilder(CommandBuilder):
|
|||||||
|
|
||||||
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
|
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
|
||||||
"""Build a MoveElementCommand."""
|
"""Build a MoveElementCommand."""
|
||||||
old_pos = start_state.get('position')
|
old_pos = start_state.get("position")
|
||||||
if old_pos is None:
|
if old_pos is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -79,6 +79,7 @@ class MoveCommandBuilder(CommandBuilder):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
from pyPhotoAlbum.commands import MoveElementCommand
|
from pyPhotoAlbum.commands import MoveElementCommand
|
||||||
|
|
||||||
command = MoveElementCommand(element, old_pos, new_pos)
|
command = MoveElementCommand(element, old_pos, new_pos)
|
||||||
|
|
||||||
self.log_command("Move", f"{old_pos} → {new_pos}")
|
self.log_command("Move", f"{old_pos} → {new_pos}")
|
||||||
@@ -90,8 +91,8 @@ class ResizeCommandBuilder(CommandBuilder):
|
|||||||
|
|
||||||
def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
|
def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
|
||||||
"""Check if position or size changed significantly."""
|
"""Check if position or size changed significantly."""
|
||||||
old_pos = start_state.get('position')
|
old_pos = start_state.get("position")
|
||||||
old_size = start_state.get('size')
|
old_size = start_state.get("size")
|
||||||
|
|
||||||
if old_pos is None or old_size is None:
|
if old_pos is None or old_size is None:
|
||||||
return False
|
return False
|
||||||
@@ -106,8 +107,8 @@ class ResizeCommandBuilder(CommandBuilder):
|
|||||||
|
|
||||||
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
|
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
|
||||||
"""Build a ResizeElementCommand."""
|
"""Build a ResizeElementCommand."""
|
||||||
old_pos = start_state.get('position')
|
old_pos = start_state.get("position")
|
||||||
old_size = start_state.get('size')
|
old_size = start_state.get("size")
|
||||||
|
|
||||||
if old_pos is None or old_size is None:
|
if old_pos is None or old_size is None:
|
||||||
return None
|
return None
|
||||||
@@ -119,6 +120,7 @@ class ResizeCommandBuilder(CommandBuilder):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
from pyPhotoAlbum.commands import ResizeElementCommand
|
from pyPhotoAlbum.commands import ResizeElementCommand
|
||||||
|
|
||||||
command = ResizeElementCommand(element, old_pos, old_size, new_pos, new_size)
|
command = ResizeElementCommand(element, old_pos, old_size, new_pos, new_size)
|
||||||
|
|
||||||
self.log_command("Resize", f"{old_size} → {new_size}")
|
self.log_command("Resize", f"{old_size} → {new_size}")
|
||||||
@@ -130,7 +132,7 @@ class RotateCommandBuilder(CommandBuilder):
|
|||||||
|
|
||||||
def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
|
def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
|
||||||
"""Check if rotation changed significantly."""
|
"""Check if rotation changed significantly."""
|
||||||
old_rotation = start_state.get('rotation')
|
old_rotation = start_state.get("rotation")
|
||||||
if old_rotation is None:
|
if old_rotation is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -139,7 +141,7 @@ class RotateCommandBuilder(CommandBuilder):
|
|||||||
|
|
||||||
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
|
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
|
||||||
"""Build a RotateElementCommand."""
|
"""Build a RotateElementCommand."""
|
||||||
old_rotation = start_state.get('rotation')
|
old_rotation = start_state.get("rotation")
|
||||||
if old_rotation is None:
|
if old_rotation is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -150,6 +152,7 @@ class RotateCommandBuilder(CommandBuilder):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
from pyPhotoAlbum.commands import RotateElementCommand
|
from pyPhotoAlbum.commands import RotateElementCommand
|
||||||
|
|
||||||
command = RotateElementCommand(element, old_rotation, new_rotation)
|
command = RotateElementCommand(element, old_rotation, new_rotation)
|
||||||
|
|
||||||
self.log_command("Rotation", f"{old_rotation:.1f}° → {new_rotation:.1f}°")
|
self.log_command("Rotation", f"{old_rotation:.1f}° → {new_rotation:.1f}°")
|
||||||
@@ -166,7 +169,7 @@ class ImagePanCommandBuilder(CommandBuilder):
|
|||||||
if not isinstance(element, ImageData):
|
if not isinstance(element, ImageData):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
old_crop = start_state.get('crop_info')
|
old_crop = start_state.get("crop_info")
|
||||||
if old_crop is None:
|
if old_crop is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -181,7 +184,7 @@ class ImagePanCommandBuilder(CommandBuilder):
|
|||||||
if not isinstance(element, ImageData):
|
if not isinstance(element, ImageData):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
old_crop = start_state.get('crop_info')
|
old_crop = start_state.get("crop_info")
|
||||||
if old_crop is None:
|
if old_crop is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -193,6 +196,7 @@ class ImagePanCommandBuilder(CommandBuilder):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
from pyPhotoAlbum.commands import AdjustImageCropCommand
|
from pyPhotoAlbum.commands import AdjustImageCropCommand
|
||||||
|
|
||||||
command = AdjustImageCropCommand(element, old_crop, new_crop)
|
command = AdjustImageCropCommand(element, old_crop, new_crop)
|
||||||
|
|
||||||
self.log_command("Image pan", f"{old_crop} → {new_crop}")
|
self.log_command("Image pan", f"{old_crop} → {new_crop}")
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from .interaction_command_builders import (
|
|||||||
MoveCommandBuilder,
|
MoveCommandBuilder,
|
||||||
ResizeCommandBuilder,
|
ResizeCommandBuilder,
|
||||||
RotateCommandBuilder,
|
RotateCommandBuilder,
|
||||||
ImagePanCommandBuilder
|
ImagePanCommandBuilder,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -31,10 +31,10 @@ class InteractionCommandFactory:
|
|||||||
|
|
||||||
def _register_default_builders(self):
|
def _register_default_builders(self):
|
||||||
"""Register the default command builders."""
|
"""Register the default command builders."""
|
||||||
self.register_builder('move', MoveCommandBuilder())
|
self.register_builder("move", MoveCommandBuilder())
|
||||||
self.register_builder('resize', ResizeCommandBuilder())
|
self.register_builder("resize", ResizeCommandBuilder())
|
||||||
self.register_builder('rotate', RotateCommandBuilder())
|
self.register_builder("rotate", RotateCommandBuilder())
|
||||||
self.register_builder('image_pan', ImagePanCommandBuilder())
|
self.register_builder("image_pan", ImagePanCommandBuilder())
|
||||||
|
|
||||||
def register_builder(self, interaction_type: str, builder: CommandBuilder):
|
def register_builder(self, interaction_type: str, builder: CommandBuilder):
|
||||||
"""
|
"""
|
||||||
@@ -46,11 +46,9 @@ class InteractionCommandFactory:
|
|||||||
"""
|
"""
|
||||||
self._builders[interaction_type] = builder
|
self._builders[interaction_type] = builder
|
||||||
|
|
||||||
def create_command(self,
|
def create_command(
|
||||||
interaction_type: str,
|
self, interaction_type: str, element: BaseLayoutElement, start_state: dict, **kwargs
|
||||||
element: BaseLayoutElement,
|
) -> Optional[Any]:
|
||||||
start_state: dict,
|
|
||||||
**kwargs) -> Optional[Any]:
|
|
||||||
"""
|
"""
|
||||||
Create a command based on interaction type and state changes.
|
Create a command based on interaction type and state changes.
|
||||||
|
|
||||||
@@ -91,13 +89,15 @@ class InteractionState:
|
|||||||
the code more maintainable.
|
the code more maintainable.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self,
|
def __init__(
|
||||||
element: Optional[BaseLayoutElement] = None,
|
self,
|
||||||
interaction_type: Optional[str] = None,
|
element: Optional[BaseLayoutElement] = None,
|
||||||
position: Optional[tuple] = None,
|
interaction_type: Optional[str] = None,
|
||||||
size: Optional[tuple] = None,
|
position: Optional[tuple] = None,
|
||||||
rotation: Optional[float] = None,
|
size: Optional[tuple] = None,
|
||||||
crop_info: Optional[tuple] = None):
|
rotation: Optional[float] = None,
|
||||||
|
crop_info: Optional[tuple] = None,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Initialize interaction state.
|
Initialize interaction state.
|
||||||
|
|
||||||
@@ -123,15 +123,15 @@ class InteractionState:
|
|||||||
Returns:
|
Returns:
|
||||||
Dict with non-None state values
|
Dict with non-None state values
|
||||||
"""
|
"""
|
||||||
state = {}
|
state: Dict[str, Any] = {}
|
||||||
if self.position is not None:
|
if self.position is not None:
|
||||||
state['position'] = self.position
|
state["position"] = self.position
|
||||||
if self.size is not None:
|
if self.size is not None:
|
||||||
state['size'] = self.size
|
state["size"] = self.size
|
||||||
if self.rotation is not None:
|
if self.rotation is not None:
|
||||||
state['rotation'] = self.rotation
|
state["rotation"] = self.rotation
|
||||||
if self.crop_info is not None:
|
if self.crop_info is not None:
|
||||||
state['crop_info'] = self.crop_info
|
state["crop_info"] = self.crop_info
|
||||||
return state
|
return state
|
||||||
|
|
||||||
def is_valid(self) -> bool:
|
def is_valid(self) -> bool:
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class UndoableInteractionMixin:
|
|||||||
element: The element being moved
|
element: The element being moved
|
||||||
"""
|
"""
|
||||||
self._interaction_state.element = element
|
self._interaction_state.element = element
|
||||||
self._interaction_state.interaction_type = 'move'
|
self._interaction_state.interaction_type = "move"
|
||||||
self._interaction_state.position = element.position
|
self._interaction_state.position = element.position
|
||||||
|
|
||||||
def _begin_resize(self, element: BaseLayoutElement):
|
def _begin_resize(self, element: BaseLayoutElement):
|
||||||
@@ -44,7 +44,7 @@ class UndoableInteractionMixin:
|
|||||||
element: The element being resized
|
element: The element being resized
|
||||||
"""
|
"""
|
||||||
self._interaction_state.element = element
|
self._interaction_state.element = element
|
||||||
self._interaction_state.interaction_type = 'resize'
|
self._interaction_state.interaction_type = "resize"
|
||||||
self._interaction_state.position = element.position
|
self._interaction_state.position = element.position
|
||||||
self._interaction_state.size = element.size
|
self._interaction_state.size = element.size
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ class UndoableInteractionMixin:
|
|||||||
element: The element being rotated
|
element: The element being rotated
|
||||||
"""
|
"""
|
||||||
self._interaction_state.element = element
|
self._interaction_state.element = element
|
||||||
self._interaction_state.interaction_type = 'rotate'
|
self._interaction_state.interaction_type = "rotate"
|
||||||
self._interaction_state.rotation = element.rotation
|
self._interaction_state.rotation = element.rotation
|
||||||
|
|
||||||
def _begin_image_pan(self, element):
|
def _begin_image_pan(self, element):
|
||||||
@@ -67,11 +67,12 @@ class UndoableInteractionMixin:
|
|||||||
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_state.element = element
|
self._interaction_state.element = element
|
||||||
self._interaction_state.interaction_type = 'image_pan'
|
self._interaction_state.interaction_type = "image_pan"
|
||||||
self._interaction_state.crop_info = element.crop_info
|
self._interaction_state.crop_info = element.crop_info
|
||||||
|
|
||||||
def _end_interaction(self):
|
def _end_interaction(self):
|
||||||
@@ -88,7 +89,7 @@ class UndoableInteractionMixin:
|
|||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
@@ -96,7 +97,7 @@ class UndoableInteractionMixin:
|
|||||||
command = self._command_factory.create_command(
|
command = self._command_factory.create_command(
|
||||||
interaction_type=self._interaction_state.interaction_type,
|
interaction_type=self._interaction_state.interaction_type,
|
||||||
element=self._interaction_state.element,
|
element=self._interaction_state.element,
|
||||||
start_state=self._interaction_state.to_dict()
|
start_state=self._interaction_state.to_dict(),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Execute the command through history if one was created
|
# Execute the command through history if one was created
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ def significant_change(threshold: float = 0.1):
|
|||||||
Returns:
|
Returns:
|
||||||
None if change is insignificant, otherwise returns the command builder result
|
None if change is insignificant, otherwise returns the command builder result
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(func):
|
def decorator(func):
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
@@ -23,7 +24,9 @@ def significant_change(threshold: float = 0.1):
|
|||||||
if result is None:
|
if result is None:
|
||||||
return None
|
return None
|
||||||
return result
|
return result
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
@@ -31,9 +34,9 @@ class ChangeValidator:
|
|||||||
"""Validates whether changes are significant enough to create commands."""
|
"""Validates whether changes are significant enough to create commands."""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def position_changed(old_pos: Optional[Tuple[float, float]],
|
def position_changed(
|
||||||
new_pos: Optional[Tuple[float, float]],
|
old_pos: Optional[Tuple[float, float]], new_pos: Optional[Tuple[float, float]], threshold: float = 0.1
|
||||||
threshold: float = 0.1) -> bool:
|
) -> bool:
|
||||||
"""Check if position changed significantly."""
|
"""Check if position changed significantly."""
|
||||||
if old_pos is None or new_pos is None:
|
if old_pos is None or new_pos is None:
|
||||||
return False
|
return False
|
||||||
@@ -43,9 +46,9 @@ class ChangeValidator:
|
|||||||
return dx > threshold or dy > threshold
|
return dx > threshold or dy > threshold
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def size_changed(old_size: Optional[Tuple[float, float]],
|
def size_changed(
|
||||||
new_size: Optional[Tuple[float, float]],
|
old_size: Optional[Tuple[float, float]], new_size: Optional[Tuple[float, float]], threshold: float = 0.1
|
||||||
threshold: float = 0.1) -> bool:
|
) -> bool:
|
||||||
"""Check if size changed significantly."""
|
"""Check if size changed significantly."""
|
||||||
if old_size is None or new_size is None:
|
if old_size is None or new_size is None:
|
||||||
return False
|
return False
|
||||||
@@ -55,9 +58,7 @@ class ChangeValidator:
|
|||||||
return dw > threshold or dh > threshold
|
return dw > threshold or dh > threshold
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def rotation_changed(old_rotation: Optional[float],
|
def rotation_changed(old_rotation: Optional[float], new_rotation: Optional[float], threshold: float = 0.1) -> bool:
|
||||||
new_rotation: Optional[float],
|
|
||||||
threshold: float = 0.1) -> bool:
|
|
||||||
"""Check if rotation changed significantly."""
|
"""Check if rotation changed significantly."""
|
||||||
if old_rotation is None or new_rotation is None:
|
if old_rotation is None or new_rotation is None:
|
||||||
return False
|
return False
|
||||||
@@ -65,9 +66,11 @@ class ChangeValidator:
|
|||||||
return abs(new_rotation - old_rotation) > threshold
|
return abs(new_rotation - old_rotation) > threshold
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def crop_changed(old_crop: Optional[Tuple[float, float, float, float]],
|
def crop_changed(
|
||||||
new_crop: Optional[Tuple[float, float, float, float]],
|
old_crop: Optional[Tuple[float, float, float, float]],
|
||||||
threshold: float = 0.001) -> bool:
|
new_crop: Optional[Tuple[float, float, float, float]],
|
||||||
|
threshold: float = 0.001,
|
||||||
|
) -> bool:
|
||||||
"""Check if crop info changed significantly."""
|
"""Check if crop info changed significantly."""
|
||||||
if old_crop is None or new_crop is None:
|
if old_crop is None or new_crop is None:
|
||||||
return False
|
return False
|
||||||
@@ -85,8 +88,7 @@ class InteractionChangeDetector:
|
|||||||
self.threshold = threshold
|
self.threshold = threshold
|
||||||
self.validator = ChangeValidator()
|
self.validator = ChangeValidator()
|
||||||
|
|
||||||
def detect_position_change(self, old_pos: Tuple[float, float],
|
def detect_position_change(self, old_pos: Tuple[float, float], new_pos: Tuple[float, float]) -> Optional[dict]:
|
||||||
new_pos: Tuple[float, float]) -> Optional[dict]:
|
|
||||||
"""
|
"""
|
||||||
Detect position change and return change info.
|
Detect position change and return change info.
|
||||||
|
|
||||||
@@ -97,14 +99,13 @@ class InteractionChangeDetector:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'old_position': old_pos,
|
"old_position": old_pos,
|
||||||
'new_position': new_pos,
|
"new_position": new_pos,
|
||||||
'delta_x': new_pos[0] - old_pos[0],
|
"delta_x": new_pos[0] - old_pos[0],
|
||||||
'delta_y': new_pos[1] - old_pos[1]
|
"delta_y": new_pos[1] - old_pos[1],
|
||||||
}
|
}
|
||||||
|
|
||||||
def detect_size_change(self, old_size: Tuple[float, float],
|
def detect_size_change(self, old_size: Tuple[float, float], new_size: Tuple[float, float]) -> Optional[dict]:
|
||||||
new_size: Tuple[float, float]) -> Optional[dict]:
|
|
||||||
"""
|
"""
|
||||||
Detect size change and return change info.
|
Detect size change and return change info.
|
||||||
|
|
||||||
@@ -115,14 +116,13 @@ class InteractionChangeDetector:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'old_size': old_size,
|
"old_size": old_size,
|
||||||
'new_size': new_size,
|
"new_size": new_size,
|
||||||
'delta_width': new_size[0] - old_size[0],
|
"delta_width": new_size[0] - old_size[0],
|
||||||
'delta_height': new_size[1] - old_size[1]
|
"delta_height": new_size[1] - old_size[1],
|
||||||
}
|
}
|
||||||
|
|
||||||
def detect_rotation_change(self, old_rotation: float,
|
def detect_rotation_change(self, old_rotation: float, new_rotation: float) -> Optional[dict]:
|
||||||
new_rotation: float) -> Optional[dict]:
|
|
||||||
"""
|
"""
|
||||||
Detect rotation change and return change info.
|
Detect rotation change and return change info.
|
||||||
|
|
||||||
@@ -132,14 +132,11 @@ class InteractionChangeDetector:
|
|||||||
if not self.validator.rotation_changed(old_rotation, new_rotation, self.threshold):
|
if not self.validator.rotation_changed(old_rotation, new_rotation, self.threshold):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return {
|
return {"old_rotation": old_rotation, "new_rotation": new_rotation, "delta_angle": new_rotation - old_rotation}
|
||||||
'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],
|
def detect_crop_change(
|
||||||
new_crop: Tuple[float, float, float, float]) -> Optional[dict]:
|
self, old_crop: Tuple[float, float, float, float], new_crop: Tuple[float, float, float, float]
|
||||||
|
) -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
Detect crop change and return change info.
|
Detect crop change and return change info.
|
||||||
|
|
||||||
@@ -149,8 +146,4 @@ class InteractionChangeDetector:
|
|||||||
if not self.validator.crop_changed(old_crop, new_crop, threshold=0.001):
|
if not self.validator.crop_changed(old_crop, new_crop, threshold=0.001):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return {
|
return {"old_crop": old_crop, "new_crop": new_crop, "delta": tuple(new_crop[i] - old_crop[i] for i in range(4))}
|
||||||
'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)
|
||||||
|
|||||||
@@ -3,9 +3,14 @@ Mouse interaction mixin for GLWidget - coordinates all mouse events
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import math
|
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:
|
||||||
@@ -16,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)
|
||||||
|
|
||||||
@@ -25,206 +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:
|
||||||
if not hasattr(self.selected_element, '_page_renderer'):
|
self._handle_image_pan_move(x, y, self.selected_element)
|
||||||
return
|
elif self.rotation_mode:
|
||||||
|
self._handle_rotation_move(x, y)
|
||||||
|
elif self.resize_handle:
|
||||||
|
self._handle_resize_move(x, y)
|
||||||
|
else:
|
||||||
|
self._handle_element_move(x, y)
|
||||||
|
|
||||||
renderer = self.selected_element._page_renderer
|
self.update()
|
||||||
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
|
||||||
@@ -293,47 +351,50 @@ class MouseInteractionMixin:
|
|||||||
if self.is_dragging and self.drag_start_pos:
|
if self.is_dragging and self.drag_start_pos:
|
||||||
pan_delta_x = self.pan_offset[0] - old_pan_x
|
pan_delta_x = self.pan_offset[0] - old_pan_x
|
||||||
pan_delta_y = self.pan_offset[1] - old_pan_y
|
pan_delta_y = self.pan_offset[1] - old_pan_y
|
||||||
self.drag_start_pos = (
|
self.drag_start_pos = (self.drag_start_pos[0] + pan_delta_x, self.drag_start_pos[1] + pan_delta_y)
|
||||||
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()
|
||||||
|
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]
|
old_pan_y = self.pan_offset[1]
|
||||||
self.pan_offset[1] += scroll_amount
|
|
||||||
|
self.pan_offset[0] += scroll_amount_x
|
||||||
|
self.pan_offset[1] += scroll_amount_y
|
||||||
|
|
||||||
# Clamp pan offset to content bounds
|
# 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 dragging, adjust drag_start_pos to account for pan_offset change
|
||||||
if self.is_dragging and self.drag_start_pos:
|
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
|
pan_delta_y = self.pan_offset[1] - old_pan_y
|
||||||
self.drag_start_pos = (
|
self.drag_start_pos = (self.drag_start_pos[0] + pan_delta_x, self.drag_start_pos[1] + pan_delta_y)
|
||||||
self.drag_start_pos[0],
|
|
||||||
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):
|
||||||
@@ -344,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()
|
||||||
|
|
||||||
|
|||||||
@@ -13,17 +13,19 @@ from pyPhotoAlbum.mixins.operations.distribution_ops import DistributionOperatio
|
|||||||
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.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',
|
"MergeOperationsMixin",
|
||||||
|
"StyleOperationsMixin",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -14,26 +14,36 @@ class AlignmentOperationsMixin:
|
|||||||
"""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",
|
||||||
@@ -41,20 +51,11 @@ class AlignmentOperationsMixin:
|
|||||||
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",
|
||||||
@@ -62,20 +63,11 @@ class AlignmentOperationsMixin:
|
|||||||
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",
|
||||||
@@ -83,20 +75,11 @@ class AlignmentOperationsMixin:
|
|||||||
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",
|
||||||
@@ -104,20 +87,11 @@ class AlignmentOperationsMixin:
|
|||||||
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",
|
||||||
@@ -125,20 +99,11 @@ class AlignmentOperationsMixin:
|
|||||||
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"""
|
||||||
|
|||||||
@@ -14,26 +14,36 @@ class DistributionOperationsMixin:
|
|||||||
"""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",
|
||||||
@@ -41,20 +51,11 @@ class DistributionOperationsMixin:
|
|||||||
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",
|
||||||
@@ -62,20 +63,11 @@ class DistributionOperationsMixin:
|
|||||||
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",
|
||||||
@@ -83,17 +75,8 @@ class DistributionOperationsMixin:
|
|||||||
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)
|
|
||||||
|
|||||||
@@ -9,13 +9,7 @@ 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():
|
||||||
@@ -27,11 +21,7 @@ class EditOperationsMixin:
|
|||||||
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"""
|
||||||
@@ -49,7 +39,7 @@ class EditOperationsMixin:
|
|||||||
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"""
|
||||||
@@ -65,11 +55,7 @@ class EditOperationsMixin:
|
|||||||
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
|
||||||
@@ -88,9 +74,9 @@ class EditOperationsMixin:
|
|||||||
@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"""
|
||||||
@@ -111,9 +97,9 @@ class EditOperationsMixin:
|
|||||||
@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"""
|
||||||
@@ -134,9 +120,9 @@ class EditOperationsMixin:
|
|||||||
@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"""
|
||||||
|
|||||||
@@ -13,11 +13,7 @@ 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"""
|
||||||
@@ -25,10 +21,7 @@ class ElementOperationsMixin:
|
|||||||
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:
|
||||||
@@ -59,20 +52,10 @@ class ElementOperationsMixin:
|
|||||||
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()
|
||||||
@@ -84,11 +67,7 @@ class ElementOperationsMixin:
|
|||||||
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"""
|
||||||
@@ -110,13 +89,7 @@ class ElementOperationsMixin:
|
|||||||
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()
|
||||||
@@ -128,7 +101,7 @@ class ElementOperationsMixin:
|
|||||||
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"""
|
||||||
@@ -151,11 +124,7 @@ class ElementOperationsMixin:
|
|||||||
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)
|
||||||
|
|||||||
@@ -3,32 +3,64 @@ File operations mixin for pyPhotoAlbum
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
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
|
||||||
@@ -153,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
|
||||||
@@ -174,27 +206,18 @@ class FileOperationsMixin:
|
|||||||
# 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
|
||||||
@@ -214,29 +237,29 @@ 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
|
# Set file path and mark as clean
|
||||||
if hasattr(self, '_opening_file_path'):
|
if hasattr(self, "_opening_file_path"):
|
||||||
self.project.file_path = self._opening_file_path
|
self.project.file_path = self._opening_file_path
|
||||||
delattr(self, '_opening_file_path')
|
delattr(self, "_opening_file_path")
|
||||||
self.project.mark_clean()
|
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)
|
||||||
@@ -254,7 +277,7 @@ class FileOperationsMixin:
|
|||||||
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}"
|
||||||
@@ -262,48 +285,89 @@ class FileOperationsMixin:
|
|||||||
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...")
|
||||||
|
return False
|
||||||
|
|
||||||
# If project has a file path, use it; otherwise prompt for location
|
# If project has a file path, use it; otherwise prompt for location
|
||||||
file_path = self.project.file_path if hasattr(self.project, 'file_path') and self.project.file_path else None
|
file_path = self.project.file_path if hasattr(self.project, "file_path") and self.project.file_path else None
|
||||||
|
|
||||||
if not file_path:
|
if not file_path:
|
||||||
file_path, _ = QFileDialog.getSaveFileName(
|
file_path, _ = QFileDialog.getSaveFileName(
|
||||||
self,
|
self, "Save Project", "", "pyPhotoAlbum Projects (*.ppz);;All Files (*)" # type: ignore[arg-type]
|
||||||
"Save Project",
|
|
||||||
"",
|
|
||||||
"pyPhotoAlbum Projects (*.ppz);;All Files (*)"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if file_path:
|
if not file_path:
|
||||||
print(f"Saving project to: {file_path}")
|
return False
|
||||||
|
|
||||||
# Save project to ZIP
|
self._save_in_progress = True
|
||||||
success, error = save_to_zip(self.project, file_path)
|
print(f"Saving project to: {file_path}")
|
||||||
|
|
||||||
|
# Create loading widget if not exists
|
||||||
|
if not hasattr(self, "_loading_widget"):
|
||||||
|
self._loading_widget = LoadingWidget(self)
|
||||||
|
|
||||||
|
# Show loading widget
|
||||||
|
self._loading_widget.show_loading("Saving project...")
|
||||||
|
|
||||||
|
# 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.file_path = file_path
|
||||||
self.project.mark_clean()
|
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)
|
||||||
@@ -343,34 +407,30 @@ class FileOperationsMixin:
|
|||||||
asset_list = "\n".join(f" • {path}" for path in missing_assets[:5])
|
asset_list = "\n".join(f" • {path}" for path in missing_assets[:5])
|
||||||
asset_list += f"\n ... and {len(missing_assets) - 5} more"
|
asset_list += f"\n ... and {len(missing_assets) - 5} more"
|
||||||
|
|
||||||
msg = QMessageBox(self)
|
msg = QMessageBox(cast(QWidget, self))
|
||||||
msg.setIcon(QMessageBox.Icon.Warning)
|
msg.setIcon(QMessageBox.Icon.Warning)
|
||||||
msg.setWindowTitle("Missing Assets")
|
msg.setWindowTitle("Missing Assets")
|
||||||
msg.setText(f"{len(missing_assets)} image(s) could not be found in the assets folder:")
|
msg.setText(f"{len(missing_assets)} image(s) could not be found in the assets folder:")
|
||||||
msg.setInformativeText(asset_list)
|
msg.setInformativeText(asset_list)
|
||||||
msg.setDetailedText("These images need to be reconnected using the 'Heal Assets' feature.\n\n"
|
msg.setDetailedText(
|
||||||
"Go to: Home → Heal Assets\n\n"
|
"These images need to be reconnected using the 'Heal Assets' feature.\n\n"
|
||||||
"Add search paths where the original images might be located, "
|
"Go to: Home → Heal Assets\n\n"
|
||||||
"then click 'Attempt Healing' to find and import them.")
|
"Add search paths where the original images might be located, "
|
||||||
|
"then click 'Attempt Healing' to find and import them."
|
||||||
|
)
|
||||||
msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Open)
|
msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Open)
|
||||||
msg.button(QMessageBox.StandardButton.Open).setText("Open Heal Assets")
|
btn = msg.button(QMessageBox.StandardButton.Open)
|
||||||
|
if btn is not None:
|
||||||
|
btn.setText("Open Heal Assets")
|
||||||
|
|
||||||
result = msg.exec()
|
result = msg.exec()
|
||||||
if result == QMessageBox.StandardButton.Open:
|
if result == QMessageBox.StandardButton.Open:
|
||||||
self.heal_assets()
|
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
|
||||||
@@ -436,14 +496,24 @@ class FileOperationsMixin:
|
|||||||
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)")
|
||||||
@@ -493,12 +563,15 @@ class FileOperationsMixin:
|
|||||||
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
|
||||||
@@ -506,22 +579,23 @@ class FileOperationsMixin:
|
|||||||
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
|
||||||
@@ -530,8 +604,9 @@ class FileOperationsMixin:
|
|||||||
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
|
||||||
|
if page.manually_sized and not include_manual:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Update page size
|
# Update page size
|
||||||
@@ -544,14 +619,14 @@ class FileOperationsMixin:
|
|||||||
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
|
||||||
@@ -593,12 +668,7 @@ class FileOperationsMixin:
|
|||||||
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
|
||||||
@@ -607,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)
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ class MergeOperationsMixin:
|
|||||||
@ribbon_action(
|
@ribbon_action(
|
||||||
label="Merge Projects",
|
label="Merge Projects",
|
||||||
tooltip="Merge another project file with the current project",
|
tooltip="Merge another project file with the current project",
|
||||||
tab="File",
|
tab="Home",
|
||||||
group="Import/Export"
|
group="File",
|
||||||
)
|
)
|
||||||
def merge_projects(self):
|
def merge_projects(self):
|
||||||
"""
|
"""
|
||||||
@@ -35,22 +35,19 @@ class MergeOperationsMixin:
|
|||||||
self,
|
self,
|
||||||
"Unsaved Changes",
|
"Unsaved Changes",
|
||||||
"You have unsaved changes in the current project. Save before merging?",
|
"You have unsaved changes in the current project. Save before merging?",
|
||||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel,
|
||||||
)
|
)
|
||||||
|
|
||||||
if reply == QMessageBox.StandardButton.Cancel:
|
if reply == QMessageBox.StandardButton.Cancel:
|
||||||
return
|
return
|
||||||
elif reply == QMessageBox.StandardButton.Yes:
|
elif reply == QMessageBox.StandardButton.Yes:
|
||||||
# Save current project first
|
# Save current project first
|
||||||
if hasattr(self, 'save_project'):
|
if hasattr(self, "save_project"):
|
||||||
self.save_project()
|
self.save_project()
|
||||||
|
|
||||||
# Select file to merge
|
# Select file to merge
|
||||||
file_path, _ = QFileDialog.getOpenFileName(
|
file_path, _ = QFileDialog.getOpenFileName(
|
||||||
self,
|
self, "Select Project to Merge", "", "Photo Album Projects (*.ppz);;All Files (*)"
|
||||||
"Select Project to Merge",
|
|
||||||
"",
|
|
||||||
"Photo Album Projects (*.ppz);;All Files (*)"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not file_path:
|
if not file_path:
|
||||||
@@ -58,7 +55,7 @@ class MergeOperationsMixin:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Disable autosave during merge
|
# Disable autosave during merge
|
||||||
if hasattr(self, '_autosave_timer'):
|
if hasattr(self, "_autosave_timer"):
|
||||||
self._autosave_timer.stop()
|
self._autosave_timer.stop()
|
||||||
|
|
||||||
# Load the other project
|
# Load the other project
|
||||||
@@ -82,14 +79,10 @@ class MergeOperationsMixin:
|
|||||||
self._perform_concatenation(our_data, their_data)
|
self._perform_concatenation(our_data, their_data)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(
|
QMessageBox.critical(self, "Merge Error", f"Failed to merge projects:\n{str(e)}")
|
||||||
self,
|
|
||||||
"Merge Error",
|
|
||||||
f"Failed to merge projects:\n{str(e)}"
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
# Re-enable autosave
|
# Re-enable autosave
|
||||||
if hasattr(self, '_autosave_timer'):
|
if hasattr(self, "_autosave_timer"):
|
||||||
self._autosave_timer.start()
|
self._autosave_timer.start()
|
||||||
|
|
||||||
def _perform_merge_with_conflicts(self, our_data, their_data):
|
def _perform_merge_with_conflicts(self, our_data, their_data):
|
||||||
@@ -104,7 +97,7 @@ class MergeOperationsMixin:
|
|||||||
self,
|
self,
|
||||||
"No Conflicts",
|
"No Conflicts",
|
||||||
"No conflicts detected. Merge projects automatically?",
|
"No conflicts detected. Merge projects automatically?",
|
||||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
)
|
)
|
||||||
|
|
||||||
if reply != QMessageBox.StandardButton.Yes:
|
if reply != QMessageBox.StandardButton.Yes:
|
||||||
@@ -117,11 +110,7 @@ class MergeOperationsMixin:
|
|||||||
dialog = MergeDialog(our_data, their_data, self)
|
dialog = MergeDialog(our_data, their_data, self)
|
||||||
|
|
||||||
if dialog.exec() != QMessageBox.DialogCode.Accepted:
|
if dialog.exec() != QMessageBox.DialogCode.Accepted:
|
||||||
QMessageBox.information(
|
QMessageBox.information(self, "Merge Cancelled", "Merge operation cancelled.")
|
||||||
self,
|
|
||||||
"Merge Cancelled",
|
|
||||||
"Merge operation cancelled."
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get merged data from dialog
|
# Get merged data from dialog
|
||||||
@@ -135,7 +124,7 @@ class MergeOperationsMixin:
|
|||||||
"Merge Complete",
|
"Merge Complete",
|
||||||
f"Projects merged successfully.\n"
|
f"Projects merged successfully.\n"
|
||||||
f"Total pages: {len(merged_data.get('pages', []))}\n"
|
f"Total pages: {len(merged_data.get('pages', []))}\n"
|
||||||
f"Resolved conflicts: {len(conflicts)}"
|
f"Resolved conflicts: {len(conflicts)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _perform_concatenation(self, our_data, their_data):
|
def _perform_concatenation(self, our_data, their_data):
|
||||||
@@ -147,7 +136,7 @@ class MergeOperationsMixin:
|
|||||||
f" • {our_data.get('name', 'Untitled')}\n"
|
f" • {our_data.get('name', 'Untitled')}\n"
|
||||||
f" • {their_data.get('name', 'Untitled')}\n\n"
|
f" • {their_data.get('name', 'Untitled')}\n\n"
|
||||||
f"Concatenate them (combine all pages)?",
|
f"Concatenate them (combine all pages)?",
|
||||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
)
|
)
|
||||||
|
|
||||||
if reply != QMessageBox.StandardButton.Yes:
|
if reply != QMessageBox.StandardButton.Yes:
|
||||||
@@ -162,8 +151,7 @@ class MergeOperationsMixin:
|
|||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self,
|
self,
|
||||||
"Concatenation Complete",
|
"Concatenation Complete",
|
||||||
f"Projects concatenated successfully.\n"
|
f"Projects concatenated successfully.\n" f"Total pages: {len(merged_data.get('pages', []))}",
|
||||||
f"Total pages: {len(merged_data.get('pages', []))}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _apply_merged_data(self, merged_data):
|
def _apply_merged_data(self, merged_data):
|
||||||
@@ -182,9 +170,9 @@ class MergeOperationsMixin:
|
|||||||
new_project.mark_dirty()
|
new_project.mark_dirty()
|
||||||
|
|
||||||
# Update UI
|
# Update UI
|
||||||
if hasattr(self, 'gl_widget'):
|
if hasattr(self, "gl_widget"):
|
||||||
self.gl_widget.set_project(new_project)
|
self.gl_widget.set_project(new_project)
|
||||||
self.gl_widget.update()
|
self.gl_widget.update()
|
||||||
|
|
||||||
if hasattr(self, 'status_bar'):
|
if hasattr(self, "status_bar"):
|
||||||
self.status_bar.showMessage("Merge completed successfully", 3000)
|
self.status_bar.showMessage("Merge completed successfully", 3000)
|
||||||
|
|||||||
@@ -14,12 +14,7 @@ class PageOperationsMixin:
|
|||||||
# 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 after the current page"""
|
"""Add a new page to the project after the current page"""
|
||||||
# Get the most visible page in viewport to determine insertion point
|
# Get the most visible page in viewport to determine insertion point
|
||||||
@@ -73,12 +68,7 @@ class PageOperationsMixin:
|
|||||||
new_page_name = self.project.get_page_display_name(new_page)
|
new_page_name = self.project.get_page_display_name(new_page)
|
||||||
print(f"Added {new_page_name} at position {insert_index + 1} with size {width_mm}×{height_mm} mm")
|
print(f"Added {new_page_name} at position {insert_index + 1} with size {width_mm}×{height_mm} mm")
|
||||||
|
|
||||||
@ribbon_action(
|
@ribbon_action(label="Page Setup", tooltip="Configure page size and settings", tab="Layout", group="Page")
|
||||||
label="Page Setup",
|
|
||||||
tooltip="Configure page size and settings",
|
|
||||||
tab="Layout",
|
|
||||||
group="Page"
|
|
||||||
)
|
|
||||||
@dialog_action(dialog_class=PageSetupDialog, requires_pages=True)
|
@dialog_action(dialog_class=PageSetupDialog, requires_pages=True)
|
||||||
def page_setup(self, values):
|
def page_setup(self, values):
|
||||||
"""
|
"""
|
||||||
@@ -90,17 +80,17 @@ class PageOperationsMixin:
|
|||||||
Args:
|
Args:
|
||||||
values: Dictionary of values from the dialog
|
values: Dictionary of values from the dialog
|
||||||
"""
|
"""
|
||||||
selected_page = values['selected_page']
|
selected_page = values["selected_page"]
|
||||||
selected_index = values['selected_index']
|
selected_index = values["selected_index"]
|
||||||
|
|
||||||
# Update project cover settings
|
# Update project cover settings
|
||||||
self.project.paper_thickness_mm = values['paper_thickness_mm']
|
self.project.paper_thickness_mm = values["paper_thickness_mm"]
|
||||||
self.project.cover_bleed_mm = values['cover_bleed_mm']
|
self.project.cover_bleed_mm = values["cover_bleed_mm"]
|
||||||
|
|
||||||
# Handle cover designation (only for first page)
|
# Handle cover designation (only for first page)
|
||||||
if selected_index == 0:
|
if selected_index == 0:
|
||||||
was_cover = selected_page.is_cover
|
was_cover = selected_page.is_cover
|
||||||
is_cover = values['is_cover']
|
is_cover = values["is_cover"]
|
||||||
|
|
||||||
if was_cover != is_cover:
|
if was_cover != is_cover:
|
||||||
selected_page.is_cover = is_cover
|
selected_page.is_cover = is_cover
|
||||||
@@ -116,8 +106,8 @@ class PageOperationsMixin:
|
|||||||
print(f"Cover removed from page 1")
|
print(f"Cover removed from page 1")
|
||||||
|
|
||||||
# Get new values
|
# Get new values
|
||||||
width_mm = values['width_mm']
|
width_mm = values["width_mm"]
|
||||||
height_mm = values['height_mm']
|
height_mm = values["height_mm"]
|
||||||
|
|
||||||
# Don't allow manual size changes for covers
|
# Don't allow manual size changes for covers
|
||||||
if not selected_page.is_cover:
|
if not selected_page.is_cover:
|
||||||
@@ -126,11 +116,11 @@ class PageOperationsMixin:
|
|||||||
if selected_page.is_double_spread:
|
if selected_page.is_double_spread:
|
||||||
old_base_width = (
|
old_base_width = (
|
||||||
selected_page.layout.base_width
|
selected_page.layout.base_width
|
||||||
if hasattr(selected_page.layout, 'base_width')
|
if hasattr(selected_page.layout, "base_width")
|
||||||
else selected_page.layout.size[0] / 2
|
else selected_page.layout.size[0] / 2
|
||||||
)
|
)
|
||||||
old_height = selected_page.layout.size[1]
|
old_height = selected_page.layout.size[1]
|
||||||
size_changed = (old_base_width != width_mm or old_height != height_mm)
|
size_changed = old_base_width != width_mm or old_height != height_mm
|
||||||
|
|
||||||
if size_changed:
|
if size_changed:
|
||||||
# Update double spread
|
# Update double spread
|
||||||
@@ -143,7 +133,7 @@ class PageOperationsMixin:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
old_size = selected_page.layout.size
|
old_size = selected_page.layout.size
|
||||||
size_changed = (old_size != (width_mm, height_mm))
|
size_changed = old_size != (width_mm, height_mm)
|
||||||
|
|
||||||
if size_changed:
|
if size_changed:
|
||||||
# Update single page
|
# Update single page
|
||||||
@@ -151,19 +141,32 @@ class PageOperationsMixin:
|
|||||||
selected_page.layout.base_width = width_mm
|
selected_page.layout.base_width = width_mm
|
||||||
selected_page.manually_sized = True
|
selected_page.manually_sized = True
|
||||||
print(
|
print(
|
||||||
f"{self.project.get_page_display_name(selected_page)} "
|
f"{self.project.get_page_display_name(selected_page)} " f"updated to {width_mm}×{height_mm} mm"
|
||||||
f"updated to {width_mm}×{height_mm} mm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update DPI settings
|
# Update DPI settings
|
||||||
self.project.working_dpi = values['working_dpi']
|
self.project.working_dpi = values["working_dpi"]
|
||||||
self.project.export_dpi = values['export_dpi']
|
self.project.export_dpi = values["export_dpi"]
|
||||||
|
|
||||||
# Set as default if checkbox is checked
|
# Apply to other pages based on scope
|
||||||
if values['set_as_default']:
|
# 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)
|
self.project.page_size_mm = (width_mm, height_mm)
|
||||||
print(f"Project default page size set to {width_mm}×{height_mm} 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()
|
self.update_view()
|
||||||
|
|
||||||
# Build status message
|
# Build status message
|
||||||
@@ -172,15 +175,12 @@ class PageOperationsMixin:
|
|||||||
status_msg = f"{page_name} updated"
|
status_msg = f"{page_name} updated"
|
||||||
else:
|
else:
|
||||||
status_msg = f"{page_name} size: {width_mm}×{height_mm} mm"
|
status_msg = f"{page_name} size: {width_mm}×{height_mm} mm"
|
||||||
if values['set_as_default']:
|
if values.get("apply_scope", 0) in (1, 2):
|
||||||
status_msg += " (set as default)"
|
status_msg += " (set as default)"
|
||||||
self.show_status(status_msg, 2000)
|
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"""
|
||||||
@@ -208,7 +208,7 @@ class PageOperationsMixin:
|
|||||||
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
|
||||||
@@ -228,12 +228,7 @@ class PageOperationsMixin:
|
|||||||
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:
|
||||||
|
|||||||
@@ -14,26 +14,61 @@ class SizeOperationsMixin:
|
|||||||
"""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",
|
||||||
@@ -41,20 +76,11 @@ class SizeOperationsMixin:
|
|||||||
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",
|
||||||
@@ -62,20 +88,11 @@ class SizeOperationsMixin:
|
|||||||
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,9 +3,16 @@ 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
|
||||||
|
|
||||||
@@ -18,7 +25,7 @@ class TemplateOperationsMixin:
|
|||||||
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"""
|
||||||
@@ -36,37 +43,26 @@ class TemplateOperationsMixin:
|
|||||||
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}")
|
||||||
|
|
||||||
@@ -75,10 +71,7 @@ class TemplateOperationsMixin:
|
|||||||
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
|
||||||
@@ -200,9 +192,9 @@ class TemplateOperationsMixin:
|
|||||||
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()
|
||||||
@@ -214,8 +206,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
|
||||||
|
|
||||||
@@ -320,11 +311,7 @@ class TemplateOperationsMixin:
|
|||||||
|
|
||||||
# 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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -23,13 +18,7 @@ class ViewOperationsMixin:
|
|||||||
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
|
||||||
@@ -38,13 +27,7 @@ class ViewOperationsMixin:
|
|||||||
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:
|
||||||
@@ -73,10 +56,11 @@ class ViewOperationsMixin:
|
|||||||
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"""
|
||||||
@@ -91,10 +75,11 @@ class ViewOperationsMixin:
|
|||||||
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"""
|
||||||
@@ -108,12 +93,7 @@ class ViewOperationsMixin:
|
|||||||
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:
|
||||||
@@ -126,12 +106,7 @@ class ViewOperationsMixin:
|
|||||||
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:
|
||||||
@@ -162,12 +164,7 @@ class ViewOperationsMixin:
|
|||||||
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()
|
||||||
@@ -176,18 +173,13 @@ class ViewOperationsMixin:
|
|||||||
|
|
||||||
# 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()
|
||||||
@@ -196,18 +188,13 @@ class ViewOperationsMixin:
|
|||||||
|
|
||||||
# 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()
|
||||||
@@ -222,10 +209,24 @@ class ViewOperationsMixin:
|
|||||||
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()
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class ZOrderOperationsMixin:
|
|||||||
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)"""
|
||||||
@@ -53,7 +53,7 @@ class ZOrderOperationsMixin:
|
|||||||
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)"""
|
||||||
@@ -91,7 +91,7 @@ class ZOrderOperationsMixin:
|
|||||||
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"""
|
||||||
@@ -129,7 +129,7 @@ class ZOrderOperationsMixin:
|
|||||||
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"""
|
||||||
@@ -168,7 +168,7 @@ 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"""
|
||||||
|
|||||||
@@ -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)}%")
|
||||||
|
|||||||
@@ -25,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
|
||||||
@@ -36,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()
|
||||||
@@ -52,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
|
||||||
|
|
||||||
@@ -68,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))
|
||||||
@@ -76,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
|
||||||
|
|
||||||
@@ -92,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
|
||||||
@@ -109,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):
|
||||||
@@ -118,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
|
||||||
@@ -188,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)
|
||||||
@@ -214,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)
|
||||||
@@ -236,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()
|
||||||
@@ -289,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
|
||||||
|
|||||||
+101
-123
@@ -61,7 +61,7 @@ class ViewportMixin:
|
|||||||
|
|
||||||
# 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):
|
||||||
@@ -72,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()
|
||||||
@@ -106,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()
|
||||||
@@ -141,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
|
||||||
@@ -167,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.
|
||||||
@@ -188,124 +266,24 @@ class ViewportMixin:
|
|||||||
when zooming on pages of different widths.
|
when zooming on pages of different widths.
|
||||||
"""
|
"""
|
||||||
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
|
||||||
|
|
||||||
viewport_width = self.width()
|
viewport_width = self.width()
|
||||||
viewport_height = self.height()
|
viewport_height = self.height()
|
||||||
|
|
||||||
|
# Vertical clamping (returns original pan_y for page selection)
|
||||||
|
original_pan_y = self._clamp_vertical_pan(viewport_height)
|
||||||
|
|
||||||
|
# Build page centerline data
|
||||||
dpi = main_window.project.working_dpi
|
dpi = main_window.project.working_dpi
|
||||||
PAGE_MARGIN = 50
|
centerlines = self._build_page_centerlines(main_window.project.pages, dpi)
|
||||||
PAGE_SPACING = 50
|
if not centerlines:
|
||||||
|
|
||||||
# Vertical clamping
|
|
||||||
bounds = self.get_content_bounds()
|
|
||||||
content_height = bounds['height']
|
|
||||||
|
|
||||||
# Save original pan_offset[1] BEFORE clamping for page selection
|
|
||||||
# This prevents clamping from changing which page we think we're on
|
|
||||||
original_pan_y = self.pan_offset[1]
|
|
||||||
|
|
||||||
if content_height > viewport_height:
|
|
||||||
# Content is taller than viewport - restrict panning
|
|
||||||
max_pan_up = 0 # Can't pan beyond top edge
|
|
||||||
min_pan_up = -(content_height - viewport_height) # Can't pan beyond bottom edge
|
|
||||||
self.pan_offset[1] = max(min_pan_up, min(max_pan_up, self.pan_offset[1]))
|
|
||||||
# Don't force centering when content fits - preserve scroll position
|
|
||||||
# This prevents jumping when zooming in/out across the content_height == viewport_height boundary
|
|
||||||
|
|
||||||
# Horizontal clamping - centerline-based approach
|
|
||||||
# Calculate the centerline position for each page and interpolate
|
|
||||||
|
|
||||||
# Build list of page centerlines and their Y positions
|
|
||||||
page_centerlines = []
|
|
||||||
current_y = PAGE_MARGIN
|
|
||||||
|
|
||||||
for page in main_window.project.pages:
|
|
||||||
page_width_mm, page_height_mm = page.layout.size
|
|
||||||
page_width_px = page_width_mm * dpi / 25.4
|
|
||||||
page_height_px = page_height_mm * dpi / 25.4
|
|
||||||
|
|
||||||
screen_page_width = page_width_px * self.zoom_level
|
|
||||||
screen_page_height = page_height_px * self.zoom_level
|
|
||||||
|
|
||||||
# Calculate page center Y position (in world coordinates)
|
|
||||||
page_center_y = current_y + screen_page_height / 2
|
|
||||||
|
|
||||||
# Calculate the centerline X position (center of the page)
|
|
||||||
# Pages are left-aligned at PAGE_MARGIN, so center is at PAGE_MARGIN + width/2
|
|
||||||
page_center_x = PAGE_MARGIN + screen_page_width / 2
|
|
||||||
|
|
||||||
page_centerlines.append((page_center_y, page_center_x, screen_page_width))
|
|
||||||
|
|
||||||
current_y += screen_page_height + PAGE_SPACING
|
|
||||||
|
|
||||||
if not page_centerlines:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Determine current viewport center Y in world coordinates using ORIGINAL pan_y
|
# Find target centerline by interpolating based on viewport position
|
||||||
# This prevents vertical clamping from changing which page we're targeting
|
viewport_center_y = -original_pan_y + viewport_height / 2
|
||||||
# viewport_center_y in screen coords = viewport_height / 2
|
target_x, target_width = self._interpolate_target_centerline(centerlines, viewport_center_y)
|
||||||
# Convert to world coords: world_y = (screen_y - pan_offset[1]) / zoom_level
|
|
||||||
# But we want screen position, so we use pan_offset directly
|
|
||||||
viewport_center_y_world = -original_pan_y + viewport_height / 2
|
|
||||||
|
|
||||||
# Find which pages we're between and interpolate
|
# Horizontal clamping
|
||||||
target_centerline_x = page_centerlines[0][1] # Default to first page
|
self._clamp_horizontal_pan(viewport_width, target_x, target_width)
|
||||||
target_page_width = page_centerlines[0][2]
|
|
||||||
selected_page_index = 0
|
|
||||||
|
|
||||||
for i in range(len(page_centerlines)):
|
|
||||||
page_y, page_x, page_w = page_centerlines[i]
|
|
||||||
|
|
||||||
if viewport_center_y_world <= page_y:
|
|
||||||
# We're above or at this page's center
|
|
||||||
if i == 0:
|
|
||||||
# First page
|
|
||||||
target_centerline_x = page_x
|
|
||||||
target_page_width = page_w
|
|
||||||
selected_page_index = 0
|
|
||||||
else:
|
|
||||||
# Interpolate between previous and current page
|
|
||||||
prev_y, prev_x, prev_w = page_centerlines[i - 1]
|
|
||||||
|
|
||||||
# Linear interpolation factor
|
|
||||||
if page_y != prev_y:
|
|
||||||
t = (viewport_center_y_world - prev_y) / (page_y - prev_y)
|
|
||||||
t = max(0, min(1, t)) # Clamp to [0, 1]
|
|
||||||
|
|
||||||
target_centerline_x = prev_x + t * (page_x - prev_x)
|
|
||||||
target_page_width = prev_w + t * (page_w - prev_w)
|
|
||||||
selected_page_index = i - 1 if t < 0.5 else i
|
|
||||||
else:
|
|
||||||
target_centerline_x = page_x
|
|
||||||
target_page_width = page_w
|
|
||||||
selected_page_index = i
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
# We're below all pages - use last page
|
|
||||||
target_centerline_x = page_centerlines[-1][1]
|
|
||||||
target_page_width = page_centerlines[-1][2]
|
|
||||||
selected_page_index = len(page_centerlines) - 1
|
|
||||||
|
|
||||||
# Horizontal clamping to keep viewport reasonably centered on the page
|
|
||||||
# The centerline should ideally be at viewport_width / 2
|
|
||||||
ideal_pan_x = viewport_width / 2 - target_centerline_x
|
|
||||||
|
|
||||||
# Calculate how far we need to allow panning to see the full width of the page
|
|
||||||
# If page is wider than viewport, allow panning to see left and right edges
|
|
||||||
# If page is narrower, keep it centered
|
|
||||||
if target_page_width > viewport_width:
|
|
||||||
# Page wider than viewport - allow panning to see edges plus some margin
|
|
||||||
# Allow user to pan to see any part of the page, with reasonable overshoot
|
|
||||||
max_deviation = (target_page_width / 2) + (viewport_width / 4)
|
|
||||||
else:
|
|
||||||
# Page narrower than viewport - keep centered with small margin for stability
|
|
||||||
max_deviation = 100 # Small margin to avoid jitter
|
|
||||||
|
|
||||||
# Calculate bounds
|
|
||||||
min_pan_x = ideal_pan_x - max_deviation
|
|
||||||
max_pan_x = ideal_pan_x + max_deviation
|
|
||||||
|
|
||||||
old_pan_x = self.pan_offset[0]
|
|
||||||
self.pan_offset[0] = max(min_pan_x, min(max_pan_x, self.pan_offset[0]))
|
|
||||||
|
|||||||
+408
-69
@@ -13,18 +13,191 @@ from PIL import Image
|
|||||||
|
|
||||||
from pyPhotoAlbum.image_utils import apply_pil_rotation, calculate_center_crop_coords
|
from pyPhotoAlbum.image_utils import apply_pil_rotation, calculate_center_crop_coords
|
||||||
from pyPhotoAlbum.gl_imports import (
|
from pyPhotoAlbum.gl_imports import (
|
||||||
GL_AVAILABLE, glBegin, glEnd, glVertex2f, glColor3f, glColor4f,
|
GL_AVAILABLE,
|
||||||
GL_QUADS, GL_LINE_LOOP, glEnable, glDisable, GL_TEXTURE_2D,
|
glBegin,
|
||||||
glBindTexture, glTexCoord2f, glTexParameteri, GL_TEXTURE_MIN_FILTER,
|
glEnd,
|
||||||
GL_TEXTURE_MAG_FILTER, GL_LINEAR, glGenTextures, glTexImage2D,
|
glVertex2f,
|
||||||
GL_RGBA, GL_UNSIGNED_BYTE, glDeleteTextures, glGetString, GL_VERSION,
|
glColor3f,
|
||||||
glLineStipple, GL_LINE_STIPPLE, glPushMatrix, glPopMatrix,
|
glColor4f,
|
||||||
glTranslatef, glRotatef, GL_BLEND, glBlendFunc, GL_SRC_ALPHA,
|
GL_QUADS,
|
||||||
|
GL_LINE_LOOP,
|
||||||
|
glEnable,
|
||||||
|
glDisable,
|
||||||
|
GL_TEXTURE_2D,
|
||||||
|
glBindTexture,
|
||||||
|
glTexCoord2f,
|
||||||
|
glTexParameteri,
|
||||||
|
GL_TEXTURE_MIN_FILTER,
|
||||||
|
GL_TEXTURE_MAG_FILTER,
|
||||||
|
GL_LINEAR,
|
||||||
|
glGenTextures,
|
||||||
|
glTexImage2D,
|
||||||
|
GL_RGBA,
|
||||||
|
GL_UNSIGNED_BYTE,
|
||||||
|
glDeleteTextures,
|
||||||
|
glGetString,
|
||||||
|
GL_VERSION,
|
||||||
|
glLineStipple,
|
||||||
|
GL_LINE_STIPPLE,
|
||||||
|
glPushMatrix,
|
||||||
|
glPopMatrix,
|
||||||
|
glTranslatef,
|
||||||
|
glRotatef,
|
||||||
|
GL_BLEND,
|
||||||
|
glBlendFunc,
|
||||||
|
GL_SRC_ALPHA,
|
||||||
GL_ONE_MINUS_SRC_ALPHA,
|
GL_ONE_MINUS_SRC_ALPHA,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Image Styling
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class ImageStyle:
|
||||||
|
"""
|
||||||
|
Styling properties for images and placeholders.
|
||||||
|
|
||||||
|
This class encapsulates all visual styling that can be applied to images:
|
||||||
|
- Rounded corners
|
||||||
|
- Borders (width, color)
|
||||||
|
- Drop shadows
|
||||||
|
- Decorative frames
|
||||||
|
|
||||||
|
Styles are attached to both ImageData and PlaceholderData. When an image
|
||||||
|
is dropped onto a placeholder, it inherits the placeholder's style.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
corner_radius: float = 0.0,
|
||||||
|
border_width: float = 0.0,
|
||||||
|
border_color: Tuple[int, int, int] = (0, 0, 0),
|
||||||
|
shadow_enabled: bool = False,
|
||||||
|
shadow_offset: Tuple[float, float] = (2.0, 2.0),
|
||||||
|
shadow_blur: float = 3.0,
|
||||||
|
shadow_color: Tuple[int, int, int, int] = (0, 0, 0, 128),
|
||||||
|
frame_style: Optional[str] = None,
|
||||||
|
frame_color: Tuple[int, int, int] = (0, 0, 0),
|
||||||
|
frame_corners: Optional[Tuple[bool, bool, bool, bool]] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize image style.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
corner_radius: Corner radius as percentage of shorter side (0-50)
|
||||||
|
border_width: Border width in mm (0 = no border)
|
||||||
|
border_color: Border color as RGB tuple (0-255)
|
||||||
|
shadow_enabled: Whether drop shadow is enabled
|
||||||
|
shadow_offset: Shadow offset in mm (x, y)
|
||||||
|
shadow_blur: Shadow blur radius in mm
|
||||||
|
shadow_color: Shadow color as RGBA tuple (0-255)
|
||||||
|
frame_style: Name of decorative frame style (None = no frame)
|
||||||
|
frame_color: Frame tint color as RGB tuple (0-255)
|
||||||
|
frame_corners: Which corners get frame decoration (TL, TR, BR, BL).
|
||||||
|
None means all corners, (True, True, True, True) means all,
|
||||||
|
(True, False, False, True) means only left corners, etc.
|
||||||
|
"""
|
||||||
|
self.corner_radius = corner_radius
|
||||||
|
self.border_width = border_width
|
||||||
|
self.border_color: Tuple[int, int, int] = border_color
|
||||||
|
self.shadow_enabled = shadow_enabled
|
||||||
|
self.shadow_offset: Tuple[float, float] = shadow_offset
|
||||||
|
self.shadow_blur = shadow_blur
|
||||||
|
self.shadow_color: Tuple[int, int, int, int] = shadow_color
|
||||||
|
self.frame_style = frame_style
|
||||||
|
self.frame_color: Tuple[int, int, int] = frame_color
|
||||||
|
# frame_corners: (top_left, top_right, bottom_right, bottom_left)
|
||||||
|
self.frame_corners: Tuple[bool, bool, bool, bool] = frame_corners if frame_corners else (True, True, True, True)
|
||||||
|
|
||||||
|
def copy(self) -> "ImageStyle":
|
||||||
|
"""Create a copy of this style."""
|
||||||
|
return ImageStyle(
|
||||||
|
corner_radius=self.corner_radius,
|
||||||
|
border_width=self.border_width,
|
||||||
|
border_color=self.border_color,
|
||||||
|
shadow_enabled=self.shadow_enabled,
|
||||||
|
shadow_offset=self.shadow_offset,
|
||||||
|
shadow_blur=self.shadow_blur,
|
||||||
|
shadow_color=self.shadow_color,
|
||||||
|
frame_style=self.frame_style,
|
||||||
|
frame_color=self.frame_color,
|
||||||
|
frame_corners=self.frame_corners,
|
||||||
|
)
|
||||||
|
|
||||||
|
def has_styling(self) -> bool:
|
||||||
|
"""Check if any styling is applied (non-default values)."""
|
||||||
|
return self.corner_radius > 0 or self.border_width > 0 or self.shadow_enabled or self.frame_style is not None
|
||||||
|
|
||||||
|
def serialize(self) -> Dict[str, Any]:
|
||||||
|
"""Serialize style to dictionary."""
|
||||||
|
return {
|
||||||
|
"corner_radius": self.corner_radius,
|
||||||
|
"border_width": self.border_width,
|
||||||
|
"border_color": list(self.border_color),
|
||||||
|
"shadow_enabled": self.shadow_enabled,
|
||||||
|
"shadow_offset": list(self.shadow_offset),
|
||||||
|
"shadow_blur": self.shadow_blur,
|
||||||
|
"shadow_color": list(self.shadow_color),
|
||||||
|
"frame_style": self.frame_style,
|
||||||
|
"frame_color": list(self.frame_color),
|
||||||
|
"frame_corners": list(self.frame_corners),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def deserialize(cls, data: Optional[Dict[str, Any]]) -> "ImageStyle":
|
||||||
|
"""Deserialize style from dictionary."""
|
||||||
|
if data is None:
|
||||||
|
return cls()
|
||||||
|
frame_corners_data = data.get("frame_corners")
|
||||||
|
frame_corners = tuple(frame_corners_data) if frame_corners_data else None
|
||||||
|
return cls(
|
||||||
|
corner_radius=data.get("corner_radius", 0.0),
|
||||||
|
border_width=data.get("border_width", 0.0),
|
||||||
|
border_color=tuple(data.get("border_color", (0, 0, 0))),
|
||||||
|
shadow_enabled=data.get("shadow_enabled", False),
|
||||||
|
shadow_offset=tuple(data.get("shadow_offset", (2.0, 2.0))),
|
||||||
|
shadow_blur=data.get("shadow_blur", 3.0),
|
||||||
|
shadow_color=tuple(data.get("shadow_color", (0, 0, 0, 128))),
|
||||||
|
frame_style=data.get("frame_style"),
|
||||||
|
frame_color=tuple(data.get("frame_color", (0, 0, 0))),
|
||||||
|
frame_corners=frame_corners,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
if not isinstance(other, ImageStyle):
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
self.corner_radius == other.corner_radius
|
||||||
|
and self.border_width == other.border_width
|
||||||
|
and self.border_color == other.border_color
|
||||||
|
and self.shadow_enabled == other.shadow_enabled
|
||||||
|
and self.shadow_offset == other.shadow_offset
|
||||||
|
and self.shadow_blur == other.shadow_blur
|
||||||
|
and self.shadow_color == other.shadow_color
|
||||||
|
and self.frame_style == other.frame_style
|
||||||
|
and self.frame_color == other.frame_color
|
||||||
|
and self.frame_corners == other.frame_corners
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
if not self.has_styling():
|
||||||
|
return "ImageStyle()"
|
||||||
|
parts = []
|
||||||
|
if self.corner_radius > 0:
|
||||||
|
parts.append(f"corner_radius={self.corner_radius}")
|
||||||
|
if self.border_width > 0:
|
||||||
|
parts.append(f"border_width={self.border_width}")
|
||||||
|
if self.shadow_enabled:
|
||||||
|
parts.append("shadow_enabled=True")
|
||||||
|
if self.frame_style:
|
||||||
|
parts.append(f"frame_style='{self.frame_style}'")
|
||||||
|
return f"ImageStyle({', '.join(parts)})"
|
||||||
|
|
||||||
|
|
||||||
# Global configuration for asset path resolution
|
# Global configuration for asset path resolution
|
||||||
_asset_search_paths: List[str] = []
|
_asset_search_paths: List[str] = []
|
||||||
_primary_project_folder: Optional[str] = None
|
_primary_project_folder: Optional[str] = None
|
||||||
@@ -48,10 +221,13 @@ def get_asset_search_paths() -> Tuple[Optional[str], List[str]]:
|
|||||||
"""Get the current asset resolution context."""
|
"""Get the current asset resolution context."""
|
||||||
return _primary_project_folder, _asset_search_paths
|
return _primary_project_folder, _asset_search_paths
|
||||||
|
|
||||||
|
|
||||||
class BaseLayoutElement(ABC):
|
class BaseLayoutElement(ABC):
|
||||||
"""Abstract base class for all layout elements"""
|
"""Abstract base class for all layout elements"""
|
||||||
|
|
||||||
def __init__(self, x: float = 0, y: float = 0, width: float = 100, height: float = 100, rotation: float = 0, z_index: int = 0):
|
def __init__(
|
||||||
|
self, x: float = 0, y: float = 0, width: float = 100, height: float = 100, rotation: float = 0, z_index: int = 0
|
||||||
|
):
|
||||||
self.position = (x, y)
|
self.position = (x, y)
|
||||||
self.size = (width, height)
|
self.size = (width, height)
|
||||||
self.rotation = rotation
|
self.rotation = rotation
|
||||||
@@ -118,11 +294,18 @@ class BaseLayoutElement(ABC):
|
|||||||
"""Deserialize from a dictionary"""
|
"""Deserialize from a dictionary"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ImageData(BaseLayoutElement):
|
class ImageData(BaseLayoutElement):
|
||||||
"""Class to store image data and properties"""
|
"""Class to store image data and properties"""
|
||||||
|
|
||||||
def __init__(self, image_path: str = "", crop_info: Optional[Tuple] = None,
|
def __init__(
|
||||||
image_dimensions: Optional[Tuple[int, int]] = None, **kwargs):
|
self,
|
||||||
|
image_path: str = "",
|
||||||
|
crop_info: Optional[Tuple] = None,
|
||||||
|
image_dimensions: Optional[Tuple[int, int]] = None,
|
||||||
|
style: Optional["ImageStyle"] = None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.image_path = image_path
|
self.image_path = image_path
|
||||||
self.crop_info = crop_info or (0, 0, 1, 1) # Default: no crop
|
self.crop_info = crop_info or (0, 0, 1, 1) # Default: no crop
|
||||||
@@ -135,6 +318,9 @@ class ImageData(BaseLayoutElement):
|
|||||||
# This is separate from the visual rotation field (which should stay at 0)
|
# This is separate from the visual rotation field (which should stay at 0)
|
||||||
self.pil_rotation_90 = 0 # 0, 1, 2, or 3 (for 0°, 90°, 180°, 270°)
|
self.pil_rotation_90 = 0 # 0, 1, 2, or 3 (for 0°, 90°, 180°, 270°)
|
||||||
|
|
||||||
|
# Styling properties (rounded corners, borders, shadows, frames)
|
||||||
|
self.style = style if style is not None else ImageStyle()
|
||||||
|
|
||||||
# If dimensions not provided and we have a path, try to extract them quickly
|
# If dimensions not provided and we have a path, try to extract them quickly
|
||||||
if not self.image_dimensions and self.image_path:
|
if not self.image_dimensions and self.image_path:
|
||||||
self._extract_dimensions_metadata()
|
self._extract_dimensions_metadata()
|
||||||
@@ -190,26 +376,54 @@ class ImageData(BaseLayoutElement):
|
|||||||
|
|
||||||
# Create texture from pending image if one exists (deferred from async load)
|
# Create texture from pending image if one exists (deferred from async load)
|
||||||
# Texture creation must happen during render when GL context is active
|
# Texture creation must happen during render when GL context is active
|
||||||
if hasattr(self, '_pending_pil_image') and self._pending_pil_image is not None:
|
if hasattr(self, "_pending_pil_image") and self._pending_pil_image is not None:
|
||||||
self._create_texture_from_pending_image()
|
self._create_texture_from_pending_image()
|
||||||
|
|
||||||
|
# Check if style changed and texture needs regeneration
|
||||||
|
if hasattr(self, "_texture_id") and self._texture_id:
|
||||||
|
current_hash = self._get_style_hash()
|
||||||
|
cached_hash = getattr(self, "_texture_style_hash", None)
|
||||||
|
if cached_hash is None:
|
||||||
|
# First time check - assume texture was loaded without styling
|
||||||
|
# Set hash to 0 (no corner radius) to match legacy behavior
|
||||||
|
self._texture_style_hash = hash((0.0,))
|
||||||
|
cached_hash = self._texture_style_hash
|
||||||
|
if cached_hash != current_hash:
|
||||||
|
# Style changed - mark for reload
|
||||||
|
self._async_load_requested = False
|
||||||
|
glDeleteTextures([self._texture_id])
|
||||||
|
delattr(self, "_texture_id") # Remove attribute so async loader will re-trigger
|
||||||
|
|
||||||
|
# Draw drop shadow first (behind everything)
|
||||||
|
if self.style.shadow_enabled:
|
||||||
|
self._render_shadow(x, y, w, h)
|
||||||
|
|
||||||
# Use cached texture if available
|
# Use cached texture if available
|
||||||
if hasattr(self, '_texture_id') and self._texture_id:
|
if hasattr(self, "_texture_id") and self._texture_id:
|
||||||
texture_id = self._texture_id
|
texture_id = self._texture_id
|
||||||
|
|
||||||
# Get image dimensions (from loaded texture or metadata)
|
# Check if texture was pre-cropped (for styled images with rounded corners)
|
||||||
if hasattr(self, '_img_width') and hasattr(self, '_img_height'):
|
if getattr(self, "_texture_precropped", False):
|
||||||
img_width, img_height = self._img_width, self._img_height
|
# Texture is already cropped to visible region - use full texture
|
||||||
elif self.image_dimensions:
|
tx_min, ty_min, tx_max, ty_max = 0.0, 0.0, 1.0, 1.0
|
||||||
img_width, img_height = self.image_dimensions
|
|
||||||
else:
|
else:
|
||||||
# No dimensions available, render without aspect ratio correction
|
# Get image dimensions (from loaded texture or metadata)
|
||||||
img_width, img_height = int(w), int(h)
|
if hasattr(self, "_img_width") and hasattr(self, "_img_height"):
|
||||||
|
img_width, img_height = self._img_width, self._img_height
|
||||||
|
elif self.image_dimensions:
|
||||||
|
img_width, img_height = self.image_dimensions
|
||||||
|
else:
|
||||||
|
# No dimensions available, render without aspect ratio correction
|
||||||
|
img_width, img_height = int(w), int(h)
|
||||||
|
|
||||||
# Calculate texture coordinates for center crop with element's crop_info
|
# Calculate texture coordinates for center crop with element's crop_info
|
||||||
tx_min, ty_min, tx_max, ty_max = calculate_center_crop_coords(
|
tx_min, ty_min, tx_max, ty_max = calculate_center_crop_coords(
|
||||||
img_width, img_height, w, h, self.crop_info
|
img_width, img_height, w, h, self.crop_info
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Enable blending for transparency (rounded corners)
|
||||||
|
glEnable(GL_BLEND)
|
||||||
|
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
|
||||||
|
|
||||||
# Enable texturing and draw with crop
|
# Enable texturing and draw with crop
|
||||||
glEnable(GL_TEXTURE_2D)
|
glEnable(GL_TEXTURE_2D)
|
||||||
@@ -217,13 +431,18 @@ class ImageData(BaseLayoutElement):
|
|||||||
glColor4f(1.0, 1.0, 1.0, 1.0) # White color to show texture as-is
|
glColor4f(1.0, 1.0, 1.0, 1.0) # White color to show texture as-is
|
||||||
|
|
||||||
glBegin(GL_QUADS)
|
glBegin(GL_QUADS)
|
||||||
glTexCoord2f(tx_min, ty_min); glVertex2f(x, y)
|
glTexCoord2f(tx_min, ty_min)
|
||||||
glTexCoord2f(tx_max, ty_min); glVertex2f(x + w, y)
|
glVertex2f(x, y)
|
||||||
glTexCoord2f(tx_max, ty_max); glVertex2f(x + w, y + h)
|
glTexCoord2f(tx_max, ty_min)
|
||||||
glTexCoord2f(tx_min, ty_max); glVertex2f(x, y + h)
|
glVertex2f(x + w, y)
|
||||||
|
glTexCoord2f(tx_max, ty_max)
|
||||||
|
glVertex2f(x + w, y + h)
|
||||||
|
glTexCoord2f(tx_min, ty_max)
|
||||||
|
glVertex2f(x, y + h)
|
||||||
glEnd()
|
glEnd()
|
||||||
|
|
||||||
glDisable(GL_TEXTURE_2D)
|
glDisable(GL_TEXTURE_2D)
|
||||||
|
glDisable(GL_BLEND)
|
||||||
|
|
||||||
# If no image or loading failed, draw placeholder
|
# If no image or loading failed, draw placeholder
|
||||||
if not texture_id:
|
if not texture_id:
|
||||||
@@ -235,14 +454,86 @@ class ImageData(BaseLayoutElement):
|
|||||||
glVertex2f(x, y + h)
|
glVertex2f(x, y + h)
|
||||||
glEnd()
|
glEnd()
|
||||||
|
|
||||||
# Draw border
|
# Draw styled border if specified, otherwise default thin black border
|
||||||
glColor3f(0.0, 0.0, 0.0) # Black border
|
if self.style.border_width > 0:
|
||||||
|
self._render_border(x, y, w, h)
|
||||||
|
else:
|
||||||
|
# Default thin border for visibility
|
||||||
|
glColor3f(0.0, 0.0, 0.0) # Black border
|
||||||
|
glBegin(GL_LINE_LOOP)
|
||||||
|
glVertex2f(x, y)
|
||||||
|
glVertex2f(x + w, y)
|
||||||
|
glVertex2f(x + w, y + h)
|
||||||
|
glVertex2f(x, y + h)
|
||||||
|
glEnd()
|
||||||
|
|
||||||
|
# Draw decorative frame if specified
|
||||||
|
if self.style.frame_style:
|
||||||
|
self._render_frame(x, y, w, h)
|
||||||
|
|
||||||
|
def _render_shadow(self, x: float, y: float, w: float, h: float):
|
||||||
|
"""Render drop shadow behind the image."""
|
||||||
|
# Convert shadow offset from mm to pixels (approximate, assuming 96 DPI for screen)
|
||||||
|
dpi = 96.0
|
||||||
|
mm_to_px = dpi / 25.4
|
||||||
|
offset_x = self.style.shadow_offset[0] * mm_to_px
|
||||||
|
offset_y = self.style.shadow_offset[1] * mm_to_px
|
||||||
|
|
||||||
|
# Shadow color with alpha
|
||||||
|
r, g, b, a = self.style.shadow_color
|
||||||
|
glEnable(GL_BLEND)
|
||||||
|
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
|
||||||
|
glColor4f(r / 255.0, g / 255.0, b / 255.0, a / 255.0)
|
||||||
|
|
||||||
|
# Draw shadow quad (slightly offset)
|
||||||
|
shadow_x = x + offset_x
|
||||||
|
shadow_y = y + offset_y
|
||||||
|
glBegin(GL_QUADS)
|
||||||
|
glVertex2f(shadow_x, shadow_y)
|
||||||
|
glVertex2f(shadow_x + w, shadow_y)
|
||||||
|
glVertex2f(shadow_x + w, shadow_y + h)
|
||||||
|
glVertex2f(shadow_x, shadow_y + h)
|
||||||
|
glEnd()
|
||||||
|
|
||||||
|
glDisable(GL_BLEND)
|
||||||
|
|
||||||
|
def _render_border(self, x: float, y: float, w: float, h: float):
|
||||||
|
"""Render styled border around the image."""
|
||||||
|
# Convert border width from mm to pixels
|
||||||
|
dpi = 96.0
|
||||||
|
mm_to_px = dpi / 25.4
|
||||||
|
border_px = self.style.border_width * mm_to_px
|
||||||
|
|
||||||
|
# Border color
|
||||||
|
r, g, b = self.style.border_color
|
||||||
|
glColor3f(r / 255.0, g / 255.0, b / 255.0)
|
||||||
|
|
||||||
|
# Draw border as thick line (OpenGL line width)
|
||||||
|
from OpenGL.GL import glLineWidth
|
||||||
|
|
||||||
|
glLineWidth(max(1.0, border_px))
|
||||||
glBegin(GL_LINE_LOOP)
|
glBegin(GL_LINE_LOOP)
|
||||||
glVertex2f(x, y)
|
glVertex2f(x, y)
|
||||||
glVertex2f(x + w, y)
|
glVertex2f(x + w, y)
|
||||||
glVertex2f(x + w, y + h)
|
glVertex2f(x + w, y + h)
|
||||||
glVertex2f(x, y + h)
|
glVertex2f(x, y + h)
|
||||||
glEnd()
|
glEnd()
|
||||||
|
glLineWidth(1.0) # Reset to default
|
||||||
|
|
||||||
|
def _render_frame(self, x: float, y: float, w: float, h: float):
|
||||||
|
"""Render decorative frame around the image."""
|
||||||
|
from pyPhotoAlbum.frame_manager import get_frame_manager
|
||||||
|
|
||||||
|
frame_manager = get_frame_manager()
|
||||||
|
frame_manager.render_frame_opengl(
|
||||||
|
frame_name=self.style.frame_style, # type: ignore[arg-type]
|
||||||
|
x=x,
|
||||||
|
y=y,
|
||||||
|
width=w,
|
||||||
|
height=h,
|
||||||
|
color=self.style.frame_color,
|
||||||
|
corners=self.style.frame_corners,
|
||||||
|
)
|
||||||
|
|
||||||
def serialize(self) -> Dict[str, Any]:
|
def serialize(self) -> Dict[str, Any]:
|
||||||
"""Serialize image data to dictionary"""
|
"""Serialize image data to dictionary"""
|
||||||
@@ -254,12 +545,16 @@ class ImageData(BaseLayoutElement):
|
|||||||
"z_index": self.z_index,
|
"z_index": self.z_index,
|
||||||
"image_path": self.image_path,
|
"image_path": self.image_path,
|
||||||
"crop_info": self.crop_info,
|
"crop_info": self.crop_info,
|
||||||
"pil_rotation_90": getattr(self, 'pil_rotation_90', 0)
|
"pil_rotation_90": getattr(self, "pil_rotation_90", 0),
|
||||||
}
|
}
|
||||||
# Include image dimensions metadata if available
|
# Include image dimensions metadata if available
|
||||||
if self.image_dimensions:
|
if self.image_dimensions:
|
||||||
data["image_dimensions"] = self.image_dimensions
|
data["image_dimensions"] = self.image_dimensions
|
||||||
|
|
||||||
|
# Include style if non-default (v3.1+)
|
||||||
|
if self.style.has_styling():
|
||||||
|
data["style"] = self.style.serialize()
|
||||||
|
|
||||||
# Add base fields (v3.0+)
|
# Add base fields (v3.0+)
|
||||||
data.update(self._serialize_base_fields())
|
data.update(self._serialize_base_fields())
|
||||||
|
|
||||||
@@ -298,6 +593,9 @@ class ImageData(BaseLayoutElement):
|
|||||||
if self.image_dimensions:
|
if self.image_dimensions:
|
||||||
self.image_dimensions = tuple(self.image_dimensions)
|
self.image_dimensions = tuple(self.image_dimensions)
|
||||||
|
|
||||||
|
# Load style (v3.1+, backwards compatible - defaults to no styling)
|
||||||
|
self.style = ImageStyle.deserialize(data.get("style"))
|
||||||
|
|
||||||
def _on_async_image_loaded(self, pil_image):
|
def _on_async_image_loaded(self, pil_image):
|
||||||
"""
|
"""
|
||||||
Callback when async image loading completes.
|
Callback when async image loading completes.
|
||||||
@@ -313,10 +611,37 @@ class ImageData(BaseLayoutElement):
|
|||||||
logger.debug(f"ImageData: Async load completed for {self.image_path}, size: {pil_image.size}")
|
logger.debug(f"ImageData: Async load completed for {self.image_path}, size: {pil_image.size}")
|
||||||
|
|
||||||
# Apply PIL-level rotation if needed
|
# Apply PIL-level rotation if needed
|
||||||
if hasattr(self, 'pil_rotation_90') and self.pil_rotation_90 > 0:
|
if hasattr(self, "pil_rotation_90") and self.pil_rotation_90 > 0:
|
||||||
pil_image = apply_pil_rotation(pil_image, self.pil_rotation_90)
|
pil_image = apply_pil_rotation(pil_image, self.pil_rotation_90)
|
||||||
logger.debug(f"ImageData: Applied PIL rotation {self.pil_rotation_90 * 90}° to {self.image_path}")
|
logger.debug(f"ImageData: Applied PIL rotation {self.pil_rotation_90 * 90}° to {self.image_path}")
|
||||||
|
|
||||||
|
# For rounded corners, we need to pre-crop the image to the visible region
|
||||||
|
# so that the corners are applied to what will actually be displayed.
|
||||||
|
# Calculate the crop region based on element aspect ratio and crop_info.
|
||||||
|
if self.style.corner_radius > 0:
|
||||||
|
from pyPhotoAlbum.image_utils import apply_rounded_corners, crop_image_to_coords
|
||||||
|
|
||||||
|
# Get element dimensions for aspect ratio calculation
|
||||||
|
element_width, element_height = self.size
|
||||||
|
|
||||||
|
# Calculate crop coordinates (same logic as render-time)
|
||||||
|
crop_coords = calculate_center_crop_coords(
|
||||||
|
pil_image.width, pil_image.height, element_width, element_height, self.crop_info
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pre-crop the image to the visible region
|
||||||
|
pil_image = crop_image_to_coords(pil_image, crop_coords)
|
||||||
|
logger.debug(f"ImageData: Pre-cropped to {pil_image.size} for styling")
|
||||||
|
|
||||||
|
# Now apply rounded corners to the cropped image
|
||||||
|
pil_image = apply_rounded_corners(pil_image, self.style.corner_radius)
|
||||||
|
logger.debug(f"ImageData: Applied {self.style.corner_radius}% corner radius to {self.image_path}")
|
||||||
|
|
||||||
|
# Mark that texture is pre-cropped (no further crop needed at render time)
|
||||||
|
self._texture_precropped = True
|
||||||
|
else:
|
||||||
|
self._texture_precropped = False
|
||||||
|
|
||||||
# Store the image for texture creation during next render()
|
# Store the image for texture creation during next render()
|
||||||
# This avoids GL context issues when callback runs on wrong thread/timing
|
# This avoids GL context issues when callback runs on wrong thread/timing
|
||||||
self._pending_pil_image = pil_image
|
self._pending_pil_image = pil_image
|
||||||
@@ -324,7 +649,10 @@ class ImageData(BaseLayoutElement):
|
|||||||
self._img_height = pil_image.height
|
self._img_height = pil_image.height
|
||||||
self._async_loading = False
|
self._async_loading = False
|
||||||
|
|
||||||
# Update metadata for future renders - always update to reflect rotated dimensions
|
# Track which style was applied to this texture (for cache invalidation)
|
||||||
|
self._texture_style_hash = self._get_style_hash()
|
||||||
|
|
||||||
|
# Update metadata for future renders - always update to reflect dimensions
|
||||||
self.image_dimensions = (pil_image.width, pil_image.height)
|
self.image_dimensions = (pil_image.width, pil_image.height)
|
||||||
|
|
||||||
logger.debug(f"ImageData: Queued for texture creation: {self.image_path}")
|
logger.debug(f"ImageData: Queued for texture creation: {self.image_path}")
|
||||||
@@ -334,12 +662,20 @@ class ImageData(BaseLayoutElement):
|
|||||||
self._pending_pil_image = None
|
self._pending_pil_image = None
|
||||||
self._async_loading = False
|
self._async_loading = False
|
||||||
|
|
||||||
|
def _get_style_hash(self) -> int:
|
||||||
|
"""Get a hash of the current style settings that affect texture rendering."""
|
||||||
|
# Corner radius affects the texture, and when styled, crop_info and size also matter
|
||||||
|
# because we pre-crop the image before applying rounded corners
|
||||||
|
if self.style.corner_radius > 0:
|
||||||
|
return hash((self.style.corner_radius, self.crop_info, self.size))
|
||||||
|
return hash((self.style.corner_radius,))
|
||||||
|
|
||||||
def _create_texture_from_pending_image(self):
|
def _create_texture_from_pending_image(self):
|
||||||
"""
|
"""
|
||||||
Create OpenGL texture from pending PIL image.
|
Create OpenGL texture from pending PIL image.
|
||||||
Called during render() when GL context is active.
|
Called during render() when GL context is active.
|
||||||
"""
|
"""
|
||||||
if not hasattr(self, '_pending_pil_image') or self._pending_pil_image is None:
|
if not hasattr(self, "_pending_pil_image") or self._pending_pil_image is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -355,11 +691,11 @@ class ImageData(BaseLayoutElement):
|
|||||||
pil_image = self._pending_pil_image
|
pil_image = self._pending_pil_image
|
||||||
|
|
||||||
# Ensure RGBA format for GL_RGBA texture (defensive check)
|
# Ensure RGBA format for GL_RGBA texture (defensive check)
|
||||||
if pil_image.mode != 'RGBA':
|
if pil_image.mode != "RGBA":
|
||||||
pil_image = pil_image.convert('RGBA')
|
pil_image = pil_image.convert("RGBA")
|
||||||
|
|
||||||
# Delete old texture if it exists
|
# Delete old texture if it exists
|
||||||
if hasattr(self, '_texture_id') and self._texture_id:
|
if hasattr(self, "_texture_id") and self._texture_id:
|
||||||
glDeleteTextures([self._texture_id])
|
glDeleteTextures([self._texture_id])
|
||||||
|
|
||||||
# Create GPU texture from pre-processed PIL image
|
# Create GPU texture from pre-processed PIL image
|
||||||
@@ -369,8 +705,9 @@ class ImageData(BaseLayoutElement):
|
|||||||
glBindTexture(GL_TEXTURE_2D, texture_id)
|
glBindTexture(GL_TEXTURE_2D, texture_id)
|
||||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
|
||||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_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,
|
glTexImage2D(
|
||||||
0, GL_RGBA, GL_UNSIGNED_BYTE, img_data)
|
GL_TEXTURE_2D, 0, GL_RGBA, pil_image.width, pil_image.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, img_data
|
||||||
|
)
|
||||||
|
|
||||||
# Cache texture
|
# Cache texture
|
||||||
self._texture_id = texture_id
|
self._texture_id = texture_id
|
||||||
@@ -380,8 +717,8 @@ class ImageData(BaseLayoutElement):
|
|||||||
self._pending_pil_image = None
|
self._pending_pil_image = None
|
||||||
|
|
||||||
# Clear the warning flag if we successfully created the texture
|
# Clear the warning flag if we successfully created the texture
|
||||||
if hasattr(self, '_gl_context_warned'):
|
if hasattr(self, "_gl_context_warned"):
|
||||||
delattr(self, '_gl_context_warned')
|
delattr(self, "_gl_context_warned")
|
||||||
|
|
||||||
logger.info(f"ImageData: Successfully created texture for {self.image_path}")
|
logger.info(f"ImageData: Successfully created texture for {self.image_path}")
|
||||||
return True
|
return True
|
||||||
@@ -390,11 +727,13 @@ class ImageData(BaseLayoutElement):
|
|||||||
error_str = str(e)
|
error_str = str(e)
|
||||||
# Check if this is a GL context error (err 1282 = GL_INVALID_OPERATION)
|
# Check if this is a GL context error (err 1282 = GL_INVALID_OPERATION)
|
||||||
# These are typically caused by no GL context being current
|
# These are typically caused by no GL context being current
|
||||||
if 'GLError' in error_str and '1282' in error_str:
|
if "GLError" in error_str and "1282" in error_str:
|
||||||
# GL context not ready - keep pending image and try again next render
|
# GL context not ready - keep pending image and try again next render
|
||||||
# Don't spam the console with repeated messages
|
# Don't spam the console with repeated messages
|
||||||
if not hasattr(self, '_gl_context_warned'):
|
if not hasattr(self, "_gl_context_warned"):
|
||||||
logger.warning(f"ImageData: GL context error (1282) for {self.image_path}, will retry on next render")
|
logger.warning(
|
||||||
|
f"ImageData: GL context error (1282) for {self.image_path}, will retry on next render"
|
||||||
|
)
|
||||||
self._gl_context_warned = True
|
self._gl_context_warned = True
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
@@ -415,13 +754,22 @@ class ImageData(BaseLayoutElement):
|
|||||||
self._async_loading = False
|
self._async_loading = False
|
||||||
self._async_load_requested = False
|
self._async_load_requested = False
|
||||||
|
|
||||||
|
|
||||||
class PlaceholderData(BaseLayoutElement):
|
class PlaceholderData(BaseLayoutElement):
|
||||||
"""Class to store placeholder data"""
|
"""Class to store placeholder data"""
|
||||||
|
|
||||||
def __init__(self, placeholder_type: str = "image", default_content: str = "", **kwargs):
|
def __init__(
|
||||||
|
self,
|
||||||
|
placeholder_type: str = "image",
|
||||||
|
default_content: str = "",
|
||||||
|
style: Optional["ImageStyle"] = None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.placeholder_type = placeholder_type
|
self.placeholder_type = placeholder_type
|
||||||
self.default_content = default_content
|
self.default_content = default_content
|
||||||
|
# Style to apply when an image is dropped onto this placeholder
|
||||||
|
self.style = style if style is not None else ImageStyle()
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
"""Render the placeholder using OpenGL"""
|
"""Render the placeholder using OpenGL"""
|
||||||
@@ -475,8 +823,11 @@ class PlaceholderData(BaseLayoutElement):
|
|||||||
"rotation": self.rotation,
|
"rotation": self.rotation,
|
||||||
"z_index": self.z_index,
|
"z_index": self.z_index,
|
||||||
"placeholder_type": self.placeholder_type,
|
"placeholder_type": self.placeholder_type,
|
||||||
"default_content": self.default_content
|
"default_content": self.default_content,
|
||||||
}
|
}
|
||||||
|
# Include style if non-default (v3.1+) - for templatable styling
|
||||||
|
if self.style.has_styling():
|
||||||
|
data["style"] = self.style.serialize()
|
||||||
# Add base fields (v3.0+)
|
# Add base fields (v3.0+)
|
||||||
data.update(self._serialize_base_fields())
|
data.update(self._serialize_base_fields())
|
||||||
return data
|
return data
|
||||||
@@ -492,6 +843,9 @@ class PlaceholderData(BaseLayoutElement):
|
|||||||
self.z_index = data.get("z_index", 0)
|
self.z_index = data.get("z_index", 0)
|
||||||
self.placeholder_type = data.get("placeholder_type", "image")
|
self.placeholder_type = data.get("placeholder_type", "image")
|
||||||
self.default_content = data.get("default_content", "")
|
self.default_content = data.get("default_content", "")
|
||||||
|
# Load style (v3.1+, backwards compatible)
|
||||||
|
self.style = ImageStyle.deserialize(data.get("style"))
|
||||||
|
|
||||||
|
|
||||||
class TextBoxData(BaseLayoutElement):
|
class TextBoxData(BaseLayoutElement):
|
||||||
"""Class to store text box data"""
|
"""Class to store text box data"""
|
||||||
@@ -519,29 +873,18 @@ class TextBoxData(BaseLayoutElement):
|
|||||||
# Now render at origin (rotation pivot is at element center)
|
# Now render at origin (rotation pivot is at element center)
|
||||||
x, y = 0, 0
|
x, y = 0, 0
|
||||||
|
|
||||||
# Enable alpha blending for transparency
|
# No background fill - text boxes are transparent in final output
|
||||||
glEnable(GL_BLEND)
|
# Just draw a light dashed border for editing visibility
|
||||||
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
|
glEnable(GL_LINE_STIPPLE)
|
||||||
|
glLineStipple(2, 0xAAAA) # Dashed line pattern
|
||||||
# Draw a semi-transparent yellow rectangle as text box background
|
glColor3f(0.7, 0.7, 0.7) # Light gray border
|
||||||
glColor4f(1.0, 1.0, 0.7, 0.3) # Light yellow with 30% opacity
|
|
||||||
glBegin(GL_QUADS)
|
|
||||||
glVertex2f(x, y)
|
|
||||||
glVertex2f(x + w, y)
|
|
||||||
glVertex2f(x + w, y + h)
|
|
||||||
glVertex2f(x, y + h)
|
|
||||||
glEnd()
|
|
||||||
|
|
||||||
glDisable(GL_BLEND)
|
|
||||||
|
|
||||||
# Draw border
|
|
||||||
glColor3f(0.0, 0.0, 0.0) # Black border
|
|
||||||
glBegin(GL_LINE_LOOP)
|
glBegin(GL_LINE_LOOP)
|
||||||
glVertex2f(x, y)
|
glVertex2f(x, y)
|
||||||
glVertex2f(x + w, y)
|
glVertex2f(x + w, y)
|
||||||
glVertex2f(x + w, y + h)
|
glVertex2f(x + w, y + h)
|
||||||
glVertex2f(x, y + h)
|
glVertex2f(x, y + h)
|
||||||
glEnd()
|
glEnd()
|
||||||
|
glDisable(GL_LINE_STIPPLE)
|
||||||
|
|
||||||
# Pop matrix if we pushed for rotation
|
# Pop matrix if we pushed for rotation
|
||||||
if self.rotation != 0:
|
if self.rotation != 0:
|
||||||
@@ -559,7 +902,7 @@ class TextBoxData(BaseLayoutElement):
|
|||||||
"z_index": self.z_index,
|
"z_index": self.z_index,
|
||||||
"text_content": self.text_content,
|
"text_content": self.text_content,
|
||||||
"font_settings": self.font_settings,
|
"font_settings": self.font_settings,
|
||||||
"alignment": self.alignment
|
"alignment": self.alignment,
|
||||||
}
|
}
|
||||||
# Add base fields (v3.0+)
|
# Add base fields (v3.0+)
|
||||||
data.update(self._serialize_base_fields())
|
data.update(self._serialize_base_fields())
|
||||||
@@ -578,6 +921,7 @@ class TextBoxData(BaseLayoutElement):
|
|||||||
self.font_settings = data.get("font_settings", {"family": "Arial", "size": 12, "color": (0, 0, 0)})
|
self.font_settings = data.get("font_settings", {"family": "Arial", "size": 12, "color": (0, 0, 0)})
|
||||||
self.alignment = data.get("alignment", "left")
|
self.alignment = data.get("alignment", "left")
|
||||||
|
|
||||||
|
|
||||||
class GhostPageData(BaseLayoutElement):
|
class GhostPageData(BaseLayoutElement):
|
||||||
"""Class to represent a ghost page placeholder for alignment in double-page spreads"""
|
"""Class to represent a ghost page placeholder for alignment in double-page spreads"""
|
||||||
|
|
||||||
@@ -640,12 +984,7 @@ class GhostPageData(BaseLayoutElement):
|
|||||||
|
|
||||||
def serialize(self) -> Dict[str, Any]:
|
def serialize(self) -> Dict[str, Any]:
|
||||||
"""Serialize ghost page data to dictionary"""
|
"""Serialize ghost page data to dictionary"""
|
||||||
data = {
|
data = {"type": "ghostpage", "position": self.position, "size": self.size, "page_size": self.page_size}
|
||||||
"type": "ghostpage",
|
|
||||||
"position": self.position,
|
|
||||||
"size": self.size,
|
|
||||||
"page_size": self.page_size
|
|
||||||
}
|
|
||||||
# Add base fields (v3.0+)
|
# Add base fields (v3.0+)
|
||||||
data.update(self._serialize_base_fields())
|
data.update(self._serialize_base_fields())
|
||||||
return data
|
return data
|
||||||
|
|||||||
+52
-37
@@ -2,16 +2,33 @@
|
|||||||
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 (
|
from pyPhotoAlbum.gl_imports import (
|
||||||
glBegin, glEnd, glVertex2f, glColor3f, glColor4f,
|
glBegin,
|
||||||
GL_QUADS, GL_LINE_LOOP, GL_LINES, glLineWidth,
|
glEnd,
|
||||||
glEnable, glDisable, GL_DEPTH_TEST, GL_BLEND,
|
glVertex2f,
|
||||||
glBlendFunc, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA,
|
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"""
|
||||||
|
|
||||||
@@ -32,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
|
||||||
|
|
||||||
@@ -97,21 +116,22 @@ 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
|
||||||
|
|
||||||
@@ -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":
|
||||||
@@ -268,6 +285,7 @@ class PageLayout:
|
|||||||
|
|
||||||
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"""
|
||||||
|
|||||||
@@ -22,13 +22,9 @@ class PageRenderer:
|
|||||||
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.
|
||||||
|
|
||||||
@@ -96,8 +92,10 @@ class PageRenderer:
|
|||||||
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]:
|
||||||
"""
|
"""
|
||||||
@@ -117,9 +115,9 @@ class PageRenderer:
|
|||||||
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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
+777
-205
File diff suppressed because it is too large
Load Diff
+27
-15
@@ -6,11 +6,13 @@ import os
|
|||||||
import math
|
import math
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import List, Dict, Any, Optional, Tuple
|
from tempfile import TemporaryDirectory
|
||||||
|
from typing import List, Dict, Any, Optional, Tuple, Union
|
||||||
from pyPhotoAlbum.page_layout import PageLayout
|
from pyPhotoAlbum.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"""
|
||||||
|
|
||||||
@@ -51,7 +53,7 @@ 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
|
||||||
@@ -133,6 +135,7 @@ class Page:
|
|||||||
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"""
|
||||||
|
|
||||||
@@ -163,12 +166,17 @@ class Project:
|
|||||||
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
|
||||||
@@ -246,11 +254,7 @@ class Project:
|
|||||||
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)
|
||||||
@@ -295,9 +299,11 @@ class Project:
|
|||||||
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:
|
||||||
"""
|
"""
|
||||||
@@ -323,7 +329,7 @@ class Project:
|
|||||||
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.
|
||||||
@@ -336,7 +342,7 @@ class Project:
|
|||||||
"""
|
"""
|
||||||
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:
|
||||||
@@ -368,11 +374,11 @@ class Project:
|
|||||||
# 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:
|
||||||
@@ -403,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,
|
||||||
@@ -435,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", {})
|
||||||
|
|||||||
@@ -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 with stable sorting for git-friendly diffs
|
# Write project.json with stable sorting for git-friendly diffs
|
||||||
project_json = json.dumps(project_data, indent=2, sort_keys=True)
|
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,6 +171,127 @@ def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]:
|
|||||||
return False, error_msg
|
return False, error_msg
|
||||||
|
|
||||||
|
|
||||||
|
def save_to_zip_async(
|
||||||
|
project: Project,
|
||||||
|
zip_path: str,
|
||||||
|
on_complete: Optional[Callable[[bool, Optional[str]], None]] = None,
|
||||||
|
on_progress: Optional[Callable[[int, str], None]] = None,
|
||||||
|
) -> threading.Thread:
|
||||||
|
"""
|
||||||
|
Save a project to a ZIP file asynchronously in a background thread.
|
||||||
|
|
||||||
|
This provides instant UI responsiveness by:
|
||||||
|
1. Immediately serializing project.json to a temp folder (fast)
|
||||||
|
2. Creating the ZIP file in a background thread (slow)
|
||||||
|
3. Calling on_complete when done
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project: The Project instance to save
|
||||||
|
zip_path: Path where the ZIP file should be created
|
||||||
|
on_complete: Optional callback(success: bool, error_msg: Optional[str])
|
||||||
|
called when save completes
|
||||||
|
on_progress: Optional callback(progress: int, message: str) where
|
||||||
|
progress is 0-100 and message describes current step
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The background thread (already started)
|
||||||
|
"""
|
||||||
|
# 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:
|
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.
|
||||||
@@ -201,20 +322,20 @@ def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Project:
|
|||||||
os.makedirs(extract_to, exist_ok=True)
|
os.makedirs(extract_to, exist_ok=True)
|
||||||
|
|
||||||
# Extract ZIP contents
|
# Extract ZIP contents
|
||||||
with zipfile.ZipFile(zip_path, 'r') as zipf:
|
with zipfile.ZipFile(zip_path, "r") as zipf:
|
||||||
zipf.extractall(extract_to)
|
zipf.extractall(extract_to)
|
||||||
|
|
||||||
# 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):
|
||||||
raise ValueError("Invalid project file: project.json not found")
|
raise ValueError("Invalid project file: project.json not found")
|
||||||
|
|
||||||
with open(project_json_path, 'r') as f:
|
with open(project_json_path, "r") as f:
|
||||||
project_data = json.load(f)
|
project_data = json.load(f)
|
||||||
|
|
||||||
# Check version compatibility
|
# Check version compatibility
|
||||||
# Try new version field first, fall back to legacy field
|
# Try new version field first, fall back to legacy field
|
||||||
file_version = project_data.get('data_version', project_data.get('serialization_version', '1.0'))
|
file_version = project_data.get("data_version", project_data.get("serialization_version", "1.0"))
|
||||||
|
|
||||||
# Check if version is compatible
|
# Check if version is compatible
|
||||||
is_compatible, error_msg = check_version_compatibility(file_version, zip_path)
|
is_compatible, error_msg = check_version_compatibility(file_version, zip_path)
|
||||||
@@ -230,7 +351,7 @@ def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Project:
|
|||||||
print(f"Note: Loading project with version {file_version}, current version is {CURRENT_DATA_VERSION}")
|
print(f"Note: Loading project with version {file_version}, current version is {CURRENT_DATA_VERSION}")
|
||||||
|
|
||||||
# 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
|
||||||
@@ -254,6 +375,7 @@ def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Project:
|
|||||||
# Set asset resolution context for ImageData rendering
|
# Set asset resolution context for ImageData rendering
|
||||||
# Only set project folder - search paths are reserved for healing functionality
|
# Only set project folder - search paths are reserved for healing functionality
|
||||||
from pyPhotoAlbum.models import set_asset_resolution_context
|
from pyPhotoAlbum.models import set_asset_resolution_context
|
||||||
|
|
||||||
set_asset_resolution_context(extract_to)
|
set_asset_resolution_context(extract_to)
|
||||||
|
|
||||||
print(f"Project loaded from {zip_path} to {extract_to}")
|
print(f"Project loaded from {zip_path} to {extract_to}")
|
||||||
@@ -271,17 +393,17 @@ def get_project_info(zip_path: str) -> Optional[dict]:
|
|||||||
Dictionary with project info, or None if error
|
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}")
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ 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):
|
||||||
@@ -49,21 +49,23 @@ def build_ribbon_config(window_class: Type) -> Dict[str, Any]:
|
|||||||
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
|
||||||
@@ -72,7 +74,7 @@ def build_ribbon_config(window_class: Type) -> Dict[str, Any]:
|
|||||||
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())
|
||||||
@@ -87,12 +89,12 @@ def build_ribbon_config(window_class: Type) -> Dict[str, Any]:
|
|||||||
|
|
||||||
# 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
|
||||||
@@ -107,14 +109,9 @@ def build_ribbon_config(window_class: Type) -> Dict[str, Any]:
|
|||||||
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] = {
|
ribbon_config[tab_name] = {"groups": groups_list}
|
||||||
'groups': groups_list
|
|
||||||
}
|
|
||||||
|
|
||||||
return ribbon_config
|
return ribbon_config
|
||||||
|
|
||||||
@@ -136,12 +133,12 @@ def get_keyboard_shortcuts(window_class: Type) -> Dict[str, str]:
|
|||||||
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
|
||||||
|
|
||||||
@@ -169,11 +166,11 @@ def validate_ribbon_config(config: Dict[str, Any]) -> List[str]:
|
|||||||
errors.append(f"Tab '{tab_name}' data must be a dictionary")
|
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
|
||||||
@@ -183,14 +180,14 @@ def validate_ribbon_config(config: Dict[str, Any]) -> List[str]:
|
|||||||
errors.append(f"Tab '{tab_name}' group {i} must be a dictionary")
|
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
|
||||||
@@ -200,12 +197,10 @@ def validate_ribbon_config(config: Dict[str, Any]) -> List[str]:
|
|||||||
errors.append(f"Tab '{tab_name}' group {i} action {j} must be a dictionary")
|
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
|
||||||
|
|
||||||
@@ -220,12 +215,8 @@ def print_ribbon_summary(config: Dict[str, Any]):
|
|||||||
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}")
|
||||||
@@ -233,9 +224,9 @@ def print_ribbon_summary(config: Dict[str, Any]):
|
|||||||
|
|
||||||
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,6 +17,7 @@ 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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+66
-141
@@ -4,35 +4,31 @@ Provides grid snapping, edge snapping, and custom guide snapping
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import math
|
import math
|
||||||
from typing import List, Tuple, Optional
|
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
|
@dataclass
|
||||||
class SnapResizeParams:
|
class SnapResizeParams:
|
||||||
"""Parameters for snap resize operations"""
|
"""Parameters for snap resize operations"""
|
||||||
|
|
||||||
position: Tuple[float, float]
|
position: Tuple[float, float]
|
||||||
size: Tuple[float, float]
|
size: Tuple[float, float]
|
||||||
dx: float
|
dx: float
|
||||||
@@ -40,7 +36,7 @@ class SnapResizeParams:
|
|||||||
resize_handle: str
|
resize_handle: str
|
||||||
page_size: Tuple[float, float]
|
page_size: Tuple[float, float]
|
||||||
dpi: int = 300
|
dpi: int = 300
|
||||||
project: Optional[any] = None
|
project: Optional[Any] = None
|
||||||
|
|
||||||
|
|
||||||
class SnappingSystem:
|
class SnappingSystem:
|
||||||
@@ -75,12 +71,14 @@ class SnappingSystem:
|
|||||||
"""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
|
||||||
|
|
||||||
@@ -124,20 +122,24 @@ class SnappingSystem:
|
|||||||
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:
|
||||||
@@ -166,8 +168,8 @@ class SnappingSystem:
|
|||||||
|
|
||||||
# 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:
|
||||||
@@ -222,21 +224,21 @@ class SnappingSystem:
|
|||||||
new_width, new_height = width, height
|
new_width, new_height = width, height
|
||||||
|
|
||||||
# Apply resize based on handle
|
# Apply resize based on handle
|
||||||
if params.resize_handle in ['nw', 'n', 'ne']:
|
if params.resize_handle in ["nw", "n", "ne"]:
|
||||||
# Top edge moving
|
# Top edge moving
|
||||||
new_y = y + params.dy
|
new_y = y + params.dy
|
||||||
new_height = height - params.dy
|
new_height = height - params.dy
|
||||||
|
|
||||||
if params.resize_handle in ['sw', 's', 'se']:
|
if params.resize_handle in ["sw", "s", "se"]:
|
||||||
# Bottom edge moving
|
# Bottom edge moving
|
||||||
new_height = height + params.dy
|
new_height = height + params.dy
|
||||||
|
|
||||||
if params.resize_handle in ['nw', 'w', 'sw']:
|
if params.resize_handle in ["nw", "w", "sw"]:
|
||||||
# Left edge moving
|
# Left edge moving
|
||||||
new_x = x + params.dx
|
new_x = x + params.dx
|
||||||
new_width = width - params.dx
|
new_width = width - params.dx
|
||||||
|
|
||||||
if params.resize_handle in ['ne', 'e', 'se']:
|
if params.resize_handle in ["ne", "e", "se"]:
|
||||||
# Right edge moving
|
# Right edge moving
|
||||||
new_width = width + params.dx
|
new_width = width + params.dx
|
||||||
|
|
||||||
@@ -244,10 +246,10 @@ class SnappingSystem:
|
|||||||
# 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 params.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, params.dpi, snap_threshold_px, 'vertical', params.project
|
new_x, page_width_mm, params.dpi, snap_threshold_px, "vertical", params.project
|
||||||
)
|
)
|
||||||
if snapped_left is not None:
|
if snapped_left is not None:
|
||||||
# Adjust width to compensate for position change
|
# Adjust width to compensate for position change
|
||||||
@@ -256,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 params.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, params.dpi, snap_threshold_px, 'vertical', params.project
|
right_edge, page_width_mm, params.dpi, snap_threshold_px, "vertical", params.project
|
||||||
)
|
)
|
||||||
if snapped_right is not None:
|
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 params.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, params.dpi, snap_threshold_px, 'horizontal', params.project
|
new_y, page_height_mm, params.dpi, snap_threshold_px, "horizontal", params.project
|
||||||
)
|
)
|
||||||
if snapped_top is not None:
|
if snapped_top is not None:
|
||||||
# Adjust height to compensate for position change
|
# Adjust height to compensate for position change
|
||||||
@@ -279,12 +281,12 @@ 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 params.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, params.dpi, snap_threshold_px, 'horizontal', params.project
|
bottom_edge, page_height_mm, params.dpi, snap_threshold_px, "horizontal", params.project
|
||||||
)
|
)
|
||||||
if snapped_bottom is not None:
|
if snapped_bottom is not None:
|
||||||
new_height = snapped_bottom - new_y
|
new_height = snapped_bottom - new_y
|
||||||
@@ -296,13 +298,15 @@ class SnappingSystem:
|
|||||||
|
|
||||||
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)
|
||||||
|
|
||||||
@@ -329,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
|
||||||
@@ -366,78 +370,6 @@ class SnappingSystem:
|
|||||||
|
|
||||||
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
|
||||||
@@ -453,42 +385,35 @@ class SnappingSystem:
|
|||||||
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
|
||||||
|
|
||||||
@@ -500,7 +425,7 @@ 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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
@@ -212,11 +215,7 @@ class TemplateManager:
|
|||||||
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,7 +267,7 @@ 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.
|
||||||
@@ -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,7 +368,7 @@ 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.
|
||||||
@@ -406,11 +393,7 @@ class TemplateManager:
|
|||||||
|
|
||||||
# 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
|
||||||
@@ -424,11 +407,7 @@ class TemplateManager:
|
|||||||
|
|
||||||
# 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)]
|
||||||
@@ -451,7 +430,7 @@ class TemplateManager:
|
|||||||
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
|
||||||
@@ -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
|
||||||
@@ -42,10 +49,9 @@ class TextEditDialog(QDialog):
|
|||||||
# 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
|
||||||
@@ -68,7 +74,7 @@ class TextEditDialog(QDialog):
|
|||||||
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)
|
||||||
@@ -96,26 +102,22 @@ class TextEditDialog(QDialog):
|
|||||||
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
|
||||||
@@ -141,15 +143,11 @@ class TextEditDialog(QDialog):
|
|||||||
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()
|
||||||
@@ -7,7 +7,6 @@ import uuid
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Dict, Any, Optional, Callable, List
|
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 = "3.0"
|
CURRENT_DATA_VERSION = "3.0"
|
||||||
|
|
||||||
@@ -24,7 +23,7 @@ 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
|
||||||
},
|
},
|
||||||
@@ -38,7 +37,7 @@ VERSION_HISTORY = {
|
|||||||
"Added deletion tracking (deleted flag and deleted_at timestamp)",
|
"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
|
"compatible_with": ["1.0", "2.0", "3.0"], # 3.0 can read older versions with migration
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -120,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
|
||||||
@@ -164,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]:
|
||||||
"""
|
"""
|
||||||
@@ -176,7 +178,7 @@ def migrate_1_0_to_2_0(data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
print("Migration 1.0 → 2.0: Asset paths will be normalized during load")
|
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
|
||||||
@@ -249,7 +251,7 @@ def migrate_2_0_to_3_0(data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
element_data["deleted_at"] = None
|
element_data["deleted_at"] = None
|
||||||
|
|
||||||
# Update version
|
# Update version
|
||||||
data['data_version'] = "3.0"
|
data["data_version"] = "3.0"
|
||||||
|
|
||||||
print(f" Migrated {len(data.get('pages', []))} pages to v3.0")
|
print(f" Migrated {len(data.get('pages', []))} pages to v3.0")
|
||||||
|
|
||||||
@@ -287,7 +289,7 @@ def check_version_compatibility(file_version: str, file_path: str = "") -> tuple
|
|||||||
error_msg += f"File version info:\n"
|
error_msg += f"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:
|
||||||
@@ -312,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]
|
||||||
|
|||||||
+26
-87
@@ -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
|
||||||
@@ -119,10 +101,7 @@ def mock_main_window():
|
|||||||
window.project = Project(name="Test Project")
|
window.project = Project(name="Test Project")
|
||||||
|
|
||||||
# Add a test page
|
# Add a test page
|
||||||
page = Page(
|
page = Page(layout=PageLayout(width=210, height=297), page_number=1) # A4 size in mm
|
||||||
layout=PageLayout(width=210, height=297), # A4 size in mm
|
|
||||||
page_number=1
|
|
||||||
)
|
|
||||||
window.project.pages.append(page)
|
window.project.pages.append(page)
|
||||||
window.project.working_dpi = 96
|
window.project.working_dpi = 96
|
||||||
window.project.page_size_mm = (210, 297)
|
window.project.page_size_mm = (210, 297)
|
||||||
@@ -139,39 +118,19 @@ def mock_main_window():
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_image_element():
|
def sample_image_element():
|
||||||
"""Create a sample ImageData element for testing"""
|
"""Create a sample ImageData element for testing"""
|
||||||
return ImageData(
|
return ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150, z_index=1)
|
||||||
image_path="test.jpg",
|
|
||||||
x=100,
|
|
||||||
y=100,
|
|
||||||
width=200,
|
|
||||||
height=150,
|
|
||||||
z_index=1
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_placeholder_element():
|
def sample_placeholder_element():
|
||||||
"""Create a sample PlaceholderData element for testing"""
|
"""Create a sample PlaceholderData element for testing"""
|
||||||
return PlaceholderData(
|
return PlaceholderData(x=50, y=50, width=100, height=100, z_index=0)
|
||||||
x=50,
|
|
||||||
y=50,
|
|
||||||
width=100,
|
|
||||||
height=100,
|
|
||||||
z_index=0
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_textbox_element():
|
def sample_textbox_element():
|
||||||
"""Create a sample TextBoxData element for testing"""
|
"""Create a sample TextBoxData element for testing"""
|
||||||
return TextBoxData(
|
return TextBoxData(x=10, y=10, width=180, height=50, text_content="Test Text", z_index=2)
|
||||||
x=10,
|
|
||||||
y=10,
|
|
||||||
width=180,
|
|
||||||
height=50,
|
|
||||||
text_content="Test Text",
|
|
||||||
z_index=2
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -189,19 +148,19 @@ def mock_page_renderer():
|
|||||||
|
|
||||||
# Mock coordinate conversion methods
|
# Mock coordinate conversion methods
|
||||||
def page_to_screen(x, y):
|
def page_to_screen(x, y):
|
||||||
return (renderer.screen_x + x * renderer.zoom,
|
return (renderer.screen_x + x * renderer.zoom, renderer.screen_y + y * renderer.zoom)
|
||||||
renderer.screen_y + y * renderer.zoom)
|
|
||||||
|
|
||||||
def screen_to_page(x, y):
|
def screen_to_page(x, y):
|
||||||
return ((x - renderer.screen_x) / renderer.zoom,
|
return ((x - renderer.screen_x) / renderer.zoom, (y - renderer.screen_y) / renderer.zoom)
|
||||||
(y - renderer.screen_y) / renderer.zoom)
|
|
||||||
|
|
||||||
def is_point_in_page(x, y):
|
def is_point_in_page(x, y):
|
||||||
# Simple bounds check (assume 210mm x 297mm page at 96 DPI)
|
# Simple bounds check (assume 210mm x 297mm page at 96 DPI)
|
||||||
page_width_px = 210 * 96 / 25.4
|
page_width_px = 210 * 96 / 25.4
|
||||||
page_height_px = 297 * 96 / 25.4
|
page_height_px = 297 * 96 / 25.4
|
||||||
return (renderer.screen_x <= x <= renderer.screen_x + page_width_px * renderer.zoom and
|
return (
|
||||||
renderer.screen_y <= y <= renderer.screen_y + page_height_px * renderer.zoom)
|
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.page_to_screen = page_to_screen
|
||||||
renderer.screen_to_page = screen_to_page
|
renderer.screen_to_page = screen_to_page
|
||||||
@@ -213,8 +172,8 @@ def mock_page_renderer():
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def create_mouse_event():
|
def create_mouse_event():
|
||||||
"""Factory fixture for creating QMouseEvent objects"""
|
"""Factory fixture for creating QMouseEvent objects"""
|
||||||
def _create_event(event_type, x, y, button=Qt.MouseButton.LeftButton,
|
|
||||||
modifiers=Qt.KeyboardModifier.NoModifier):
|
def _create_event(event_type, x, y, button=Qt.MouseButton.LeftButton, modifiers=Qt.KeyboardModifier.NoModifier):
|
||||||
"""Create a QMouseEvent for testing
|
"""Create a QMouseEvent for testing
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -224,19 +183,15 @@ def create_mouse_event():
|
|||||||
modifiers: Keyboard modifiers
|
modifiers: Keyboard modifiers
|
||||||
"""
|
"""
|
||||||
pos = QPointF(x, y)
|
pos = QPointF(x, y)
|
||||||
return QMouseEvent(
|
return QMouseEvent(event_type, pos, button, button, modifiers)
|
||||||
event_type,
|
|
||||||
pos,
|
|
||||||
button,
|
|
||||||
button,
|
|
||||||
modifiers
|
|
||||||
)
|
|
||||||
return _create_event
|
return _create_event
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def create_wheel_event():
|
def create_wheel_event():
|
||||||
"""Factory fixture for creating QWheelEvent objects"""
|
"""Factory fixture for creating QWheelEvent objects"""
|
||||||
|
|
||||||
def _create_event(x, y, delta_y=120, modifiers=Qt.KeyboardModifier.NoModifier):
|
def _create_event(x, y, delta_y=120, modifiers=Qt.KeyboardModifier.NoModifier):
|
||||||
"""Create a QWheelEvent for testing
|
"""Create a QWheelEvent for testing
|
||||||
|
|
||||||
@@ -257,38 +212,22 @@ def create_wheel_event():
|
|||||||
Qt.MouseButton.NoButton,
|
Qt.MouseButton.NoButton,
|
||||||
modifiers,
|
modifiers,
|
||||||
Qt.ScrollPhase.NoScrollPhase,
|
Qt.ScrollPhase.NoScrollPhase,
|
||||||
False
|
False,
|
||||||
)
|
)
|
||||||
|
|
||||||
return _create_event
|
return _create_event
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def populated_page():
|
def populated_page():
|
||||||
"""Create a page with multiple elements for testing"""
|
"""Create a page with multiple elements for testing"""
|
||||||
page = Page(
|
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||||
layout=PageLayout(width=210, height=297),
|
|
||||||
page_number=1
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add various elements
|
# Add various elements
|
||||||
page.layout.add_element(ImageData(
|
page.layout.add_element(ImageData(image_path="img1.jpg", x=10, y=10, width=100, height=75, z_index=0))
|
||||||
image_path="img1.jpg",
|
|
||||||
x=10, y=10,
|
|
||||||
width=100, height=75,
|
|
||||||
z_index=0
|
|
||||||
))
|
|
||||||
|
|
||||||
page.layout.add_element(PlaceholderData(
|
page.layout.add_element(PlaceholderData(x=120, y=10, width=80, height=60, z_index=1))
|
||||||
x=120, y=10,
|
|
||||||
width=80, height=60,
|
|
||||||
z_index=1
|
|
||||||
))
|
|
||||||
|
|
||||||
page.layout.add_element(TextBoxData(
|
page.layout.add_element(TextBoxData(x=10, y=100, width=190, height=40, text_content="Sample Text", z_index=2))
|
||||||
x=10, y=100,
|
|
||||||
width=190, height=40,
|
|
||||||
text_content="Sample Text",
|
|
||||||
z_index=2
|
|
||||||
))
|
|
||||||
|
|
||||||
return page
|
return page
|
||||||
|
|||||||
+24
-28
@@ -72,8 +72,8 @@ 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])
|
||||||
|
|
||||||
@@ -108,8 +108,8 @@ 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])
|
||||||
|
|
||||||
@@ -125,17 +125,17 @@ 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
|
||||||
@@ -144,17 +144,17 @@ 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ class TestAssetDropWidget(AssetDropMixin, AssetPathMixin, PageNavigationMixin, V
|
|||||||
def _get_project_folder(self):
|
def _get_project_folder(self):
|
||||||
"""Override to access project via window mock"""
|
"""Override to access project via window mock"""
|
||||||
main_window = self.window()
|
main_window = self.window()
|
||||||
if hasattr(main_window, 'project') and main_window.project:
|
if hasattr(main_window, "project") and main_window.project:
|
||||||
return getattr(main_window.project, 'folder_path', None)
|
return getattr(main_window.project, "folder_path", None)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -43,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:
|
||||||
@@ -141,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()
|
||||||
@@ -239,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()
|
||||||
@@ -248,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)
|
||||||
|
|
||||||
@@ -280,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()
|
||||||
@@ -311,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)
|
||||||
@@ -364,7 +367,7 @@ class TestDropEvent:
|
|||||||
|
|
||||||
# Create a real test image file
|
# Create a real test image file
|
||||||
test_image = tmp_path / "new_image.jpg"
|
test_image = tmp_path / "new_image.jpg"
|
||||||
test_image.write_bytes(b'\xFF\xD8\xFF\xE0' + b'\x00' * 100)
|
test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
|
||||||
|
|
||||||
# Setup project with page containing existing ImageData
|
# Setup project with page containing existing ImageData
|
||||||
mock_window = Mock()
|
mock_window = Mock()
|
||||||
@@ -372,10 +375,7 @@ class TestDropEvent:
|
|||||||
mock_window.project.working_dpi = 96
|
mock_window.project.working_dpi = 96
|
||||||
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||||
|
|
||||||
existing_image = ImageData(
|
existing_image = ImageData(image_path="assets/old_image.jpg", x=100, y=100, width=200, height=150)
|
||||||
image_path="assets/old_image.jpg",
|
|
||||||
x=100, y=100, width=200, height=150
|
|
||||||
)
|
|
||||||
page.layout.elements.append(existing_image)
|
page.layout.elements.append(existing_image)
|
||||||
mock_window.project.pages = [page]
|
mock_window.project.pages = [page]
|
||||||
|
|
||||||
@@ -407,24 +407,19 @@ class TestDropEvent:
|
|||||||
widget.update = Mock()
|
widget.update = Mock()
|
||||||
|
|
||||||
test_image = tmp_path / "test.jpg"
|
test_image = tmp_path / "test.jpg"
|
||||||
test_image.write_bytes(b'\xFF\xD8\xFF\xE0' + b'\x00' * 100)
|
test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
|
||||||
|
|
||||||
mock_window = Mock()
|
mock_window = Mock()
|
||||||
mock_window.project = Project(name="Test")
|
mock_window.project = Project(name="Test")
|
||||||
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||||
|
|
||||||
existing_image = ImageData(
|
existing_image = ImageData(image_path="assets/old.jpg", x=100, y=100, width=200, height=150)
|
||||||
image_path="assets/old.jpg",
|
|
||||||
x=100, y=100, width=200, height=150
|
|
||||||
)
|
|
||||||
page.layout.elements.append(existing_image)
|
page.layout.elements.append(existing_image)
|
||||||
mock_window.project.pages = [page]
|
mock_window.project.pages = [page]
|
||||||
|
|
||||||
# Mock asset manager to raise exception
|
# Mock asset manager to raise exception
|
||||||
mock_window.project.asset_manager = Mock()
|
mock_window.project.asset_manager = Mock()
|
||||||
mock_window.project.asset_manager.import_asset = Mock(
|
mock_window.project.asset_manager.import_asset = Mock(side_effect=Exception("Import failed"))
|
||||||
side_effect=Exception("Import failed")
|
|
||||||
)
|
|
||||||
|
|
||||||
widget.window = Mock(return_value=mock_window)
|
widget.window = Mock(return_value=mock_window)
|
||||||
widget._get_element_at = Mock(return_value=existing_image)
|
widget._get_element_at = Mock(return_value=existing_image)
|
||||||
@@ -454,7 +449,7 @@ class TestDropEvent:
|
|||||||
|
|
||||||
# Create a corrupted/invalid image file
|
# Create a corrupted/invalid image file
|
||||||
corrupted_image = tmp_path / "corrupted.jpg"
|
corrupted_image = tmp_path / "corrupted.jpg"
|
||||||
corrupted_image.write_bytes(b'not a valid image')
|
corrupted_image.write_bytes(b"not a valid image")
|
||||||
|
|
||||||
mock_window = Mock()
|
mock_window = Mock()
|
||||||
mock_window.project = Project(name="Test")
|
mock_window.project = Project(name="Test")
|
||||||
@@ -488,7 +483,8 @@ class TestDropEvent:
|
|||||||
# Should use default dimensions (200, 150) from _calculate_image_dimensions
|
# Should use default dimensions (200, 150) from _calculate_image_dimensions
|
||||||
# Check that AddElementCommand was called with an ImageData
|
# Check that AddElementCommand was called with an ImageData
|
||||||
from pyPhotoAlbum.commands import AddElementCommand
|
from pyPhotoAlbum.commands import AddElementCommand
|
||||||
with patch('pyPhotoAlbum.mixins.asset_drop.AddElementCommand') as mock_cmd:
|
|
||||||
|
with patch("pyPhotoAlbum.mixins.asset_drop.AddElementCommand") as mock_cmd:
|
||||||
# Re-run to check the call
|
# Re-run to check the call
|
||||||
widget.dropEvent(event)
|
widget.dropEvent(event)
|
||||||
assert mock_cmd.called
|
assert mock_cmd.called
|
||||||
@@ -527,10 +523,7 @@ class TestExtractImagePathEdgeCases:
|
|||||||
widget.update = Mock()
|
widget.update = Mock()
|
||||||
|
|
||||||
mime_data = QMimeData()
|
mime_data = QMimeData()
|
||||||
mime_data.setUrls([
|
mime_data.setUrls([QUrl.fromLocalFile("/path/to/document.pdf"), QUrl.fromLocalFile("/path/to/file.txt")])
|
||||||
QUrl.fromLocalFile("/path/to/document.pdf"),
|
|
||||||
QUrl.fromLocalFile("/path/to/file.txt")
|
|
||||||
])
|
|
||||||
|
|
||||||
event = Mock()
|
event = Mock()
|
||||||
event.mimeData = Mock(return_value=mime_data)
|
event.mimeData = Mock(return_value=mime_data)
|
||||||
@@ -576,7 +569,7 @@ class TestPlaceholderReplacementEdgeCases:
|
|||||||
widget.update = Mock()
|
widget.update = Mock()
|
||||||
|
|
||||||
test_image = tmp_path / "test.jpg"
|
test_image = tmp_path / "test.jpg"
|
||||||
test_image.write_bytes(b'\xFF\xD8\xFF\xE0' + b'\x00' * 100)
|
test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
|
||||||
|
|
||||||
# Setup project WITHOUT pages
|
# Setup project WITHOUT pages
|
||||||
mock_window = Mock()
|
mock_window = Mock()
|
||||||
@@ -585,6 +578,7 @@ class TestPlaceholderReplacementEdgeCases:
|
|||||||
mock_window.project.pages = [] # Empty pages list
|
mock_window.project.pages = [] # Empty pages list
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
mock_window.project.asset_manager = Mock()
|
mock_window.project.asset_manager = Mock()
|
||||||
|
|||||||
@@ -0,0 +1,596 @@
|
|||||||
|
"""
|
||||||
|
Tests for asset_heal_dialog module
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock, MagicMock, patch, call
|
||||||
|
from PyQt6.QtWidgets import QMessageBox, QFileDialog
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
|
||||||
|
|
||||||
|
class TestAssetHealDialog:
|
||||||
|
"""Tests for AssetHealDialog class"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_project(self, tmp_path):
|
||||||
|
"""Create a mock project with folder_path"""
|
||||||
|
project = Mock()
|
||||||
|
project.folder_path = str(tmp_path / "project")
|
||||||
|
os.makedirs(project.folder_path, exist_ok=True)
|
||||||
|
|
||||||
|
# Create assets folder
|
||||||
|
assets_path = os.path.join(project.folder_path, "assets")
|
||||||
|
os.makedirs(assets_path, exist_ok=True)
|
||||||
|
|
||||||
|
project.asset_manager = Mock()
|
||||||
|
project.pages = []
|
||||||
|
return project
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_page_with_image(self):
|
||||||
|
"""Create a mock page with an image element"""
|
||||||
|
from pyPhotoAlbum.models import ImageData
|
||||||
|
|
||||||
|
page = Mock()
|
||||||
|
element = Mock(spec=ImageData)
|
||||||
|
element.image_path = "assets/image.jpg"
|
||||||
|
page.layout = Mock()
|
||||||
|
page.layout.elements = [element]
|
||||||
|
return page
|
||||||
|
|
||||||
|
def test_init(self, qtbot, mock_project):
|
||||||
|
"""Test AssetHealDialog initialization"""
|
||||||
|
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||||
|
|
||||||
|
with patch.object(AssetHealDialog, '_scan_missing_assets'):
|
||||||
|
dialog = AssetHealDialog(mock_project)
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
|
assert dialog.project is mock_project
|
||||||
|
assert dialog.search_paths == []
|
||||||
|
assert dialog.missing_assets == set()
|
||||||
|
assert dialog.windowTitle() == "Heal Missing Assets"
|
||||||
|
|
||||||
|
def test_init_ui(self, qtbot, mock_project):
|
||||||
|
"""Test UI initialization"""
|
||||||
|
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||||
|
|
||||||
|
with patch.object(AssetHealDialog, '_scan_missing_assets'):
|
||||||
|
dialog = AssetHealDialog(mock_project)
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
|
# Check that UI elements exist
|
||||||
|
assert dialog.missing_list is not None
|
||||||
|
assert dialog.search_list is not None
|
||||||
|
|
||||||
|
def test_scan_missing_assets_no_missing(self, qtbot, mock_project, tmp_path):
|
||||||
|
"""Test scanning when no assets are missing"""
|
||||||
|
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||||
|
from pyPhotoAlbum.models import ImageData
|
||||||
|
|
||||||
|
# Create a valid image in assets folder
|
||||||
|
img_path = os.path.join(mock_project.folder_path, "assets", "image.jpg")
|
||||||
|
Path(img_path).touch()
|
||||||
|
|
||||||
|
element = Mock(spec=ImageData)
|
||||||
|
element.image_path = "assets/image.jpg"
|
||||||
|
|
||||||
|
page = Mock()
|
||||||
|
page.layout = Mock()
|
||||||
|
page.layout.elements = [element]
|
||||||
|
|
||||||
|
mock_project.pages = [page]
|
||||||
|
|
||||||
|
dialog = AssetHealDialog(mock_project)
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
|
assert len(dialog.missing_assets) == 0
|
||||||
|
assert dialog.missing_list.count() == 1 # "No missing assets found!" message
|
||||||
|
item = dialog.missing_list.item(0)
|
||||||
|
assert "No missing assets" in item.text()
|
||||||
|
|
||||||
|
def test_scan_missing_assets_absolute_path(self, qtbot, mock_project):
|
||||||
|
"""Test scanning detects absolute paths as needing healing"""
|
||||||
|
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||||
|
from pyPhotoAlbum.models import ImageData
|
||||||
|
|
||||||
|
element = Mock(spec=ImageData)
|
||||||
|
element.image_path = "/absolute/path/to/image.jpg"
|
||||||
|
|
||||||
|
page = Mock()
|
||||||
|
page.layout = Mock()
|
||||||
|
page.layout.elements = [element]
|
||||||
|
|
||||||
|
mock_project.pages = [page]
|
||||||
|
|
||||||
|
dialog = AssetHealDialog(mock_project)
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
|
assert "/absolute/path/to/image.jpg" in dialog.missing_assets
|
||||||
|
|
||||||
|
def test_scan_missing_assets_not_in_assets_folder(self, qtbot, mock_project):
|
||||||
|
"""Test scanning detects paths not in assets/ folder"""
|
||||||
|
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||||
|
from pyPhotoAlbum.models import ImageData
|
||||||
|
|
||||||
|
element = Mock(spec=ImageData)
|
||||||
|
element.image_path = "images/photo.jpg" # Not in assets/
|
||||||
|
|
||||||
|
page = Mock()
|
||||||
|
page.layout = Mock()
|
||||||
|
page.layout.elements = [element]
|
||||||
|
|
||||||
|
mock_project.pages = [page]
|
||||||
|
|
||||||
|
dialog = AssetHealDialog(mock_project)
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
|
assert "images/photo.jpg" in dialog.missing_assets
|
||||||
|
|
||||||
|
def test_scan_missing_assets_file_missing(self, qtbot, mock_project):
|
||||||
|
"""Test scanning detects missing files in assets/ folder"""
|
||||||
|
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||||
|
from pyPhotoAlbum.models import ImageData
|
||||||
|
|
||||||
|
element = Mock(spec=ImageData)
|
||||||
|
element.image_path = "assets/missing.jpg" # File doesn't exist
|
||||||
|
|
||||||
|
page = Mock()
|
||||||
|
page.layout = Mock()
|
||||||
|
page.layout.elements = [element]
|
||||||
|
|
||||||
|
mock_project.pages = [page]
|
||||||
|
|
||||||
|
dialog = AssetHealDialog(mock_project)
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
|
assert "assets/missing.jpg" in dialog.missing_assets
|
||||||
|
|
||||||
|
def test_scan_missing_assets_non_image_elements_ignored(self, qtbot, mock_project):
|
||||||
|
"""Test that non-ImageData elements are ignored"""
|
||||||
|
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||||
|
|
||||||
|
# TextBox element (not ImageData)
|
||||||
|
element = Mock()
|
||||||
|
element.image_path = None
|
||||||
|
|
||||||
|
page = Mock()
|
||||||
|
page.layout = Mock()
|
||||||
|
page.layout.elements = [element]
|
||||||
|
|
||||||
|
mock_project.pages = [page]
|
||||||
|
|
||||||
|
dialog = AssetHealDialog(mock_project)
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
|
assert len(dialog.missing_assets) == 0
|
||||||
|
|
||||||
|
def test_scan_missing_assets_empty_image_path(self, qtbot, mock_project):
|
||||||
|
"""Test that elements with empty image_path are ignored"""
|
||||||
|
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||||
|
from pyPhotoAlbum.models import ImageData
|
||||||
|
|
||||||
|
element = Mock(spec=ImageData)
|
||||||
|
element.image_path = ""
|
||||||
|
|
||||||
|
page = Mock()
|
||||||
|
page.layout = Mock()
|
||||||
|
page.layout.elements = [element]
|
||||||
|
|
||||||
|
mock_project.pages = [page]
|
||||||
|
|
||||||
|
dialog = AssetHealDialog(mock_project)
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
|
assert len(dialog.missing_assets) == 0
|
||||||
|
|
||||||
|
def test_add_search_path(self, qtbot, mock_project, tmp_path):
|
||||||
|
"""Test adding a search path"""
|
||||||
|
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||||
|
|
||||||
|
with patch.object(AssetHealDialog, '_scan_missing_assets'):
|
||||||
|
dialog = AssetHealDialog(mock_project)
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
|
search_path = str(tmp_path / "search")
|
||||||
|
os.makedirs(search_path, exist_ok=True)
|
||||||
|
|
||||||
|
with patch.object(QFileDialog, 'getExistingDirectory', return_value=search_path):
|
||||||
|
dialog._add_search_path()
|
||||||
|
|
||||||
|
assert search_path in dialog.search_paths
|
||||||
|
assert dialog.search_list.count() == 1
|
||||||
|
|
||||||
|
def test_add_search_path_duplicate(self, qtbot, mock_project, tmp_path):
|
||||||
|
"""Test adding duplicate search path is ignored"""
|
||||||
|
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||||
|
|
||||||
|
with patch.object(AssetHealDialog, '_scan_missing_assets'):
|
||||||
|
dialog = AssetHealDialog(mock_project)
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
|
search_path = str(tmp_path / "search")
|
||||||
|
os.makedirs(search_path, exist_ok=True)
|
||||||
|
|
||||||
|
with patch.object(QFileDialog, 'getExistingDirectory', return_value=search_path):
|
||||||
|
dialog._add_search_path()
|
||||||
|
dialog._add_search_path()
|
||||||
|
|
||||||
|
assert dialog.search_paths.count(search_path) == 1
|
||||||
|
assert dialog.search_list.count() == 1
|
||||||
|
|
||||||
|
def test_add_search_path_cancelled(self, qtbot, mock_project):
|
||||||
|
"""Test cancelling search path dialog"""
|
||||||
|
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||||
|
|
||||||
|
with patch.object(AssetHealDialog, '_scan_missing_assets'):
|
||||||
|
dialog = AssetHealDialog(mock_project)
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
|
with patch.object(QFileDialog, 'getExistingDirectory', return_value=""):
|
||||||
|
dialog._add_search_path()
|
||||||
|
|
||||||
|
assert len(dialog.search_paths) == 0
|
||||||
|
|
||||||
|
def test_remove_search_path(self, qtbot, mock_project, tmp_path):
|
||||||
|
"""Test removing a search path"""
|
||||||
|
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||||
|
|
||||||
|
with patch.object(AssetHealDialog, '_scan_missing_assets'):
|
||||||
|
dialog = AssetHealDialog(mock_project)
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
|
search_path = str(tmp_path / "search")
|
||||||
|
dialog.search_paths.append(search_path)
|
||||||
|
dialog.search_list.addItem(search_path)
|
||||||
|
|
||||||
|
dialog.search_list.setCurrentRow(0)
|
||||||
|
dialog._remove_search_path()
|
||||||
|
|
||||||
|
assert len(dialog.search_paths) == 0
|
||||||
|
assert dialog.search_list.count() == 0
|
||||||
|
|
||||||
|
def test_remove_search_path_none_selected(self, qtbot, mock_project):
|
||||||
|
"""Test removing when no path is selected"""
|
||||||
|
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||||
|
|
||||||
|
with patch.object(AssetHealDialog, '_scan_missing_assets'):
|
||||||
|
dialog = AssetHealDialog(mock_project)
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
|
dialog.search_paths.append("/some/path")
|
||||||
|
dialog.search_list.addItem("/some/path")
|
||||||
|
|
||||||
|
dialog.search_list.setCurrentRow(-1) # No selection
|
||||||
|
dialog._remove_search_path()
|
||||||
|
|
||||||
|
# Should not remove anything
|
||||||
|
assert len(dialog.search_paths) == 1
|
||||||
|
|
||||||
|
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
|
||||||
|
def test_attempt_healing_no_missing_assets(self, mock_set_context, qtbot, mock_project):
|
||||||
|
"""Test healing when there are no missing assets"""
|
||||||
|
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||||
|
|
||||||
|
mock_project.pages = []
|
||||||
|
|
||||||
|
dialog = AssetHealDialog(mock_project)
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
|
with patch.object(QMessageBox, 'information') as mock_info:
|
||||||
|
dialog._attempt_healing()
|
||||||
|
|
||||||
|
mock_info.assert_called_once()
|
||||||
|
args = mock_info.call_args[0]
|
||||||
|
assert "Assets found: 0" in args[2]
|
||||||
|
|
||||||
|
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
|
||||||
|
def test_attempt_healing_resolve_relative_path(self, mock_set_context, qtbot, mock_project, tmp_path):
|
||||||
|
"""Test healing by resolving relative path"""
|
||||||
|
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||||
|
from pyPhotoAlbum.models import ImageData
|
||||||
|
|
||||||
|
# Create the actual image file outside project
|
||||||
|
external_img = tmp_path / "external" / "image.jpg"
|
||||||
|
external_img.parent.mkdir(exist_ok=True)
|
||||||
|
external_img.touch()
|
||||||
|
|
||||||
|
# Element with relative path that resolves to external image
|
||||||
|
element = Mock(spec=ImageData)
|
||||||
|
rel_path = os.path.relpath(str(external_img), mock_project.folder_path)
|
||||||
|
element.image_path = rel_path
|
||||||
|
|
||||||
|
page = Mock()
|
||||||
|
page.layout = Mock()
|
||||||
|
page.layout.elements = [element]
|
||||||
|
|
||||||
|
mock_project.pages = [page]
|
||||||
|
|
||||||
|
dialog = AssetHealDialog(mock_project)
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
|
# Mock the import_asset method
|
||||||
|
mock_project.asset_manager.import_asset.return_value = "assets/image.jpg"
|
||||||
|
|
||||||
|
with patch.object(QMessageBox, 'information') as mock_info:
|
||||||
|
dialog._attempt_healing()
|
||||||
|
|
||||||
|
# Should have imported the asset
|
||||||
|
mock_project.asset_manager.import_asset.assert_called()
|
||||||
|
|
||||||
|
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
|
||||||
|
def test_attempt_healing_absolute_path_exists(self, mock_set_context, qtbot, mock_project, tmp_path):
|
||||||
|
"""Test healing absolute path that exists"""
|
||||||
|
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||||
|
from pyPhotoAlbum.models import ImageData
|
||||||
|
|
||||||
|
# Create image at absolute path
|
||||||
|
abs_img = tmp_path / "image.jpg"
|
||||||
|
abs_img.touch()
|
||||||
|
|
||||||
|
element = Mock(spec=ImageData)
|
||||||
|
element.image_path = str(abs_img)
|
||||||
|
|
||||||
|
page = Mock()
|
||||||
|
page.layout = Mock()
|
||||||
|
page.layout.elements = [element]
|
||||||
|
|
||||||
|
mock_project.pages = [page]
|
||||||
|
|
||||||
|
dialog = AssetHealDialog(mock_project)
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
|
mock_project.asset_manager.import_asset.return_value = "assets/image.jpg"
|
||||||
|
|
||||||
|
with patch.object(QMessageBox, 'information') as mock_info:
|
||||||
|
dialog._attempt_healing()
|
||||||
|
|
||||||
|
# Should import the asset
|
||||||
|
mock_project.asset_manager.import_asset.assert_called_with(str(abs_img))
|
||||||
|
# Should update element path
|
||||||
|
assert element.image_path == "assets/image.jpg"
|
||||||
|
|
||||||
|
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
|
||||||
|
def test_attempt_healing_search_path_by_filename(self, mock_set_context, qtbot, mock_project, tmp_path):
|
||||||
|
"""Test healing by finding file in search path by filename"""
|
||||||
|
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||||
|
from pyPhotoAlbum.models import ImageData
|
||||||
|
|
||||||
|
# Create search path with image
|
||||||
|
search_dir = tmp_path / "search"
|
||||||
|
search_dir.mkdir()
|
||||||
|
found_img = search_dir / "photo.jpg"
|
||||||
|
found_img.touch()
|
||||||
|
|
||||||
|
element = Mock(spec=ImageData)
|
||||||
|
element.image_path = "/missing/path/photo.jpg"
|
||||||
|
|
||||||
|
page = Mock()
|
||||||
|
page.layout = Mock()
|
||||||
|
page.layout.elements = [element]
|
||||||
|
|
||||||
|
mock_project.pages = [page]
|
||||||
|
|
||||||
|
with patch.object(AssetHealDialog, '_scan_missing_assets'):
|
||||||
|
dialog = AssetHealDialog(mock_project)
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
|
dialog.missing_assets.add("/missing/path/photo.jpg")
|
||||||
|
dialog.search_paths.append(str(search_dir))
|
||||||
|
|
||||||
|
mock_project.asset_manager.import_asset.return_value = "assets/photo.jpg"
|
||||||
|
|
||||||
|
with patch.object(QMessageBox, 'information'):
|
||||||
|
dialog._attempt_healing()
|
||||||
|
|
||||||
|
mock_project.asset_manager.import_asset.assert_called_with(str(found_img))
|
||||||
|
|
||||||
|
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
|
||||||
|
def test_attempt_healing_search_path_by_relative_structure(self, mock_set_context, qtbot, mock_project, tmp_path):
|
||||||
|
"""Test healing by finding file in search path with same relative structure"""
|
||||||
|
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||||
|
from pyPhotoAlbum.models import ImageData
|
||||||
|
|
||||||
|
# Create search path with subdirectory structure
|
||||||
|
search_dir = tmp_path / "search"
|
||||||
|
subdir = search_dir / "photos"
|
||||||
|
subdir.mkdir(parents=True)
|
||||||
|
found_img = subdir / "image.jpg"
|
||||||
|
found_img.touch()
|
||||||
|
|
||||||
|
element = Mock(spec=ImageData)
|
||||||
|
element.image_path = "photos/image.jpg"
|
||||||
|
|
||||||
|
page = Mock()
|
||||||
|
page.layout = Mock()
|
||||||
|
page.layout.elements = [element]
|
||||||
|
|
||||||
|
mock_project.pages = [page]
|
||||||
|
|
||||||
|
with patch.object(AssetHealDialog, '_scan_missing_assets'):
|
||||||
|
dialog = AssetHealDialog(mock_project)
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
|
dialog.missing_assets.add("photos/image.jpg")
|
||||||
|
dialog.search_paths.append(str(search_dir))
|
||||||
|
|
||||||
|
mock_project.asset_manager.import_asset.return_value = "assets/image.jpg"
|
||||||
|
|
||||||
|
with patch.object(QMessageBox, 'information'):
|
||||||
|
dialog._attempt_healing()
|
||||||
|
|
||||||
|
mock_project.asset_manager.import_asset.assert_called_with(str(found_img))
|
||||||
|
|
||||||
|
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
|
||||||
|
def test_attempt_healing_restore_to_assets_folder(self, mock_set_context, qtbot, mock_project, tmp_path):
|
||||||
|
"""Test healing by restoring file to assets folder"""
|
||||||
|
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||||
|
from pyPhotoAlbum.models import ImageData
|
||||||
|
|
||||||
|
# Create source image
|
||||||
|
source_img = tmp_path / "source" / "image.jpg"
|
||||||
|
source_img.parent.mkdir()
|
||||||
|
source_img.touch()
|
||||||
|
|
||||||
|
element = Mock(spec=ImageData)
|
||||||
|
element.image_path = "assets/image.jpg" # Already correct path, just missing file
|
||||||
|
|
||||||
|
page = Mock()
|
||||||
|
page.layout = Mock()
|
||||||
|
page.layout.elements = [element]
|
||||||
|
|
||||||
|
mock_project.pages = [page]
|
||||||
|
|
||||||
|
with patch.object(AssetHealDialog, '_scan_missing_assets'):
|
||||||
|
dialog = AssetHealDialog(mock_project)
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
|
dialog.missing_assets.add("assets/image.jpg")
|
||||||
|
dialog.search_paths.append(str(source_img.parent))
|
||||||
|
|
||||||
|
with patch('shutil.copy2') as mock_copy:
|
||||||
|
with patch.object(QMessageBox, 'information'):
|
||||||
|
dialog._attempt_healing()
|
||||||
|
|
||||||
|
# Should copy file, not import
|
||||||
|
mock_copy.assert_called_once()
|
||||||
|
assert not mock_project.asset_manager.import_asset.called
|
||||||
|
|
||||||
|
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
|
||||||
|
def test_attempt_healing_not_found(self, mock_set_context, qtbot, mock_project):
|
||||||
|
"""Test healing when asset cannot be found"""
|
||||||
|
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||||
|
from pyPhotoAlbum.models import ImageData
|
||||||
|
|
||||||
|
element = Mock(spec=ImageData)
|
||||||
|
element.image_path = "/missing/image.jpg"
|
||||||
|
|
||||||
|
page = Mock()
|
||||||
|
page.layout = Mock()
|
||||||
|
page.layout.elements = [element]
|
||||||
|
|
||||||
|
mock_project.pages = [page]
|
||||||
|
|
||||||
|
dialog = AssetHealDialog(mock_project)
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
|
with patch.object(QMessageBox, 'information') as mock_info:
|
||||||
|
dialog._attempt_healing()
|
||||||
|
|
||||||
|
args = mock_info.call_args[0]
|
||||||
|
assert "Still missing: 1" in args[2]
|
||||||
|
assert "/missing/image.jpg" in args[2]
|
||||||
|
|
||||||
|
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
|
||||||
|
def test_attempt_healing_import_error(self, mock_set_context, qtbot, mock_project, tmp_path):
|
||||||
|
"""Test healing when import_asset raises an error"""
|
||||||
|
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||||
|
from pyPhotoAlbum.models import ImageData
|
||||||
|
|
||||||
|
# Create image
|
||||||
|
img = tmp_path / "image.jpg"
|
||||||
|
img.touch()
|
||||||
|
|
||||||
|
element = Mock(spec=ImageData)
|
||||||
|
element.image_path = str(img)
|
||||||
|
|
||||||
|
page = Mock()
|
||||||
|
page.layout = Mock()
|
||||||
|
page.layout.elements = [element]
|
||||||
|
|
||||||
|
mock_project.pages = [page]
|
||||||
|
|
||||||
|
dialog = AssetHealDialog(mock_project)
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
|
# Mock import to raise error
|
||||||
|
mock_project.asset_manager.import_asset.side_effect = Exception("Import failed")
|
||||||
|
|
||||||
|
with patch.object(QMessageBox, 'information') as mock_info:
|
||||||
|
dialog._attempt_healing()
|
||||||
|
|
||||||
|
# Should be in still_missing list
|
||||||
|
args = mock_info.call_args[0]
|
||||||
|
assert "Still missing: 1" in args[2]
|
||||||
|
|
||||||
|
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
|
||||||
|
def test_attempt_healing_many_missing(self, mock_set_context, qtbot, mock_project):
|
||||||
|
"""Test healing with more than 10 missing assets (tests truncation)"""
|
||||||
|
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||||
|
from pyPhotoAlbum.models import ImageData
|
||||||
|
|
||||||
|
pages = []
|
||||||
|
for i in range(15):
|
||||||
|
element = Mock(spec=ImageData)
|
||||||
|
element.image_path = f"/missing/image{i}.jpg"
|
||||||
|
|
||||||
|
page = Mock()
|
||||||
|
page.layout = Mock()
|
||||||
|
page.layout.elements = [element]
|
||||||
|
pages.append(page)
|
||||||
|
|
||||||
|
mock_project.pages = pages
|
||||||
|
|
||||||
|
dialog = AssetHealDialog(mock_project)
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
|
with patch.object(QMessageBox, 'information') as mock_info:
|
||||||
|
dialog._attempt_healing()
|
||||||
|
|
||||||
|
args = mock_info.call_args[0]
|
||||||
|
# Should show "... and X more"
|
||||||
|
assert "and 5 more" in args[2]
|
||||||
|
|
||||||
|
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
|
||||||
|
def test_attempt_healing_multiple_elements_same_path(self, mock_set_context, qtbot, mock_project, tmp_path):
|
||||||
|
"""Test healing when multiple elements reference the same missing path"""
|
||||||
|
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||||
|
from pyPhotoAlbum.models import ImageData
|
||||||
|
|
||||||
|
# Create image
|
||||||
|
img = tmp_path / "image.jpg"
|
||||||
|
img.touch()
|
||||||
|
|
||||||
|
# Two elements with same path
|
||||||
|
element1 = Mock(spec=ImageData)
|
||||||
|
element1.image_path = str(img)
|
||||||
|
element2 = Mock(spec=ImageData)
|
||||||
|
element2.image_path = str(img)
|
||||||
|
|
||||||
|
page = Mock()
|
||||||
|
page.layout = Mock()
|
||||||
|
page.layout.elements = [element1, element2]
|
||||||
|
|
||||||
|
mock_project.pages = [page]
|
||||||
|
|
||||||
|
dialog = AssetHealDialog(mock_project)
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
|
mock_project.asset_manager.import_asset.return_value = "assets/image.jpg"
|
||||||
|
|
||||||
|
with patch.object(QMessageBox, 'information'):
|
||||||
|
dialog._attempt_healing()
|
||||||
|
|
||||||
|
# Both elements should be updated
|
||||||
|
assert element1.image_path == "assets/image.jpg"
|
||||||
|
assert element2.image_path == "assets/image.jpg"
|
||||||
|
|
||||||
|
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
|
||||||
|
def test_attempt_healing_rescans_after(self, mock_set_context, qtbot, mock_project):
|
||||||
|
"""Test that _scan_missing_assets is called after healing"""
|
||||||
|
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||||
|
|
||||||
|
mock_project.pages = []
|
||||||
|
|
||||||
|
dialog = AssetHealDialog(mock_project)
|
||||||
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
|
with patch.object(dialog, '_scan_missing_assets') as mock_scan:
|
||||||
|
with patch.object(QMessageBox, 'information'):
|
||||||
|
dialog._attempt_healing()
|
||||||
|
|
||||||
|
# Should rescan after healing
|
||||||
|
mock_scan.assert_called_once()
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
"""
|
||||||
|
Tests for AssetManager functionality including deduplication and unused asset detection
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from pyPhotoAlbum.asset_manager import AssetManager, compute_file_md5
|
||||||
|
|
||||||
|
|
||||||
|
class TestComputeFileMd5:
|
||||||
|
"""Tests for the compute_file_md5 function"""
|
||||||
|
|
||||||
|
def test_compute_md5_existing_file(self, tmp_path):
|
||||||
|
"""Test MD5 computation for an existing file"""
|
||||||
|
# Create a test file
|
||||||
|
test_file = tmp_path / "test.txt"
|
||||||
|
test_file.write_text("Hello, World!")
|
||||||
|
|
||||||
|
md5_hash = compute_file_md5(str(test_file))
|
||||||
|
assert md5_hash is not None
|
||||||
|
# Known MD5 for "Hello, World!"
|
||||||
|
assert md5_hash == "65a8e27d8879283831b664bd8b7f0ad4"
|
||||||
|
|
||||||
|
def test_compute_md5_nonexistent_file(self):
|
||||||
|
"""Test MD5 computation returns None for non-existent file"""
|
||||||
|
md5_hash = compute_file_md5("/nonexistent/path/file.txt")
|
||||||
|
assert md5_hash is None
|
||||||
|
|
||||||
|
def test_compute_md5_same_content_same_hash(self, tmp_path):
|
||||||
|
"""Test that identical content produces identical hashes"""
|
||||||
|
content = b"Test content for hashing"
|
||||||
|
|
||||||
|
file1 = tmp_path / "file1.bin"
|
||||||
|
file2 = tmp_path / "file2.bin"
|
||||||
|
file1.write_bytes(content)
|
||||||
|
file2.write_bytes(content)
|
||||||
|
|
||||||
|
hash1 = compute_file_md5(str(file1))
|
||||||
|
hash2 = compute_file_md5(str(file2))
|
||||||
|
|
||||||
|
assert hash1 == hash2
|
||||||
|
|
||||||
|
def test_compute_md5_different_content_different_hash(self, tmp_path):
|
||||||
|
"""Test that different content produces different hashes"""
|
||||||
|
file1 = tmp_path / "file1.txt"
|
||||||
|
file2 = tmp_path / "file2.txt"
|
||||||
|
file1.write_text("Content A")
|
||||||
|
file2.write_text("Content B")
|
||||||
|
|
||||||
|
hash1 = compute_file_md5(str(file1))
|
||||||
|
hash2 = compute_file_md5(str(file2))
|
||||||
|
|
||||||
|
assert hash1 != hash2
|
||||||
|
|
||||||
|
|
||||||
|
class TestAssetManagerDeduplication:
|
||||||
|
"""Tests for AssetManager deduplication methods"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def asset_manager(self, tmp_path):
|
||||||
|
"""Create an AssetManager with a temporary project folder"""
|
||||||
|
project_folder = str(tmp_path / "test_project")
|
||||||
|
os.makedirs(project_folder)
|
||||||
|
return AssetManager(project_folder)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def create_test_image(self):
|
||||||
|
"""Factory fixture for creating test images"""
|
||||||
|
def _create(path, color="red", size=(100, 100)):
|
||||||
|
img = Image.new("RGB", size, color=color)
|
||||||
|
img.save(path)
|
||||||
|
return path
|
||||||
|
return _create
|
||||||
|
|
||||||
|
def test_compute_all_hashes_empty_folder(self, asset_manager):
|
||||||
|
"""Test hash computation on empty assets folder"""
|
||||||
|
hashes = asset_manager.compute_all_hashes()
|
||||||
|
assert len(hashes) == 0
|
||||||
|
|
||||||
|
def test_compute_all_hashes_with_files(self, asset_manager, create_test_image):
|
||||||
|
"""Test hash computation with files in assets folder"""
|
||||||
|
# Create some test images
|
||||||
|
img1 = os.path.join(asset_manager.assets_folder, "image1.png")
|
||||||
|
img2 = os.path.join(asset_manager.assets_folder, "image2.png")
|
||||||
|
create_test_image(img1, color="red")
|
||||||
|
create_test_image(img2, color="blue")
|
||||||
|
|
||||||
|
hashes = asset_manager.compute_all_hashes()
|
||||||
|
|
||||||
|
assert len(hashes) == 2
|
||||||
|
assert "assets/image1.png" in hashes
|
||||||
|
assert "assets/image2.png" in hashes
|
||||||
|
|
||||||
|
def test_find_duplicates_no_duplicates(self, asset_manager, create_test_image):
|
||||||
|
"""Test finding duplicates when there are none"""
|
||||||
|
img1 = os.path.join(asset_manager.assets_folder, "image1.png")
|
||||||
|
img2 = os.path.join(asset_manager.assets_folder, "image2.png")
|
||||||
|
create_test_image(img1, color="red")
|
||||||
|
create_test_image(img2, color="blue")
|
||||||
|
|
||||||
|
duplicates = asset_manager.find_duplicates()
|
||||||
|
assert len(duplicates) == 0
|
||||||
|
|
||||||
|
def test_find_duplicates_with_duplicates(self, asset_manager, tmp_path):
|
||||||
|
"""Test finding actual duplicate files"""
|
||||||
|
# Create a source image
|
||||||
|
source_img = tmp_path / "source.png"
|
||||||
|
img = Image.new("RGB", (50, 50), color="green")
|
||||||
|
img.save(str(source_img))
|
||||||
|
|
||||||
|
# Copy the same image twice to assets folder
|
||||||
|
dup1 = os.path.join(asset_manager.assets_folder, "dup1.png")
|
||||||
|
dup2 = os.path.join(asset_manager.assets_folder, "dup2.png")
|
||||||
|
shutil.copy(str(source_img), dup1)
|
||||||
|
shutil.copy(str(source_img), dup2)
|
||||||
|
|
||||||
|
duplicates = asset_manager.find_duplicates()
|
||||||
|
|
||||||
|
assert len(duplicates) == 1 # One group of duplicates
|
||||||
|
# The group should contain both files
|
||||||
|
for paths in duplicates.values():
|
||||||
|
assert len(paths) == 2
|
||||||
|
assert "assets/dup1.png" in paths
|
||||||
|
assert "assets/dup2.png" in paths
|
||||||
|
|
||||||
|
def test_get_duplicate_stats_no_duplicates(self, asset_manager, create_test_image):
|
||||||
|
"""Test duplicate stats when there are no duplicates"""
|
||||||
|
img1 = os.path.join(asset_manager.assets_folder, "image1.png")
|
||||||
|
create_test_image(img1, color="red")
|
||||||
|
|
||||||
|
groups, files, bytes_to_save = asset_manager.get_duplicate_stats()
|
||||||
|
|
||||||
|
assert groups == 0
|
||||||
|
assert files == 0
|
||||||
|
assert bytes_to_save == 0
|
||||||
|
|
||||||
|
def test_get_duplicate_stats_with_duplicates(self, asset_manager, tmp_path):
|
||||||
|
"""Test duplicate stats with actual duplicates"""
|
||||||
|
# Create a source image
|
||||||
|
source_img = tmp_path / "source.png"
|
||||||
|
img = Image.new("RGB", (100, 100), color="purple")
|
||||||
|
img.save(str(source_img))
|
||||||
|
|
||||||
|
# Copy to assets folder 3 times (creates 2 duplicates)
|
||||||
|
for i in range(3):
|
||||||
|
dest = os.path.join(asset_manager.assets_folder, f"image{i}.png")
|
||||||
|
shutil.copy(str(source_img), dest)
|
||||||
|
|
||||||
|
groups, files, bytes_to_save = asset_manager.get_duplicate_stats()
|
||||||
|
|
||||||
|
assert groups == 1 # One group
|
||||||
|
assert files == 2 # Two extra copies to remove
|
||||||
|
assert bytes_to_save > 0
|
||||||
|
|
||||||
|
def test_deduplicate_assets_removes_files(self, asset_manager, tmp_path):
|
||||||
|
"""Test that deduplication actually removes duplicate files"""
|
||||||
|
# Create a source image
|
||||||
|
source_img = tmp_path / "source.png"
|
||||||
|
img = Image.new("RGB", (50, 50), color="yellow")
|
||||||
|
img.save(str(source_img))
|
||||||
|
|
||||||
|
# Copy to assets folder 3 times
|
||||||
|
for i in range(3):
|
||||||
|
dest = os.path.join(asset_manager.assets_folder, f"image{i}.png")
|
||||||
|
shutil.copy(str(source_img), dest)
|
||||||
|
asset_manager.reference_counts[f"assets/image{i}.png"] = 1
|
||||||
|
|
||||||
|
# Count files before
|
||||||
|
files_before = len(os.listdir(asset_manager.assets_folder))
|
||||||
|
assert files_before == 3
|
||||||
|
|
||||||
|
# Run deduplication
|
||||||
|
files_removed, bytes_saved = asset_manager.deduplicate_assets()
|
||||||
|
|
||||||
|
# Check results
|
||||||
|
assert files_removed == 2
|
||||||
|
assert bytes_saved > 0
|
||||||
|
|
||||||
|
# Count files after
|
||||||
|
files_after = len(os.listdir(asset_manager.assets_folder))
|
||||||
|
assert files_after == 1
|
||||||
|
|
||||||
|
def test_deduplicate_assets_updates_callback(self, asset_manager, tmp_path):
|
||||||
|
"""Test that deduplication calls the update callback correctly"""
|
||||||
|
# Create a source image
|
||||||
|
source_img = tmp_path / "source.png"
|
||||||
|
img = Image.new("RGB", (50, 50), color="cyan")
|
||||||
|
img.save(str(source_img))
|
||||||
|
|
||||||
|
# Copy to assets folder
|
||||||
|
dest1 = os.path.join(asset_manager.assets_folder, "a_first.png")
|
||||||
|
dest2 = os.path.join(asset_manager.assets_folder, "b_second.png")
|
||||||
|
shutil.copy(str(source_img), dest1)
|
||||||
|
shutil.copy(str(source_img), dest2)
|
||||||
|
|
||||||
|
# Track callback invocations
|
||||||
|
callback_calls = []
|
||||||
|
|
||||||
|
def track_callback(old_path, new_path):
|
||||||
|
callback_calls.append((old_path, new_path))
|
||||||
|
|
||||||
|
# Run deduplication
|
||||||
|
asset_manager.deduplicate_assets(update_references_callback=track_callback)
|
||||||
|
|
||||||
|
# Callback should have been called for the duplicate
|
||||||
|
assert len(callback_calls) == 1
|
||||||
|
# b_second.png should be remapped to a_first.png (alphabetical order)
|
||||||
|
assert callback_calls[0] == ("assets/b_second.png", "assets/a_first.png")
|
||||||
|
|
||||||
|
def test_deduplicate_assets_transfers_reference_counts(self, asset_manager, tmp_path):
|
||||||
|
"""Test that reference counts are properly transferred during deduplication"""
|
||||||
|
# Create a source image
|
||||||
|
source_img = tmp_path / "source.png"
|
||||||
|
img = Image.new("RGB", (50, 50), color="magenta")
|
||||||
|
img.save(str(source_img))
|
||||||
|
|
||||||
|
# Copy to assets folder
|
||||||
|
dest1 = os.path.join(asset_manager.assets_folder, "a_first.png")
|
||||||
|
dest2 = os.path.join(asset_manager.assets_folder, "b_second.png")
|
||||||
|
shutil.copy(str(source_img), dest1)
|
||||||
|
shutil.copy(str(source_img), dest2)
|
||||||
|
|
||||||
|
# Set reference counts
|
||||||
|
asset_manager.reference_counts["assets/a_first.png"] = 2
|
||||||
|
asset_manager.reference_counts["assets/b_second.png"] = 3
|
||||||
|
|
||||||
|
# Run deduplication
|
||||||
|
asset_manager.deduplicate_assets()
|
||||||
|
|
||||||
|
# Check reference counts were merged
|
||||||
|
assert asset_manager.reference_counts.get("assets/a_first.png") == 5
|
||||||
|
assert "assets/b_second.png" not in asset_manager.reference_counts
|
||||||
|
|
||||||
|
def test_serialize_includes_hashes(self, asset_manager, create_test_image):
|
||||||
|
"""Test that serialization includes asset hashes"""
|
||||||
|
img1 = os.path.join(asset_manager.assets_folder, "image1.png")
|
||||||
|
create_test_image(img1, color="red")
|
||||||
|
asset_manager.compute_all_hashes()
|
||||||
|
|
||||||
|
data = asset_manager.serialize()
|
||||||
|
|
||||||
|
assert "asset_hashes" in data
|
||||||
|
assert "assets/image1.png" in data["asset_hashes"]
|
||||||
|
|
||||||
|
def test_deserialize_restores_hashes(self, asset_manager):
|
||||||
|
"""Test that deserialization restores asset hashes"""
|
||||||
|
test_data = {
|
||||||
|
"reference_counts": {"assets/test.png": 1},
|
||||||
|
"asset_hashes": {"assets/test.png": "abc123hash"}
|
||||||
|
}
|
||||||
|
|
||||||
|
asset_manager.deserialize(test_data)
|
||||||
|
|
||||||
|
assert asset_manager.asset_hashes.get("assets/test.png") == "abc123hash"
|
||||||
|
|
||||||
|
def test_compute_asset_hash_single_file(self, asset_manager, create_test_image):
|
||||||
|
"""Test computing hash for a single asset"""
|
||||||
|
img_path = os.path.join(asset_manager.assets_folder, "single.png")
|
||||||
|
create_test_image(img_path, color="orange")
|
||||||
|
|
||||||
|
hash_result = asset_manager.compute_asset_hash("assets/single.png")
|
||||||
|
|
||||||
|
assert hash_result is not None
|
||||||
|
assert "assets/single.png" in asset_manager.asset_hashes
|
||||||
|
assert asset_manager.asset_hashes["assets/single.png"] == hash_result
|
||||||
|
|
||||||
|
|
||||||
|
class TestAssetManagerIntegration:
|
||||||
|
"""Integration tests for AssetManager with import and deduplication"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def asset_manager(self, tmp_path):
|
||||||
|
"""Create an AssetManager with a temporary project folder"""
|
||||||
|
project_folder = str(tmp_path / "test_project")
|
||||||
|
os.makedirs(project_folder)
|
||||||
|
return AssetManager(project_folder)
|
||||||
|
|
||||||
|
def test_import_then_deduplicate(self, asset_manager, tmp_path):
|
||||||
|
"""Test importing duplicate images and then deduplicating"""
|
||||||
|
# Create a source image
|
||||||
|
source_img = tmp_path / "source.png"
|
||||||
|
img = Image.new("RGB", (80, 80), color="navy")
|
||||||
|
img.save(str(source_img))
|
||||||
|
|
||||||
|
# Import the same image twice
|
||||||
|
path1 = asset_manager.import_asset(str(source_img))
|
||||||
|
path2 = asset_manager.import_asset(str(source_img))
|
||||||
|
|
||||||
|
assert path1 != path2 # Should have different names due to collision handling
|
||||||
|
|
||||||
|
# Check both files exist
|
||||||
|
assert os.path.exists(asset_manager.get_absolute_path(path1))
|
||||||
|
assert os.path.exists(asset_manager.get_absolute_path(path2))
|
||||||
|
|
||||||
|
# Find duplicates
|
||||||
|
duplicates = asset_manager.find_duplicates()
|
||||||
|
assert len(duplicates) == 1
|
||||||
|
|
||||||
|
# Deduplicate
|
||||||
|
files_removed, _ = asset_manager.deduplicate_assets()
|
||||||
|
assert files_removed == 1
|
||||||
|
|
||||||
|
# Only one file should remain
|
||||||
|
files_in_assets = os.listdir(asset_manager.assets_folder)
|
||||||
|
assert len(files_in_assets) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestAssetManagerUnused:
|
||||||
|
"""Tests for AssetManager unused asset detection and removal"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def asset_manager(self, tmp_path):
|
||||||
|
"""Create an AssetManager with a temporary project folder"""
|
||||||
|
project_folder = str(tmp_path / "test_project")
|
||||||
|
os.makedirs(project_folder)
|
||||||
|
return AssetManager(project_folder)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def create_test_image(self):
|
||||||
|
"""Factory fixture for creating test images"""
|
||||||
|
def _create(path, color="red", size=(100, 100)):
|
||||||
|
img = Image.new("RGB", size, color=color)
|
||||||
|
img.save(path)
|
||||||
|
return path
|
||||||
|
return _create
|
||||||
|
|
||||||
|
def test_find_unused_assets_empty_folder(self, asset_manager):
|
||||||
|
"""Test finding unused assets in empty folder"""
|
||||||
|
unused = asset_manager.find_unused_assets()
|
||||||
|
assert len(unused) == 0
|
||||||
|
|
||||||
|
def test_find_unused_assets_all_referenced(self, asset_manager, create_test_image):
|
||||||
|
"""Test finding unused assets when all are referenced"""
|
||||||
|
img1 = os.path.join(asset_manager.assets_folder, "image1.png")
|
||||||
|
img2 = os.path.join(asset_manager.assets_folder, "image2.png")
|
||||||
|
create_test_image(img1, color="red")
|
||||||
|
create_test_image(img2, color="blue")
|
||||||
|
|
||||||
|
# Add references for both
|
||||||
|
asset_manager.reference_counts["assets/image1.png"] = 1
|
||||||
|
asset_manager.reference_counts["assets/image2.png"] = 2
|
||||||
|
|
||||||
|
unused = asset_manager.find_unused_assets()
|
||||||
|
assert len(unused) == 0
|
||||||
|
|
||||||
|
def test_find_unused_assets_some_unreferenced(self, asset_manager, create_test_image):
|
||||||
|
"""Test finding unused assets when some have no references"""
|
||||||
|
img1 = os.path.join(asset_manager.assets_folder, "used.png")
|
||||||
|
img2 = os.path.join(asset_manager.assets_folder, "unused.png")
|
||||||
|
create_test_image(img1, color="red")
|
||||||
|
create_test_image(img2, color="blue")
|
||||||
|
|
||||||
|
# Only reference one
|
||||||
|
asset_manager.reference_counts["assets/used.png"] = 1
|
||||||
|
|
||||||
|
unused = asset_manager.find_unused_assets()
|
||||||
|
assert len(unused) == 1
|
||||||
|
assert "assets/unused.png" in unused
|
||||||
|
|
||||||
|
def test_find_unused_assets_zero_reference_count(self, asset_manager, create_test_image):
|
||||||
|
"""Test that zero reference count is considered unused"""
|
||||||
|
img = os.path.join(asset_manager.assets_folder, "orphan.png")
|
||||||
|
create_test_image(img, color="red")
|
||||||
|
|
||||||
|
# Set reference count to 0
|
||||||
|
asset_manager.reference_counts["assets/orphan.png"] = 0
|
||||||
|
|
||||||
|
unused = asset_manager.find_unused_assets()
|
||||||
|
assert len(unused) == 1
|
||||||
|
assert "assets/orphan.png" in unused
|
||||||
|
|
||||||
|
def test_get_unused_stats_no_unused(self, asset_manager, create_test_image):
|
||||||
|
"""Test unused stats when all assets are referenced"""
|
||||||
|
img = os.path.join(asset_manager.assets_folder, "image.png")
|
||||||
|
create_test_image(img, color="red")
|
||||||
|
asset_manager.reference_counts["assets/image.png"] = 1
|
||||||
|
|
||||||
|
count, total_bytes = asset_manager.get_unused_stats()
|
||||||
|
assert count == 0
|
||||||
|
assert total_bytes == 0
|
||||||
|
|
||||||
|
def test_get_unused_stats_with_unused(self, asset_manager, create_test_image):
|
||||||
|
"""Test unused stats with unreferenced files"""
|
||||||
|
img1 = os.path.join(asset_manager.assets_folder, "unused1.png")
|
||||||
|
img2 = os.path.join(asset_manager.assets_folder, "unused2.png")
|
||||||
|
create_test_image(img1, color="red")
|
||||||
|
create_test_image(img2, color="blue")
|
||||||
|
|
||||||
|
# No references for either file
|
||||||
|
|
||||||
|
count, total_bytes = asset_manager.get_unused_stats()
|
||||||
|
assert count == 2
|
||||||
|
assert total_bytes > 0
|
||||||
|
|
||||||
|
def test_remove_unused_assets_removes_files(self, asset_manager, create_test_image):
|
||||||
|
"""Test that unused assets are actually removed"""
|
||||||
|
used_path = os.path.join(asset_manager.assets_folder, "used.png")
|
||||||
|
unused_path = os.path.join(asset_manager.assets_folder, "unused.png")
|
||||||
|
create_test_image(used_path, color="red")
|
||||||
|
create_test_image(unused_path, color="blue")
|
||||||
|
|
||||||
|
# Only reference the used file
|
||||||
|
asset_manager.reference_counts["assets/used.png"] = 1
|
||||||
|
|
||||||
|
# Remove unused
|
||||||
|
files_removed, bytes_freed = asset_manager.remove_unused_assets()
|
||||||
|
|
||||||
|
assert files_removed == 1
|
||||||
|
assert bytes_freed > 0
|
||||||
|
|
||||||
|
# Check files on disk
|
||||||
|
assert os.path.exists(used_path)
|
||||||
|
assert not os.path.exists(unused_path)
|
||||||
|
|
||||||
|
def test_remove_unused_assets_no_unused(self, asset_manager, create_test_image):
|
||||||
|
"""Test removing unused when all assets are referenced"""
|
||||||
|
img = os.path.join(asset_manager.assets_folder, "used.png")
|
||||||
|
create_test_image(img, color="red")
|
||||||
|
asset_manager.reference_counts["assets/used.png"] = 1
|
||||||
|
|
||||||
|
files_removed, bytes_freed = asset_manager.remove_unused_assets()
|
||||||
|
|
||||||
|
assert files_removed == 0
|
||||||
|
assert bytes_freed == 0
|
||||||
|
assert os.path.exists(img)
|
||||||
|
|
||||||
|
def test_remove_unused_assets_cleans_tracking(self, asset_manager, create_test_image):
|
||||||
|
"""Test that removing unused assets cleans up internal tracking"""
|
||||||
|
img = os.path.join(asset_manager.assets_folder, "orphan.png")
|
||||||
|
create_test_image(img, color="red")
|
||||||
|
|
||||||
|
# Set up tracking with zero refs and a hash
|
||||||
|
asset_manager.reference_counts["assets/orphan.png"] = 0
|
||||||
|
asset_manager.asset_hashes["assets/orphan.png"] = "somehash"
|
||||||
|
|
||||||
|
asset_manager.remove_unused_assets()
|
||||||
|
|
||||||
|
# Tracking should be cleaned up
|
||||||
|
assert "assets/orphan.png" not in asset_manager.reference_counts
|
||||||
|
assert "assets/orphan.png" not in asset_manager.asset_hashes
|
||||||
|
|
||||||
|
def test_remove_unused_preserves_referenced(self, asset_manager, create_test_image):
|
||||||
|
"""Test that removing unused preserves all referenced assets"""
|
||||||
|
# Create several files
|
||||||
|
for i in range(5):
|
||||||
|
img = os.path.join(asset_manager.assets_folder, f"image{i}.png")
|
||||||
|
create_test_image(img, color="red")
|
||||||
|
|
||||||
|
# Reference only some of them
|
||||||
|
asset_manager.reference_counts["assets/image0.png"] = 1
|
||||||
|
asset_manager.reference_counts["assets/image2.png"] = 3
|
||||||
|
asset_manager.reference_counts["assets/image4.png"] = 1
|
||||||
|
|
||||||
|
files_removed, _ = asset_manager.remove_unused_assets()
|
||||||
|
|
||||||
|
assert files_removed == 2 # image1 and image3
|
||||||
|
|
||||||
|
# Check that referenced files still exist
|
||||||
|
assert os.path.exists(os.path.join(asset_manager.assets_folder, "image0.png"))
|
||||||
|
assert os.path.exists(os.path.join(asset_manager.assets_folder, "image2.png"))
|
||||||
|
assert os.path.exists(os.path.join(asset_manager.assets_folder, "image4.png"))
|
||||||
|
|
||||||
|
# Check that unreferenced files are gone
|
||||||
|
assert not os.path.exists(os.path.join(asset_manager.assets_folder, "image1.png"))
|
||||||
|
assert not os.path.exists(os.path.join(asset_manager.assets_folder, "image3.png"))
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"""
|
||||||
|
Tests for asset_path mixin module
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import os
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
|
||||||
|
class TestAssetPathMixin:
|
||||||
|
"""Tests for AssetPathMixin class"""
|
||||||
|
|
||||||
|
def test_resolve_asset_path_empty_path(self, tmp_path):
|
||||||
|
"""Test resolve_asset_path with empty path returns None"""
|
||||||
|
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
|
||||||
|
|
||||||
|
class TestClass(AssetPathMixin):
|
||||||
|
def __init__(self):
|
||||||
|
self.project = Mock()
|
||||||
|
self.project.folder_path = str(tmp_path)
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
assert obj.resolve_asset_path("") is None
|
||||||
|
assert obj.resolve_asset_path(None) is None
|
||||||
|
|
||||||
|
def test_resolve_asset_path_absolute_exists(self, tmp_path):
|
||||||
|
"""Test resolve_asset_path with existing absolute path"""
|
||||||
|
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
|
||||||
|
|
||||||
|
# Create a test file
|
||||||
|
test_file = tmp_path / "test_image.jpg"
|
||||||
|
test_file.write_text("test")
|
||||||
|
|
||||||
|
class TestClass(AssetPathMixin):
|
||||||
|
def __init__(self):
|
||||||
|
self.project = Mock()
|
||||||
|
self.project.folder_path = str(tmp_path)
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
result = obj.resolve_asset_path(str(test_file))
|
||||||
|
|
||||||
|
assert result == str(test_file)
|
||||||
|
|
||||||
|
def test_resolve_asset_path_absolute_not_exists(self, tmp_path):
|
||||||
|
"""Test resolve_asset_path with non-existing absolute path"""
|
||||||
|
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
|
||||||
|
|
||||||
|
class TestClass(AssetPathMixin):
|
||||||
|
def __init__(self):
|
||||||
|
self.project = Mock()
|
||||||
|
self.project.folder_path = str(tmp_path)
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
result = obj.resolve_asset_path("/nonexistent/path/image.jpg")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_resolve_asset_path_relative_exists(self, tmp_path):
|
||||||
|
"""Test resolve_asset_path with existing relative path"""
|
||||||
|
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
|
||||||
|
|
||||||
|
# Create assets folder and test file
|
||||||
|
assets_dir = tmp_path / "assets"
|
||||||
|
assets_dir.mkdir()
|
||||||
|
test_file = assets_dir / "photo.jpg"
|
||||||
|
test_file.write_text("test")
|
||||||
|
|
||||||
|
class TestClass(AssetPathMixin):
|
||||||
|
def __init__(self):
|
||||||
|
self.project = Mock()
|
||||||
|
self.project.folder_path = str(tmp_path)
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
result = obj.resolve_asset_path("assets/photo.jpg")
|
||||||
|
|
||||||
|
assert result == str(test_file)
|
||||||
|
|
||||||
|
def test_resolve_asset_path_relative_not_exists(self, tmp_path):
|
||||||
|
"""Test resolve_asset_path with non-existing relative path"""
|
||||||
|
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
|
||||||
|
|
||||||
|
class TestClass(AssetPathMixin):
|
||||||
|
def __init__(self):
|
||||||
|
self.project = Mock()
|
||||||
|
self.project.folder_path = str(tmp_path)
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
result = obj.resolve_asset_path("assets/nonexistent.jpg")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_resolve_asset_path_no_project_folder(self):
|
||||||
|
"""Test resolve_asset_path when project folder is not available"""
|
||||||
|
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
|
||||||
|
|
||||||
|
class TestClass(AssetPathMixin):
|
||||||
|
def __init__(self):
|
||||||
|
self.project = None
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
result = obj.resolve_asset_path("assets/photo.jpg")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_get_asset_full_path_with_project(self, tmp_path):
|
||||||
|
"""Test get_asset_full_path returns correct path"""
|
||||||
|
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
|
||||||
|
|
||||||
|
class TestClass(AssetPathMixin):
|
||||||
|
def __init__(self):
|
||||||
|
self.project = Mock()
|
||||||
|
self.project.folder_path = str(tmp_path)
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
result = obj.get_asset_full_path("assets/photo.jpg")
|
||||||
|
|
||||||
|
expected = os.path.join(str(tmp_path), "assets/photo.jpg")
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
def test_get_asset_full_path_no_project(self):
|
||||||
|
"""Test get_asset_full_path without project returns None"""
|
||||||
|
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
|
||||||
|
|
||||||
|
class TestClass(AssetPathMixin):
|
||||||
|
def __init__(self):
|
||||||
|
self.project = None
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
result = obj.get_asset_full_path("assets/photo.jpg")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_get_asset_full_path_empty_path(self, tmp_path):
|
||||||
|
"""Test get_asset_full_path with empty path returns None"""
|
||||||
|
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
|
||||||
|
|
||||||
|
class TestClass(AssetPathMixin):
|
||||||
|
def __init__(self):
|
||||||
|
self.project = Mock()
|
||||||
|
self.project.folder_path = str(tmp_path)
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
assert obj.get_asset_full_path("") is None
|
||||||
|
assert obj.get_asset_full_path(None) is None
|
||||||
|
|
||||||
|
def test_get_project_folder_with_project(self, tmp_path):
|
||||||
|
"""Test _get_project_folder returns project folder"""
|
||||||
|
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
|
||||||
|
|
||||||
|
class TestClass(AssetPathMixin):
|
||||||
|
def __init__(self):
|
||||||
|
self.project = Mock()
|
||||||
|
self.project.folder_path = str(tmp_path)
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
result = obj._get_project_folder()
|
||||||
|
|
||||||
|
assert result == str(tmp_path)
|
||||||
|
|
||||||
|
def test_get_project_folder_no_project(self):
|
||||||
|
"""Test _get_project_folder without project returns None"""
|
||||||
|
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
|
||||||
|
|
||||||
|
class TestClass(AssetPathMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
result = obj._get_project_folder()
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_get_project_folder_project_without_folder_path(self):
|
||||||
|
"""Test _get_project_folder with project missing folder_path"""
|
||||||
|
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
|
||||||
|
|
||||||
|
class TestClass(AssetPathMixin):
|
||||||
|
def __init__(self):
|
||||||
|
self.project = Mock(spec=[]) # No folder_path attribute
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
result = obj._get_project_folder()
|
||||||
|
|
||||||
|
assert result is None
|
||||||
@@ -0,0 +1,824 @@
|
|||||||
|
"""
|
||||||
|
Tests for async_backend module
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import asyncio
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock, MagicMock, patch, call
|
||||||
|
from PIL import Image
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadPriority:
|
||||||
|
"""Tests for LoadPriority enum"""
|
||||||
|
|
||||||
|
def test_load_priority_values(self):
|
||||||
|
"""Test that LoadPriority enum has correct values"""
|
||||||
|
from pyPhotoAlbum.async_backend import LoadPriority
|
||||||
|
|
||||||
|
assert LoadPriority.LOW.value == 0
|
||||||
|
assert LoadPriority.NORMAL.value == 1
|
||||||
|
assert LoadPriority.HIGH.value == 2
|
||||||
|
assert LoadPriority.URGENT.value == 3
|
||||||
|
|
||||||
|
def test_load_priority_ordering(self):
|
||||||
|
"""Test that LoadPriority values are ordered correctly"""
|
||||||
|
from pyPhotoAlbum.async_backend import LoadPriority
|
||||||
|
|
||||||
|
assert LoadPriority.LOW.value < LoadPriority.NORMAL.value
|
||||||
|
assert LoadPriority.NORMAL.value < LoadPriority.HIGH.value
|
||||||
|
assert LoadPriority.HIGH.value < LoadPriority.URGENT.value
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetImageDimensions:
|
||||||
|
"""Tests for get_image_dimensions function"""
|
||||||
|
|
||||||
|
def test_get_image_dimensions_simple(self, tmp_path):
|
||||||
|
"""Test getting dimensions of a simple image"""
|
||||||
|
from pyPhotoAlbum.async_backend import get_image_dimensions
|
||||||
|
|
||||||
|
# Create a test image
|
||||||
|
img = Image.new("RGB", (800, 600), color="red")
|
||||||
|
img_path = tmp_path / "test.jpg"
|
||||||
|
img.save(img_path)
|
||||||
|
|
||||||
|
dims = get_image_dimensions(str(img_path))
|
||||||
|
|
||||||
|
assert dims == (800, 600)
|
||||||
|
|
||||||
|
def test_get_image_dimensions_with_max_size_width_larger(self, tmp_path):
|
||||||
|
"""Test dimensions scaled down when width is larger"""
|
||||||
|
from pyPhotoAlbum.async_backend import get_image_dimensions
|
||||||
|
|
||||||
|
# Create a wide image
|
||||||
|
img = Image.new("RGB", (1000, 500), color="blue")
|
||||||
|
img_path = tmp_path / "wide.jpg"
|
||||||
|
img.save(img_path)
|
||||||
|
|
||||||
|
dims = get_image_dimensions(str(img_path), max_size=300)
|
||||||
|
|
||||||
|
# Should be scaled down to fit within 300
|
||||||
|
assert dims == (300, 150)
|
||||||
|
|
||||||
|
def test_get_image_dimensions_with_max_size_height_larger(self, tmp_path):
|
||||||
|
"""Test dimensions scaled down when height is larger"""
|
||||||
|
from pyPhotoAlbum.async_backend import get_image_dimensions
|
||||||
|
|
||||||
|
# Create a tall image
|
||||||
|
img = Image.new("RGB", (500, 1000), color="green")
|
||||||
|
img_path = tmp_path / "tall.jpg"
|
||||||
|
img.save(img_path)
|
||||||
|
|
||||||
|
dims = get_image_dimensions(str(img_path), max_size=300)
|
||||||
|
|
||||||
|
# Should be scaled down to fit within 300
|
||||||
|
assert dims == (150, 300)
|
||||||
|
|
||||||
|
def test_get_image_dimensions_already_smaller_than_max(self, tmp_path):
|
||||||
|
"""Test dimensions not scaled when already smaller than max"""
|
||||||
|
from pyPhotoAlbum.async_backend import get_image_dimensions
|
||||||
|
|
||||||
|
img = Image.new("RGB", (200, 150), color="yellow")
|
||||||
|
img_path = tmp_path / "small.jpg"
|
||||||
|
img.save(img_path)
|
||||||
|
|
||||||
|
dims = get_image_dimensions(str(img_path), max_size=300)
|
||||||
|
|
||||||
|
# Should remain the same
|
||||||
|
assert dims == (200, 150)
|
||||||
|
|
||||||
|
def test_get_image_dimensions_invalid_file(self):
|
||||||
|
"""Test get_image_dimensions with invalid file returns None"""
|
||||||
|
from pyPhotoAlbum.async_backend import get_image_dimensions
|
||||||
|
|
||||||
|
dims = get_image_dimensions("/nonexistent/file.jpg")
|
||||||
|
|
||||||
|
assert dims is None
|
||||||
|
|
||||||
|
def test_get_image_dimensions_not_an_image(self, tmp_path):
|
||||||
|
"""Test get_image_dimensions with non-image file returns None"""
|
||||||
|
from pyPhotoAlbum.async_backend import get_image_dimensions
|
||||||
|
|
||||||
|
text_file = tmp_path / "not_image.txt"
|
||||||
|
text_file.write_text("This is not an image")
|
||||||
|
|
||||||
|
dims = get_image_dimensions(str(text_file))
|
||||||
|
|
||||||
|
assert dims is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadRequest:
|
||||||
|
"""Tests for LoadRequest dataclass"""
|
||||||
|
|
||||||
|
def test_load_request_creation(self):
|
||||||
|
"""Test creating a LoadRequest"""
|
||||||
|
from pyPhotoAlbum.async_backend import LoadRequest, LoadPriority
|
||||||
|
|
||||||
|
request = LoadRequest(
|
||||||
|
priority=LoadPriority.HIGH,
|
||||||
|
request_id=1,
|
||||||
|
path=Path("/test/image.jpg"),
|
||||||
|
target_size=(300, 300),
|
||||||
|
callback=None,
|
||||||
|
user_data={"test": "data"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert request.priority == LoadPriority.HIGH
|
||||||
|
assert request.request_id == 1
|
||||||
|
assert request.path == Path("/test/image.jpg")
|
||||||
|
assert request.target_size == (300, 300)
|
||||||
|
assert request.user_data == {"test": "data"}
|
||||||
|
|
||||||
|
def test_load_request_ordering_by_priority(self):
|
||||||
|
"""Test that LoadRequests are ordered by priority (fixed with IntEnum)"""
|
||||||
|
from pyPhotoAlbum.async_backend import LoadRequest, LoadPriority
|
||||||
|
|
||||||
|
req1 = LoadRequest(priority=LoadPriority.LOW, request_id=1, path=Path("/a.jpg"))
|
||||||
|
req2 = LoadRequest(priority=LoadPriority.HIGH, request_id=2, path=Path("/b.jpg"))
|
||||||
|
|
||||||
|
# LOW priority (value 0) should be < HIGH priority (value 2) in the priority queue
|
||||||
|
# This means LOW will be processed before HIGH (priority queue uses min-heap)
|
||||||
|
assert req1 < req2
|
||||||
|
|
||||||
|
def test_load_request_ordering_by_id_when_same_priority(self):
|
||||||
|
"""Test that LoadRequests with same priority are ordered by request_id"""
|
||||||
|
from pyPhotoAlbum.async_backend import LoadRequest, LoadPriority
|
||||||
|
|
||||||
|
req1 = LoadRequest(priority=LoadPriority.NORMAL, request_id=1, path=Path("/a.jpg"))
|
||||||
|
req2 = LoadRequest(priority=LoadPriority.NORMAL, request_id=2, path=Path("/b.jpg"))
|
||||||
|
|
||||||
|
assert req1 < req2
|
||||||
|
|
||||||
|
|
||||||
|
class TestImageCache:
|
||||||
|
"""Tests for ImageCache class"""
|
||||||
|
|
||||||
|
def test_image_cache_init(self):
|
||||||
|
"""Test ImageCache initialization"""
|
||||||
|
from pyPhotoAlbum.async_backend import ImageCache
|
||||||
|
|
||||||
|
cache = ImageCache(max_memory_mb=256)
|
||||||
|
|
||||||
|
assert cache.max_memory_bytes == 256 * 1024 * 1024
|
||||||
|
assert cache.current_memory_bytes == 0
|
||||||
|
|
||||||
|
def test_image_cache_estimate_image_size_rgba(self):
|
||||||
|
"""Test estimating RGBA image size"""
|
||||||
|
from pyPhotoAlbum.async_backend import ImageCache
|
||||||
|
|
||||||
|
cache = ImageCache()
|
||||||
|
img = Image.new("RGBA", (100, 100))
|
||||||
|
|
||||||
|
size = cache._estimate_image_size(img)
|
||||||
|
|
||||||
|
# 100 * 100 * 4 bytes (RGBA)
|
||||||
|
assert size == 40000
|
||||||
|
|
||||||
|
def test_image_cache_estimate_image_size_rgb(self):
|
||||||
|
"""Test estimating RGB image size"""
|
||||||
|
from pyPhotoAlbum.async_backend import ImageCache
|
||||||
|
|
||||||
|
cache = ImageCache()
|
||||||
|
img = Image.new("RGB", (100, 100))
|
||||||
|
|
||||||
|
size = cache._estimate_image_size(img)
|
||||||
|
|
||||||
|
# 100 * 100 * 3 bytes (RGB)
|
||||||
|
assert size == 30000
|
||||||
|
|
||||||
|
def test_image_cache_make_key_without_size(self):
|
||||||
|
"""Test making cache key without target size"""
|
||||||
|
from pyPhotoAlbum.async_backend import ImageCache
|
||||||
|
|
||||||
|
cache = ImageCache()
|
||||||
|
key = cache._make_key(Path("/test/image.jpg"))
|
||||||
|
|
||||||
|
assert key == "/test/image.jpg"
|
||||||
|
|
||||||
|
def test_image_cache_make_key_with_size(self):
|
||||||
|
"""Test making cache key with target size"""
|
||||||
|
from pyPhotoAlbum.async_backend import ImageCache
|
||||||
|
|
||||||
|
cache = ImageCache()
|
||||||
|
key = cache._make_key(Path("/test/image.jpg"), (300, 300))
|
||||||
|
|
||||||
|
assert key == "/test/image.jpg:300x300"
|
||||||
|
|
||||||
|
def test_image_cache_put_and_get(self):
|
||||||
|
"""Test putting and getting image from cache"""
|
||||||
|
from pyPhotoAlbum.async_backend import ImageCache
|
||||||
|
|
||||||
|
cache = ImageCache()
|
||||||
|
img = Image.new("RGB", (100, 100), color="red")
|
||||||
|
path = Path("/test/image.jpg")
|
||||||
|
|
||||||
|
cache.put(path, img)
|
||||||
|
cached_img = cache.get(path)
|
||||||
|
|
||||||
|
assert cached_img is not None
|
||||||
|
assert cached_img.size == img.size
|
||||||
|
assert cached_img.mode == img.mode
|
||||||
|
|
||||||
|
def test_image_cache_get_returns_copy(self):
|
||||||
|
"""Test that get returns a copy of the image"""
|
||||||
|
from pyPhotoAlbum.async_backend import ImageCache
|
||||||
|
|
||||||
|
cache = ImageCache()
|
||||||
|
img = Image.new("RGB", (100, 100))
|
||||||
|
path = Path("/test/image.jpg")
|
||||||
|
|
||||||
|
cache.put(path, img)
|
||||||
|
cached_img = cache.get(path)
|
||||||
|
|
||||||
|
# Modify the cached image
|
||||||
|
cached_img.putpixel((0, 0), (255, 0, 0))
|
||||||
|
|
||||||
|
# Get it again - should be unchanged
|
||||||
|
cached_img2 = cache.get(path)
|
||||||
|
assert cached_img2.getpixel((0, 0)) != (255, 0, 0)
|
||||||
|
|
||||||
|
def test_image_cache_miss(self):
|
||||||
|
"""Test cache miss returns None"""
|
||||||
|
from pyPhotoAlbum.async_backend import ImageCache
|
||||||
|
|
||||||
|
cache = ImageCache()
|
||||||
|
cached_img = cache.get(Path("/nonexistent.jpg"))
|
||||||
|
|
||||||
|
assert cached_img is None
|
||||||
|
|
||||||
|
def test_image_cache_different_sizes_different_keys(self):
|
||||||
|
"""Test that different target sizes use different cache keys"""
|
||||||
|
from pyPhotoAlbum.async_backend import ImageCache
|
||||||
|
|
||||||
|
cache = ImageCache()
|
||||||
|
img1 = Image.new("RGB", (100, 100), color="red")
|
||||||
|
img2 = Image.new("RGB", (50, 50), color="blue")
|
||||||
|
path = Path("/test/image.jpg")
|
||||||
|
|
||||||
|
cache.put(path, img1, target_size=None)
|
||||||
|
cache.put(path, img2, target_size=(50, 50))
|
||||||
|
|
||||||
|
cached_full = cache.get(path, target_size=None)
|
||||||
|
cached_small = cache.get(path, target_size=(50, 50))
|
||||||
|
|
||||||
|
assert cached_full.size == (100, 100)
|
||||||
|
assert cached_small.size == (50, 50)
|
||||||
|
|
||||||
|
def test_image_cache_lru_eviction(self):
|
||||||
|
"""Test that LRU items are evicted when cache is full"""
|
||||||
|
from pyPhotoAlbum.async_backend import ImageCache
|
||||||
|
|
||||||
|
# Small cache that can hold only 1 small image
|
||||||
|
cache = ImageCache(max_memory_mb=1)
|
||||||
|
|
||||||
|
# Create images that will fill the cache
|
||||||
|
img1 = Image.new("RGB", (500, 500)) # ~750KB
|
||||||
|
img2 = Image.new("RGB", (500, 500)) # ~750KB
|
||||||
|
|
||||||
|
# Add img1
|
||||||
|
cache.put(Path("/img1.jpg"), img1)
|
||||||
|
assert cache.get(Path("/img1.jpg")) is not None
|
||||||
|
|
||||||
|
# Add img2 - should evict img1 due to memory limit
|
||||||
|
cache.put(Path("/img2.jpg"), img2)
|
||||||
|
|
||||||
|
# img1 should be evicted to make room for img2
|
||||||
|
assert cache.get(Path("/img1.jpg")) is None
|
||||||
|
# img2 should be there
|
||||||
|
assert cache.get(Path("/img2.jpg")) is not None
|
||||||
|
|
||||||
|
def test_image_cache_update_existing(self):
|
||||||
|
"""Test updating an existing cache entry"""
|
||||||
|
from pyPhotoAlbum.async_backend import ImageCache
|
||||||
|
|
||||||
|
cache = ImageCache()
|
||||||
|
img1 = Image.new("RGB", (100, 100), color="red")
|
||||||
|
img2 = Image.new("RGB", (200, 200), color="blue")
|
||||||
|
path = Path("/test/image.jpg")
|
||||||
|
|
||||||
|
cache.put(path, img1)
|
||||||
|
cache.put(path, img2) # Update
|
||||||
|
|
||||||
|
cached = cache.get(path)
|
||||||
|
assert cached.size == (200, 200)
|
||||||
|
|
||||||
|
def test_image_cache_clear(self):
|
||||||
|
"""Test clearing the cache"""
|
||||||
|
from pyPhotoAlbum.async_backend import ImageCache
|
||||||
|
|
||||||
|
cache = ImageCache()
|
||||||
|
img = Image.new("RGB", (100, 100))
|
||||||
|
|
||||||
|
cache.put(Path("/img1.jpg"), img)
|
||||||
|
cache.put(Path("/img2.jpg"), img)
|
||||||
|
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
|
assert cache.current_memory_bytes == 0
|
||||||
|
assert cache.get(Path("/img1.jpg")) is None
|
||||||
|
assert cache.get(Path("/img2.jpg")) is None
|
||||||
|
|
||||||
|
def test_image_cache_get_stats(self):
|
||||||
|
"""Test getting cache statistics"""
|
||||||
|
from pyPhotoAlbum.async_backend import ImageCache
|
||||||
|
|
||||||
|
cache = ImageCache(max_memory_mb=100)
|
||||||
|
img = Image.new("RGB", (100, 100))
|
||||||
|
|
||||||
|
cache.put(Path("/img1.jpg"), img)
|
||||||
|
cache.put(Path("/img2.jpg"), img)
|
||||||
|
|
||||||
|
stats = cache.get_stats()
|
||||||
|
|
||||||
|
assert stats["items"] == 2
|
||||||
|
assert stats["memory_mb"] > 0
|
||||||
|
assert stats["max_memory_mb"] == 100
|
||||||
|
assert 0 <= stats["utilization"] <= 100
|
||||||
|
|
||||||
|
def test_image_cache_thread_safety(self):
|
||||||
|
"""Test that cache operations are thread-safe"""
|
||||||
|
from pyPhotoAlbum.async_backend import ImageCache
|
||||||
|
|
||||||
|
cache = ImageCache()
|
||||||
|
img = Image.new("RGB", (50, 50))
|
||||||
|
|
||||||
|
def put_images(start):
|
||||||
|
for i in range(start, start + 10):
|
||||||
|
cache.put(Path(f"/img{i}.jpg"), img)
|
||||||
|
|
||||||
|
def get_images(start):
|
||||||
|
for i in range(start, start + 10):
|
||||||
|
cache.get(Path(f"/img{i}.jpg"))
|
||||||
|
|
||||||
|
threads = []
|
||||||
|
for i in range(5):
|
||||||
|
t1 = threading.Thread(target=put_images, args=(i * 10,))
|
||||||
|
t2 = threading.Thread(target=get_images, args=(i * 10,))
|
||||||
|
threads.extend([t1, t2])
|
||||||
|
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
for t in threads:
|
||||||
|
t.join()
|
||||||
|
|
||||||
|
# Should not crash
|
||||||
|
assert cache.current_memory_bytes >= 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestAsyncImageLoader:
|
||||||
|
"""Tests for AsyncImageLoader class"""
|
||||||
|
|
||||||
|
def test_async_image_loader_init(self):
|
||||||
|
"""Test AsyncImageLoader initialization"""
|
||||||
|
from pyPhotoAlbum.async_backend import AsyncImageLoader, ImageCache
|
||||||
|
|
||||||
|
cache = ImageCache()
|
||||||
|
loader = AsyncImageLoader(cache=cache, max_workers=2)
|
||||||
|
|
||||||
|
assert loader.cache is cache
|
||||||
|
assert loader.max_workers == 2
|
||||||
|
assert loader._shutdown is False
|
||||||
|
|
||||||
|
def test_async_image_loader_init_creates_cache(self):
|
||||||
|
"""Test AsyncImageLoader creates cache if not provided"""
|
||||||
|
from pyPhotoAlbum.async_backend import AsyncImageLoader
|
||||||
|
|
||||||
|
loader = AsyncImageLoader()
|
||||||
|
|
||||||
|
assert loader.cache is not None
|
||||||
|
|
||||||
|
def test_async_image_loader_start(self):
|
||||||
|
"""Test starting AsyncImageLoader"""
|
||||||
|
from pyPhotoAlbum.async_backend import AsyncImageLoader
|
||||||
|
|
||||||
|
loader = AsyncImageLoader()
|
||||||
|
loader.start()
|
||||||
|
|
||||||
|
# Give it time to start
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
assert loader._loop is not None
|
||||||
|
assert loader._loop_thread is not None
|
||||||
|
assert loader._loop_thread.is_alive()
|
||||||
|
|
||||||
|
loader.stop()
|
||||||
|
|
||||||
|
def test_async_image_loader_start_twice(self):
|
||||||
|
"""Test starting AsyncImageLoader twice doesn't create multiple threads"""
|
||||||
|
from pyPhotoAlbum.async_backend import AsyncImageLoader
|
||||||
|
|
||||||
|
loader = AsyncImageLoader()
|
||||||
|
loader.start()
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
thread1 = loader._loop_thread
|
||||||
|
|
||||||
|
loader.start() # Should warn but not create new thread
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
assert loader._loop_thread is thread1
|
||||||
|
|
||||||
|
loader.stop()
|
||||||
|
|
||||||
|
def test_async_image_loader_stop(self):
|
||||||
|
"""Test stopping AsyncImageLoader"""
|
||||||
|
from pyPhotoAlbum.async_backend import AsyncImageLoader
|
||||||
|
|
||||||
|
loader = AsyncImageLoader()
|
||||||
|
loader.start()
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
loader.stop()
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
assert loader._shutdown is True
|
||||||
|
|
||||||
|
def test_async_image_loader_load_and_process_image(self, tmp_path):
|
||||||
|
"""Test _load_and_process_image method"""
|
||||||
|
from pyPhotoAlbum.async_backend import AsyncImageLoader
|
||||||
|
|
||||||
|
# Create test image
|
||||||
|
img = Image.new("RGB", (800, 600), color="blue")
|
||||||
|
img_path = tmp_path / "test.jpg"
|
||||||
|
img.save(img_path)
|
||||||
|
|
||||||
|
loader = AsyncImageLoader()
|
||||||
|
result = loader._load_and_process_image(img_path, None)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.mode == "RGBA" # Should be converted to RGBA
|
||||||
|
|
||||||
|
def test_async_image_loader_load_and_process_image_with_resize(self, tmp_path):
|
||||||
|
"""Test _load_and_process_image with target size"""
|
||||||
|
from pyPhotoAlbum.async_backend import AsyncImageLoader
|
||||||
|
|
||||||
|
# Create large test image
|
||||||
|
img = Image.new("RGB", (2000, 1500), color="green")
|
||||||
|
img_path = tmp_path / "large.jpg"
|
||||||
|
img.save(img_path)
|
||||||
|
|
||||||
|
loader = AsyncImageLoader()
|
||||||
|
result = loader._load_and_process_image(img_path, (500, 500))
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
# Should be resized to fit within 500x500
|
||||||
|
assert result.size[0] <= 500
|
||||||
|
assert result.size[1] <= 500
|
||||||
|
|
||||||
|
def test_async_image_loader_emit_loaded(self, qtbot):
|
||||||
|
"""Test _emit_loaded signal"""
|
||||||
|
from pyPhotoAlbum.async_backend import AsyncImageLoader
|
||||||
|
|
||||||
|
loader = AsyncImageLoader()
|
||||||
|
|
||||||
|
signal_received = []
|
||||||
|
|
||||||
|
def on_loaded(path, img, user_data):
|
||||||
|
signal_received.append((path, img, user_data))
|
||||||
|
|
||||||
|
loader.image_loaded.connect(on_loaded)
|
||||||
|
|
||||||
|
mock_img = Mock()
|
||||||
|
user_data = {"test": "data"}
|
||||||
|
|
||||||
|
loader._emit_loaded(Path("/test.jpg"), mock_img, user_data)
|
||||||
|
|
||||||
|
assert len(signal_received) == 1
|
||||||
|
assert signal_received[0][0] == Path("/test.jpg")
|
||||||
|
assert signal_received[0][2] == user_data
|
||||||
|
|
||||||
|
def test_async_image_loader_emit_failed(self, qtbot):
|
||||||
|
"""Test _emit_failed signal"""
|
||||||
|
from pyPhotoAlbum.async_backend import AsyncImageLoader
|
||||||
|
|
||||||
|
loader = AsyncImageLoader()
|
||||||
|
|
||||||
|
signal_received = []
|
||||||
|
|
||||||
|
def on_failed(path, error, user_data):
|
||||||
|
signal_received.append((path, error, user_data))
|
||||||
|
|
||||||
|
loader.load_failed.connect(on_failed)
|
||||||
|
|
||||||
|
user_data = {"test": "data"}
|
||||||
|
|
||||||
|
loader._emit_failed(Path("/test.jpg"), "Error message", user_data)
|
||||||
|
|
||||||
|
assert len(signal_received) == 1
|
||||||
|
assert signal_received[0][0] == Path("/test.jpg")
|
||||||
|
assert signal_received[0][1] == "Error message"
|
||||||
|
|
||||||
|
def test_async_image_loader_request_load_not_started(self):
|
||||||
|
"""Test request_load when loader not started"""
|
||||||
|
from pyPhotoAlbum.async_backend import AsyncImageLoader, LoadPriority
|
||||||
|
|
||||||
|
loader = AsyncImageLoader()
|
||||||
|
|
||||||
|
result = loader.request_load(Path("/test.jpg"), priority=LoadPriority.HIGH)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_async_image_loader_request_load_success(self, tmp_path):
|
||||||
|
"""Test successful request_load"""
|
||||||
|
from pyPhotoAlbum.async_backend import AsyncImageLoader, LoadPriority
|
||||||
|
|
||||||
|
# Create test image
|
||||||
|
img = Image.new("RGB", (100, 100), color="red")
|
||||||
|
img_path = tmp_path / "test.jpg"
|
||||||
|
img.save(img_path)
|
||||||
|
|
||||||
|
loader = AsyncImageLoader()
|
||||||
|
loader.start()
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
result = loader.request_load(img_path, priority=LoadPriority.HIGH)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
loader.stop()
|
||||||
|
|
||||||
|
def test_async_image_loader_request_load_duplicate(self, tmp_path):
|
||||||
|
"""Test requesting same image twice returns False"""
|
||||||
|
from pyPhotoAlbum.async_backend import AsyncImageLoader, LoadPriority
|
||||||
|
|
||||||
|
img = Image.new("RGB", (100, 100))
|
||||||
|
img_path = tmp_path / "test.jpg"
|
||||||
|
img.save(img_path)
|
||||||
|
|
||||||
|
loader = AsyncImageLoader()
|
||||||
|
loader.start()
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
result1 = loader.request_load(img_path, priority=LoadPriority.HIGH)
|
||||||
|
result2 = loader.request_load(img_path, priority=LoadPriority.HIGH)
|
||||||
|
|
||||||
|
assert result1 is True
|
||||||
|
assert result2 is False # Already pending
|
||||||
|
|
||||||
|
loader.stop()
|
||||||
|
|
||||||
|
def test_async_image_loader_cancel_pending(self, tmp_path):
|
||||||
|
"""Test canceling a pending load request"""
|
||||||
|
from pyPhotoAlbum.async_backend import AsyncImageLoader, LoadPriority
|
||||||
|
|
||||||
|
img = Image.new("RGB", (100, 100))
|
||||||
|
img_path = tmp_path / "test.jpg"
|
||||||
|
img.save(img_path)
|
||||||
|
|
||||||
|
loader = AsyncImageLoader()
|
||||||
|
loader.start()
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
loader.request_load(img_path, priority=LoadPriority.LOW)
|
||||||
|
result = loader.cancel_load(img_path)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
loader.stop()
|
||||||
|
|
||||||
|
def test_async_image_loader_cancel_nonexistent(self):
|
||||||
|
"""Test canceling a non-existent load request"""
|
||||||
|
from pyPhotoAlbum.async_backend import AsyncImageLoader
|
||||||
|
|
||||||
|
loader = AsyncImageLoader()
|
||||||
|
|
||||||
|
result = loader.cancel_load(Path("/nonexistent.jpg"))
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_async_image_loader_get_stats(self):
|
||||||
|
"""Test getting loader statistics"""
|
||||||
|
from pyPhotoAlbum.async_backend import AsyncImageLoader
|
||||||
|
|
||||||
|
loader = AsyncImageLoader()
|
||||||
|
|
||||||
|
stats = loader.get_stats()
|
||||||
|
|
||||||
|
assert "pending" in stats
|
||||||
|
assert "active" in stats
|
||||||
|
assert "cache" in stats
|
||||||
|
|
||||||
|
|
||||||
|
class TestAsyncPDFGenerator:
|
||||||
|
"""Tests for AsyncPDFGenerator class"""
|
||||||
|
|
||||||
|
def test_async_pdf_generator_init(self):
|
||||||
|
"""Test AsyncPDFGenerator initialization"""
|
||||||
|
from pyPhotoAlbum.async_backend import AsyncPDFGenerator, ImageCache
|
||||||
|
|
||||||
|
cache = ImageCache()
|
||||||
|
generator = AsyncPDFGenerator(image_cache=cache, max_workers=1)
|
||||||
|
|
||||||
|
assert generator.image_cache is cache
|
||||||
|
assert generator.max_workers == 1
|
||||||
|
assert generator._shutdown is False
|
||||||
|
|
||||||
|
def test_async_pdf_generator_init_creates_cache(self):
|
||||||
|
"""Test AsyncPDFGenerator creates cache if not provided"""
|
||||||
|
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
|
||||||
|
|
||||||
|
generator = AsyncPDFGenerator()
|
||||||
|
|
||||||
|
assert generator.image_cache is not None
|
||||||
|
|
||||||
|
def test_async_pdf_generator_start(self):
|
||||||
|
"""Test starting AsyncPDFGenerator"""
|
||||||
|
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
|
||||||
|
|
||||||
|
generator = AsyncPDFGenerator()
|
||||||
|
generator.start()
|
||||||
|
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
assert generator._loop is not None
|
||||||
|
assert generator._loop_thread is not None
|
||||||
|
assert generator._loop_thread.is_alive()
|
||||||
|
|
||||||
|
generator.stop()
|
||||||
|
|
||||||
|
def test_async_pdf_generator_start_twice(self):
|
||||||
|
"""Test starting AsyncPDFGenerator twice doesn't create multiple threads"""
|
||||||
|
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
|
||||||
|
|
||||||
|
generator = AsyncPDFGenerator()
|
||||||
|
generator.start()
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
thread1 = generator._loop_thread
|
||||||
|
|
||||||
|
generator.start() # Should warn
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
assert generator._loop_thread is thread1
|
||||||
|
|
||||||
|
generator.stop()
|
||||||
|
|
||||||
|
def test_async_pdf_generator_stop(self):
|
||||||
|
"""Test stopping AsyncPDFGenerator"""
|
||||||
|
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
|
||||||
|
|
||||||
|
generator = AsyncPDFGenerator()
|
||||||
|
generator.start()
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
generator.stop()
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
assert generator._shutdown is True
|
||||||
|
|
||||||
|
def test_async_pdf_generator_export_not_started(self):
|
||||||
|
"""Test export_pdf when generator not started"""
|
||||||
|
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
|
||||||
|
|
||||||
|
generator = AsyncPDFGenerator()
|
||||||
|
mock_project = Mock()
|
||||||
|
|
||||||
|
result = generator.export_pdf(mock_project, "/output.pdf")
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_async_pdf_generator_export_already_exporting(self):
|
||||||
|
"""Test export_pdf when already exporting"""
|
||||||
|
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
|
||||||
|
|
||||||
|
generator = AsyncPDFGenerator()
|
||||||
|
generator.start()
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
mock_project = Mock()
|
||||||
|
|
||||||
|
# Start first export
|
||||||
|
generator._current_export = Mock()
|
||||||
|
generator._current_export.done.return_value = False
|
||||||
|
|
||||||
|
result = generator.export_pdf(mock_project, "/output.pdf")
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
generator.stop()
|
||||||
|
|
||||||
|
def test_async_pdf_generator_cancel_export(self):
|
||||||
|
"""Test cancel_export method"""
|
||||||
|
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
|
||||||
|
|
||||||
|
generator = AsyncPDFGenerator()
|
||||||
|
|
||||||
|
# Mock an active export
|
||||||
|
generator._current_export = Mock()
|
||||||
|
generator._current_export.done.return_value = False
|
||||||
|
|
||||||
|
generator.cancel_export()
|
||||||
|
|
||||||
|
assert generator._cancel_requested is True
|
||||||
|
generator._current_export.cancel.assert_called_once()
|
||||||
|
|
||||||
|
def test_async_pdf_generator_is_exporting_true(self):
|
||||||
|
"""Test is_exporting returns True when exporting"""
|
||||||
|
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
|
||||||
|
|
||||||
|
generator = AsyncPDFGenerator()
|
||||||
|
generator._current_export = Mock()
|
||||||
|
generator._current_export.done.return_value = False
|
||||||
|
|
||||||
|
assert generator.is_exporting() is True
|
||||||
|
|
||||||
|
def test_async_pdf_generator_is_exporting_false(self):
|
||||||
|
"""Test is_exporting returns False when not exporting"""
|
||||||
|
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
|
||||||
|
|
||||||
|
generator = AsyncPDFGenerator()
|
||||||
|
|
||||||
|
assert generator.is_exporting() is False
|
||||||
|
|
||||||
|
def test_async_pdf_generator_get_stats(self):
|
||||||
|
"""Test getting generator statistics"""
|
||||||
|
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
|
||||||
|
|
||||||
|
generator = AsyncPDFGenerator()
|
||||||
|
|
||||||
|
stats = generator.get_stats()
|
||||||
|
|
||||||
|
assert "exporting" in stats
|
||||||
|
assert "cache" in stats
|
||||||
|
|
||||||
|
def test_async_pdf_generator_export_with_cache_uses_cache(self):
|
||||||
|
"""Test _export_with_cache uses cached images"""
|
||||||
|
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
|
||||||
|
from PIL import Image
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
generator = AsyncPDFGenerator()
|
||||||
|
|
||||||
|
# Mock exporter that tries to open an image
|
||||||
|
mock_exporter = Mock()
|
||||||
|
mock_exporter.export.return_value = (True, [])
|
||||||
|
|
||||||
|
def mock_progress(current, total, msg):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Run export (just verify the method exists and can be called)
|
||||||
|
with patch('PIL.Image.open') as mock_open:
|
||||||
|
mock_img = Image.new("RGBA", (50, 50), color="blue")
|
||||||
|
mock_open.return_value = mock_img
|
||||||
|
|
||||||
|
success, warnings = generator._export_with_cache(mock_exporter, "/fake/output.pdf", mock_progress)
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
mock_exporter.export.assert_called_once()
|
||||||
|
|
||||||
|
def test_async_pdf_generator_progress_signal(self, qtbot):
|
||||||
|
"""Test progress_updated signal"""
|
||||||
|
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
|
||||||
|
|
||||||
|
generator = AsyncPDFGenerator()
|
||||||
|
|
||||||
|
signal_received = []
|
||||||
|
|
||||||
|
def on_progress(current, total, message):
|
||||||
|
signal_received.append((current, total, message))
|
||||||
|
|
||||||
|
generator.progress_updated.connect(on_progress)
|
||||||
|
|
||||||
|
generator.progress_updated.emit(5, 10, "Processing page 5")
|
||||||
|
|
||||||
|
assert len(signal_received) == 1
|
||||||
|
assert signal_received[0] == (5, 10, "Processing page 5")
|
||||||
|
|
||||||
|
def test_async_pdf_generator_complete_signal(self, qtbot):
|
||||||
|
"""Test export_complete signal"""
|
||||||
|
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
|
||||||
|
|
||||||
|
generator = AsyncPDFGenerator()
|
||||||
|
|
||||||
|
signal_received = []
|
||||||
|
|
||||||
|
def on_complete(success, warnings):
|
||||||
|
signal_received.append((success, warnings))
|
||||||
|
|
||||||
|
generator.export_complete.connect(on_complete)
|
||||||
|
|
||||||
|
generator.export_complete.emit(True, ["warning1"])
|
||||||
|
|
||||||
|
assert len(signal_received) == 1
|
||||||
|
assert signal_received[0] == (True, ["warning1"])
|
||||||
|
|
||||||
|
def test_async_pdf_generator_failed_signal(self, qtbot):
|
||||||
|
"""Test export_failed signal"""
|
||||||
|
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
|
||||||
|
|
||||||
|
generator = AsyncPDFGenerator()
|
||||||
|
|
||||||
|
signal_received = []
|
||||||
|
|
||||||
|
def on_failed(error_msg):
|
||||||
|
signal_received.append(error_msg)
|
||||||
|
|
||||||
|
generator.export_failed.connect(on_failed)
|
||||||
|
|
||||||
|
generator.export_failed.emit("Export failed")
|
||||||
|
|
||||||
|
assert len(signal_received) == 1
|
||||||
|
assert signal_received[0] == "Export failed"
|
||||||
@@ -0,0 +1,635 @@
|
|||||||
|
"""
|
||||||
|
Tests for async_loading mixin module
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock, MagicMock, patch, PropertyMock
|
||||||
|
|
||||||
|
|
||||||
|
class TestAsyncLoadingMixinInit:
|
||||||
|
"""Tests for AsyncLoadingMixin initialization"""
|
||||||
|
|
||||||
|
def test_init_async_loading_creates_cache(self):
|
||||||
|
"""Test that _init_async_loading creates image cache"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("pyPhotoAlbum.mixins.async_loading.ImageCache") as mock_cache,
|
||||||
|
patch("pyPhotoAlbum.mixins.async_loading.AsyncImageLoader") as mock_loader,
|
||||||
|
patch("pyPhotoAlbum.mixins.async_loading.AsyncPDFGenerator") as mock_pdf,
|
||||||
|
):
|
||||||
|
|
||||||
|
mock_loader_instance = Mock()
|
||||||
|
mock_loader.return_value = mock_loader_instance
|
||||||
|
mock_pdf_instance = Mock()
|
||||||
|
mock_pdf.return_value = mock_pdf_instance
|
||||||
|
|
||||||
|
class TestClass(AsyncLoadingMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
obj._init_async_loading()
|
||||||
|
|
||||||
|
mock_cache.assert_called_once_with(max_memory_mb=512)
|
||||||
|
assert hasattr(obj, "image_cache")
|
||||||
|
|
||||||
|
def test_init_async_loading_creates_image_loader(self):
|
||||||
|
"""Test that _init_async_loading creates async image loader"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("pyPhotoAlbum.mixins.async_loading.ImageCache") as mock_cache,
|
||||||
|
patch("pyPhotoAlbum.mixins.async_loading.AsyncImageLoader") as mock_loader,
|
||||||
|
patch("pyPhotoAlbum.mixins.async_loading.AsyncPDFGenerator") as mock_pdf,
|
||||||
|
):
|
||||||
|
|
||||||
|
mock_loader_instance = Mock()
|
||||||
|
mock_loader.return_value = mock_loader_instance
|
||||||
|
mock_pdf_instance = Mock()
|
||||||
|
mock_pdf.return_value = mock_pdf_instance
|
||||||
|
|
||||||
|
class TestClass(AsyncLoadingMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
obj._init_async_loading()
|
||||||
|
|
||||||
|
mock_loader.assert_called_once()
|
||||||
|
assert hasattr(obj, "async_image_loader")
|
||||||
|
mock_loader_instance.start.assert_called_once()
|
||||||
|
|
||||||
|
def test_init_async_loading_creates_pdf_generator(self):
|
||||||
|
"""Test that _init_async_loading creates async PDF generator"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("pyPhotoAlbum.mixins.async_loading.ImageCache") as mock_cache,
|
||||||
|
patch("pyPhotoAlbum.mixins.async_loading.AsyncImageLoader") as mock_loader,
|
||||||
|
patch("pyPhotoAlbum.mixins.async_loading.AsyncPDFGenerator") as mock_pdf,
|
||||||
|
):
|
||||||
|
|
||||||
|
mock_loader_instance = Mock()
|
||||||
|
mock_loader.return_value = mock_loader_instance
|
||||||
|
mock_pdf_instance = Mock()
|
||||||
|
mock_pdf.return_value = mock_pdf_instance
|
||||||
|
|
||||||
|
class TestClass(AsyncLoadingMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
obj._init_async_loading()
|
||||||
|
|
||||||
|
mock_pdf.assert_called_once()
|
||||||
|
assert hasattr(obj, "async_pdf_generator")
|
||||||
|
mock_pdf_instance.start.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAsyncLoadingMixinCleanup:
|
||||||
|
"""Tests for AsyncLoadingMixin cleanup"""
|
||||||
|
|
||||||
|
def test_cleanup_stops_image_loader(self):
|
||||||
|
"""Test that _cleanup_async_loading stops image loader"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
|
||||||
|
class TestClass(AsyncLoadingMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
obj.async_image_loader = Mock()
|
||||||
|
obj.async_pdf_generator = Mock()
|
||||||
|
obj.image_cache = Mock()
|
||||||
|
|
||||||
|
obj._cleanup_async_loading()
|
||||||
|
|
||||||
|
obj.async_image_loader.stop.assert_called_once()
|
||||||
|
|
||||||
|
def test_cleanup_stops_pdf_generator(self):
|
||||||
|
"""Test that _cleanup_async_loading stops PDF generator"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
|
||||||
|
class TestClass(AsyncLoadingMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
obj.async_image_loader = Mock()
|
||||||
|
obj.async_pdf_generator = Mock()
|
||||||
|
obj.image_cache = Mock()
|
||||||
|
|
||||||
|
obj._cleanup_async_loading()
|
||||||
|
|
||||||
|
obj.async_pdf_generator.stop.assert_called_once()
|
||||||
|
|
||||||
|
def test_cleanup_clears_cache(self):
|
||||||
|
"""Test that _cleanup_async_loading clears image cache"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
|
||||||
|
class TestClass(AsyncLoadingMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
obj.async_image_loader = Mock()
|
||||||
|
obj.async_pdf_generator = Mock()
|
||||||
|
obj.image_cache = Mock()
|
||||||
|
|
||||||
|
obj._cleanup_async_loading()
|
||||||
|
|
||||||
|
obj.image_cache.clear.assert_called_once()
|
||||||
|
|
||||||
|
def test_cleanup_handles_missing_components(self):
|
||||||
|
"""Test that _cleanup_async_loading handles missing components gracefully"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
|
||||||
|
class TestClass(AsyncLoadingMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
# Don't set any async components
|
||||||
|
|
||||||
|
# Should not raise
|
||||||
|
obj._cleanup_async_loading()
|
||||||
|
|
||||||
|
|
||||||
|
class TestOnImageLoaded:
|
||||||
|
"""Tests for _on_image_loaded callback"""
|
||||||
|
|
||||||
|
def test_on_image_loaded_calls_element_callback(self):
|
||||||
|
"""Test that _on_image_loaded calls element's callback"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
|
||||||
|
class TestClass(AsyncLoadingMixin):
|
||||||
|
def update(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
|
||||||
|
mock_image = Mock()
|
||||||
|
mock_user_data = Mock()
|
||||||
|
mock_user_data._on_async_image_loaded = Mock()
|
||||||
|
|
||||||
|
obj._on_image_loaded(Path("/test/image.jpg"), mock_image, mock_user_data)
|
||||||
|
|
||||||
|
mock_user_data._on_async_image_loaded.assert_called_once_with(mock_image)
|
||||||
|
|
||||||
|
def test_on_image_loaded_triggers_update(self):
|
||||||
|
"""Test that _on_image_loaded triggers widget update"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
|
||||||
|
class TestClass(AsyncLoadingMixin):
|
||||||
|
def __init__(self):
|
||||||
|
self.update_called = False
|
||||||
|
|
||||||
|
def update(self):
|
||||||
|
self.update_called = True
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
|
||||||
|
obj._on_image_loaded(Path("/test/image.jpg"), Mock(), None)
|
||||||
|
|
||||||
|
assert obj.update_called
|
||||||
|
|
||||||
|
def test_on_image_loaded_handles_none_user_data(self):
|
||||||
|
"""Test that _on_image_loaded handles None user_data"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
|
||||||
|
class TestClass(AsyncLoadingMixin):
|
||||||
|
def update(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
|
||||||
|
# Should not raise
|
||||||
|
obj._on_image_loaded(Path("/test/image.jpg"), Mock(), None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOnImageLoadFailed:
|
||||||
|
"""Tests for _on_image_load_failed callback"""
|
||||||
|
|
||||||
|
def test_on_image_load_failed_calls_element_callback(self):
|
||||||
|
"""Test that _on_image_load_failed calls element's callback"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
|
||||||
|
class TestClass(AsyncLoadingMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
|
||||||
|
mock_user_data = Mock()
|
||||||
|
mock_user_data._on_async_image_load_failed = Mock()
|
||||||
|
|
||||||
|
obj._on_image_load_failed(Path("/test/image.jpg"), "Error message", mock_user_data)
|
||||||
|
|
||||||
|
mock_user_data._on_async_image_load_failed.assert_called_once_with("Error message")
|
||||||
|
|
||||||
|
def test_on_image_load_failed_handles_none_user_data(self):
|
||||||
|
"""Test that _on_image_load_failed handles None user_data"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
|
||||||
|
class TestClass(AsyncLoadingMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
|
||||||
|
# Should not raise
|
||||||
|
obj._on_image_load_failed(Path("/test/image.jpg"), "Error", None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOnPdfProgress:
|
||||||
|
"""Tests for _on_pdf_progress callback"""
|
||||||
|
|
||||||
|
def test_on_pdf_progress_updates_dialog(self):
|
||||||
|
"""Test that _on_pdf_progress updates progress dialog"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
|
||||||
|
class TestClass(AsyncLoadingMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
obj._pdf_progress_dialog = Mock()
|
||||||
|
|
||||||
|
obj._on_pdf_progress(5, 10, "Processing page 5")
|
||||||
|
|
||||||
|
obj._pdf_progress_dialog.setValue.assert_called_once_with(5)
|
||||||
|
obj._pdf_progress_dialog.setLabelText.assert_called_once_with("Processing page 5")
|
||||||
|
|
||||||
|
def test_on_pdf_progress_handles_no_dialog(self):
|
||||||
|
"""Test that _on_pdf_progress handles missing dialog"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
|
||||||
|
class TestClass(AsyncLoadingMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
# No _pdf_progress_dialog attribute
|
||||||
|
|
||||||
|
# Should not raise
|
||||||
|
obj._on_pdf_progress(5, 10, "Processing")
|
||||||
|
|
||||||
|
|
||||||
|
class TestOnPdfComplete:
|
||||||
|
"""Tests for _on_pdf_complete callback"""
|
||||||
|
|
||||||
|
def test_on_pdf_complete_closes_dialog(self):
|
||||||
|
"""Test that _on_pdf_complete closes progress dialog"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
|
||||||
|
class TestClass(AsyncLoadingMixin):
|
||||||
|
def window(self):
|
||||||
|
return Mock(spec=[])
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
mock_dialog = Mock()
|
||||||
|
obj._pdf_progress_dialog = mock_dialog
|
||||||
|
|
||||||
|
obj._on_pdf_complete(True, [])
|
||||||
|
|
||||||
|
mock_dialog.close.assert_called_once()
|
||||||
|
assert obj._pdf_progress_dialog is None
|
||||||
|
|
||||||
|
def test_on_pdf_complete_shows_success_status(self):
|
||||||
|
"""Test that _on_pdf_complete shows success status"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
|
||||||
|
mock_main_window = Mock()
|
||||||
|
|
||||||
|
class TestClass(AsyncLoadingMixin):
|
||||||
|
def window(self):
|
||||||
|
return mock_main_window
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
|
||||||
|
obj._on_pdf_complete(True, [])
|
||||||
|
|
||||||
|
mock_main_window.show_status.assert_called_once()
|
||||||
|
call_args = mock_main_window.show_status.call_args[0]
|
||||||
|
assert "successfully" in call_args[0]
|
||||||
|
|
||||||
|
def test_on_pdf_complete_shows_warnings(self):
|
||||||
|
"""Test that _on_pdf_complete shows warning count"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
|
||||||
|
mock_main_window = Mock()
|
||||||
|
|
||||||
|
class TestClass(AsyncLoadingMixin):
|
||||||
|
def window(self):
|
||||||
|
return mock_main_window
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
|
||||||
|
obj._on_pdf_complete(True, ["warning1", "warning2"])
|
||||||
|
|
||||||
|
mock_main_window.show_status.assert_called_once()
|
||||||
|
call_args = mock_main_window.show_status.call_args[0]
|
||||||
|
assert "2 warnings" in call_args[0]
|
||||||
|
|
||||||
|
def test_on_pdf_complete_shows_failure_status(self):
|
||||||
|
"""Test that _on_pdf_complete shows failure status"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
|
||||||
|
mock_main_window = Mock()
|
||||||
|
|
||||||
|
class TestClass(AsyncLoadingMixin):
|
||||||
|
def window(self):
|
||||||
|
return mock_main_window
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
|
||||||
|
obj._on_pdf_complete(False, [])
|
||||||
|
|
||||||
|
mock_main_window.show_status.assert_called_once()
|
||||||
|
call_args = mock_main_window.show_status.call_args[0]
|
||||||
|
assert "failed" in call_args[0]
|
||||||
|
|
||||||
|
|
||||||
|
class TestOnPdfFailed:
|
||||||
|
"""Tests for _on_pdf_failed callback"""
|
||||||
|
|
||||||
|
def test_on_pdf_failed_closes_dialog(self):
|
||||||
|
"""Test that _on_pdf_failed closes progress dialog"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
|
||||||
|
class TestClass(AsyncLoadingMixin):
|
||||||
|
def window(self):
|
||||||
|
return Mock(spec=[])
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
mock_dialog = Mock()
|
||||||
|
obj._pdf_progress_dialog = mock_dialog
|
||||||
|
|
||||||
|
obj._on_pdf_failed("Error occurred")
|
||||||
|
|
||||||
|
mock_dialog.close.assert_called_once()
|
||||||
|
assert obj._pdf_progress_dialog is None
|
||||||
|
|
||||||
|
def test_on_pdf_failed_shows_error_status(self):
|
||||||
|
"""Test that _on_pdf_failed shows error status"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
|
||||||
|
mock_main_window = Mock()
|
||||||
|
|
||||||
|
class TestClass(AsyncLoadingMixin):
|
||||||
|
def window(self):
|
||||||
|
return mock_main_window
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
|
||||||
|
obj._on_pdf_failed("Something went wrong")
|
||||||
|
|
||||||
|
mock_main_window.show_status.assert_called_once()
|
||||||
|
call_args = mock_main_window.show_status.call_args[0]
|
||||||
|
assert "failed" in call_args[0]
|
||||||
|
assert "Something went wrong" in call_args[0]
|
||||||
|
|
||||||
|
|
||||||
|
class TestRequestImageLoad:
|
||||||
|
"""Tests for request_image_load method"""
|
||||||
|
|
||||||
|
def test_request_image_load_no_loader(self):
|
||||||
|
"""Test request_image_load when loader not initialized"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
|
||||||
|
class TestClass(AsyncLoadingMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
mock_image_data = Mock()
|
||||||
|
|
||||||
|
# Should not raise
|
||||||
|
obj.request_image_load(mock_image_data)
|
||||||
|
|
||||||
|
def test_request_image_load_empty_path(self):
|
||||||
|
"""Test request_image_load with empty image path"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
|
||||||
|
class TestClass(AsyncLoadingMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
obj.async_image_loader = Mock()
|
||||||
|
|
||||||
|
mock_image_data = Mock()
|
||||||
|
mock_image_data.image_path = ""
|
||||||
|
|
||||||
|
obj.request_image_load(mock_image_data)
|
||||||
|
|
||||||
|
obj.async_image_loader.request_load.assert_not_called()
|
||||||
|
|
||||||
|
def test_request_image_load_non_assets_path_skipped(self):
|
||||||
|
"""Test request_image_load skips paths outside assets folder"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
|
||||||
|
class TestClass(AsyncLoadingMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
obj.async_image_loader = Mock()
|
||||||
|
|
||||||
|
mock_image_data = Mock()
|
||||||
|
mock_image_data.image_path = "/absolute/path/image.jpg"
|
||||||
|
|
||||||
|
obj.request_image_load(mock_image_data)
|
||||||
|
|
||||||
|
obj.async_image_loader.request_load.assert_not_called()
|
||||||
|
|
||||||
|
def test_request_image_load_path_not_resolved(self):
|
||||||
|
"""Test request_image_load when path resolution fails"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
|
||||||
|
class TestClass(AsyncLoadingMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
obj.async_image_loader = Mock()
|
||||||
|
|
||||||
|
mock_image_data = Mock()
|
||||||
|
mock_image_data.image_path = "assets/missing.jpg"
|
||||||
|
mock_image_data.resolve_image_path.return_value = None
|
||||||
|
|
||||||
|
obj.request_image_load(mock_image_data)
|
||||||
|
|
||||||
|
obj.async_image_loader.request_load.assert_not_called()
|
||||||
|
|
||||||
|
def test_request_image_load_success(self, tmp_path):
|
||||||
|
"""Test successful request_image_load"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin, LoadPriority
|
||||||
|
|
||||||
|
class TestClass(AsyncLoadingMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
obj.async_image_loader = Mock()
|
||||||
|
|
||||||
|
# Create actual file
|
||||||
|
asset_path = tmp_path / "assets" / "photo.jpg"
|
||||||
|
asset_path.parent.mkdir(parents=True)
|
||||||
|
asset_path.write_text("test")
|
||||||
|
|
||||||
|
mock_image_data = Mock()
|
||||||
|
mock_image_data.image_path = "assets/photo.jpg"
|
||||||
|
mock_image_data.resolve_image_path.return_value = str(asset_path)
|
||||||
|
|
||||||
|
obj.request_image_load(mock_image_data, priority=LoadPriority.HIGH)
|
||||||
|
|
||||||
|
obj.async_image_loader.request_load.assert_called_once()
|
||||||
|
call_kwargs = obj.async_image_loader.request_load.call_args[1]
|
||||||
|
assert call_kwargs["priority"] == LoadPriority.HIGH
|
||||||
|
assert call_kwargs["user_data"] == mock_image_data
|
||||||
|
|
||||||
|
|
||||||
|
class TestExportPdfAsync:
|
||||||
|
"""Tests for export_pdf_async method"""
|
||||||
|
|
||||||
|
def test_export_pdf_async_no_generator(self):
|
||||||
|
"""Test export_pdf_async when generator not initialized"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
|
||||||
|
class TestClass(AsyncLoadingMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
mock_project = Mock()
|
||||||
|
|
||||||
|
result = obj.export_pdf_async(mock_project, "/output.pdf")
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_export_pdf_async_creates_progress_dialog(self, qtbot):
|
||||||
|
"""Test export_pdf_async creates progress dialog"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
from PyQt6.QtWidgets import QWidget
|
||||||
|
|
||||||
|
class TestWidget(QWidget, AsyncLoadingMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
widget = TestWidget()
|
||||||
|
qtbot.addWidget(widget)
|
||||||
|
|
||||||
|
widget.async_pdf_generator = Mock()
|
||||||
|
widget.async_pdf_generator.export_pdf.return_value = True
|
||||||
|
|
||||||
|
mock_project = Mock()
|
||||||
|
mock_project.pages = [Mock(is_cover=False, is_double_spread=False)]
|
||||||
|
|
||||||
|
widget.export_pdf_async(mock_project, "/output.pdf")
|
||||||
|
|
||||||
|
assert hasattr(widget, "_pdf_progress_dialog")
|
||||||
|
assert widget._pdf_progress_dialog is not None
|
||||||
|
|
||||||
|
def test_export_pdf_async_calls_generator(self, qtbot):
|
||||||
|
"""Test export_pdf_async calls the PDF generator"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
from PyQt6.QtWidgets import QWidget
|
||||||
|
|
||||||
|
class TestWidget(QWidget, AsyncLoadingMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
widget = TestWidget()
|
||||||
|
qtbot.addWidget(widget)
|
||||||
|
|
||||||
|
widget.async_pdf_generator = Mock()
|
||||||
|
widget.async_pdf_generator.export_pdf.return_value = True
|
||||||
|
|
||||||
|
mock_project = Mock()
|
||||||
|
mock_project.pages = []
|
||||||
|
|
||||||
|
result = widget.export_pdf_async(mock_project, "/output.pdf", export_dpi=150)
|
||||||
|
|
||||||
|
widget.async_pdf_generator.export_pdf.assert_called_once_with(mock_project, "/output.pdf", 150)
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestOnPdfCancel:
|
||||||
|
"""Tests for _on_pdf_cancel callback"""
|
||||||
|
|
||||||
|
def test_on_pdf_cancel_cancels_export(self):
|
||||||
|
"""Test that _on_pdf_cancel cancels the export"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
|
||||||
|
class TestClass(AsyncLoadingMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
obj.async_pdf_generator = Mock()
|
||||||
|
|
||||||
|
obj._on_pdf_cancel()
|
||||||
|
|
||||||
|
obj.async_pdf_generator.cancel_export.assert_called_once()
|
||||||
|
|
||||||
|
def test_on_pdf_cancel_handles_no_generator(self):
|
||||||
|
"""Test that _on_pdf_cancel handles missing generator"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
|
||||||
|
class TestClass(AsyncLoadingMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
# No async_pdf_generator
|
||||||
|
|
||||||
|
# Should not raise
|
||||||
|
obj._on_pdf_cancel()
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetAsyncStats:
|
||||||
|
"""Tests for get_async_stats method"""
|
||||||
|
|
||||||
|
def test_get_async_stats_empty(self):
|
||||||
|
"""Test get_async_stats with no components initialized"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
|
||||||
|
class TestClass(AsyncLoadingMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
stats = obj.get_async_stats()
|
||||||
|
|
||||||
|
assert stats == {}
|
||||||
|
|
||||||
|
def test_get_async_stats_with_loader(self):
|
||||||
|
"""Test get_async_stats includes loader stats"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
|
||||||
|
class TestClass(AsyncLoadingMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
obj.async_image_loader = Mock()
|
||||||
|
obj.async_image_loader.get_stats.return_value = {"loaded": 10}
|
||||||
|
|
||||||
|
stats = obj.get_async_stats()
|
||||||
|
|
||||||
|
assert "image_loader" in stats
|
||||||
|
assert stats["image_loader"]["loaded"] == 10
|
||||||
|
|
||||||
|
def test_get_async_stats_with_pdf_generator(self):
|
||||||
|
"""Test get_async_stats includes PDF generator stats"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
|
||||||
|
class TestClass(AsyncLoadingMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
obj.async_pdf_generator = Mock()
|
||||||
|
obj.async_pdf_generator.get_stats.return_value = {"exports": 5}
|
||||||
|
|
||||||
|
stats = obj.get_async_stats()
|
||||||
|
|
||||||
|
assert "pdf_generator" in stats
|
||||||
|
assert stats["pdf_generator"]["exports"] == 5
|
||||||
|
|
||||||
|
def test_get_async_stats_with_all_components(self):
|
||||||
|
"""Test get_async_stats includes all component stats"""
|
||||||
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
||||||
|
|
||||||
|
class TestClass(AsyncLoadingMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
obj = TestClass()
|
||||||
|
obj.async_image_loader = Mock()
|
||||||
|
obj.async_image_loader.get_stats.return_value = {"loaded": 10}
|
||||||
|
obj.async_pdf_generator = Mock()
|
||||||
|
obj.async_pdf_generator.get_stats.return_value = {"exports": 5}
|
||||||
|
|
||||||
|
stats = obj.get_async_stats()
|
||||||
|
|
||||||
|
assert "image_loader" in stats
|
||||||
|
assert "pdf_generator" in stats
|
||||||
@@ -0,0 +1,511 @@
|
|||||||
|
"""
|
||||||
|
Tests for AutosaveManager
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from unittest.mock import Mock, patch, MagicMock
|
||||||
|
|
||||||
|
from pyPhotoAlbum.autosave_manager import AutosaveManager
|
||||||
|
|
||||||
|
|
||||||
|
class TestAutosaveManagerInit:
|
||||||
|
"""Tests for AutosaveManager initialization"""
|
||||||
|
|
||||||
|
def test_init_creates_checkpoint_directory(self, tmp_path, monkeypatch):
|
||||||
|
"""Test that init creates the checkpoint directory"""
|
||||||
|
checkpoint_dir = tmp_path / "checkpoints"
|
||||||
|
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
|
||||||
|
|
||||||
|
manager = AutosaveManager()
|
||||||
|
|
||||||
|
assert checkpoint_dir.exists()
|
||||||
|
|
||||||
|
def test_init_with_existing_directory(self, tmp_path, monkeypatch):
|
||||||
|
"""Test init when checkpoint directory already exists"""
|
||||||
|
checkpoint_dir = tmp_path / "checkpoints"
|
||||||
|
checkpoint_dir.mkdir(parents=True)
|
||||||
|
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
|
||||||
|
|
||||||
|
manager = AutosaveManager()
|
||||||
|
|
||||||
|
assert checkpoint_dir.exists()
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetCheckpointPath:
|
||||||
|
"""Tests for _get_checkpoint_path method"""
|
||||||
|
|
||||||
|
def test_get_checkpoint_path_basic(self, tmp_path, monkeypatch):
|
||||||
|
"""Test basic checkpoint path generation"""
|
||||||
|
checkpoint_dir = tmp_path / "checkpoints"
|
||||||
|
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
|
||||||
|
|
||||||
|
manager = AutosaveManager()
|
||||||
|
path = manager._get_checkpoint_path("MyProject")
|
||||||
|
|
||||||
|
assert path.parent == checkpoint_dir
|
||||||
|
assert path.suffix == ".ppz"
|
||||||
|
assert "checkpoint_MyProject_" in path.name
|
||||||
|
|
||||||
|
def test_get_checkpoint_path_with_timestamp(self, tmp_path, monkeypatch):
|
||||||
|
"""Test checkpoint path with specific timestamp"""
|
||||||
|
checkpoint_dir = tmp_path / "checkpoints"
|
||||||
|
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
|
||||||
|
|
||||||
|
manager = AutosaveManager()
|
||||||
|
timestamp = datetime(2024, 1, 15, 10, 30, 45)
|
||||||
|
path = manager._get_checkpoint_path("TestProject", timestamp)
|
||||||
|
|
||||||
|
assert "20240115_103045" in path.name
|
||||||
|
|
||||||
|
def test_get_checkpoint_path_sanitizes_name(self, tmp_path, monkeypatch):
|
||||||
|
"""Test that special characters in project name are sanitized"""
|
||||||
|
checkpoint_dir = tmp_path / "checkpoints"
|
||||||
|
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
|
||||||
|
|
||||||
|
manager = AutosaveManager()
|
||||||
|
path = manager._get_checkpoint_path("My Project!@#$%")
|
||||||
|
|
||||||
|
# Should not contain special characters except - and _
|
||||||
|
name_without_ext = path.stem
|
||||||
|
for char in name_without_ext:
|
||||||
|
assert char.isalnum() or char in "-_", f"Invalid char: {char}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateCheckpoint:
|
||||||
|
"""Tests for create_checkpoint method"""
|
||||||
|
|
||||||
|
def test_create_checkpoint_success(self, tmp_path, monkeypatch):
|
||||||
|
"""Test successful checkpoint creation"""
|
||||||
|
checkpoint_dir = tmp_path / "checkpoints"
|
||||||
|
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
|
||||||
|
|
||||||
|
manager = AutosaveManager()
|
||||||
|
|
||||||
|
# Mock save_to_zip - note the return value format
|
||||||
|
with patch("pyPhotoAlbum.autosave_manager.save_to_zip") as mock_save:
|
||||||
|
mock_save.return_value = (True, "Success")
|
||||||
|
|
||||||
|
mock_project = Mock()
|
||||||
|
mock_project.name = "TestProject"
|
||||||
|
mock_project.file_path = "/path/to/project.ppz"
|
||||||
|
|
||||||
|
success, message = manager.create_checkpoint(mock_project)
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
assert "Checkpoint created" in message
|
||||||
|
mock_save.assert_called_once()
|
||||||
|
|
||||||
|
def test_create_checkpoint_failure(self, tmp_path, monkeypatch):
|
||||||
|
"""Test checkpoint creation failure"""
|
||||||
|
checkpoint_dir = tmp_path / "checkpoints"
|
||||||
|
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
|
||||||
|
|
||||||
|
manager = AutosaveManager()
|
||||||
|
|
||||||
|
with patch("pyPhotoAlbum.autosave_manager.save_to_zip") as mock_save:
|
||||||
|
mock_save.return_value = (False, "Disk full")
|
||||||
|
|
||||||
|
mock_project = Mock()
|
||||||
|
mock_project.name = "TestProject"
|
||||||
|
|
||||||
|
success, message = manager.create_checkpoint(mock_project)
|
||||||
|
|
||||||
|
assert success is False
|
||||||
|
assert "Checkpoint failed" in message
|
||||||
|
|
||||||
|
def test_create_checkpoint_exception(self, tmp_path, monkeypatch):
|
||||||
|
"""Test checkpoint creation with exception"""
|
||||||
|
checkpoint_dir = tmp_path / "checkpoints"
|
||||||
|
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
|
||||||
|
|
||||||
|
manager = AutosaveManager()
|
||||||
|
|
||||||
|
with patch("pyPhotoAlbum.autosave_manager.save_to_zip") as mock_save:
|
||||||
|
mock_save.side_effect = Exception("IO Error")
|
||||||
|
|
||||||
|
mock_project = Mock()
|
||||||
|
mock_project.name = "TestProject"
|
||||||
|
|
||||||
|
success, message = manager.create_checkpoint(mock_project)
|
||||||
|
|
||||||
|
assert success is False
|
||||||
|
assert "Checkpoint error" in message
|
||||||
|
|
||||||
|
|
||||||
|
class TestSaveCheckpointMetadata:
|
||||||
|
"""Tests for _save_checkpoint_metadata method"""
|
||||||
|
|
||||||
|
def test_save_metadata(self, tmp_path, monkeypatch):
|
||||||
|
"""Test saving checkpoint metadata"""
|
||||||
|
checkpoint_dir = tmp_path / "checkpoints"
|
||||||
|
checkpoint_dir.mkdir(parents=True)
|
||||||
|
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
|
||||||
|
|
||||||
|
manager = AutosaveManager()
|
||||||
|
|
||||||
|
mock_project = Mock()
|
||||||
|
mock_project.name = "TestProject"
|
||||||
|
mock_project.file_path = "/path/to/original.ppz"
|
||||||
|
|
||||||
|
checkpoint_path = checkpoint_dir / "checkpoint_TestProject_20240115_103045.ppz"
|
||||||
|
checkpoint_path.touch()
|
||||||
|
|
||||||
|
manager._save_checkpoint_metadata(mock_project, checkpoint_path)
|
||||||
|
|
||||||
|
metadata_path = checkpoint_path.with_suffix(".json")
|
||||||
|
assert metadata_path.exists()
|
||||||
|
|
||||||
|
with open(metadata_path, "r") as f:
|
||||||
|
metadata = json.load(f)
|
||||||
|
|
||||||
|
assert metadata["project_name"] == "TestProject"
|
||||||
|
assert metadata["original_path"] == "/path/to/original.ppz"
|
||||||
|
assert "timestamp" in metadata
|
||||||
|
|
||||||
|
|
||||||
|
class TestListCheckpoints:
|
||||||
|
"""Tests for list_checkpoints method"""
|
||||||
|
|
||||||
|
def test_list_checkpoints_empty(self, tmp_path, monkeypatch):
|
||||||
|
"""Test listing checkpoints when none exist"""
|
||||||
|
checkpoint_dir = tmp_path / "checkpoints"
|
||||||
|
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
|
||||||
|
|
||||||
|
manager = AutosaveManager()
|
||||||
|
checkpoints = manager.list_checkpoints()
|
||||||
|
|
||||||
|
assert checkpoints == []
|
||||||
|
|
||||||
|
def test_list_checkpoints_with_files(self, tmp_path, monkeypatch):
|
||||||
|
"""Test listing checkpoints with existing files"""
|
||||||
|
checkpoint_dir = tmp_path / "checkpoints"
|
||||||
|
checkpoint_dir.mkdir(parents=True)
|
||||||
|
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
|
||||||
|
|
||||||
|
# Create some checkpoint files
|
||||||
|
cp1 = checkpoint_dir / "checkpoint_Project1_20240115_100000.ppz"
|
||||||
|
cp2 = checkpoint_dir / "checkpoint_Project2_20240115_110000.ppz"
|
||||||
|
cp1.touch()
|
||||||
|
cp2.touch()
|
||||||
|
|
||||||
|
# Create metadata for first checkpoint
|
||||||
|
metadata1 = {"project_name": "Project1", "timestamp": "2024-01-15T10:00:00"}
|
||||||
|
with open(cp1.with_suffix(".json"), "w") as f:
|
||||||
|
json.dump(metadata1, f)
|
||||||
|
|
||||||
|
manager = AutosaveManager()
|
||||||
|
checkpoints = manager.list_checkpoints()
|
||||||
|
|
||||||
|
assert len(checkpoints) == 2
|
||||||
|
|
||||||
|
def test_list_checkpoints_filter_by_project(self, tmp_path, monkeypatch):
|
||||||
|
"""Test listing checkpoints filtered by project name"""
|
||||||
|
checkpoint_dir = tmp_path / "checkpoints"
|
||||||
|
checkpoint_dir.mkdir(parents=True)
|
||||||
|
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
|
||||||
|
|
||||||
|
# Create checkpoint files with metadata
|
||||||
|
cp1 = checkpoint_dir / "checkpoint_Project1_20240115_100000.ppz"
|
||||||
|
cp2 = checkpoint_dir / "checkpoint_Project2_20240115_110000.ppz"
|
||||||
|
cp1.touch()
|
||||||
|
cp2.touch()
|
||||||
|
|
||||||
|
metadata1 = {"project_name": "Project1", "timestamp": "2024-01-15T10:00:00"}
|
||||||
|
metadata2 = {"project_name": "Project2", "timestamp": "2024-01-15T11:00:00"}
|
||||||
|
|
||||||
|
with open(cp1.with_suffix(".json"), "w") as f:
|
||||||
|
json.dump(metadata1, f)
|
||||||
|
with open(cp2.with_suffix(".json"), "w") as f:
|
||||||
|
json.dump(metadata2, f)
|
||||||
|
|
||||||
|
manager = AutosaveManager()
|
||||||
|
checkpoints = manager.list_checkpoints("Project1")
|
||||||
|
|
||||||
|
assert len(checkpoints) == 1
|
||||||
|
assert checkpoints[0][1]["project_name"] == "Project1"
|
||||||
|
|
||||||
|
def test_list_checkpoints_sorted_by_timestamp(self, tmp_path, monkeypatch):
|
||||||
|
"""Test that checkpoints are sorted by timestamp (newest first)"""
|
||||||
|
checkpoint_dir = tmp_path / "checkpoints"
|
||||||
|
checkpoint_dir.mkdir(parents=True)
|
||||||
|
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
|
||||||
|
|
||||||
|
# Create checkpoints with different timestamps
|
||||||
|
cp1 = checkpoint_dir / "checkpoint_Project_20240115_080000.ppz"
|
||||||
|
cp2 = checkpoint_dir / "checkpoint_Project_20240115_120000.ppz"
|
||||||
|
cp3 = checkpoint_dir / "checkpoint_Project_20240115_100000.ppz"
|
||||||
|
cp1.touch()
|
||||||
|
cp2.touch()
|
||||||
|
cp3.touch()
|
||||||
|
|
||||||
|
for cp, hour in [(cp1, "08"), (cp2, "12"), (cp3, "10")]:
|
||||||
|
metadata = {"project_name": "Project", "timestamp": f"2024-01-15T{hour}:00:00"}
|
||||||
|
with open(cp.with_suffix(".json"), "w") as f:
|
||||||
|
json.dump(metadata, f)
|
||||||
|
|
||||||
|
manager = AutosaveManager()
|
||||||
|
checkpoints = manager.list_checkpoints()
|
||||||
|
|
||||||
|
# Should be sorted newest first: 12:00, 10:00, 08:00
|
||||||
|
assert "12:00:00" in checkpoints[0][1]["timestamp"]
|
||||||
|
assert "10:00:00" in checkpoints[1][1]["timestamp"]
|
||||||
|
assert "08:00:00" in checkpoints[2][1]["timestamp"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadCheckpoint:
|
||||||
|
"""Tests for load_checkpoint method"""
|
||||||
|
|
||||||
|
def test_load_checkpoint_success(self, tmp_path, monkeypatch):
|
||||||
|
"""Test successful checkpoint loading"""
|
||||||
|
checkpoint_dir = tmp_path / "checkpoints"
|
||||||
|
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
|
||||||
|
|
||||||
|
manager = AutosaveManager()
|
||||||
|
|
||||||
|
with patch("pyPhotoAlbum.autosave_manager.load_from_zip") as mock_load:
|
||||||
|
mock_project = Mock()
|
||||||
|
mock_load.return_value = mock_project
|
||||||
|
|
||||||
|
checkpoint_path = checkpoint_dir / "checkpoint_Test.ppz"
|
||||||
|
success, result = manager.load_checkpoint(checkpoint_path)
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
assert result == mock_project
|
||||||
|
|
||||||
|
def test_load_checkpoint_failure(self, tmp_path, monkeypatch):
|
||||||
|
"""Test checkpoint loading failure"""
|
||||||
|
checkpoint_dir = tmp_path / "checkpoints"
|
||||||
|
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
|
||||||
|
|
||||||
|
manager = AutosaveManager()
|
||||||
|
|
||||||
|
with patch("pyPhotoAlbum.autosave_manager.load_from_zip") as mock_load:
|
||||||
|
mock_load.side_effect = Exception("Corrupt file")
|
||||||
|
|
||||||
|
checkpoint_path = checkpoint_dir / "checkpoint_Test.ppz"
|
||||||
|
success, result = manager.load_checkpoint(checkpoint_path)
|
||||||
|
|
||||||
|
assert success is False
|
||||||
|
assert "Failed to load checkpoint" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteCheckpoint:
|
||||||
|
"""Tests for delete_checkpoint method"""
|
||||||
|
|
||||||
|
def test_delete_checkpoint_success(self, tmp_path, monkeypatch):
|
||||||
|
"""Test successful checkpoint deletion"""
|
||||||
|
checkpoint_dir = tmp_path / "checkpoints"
|
||||||
|
checkpoint_dir.mkdir(parents=True)
|
||||||
|
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
|
||||||
|
|
||||||
|
# Create checkpoint and metadata files
|
||||||
|
cp = checkpoint_dir / "checkpoint_Test.ppz"
|
||||||
|
cp.touch()
|
||||||
|
metadata = cp.with_suffix(".json")
|
||||||
|
metadata.touch()
|
||||||
|
|
||||||
|
manager = AutosaveManager()
|
||||||
|
result = manager.delete_checkpoint(cp)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert not cp.exists()
|
||||||
|
assert not metadata.exists()
|
||||||
|
|
||||||
|
def test_delete_checkpoint_nonexistent(self, tmp_path, monkeypatch):
|
||||||
|
"""Test deleting nonexistent checkpoint"""
|
||||||
|
checkpoint_dir = tmp_path / "checkpoints"
|
||||||
|
checkpoint_dir.mkdir(parents=True)
|
||||||
|
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
|
||||||
|
|
||||||
|
manager = AutosaveManager()
|
||||||
|
cp = checkpoint_dir / "nonexistent.ppz"
|
||||||
|
result = manager.delete_checkpoint(cp)
|
||||||
|
|
||||||
|
assert result is True # Should succeed even if file doesn't exist
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteAllCheckpoints:
|
||||||
|
"""Tests for delete_all_checkpoints method"""
|
||||||
|
|
||||||
|
def test_delete_all_checkpoints(self, tmp_path, monkeypatch):
|
||||||
|
"""Test deleting all checkpoints"""
|
||||||
|
checkpoint_dir = tmp_path / "checkpoints"
|
||||||
|
checkpoint_dir.mkdir(parents=True)
|
||||||
|
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
|
||||||
|
|
||||||
|
# Create multiple checkpoints
|
||||||
|
for i in range(3):
|
||||||
|
cp = checkpoint_dir / f"checkpoint_Project_{i}.ppz"
|
||||||
|
cp.touch()
|
||||||
|
metadata = {"project_name": "Project", "timestamp": f"2024-01-15T{i}:00:00"}
|
||||||
|
with open(cp.with_suffix(".json"), "w") as f:
|
||||||
|
json.dump(metadata, f)
|
||||||
|
|
||||||
|
manager = AutosaveManager()
|
||||||
|
manager.delete_all_checkpoints()
|
||||||
|
|
||||||
|
remaining = list(checkpoint_dir.glob("checkpoint_*.ppz"))
|
||||||
|
assert len(remaining) == 0
|
||||||
|
|
||||||
|
def test_delete_all_checkpoints_filtered(self, tmp_path, monkeypatch):
|
||||||
|
"""Test deleting all checkpoints for specific project"""
|
||||||
|
checkpoint_dir = tmp_path / "checkpoints"
|
||||||
|
checkpoint_dir.mkdir(parents=True)
|
||||||
|
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
|
||||||
|
|
||||||
|
# Create checkpoints for different projects
|
||||||
|
for name in ["ProjectA", "ProjectB", "ProjectA"]:
|
||||||
|
cp = checkpoint_dir / f"checkpoint_{name}_{datetime.now().strftime('%Y%m%d_%H%M%S%f')}.ppz"
|
||||||
|
cp.touch()
|
||||||
|
metadata = {"project_name": name, "timestamp": datetime.now().isoformat()}
|
||||||
|
with open(cp.with_suffix(".json"), "w") as f:
|
||||||
|
json.dump(metadata, f)
|
||||||
|
|
||||||
|
manager = AutosaveManager()
|
||||||
|
manager.delete_all_checkpoints("ProjectA")
|
||||||
|
|
||||||
|
# Only ProjectB should remain
|
||||||
|
remaining = list(checkpoint_dir.glob("checkpoint_*.ppz"))
|
||||||
|
assert len(remaining) == 1
|
||||||
|
assert "ProjectB" in remaining[0].name
|
||||||
|
|
||||||
|
|
||||||
|
class TestCleanupOldCheckpoints:
|
||||||
|
"""Tests for cleanup_old_checkpoints method"""
|
||||||
|
|
||||||
|
def test_cleanup_old_checkpoints_by_age(self, tmp_path, monkeypatch):
|
||||||
|
"""Test cleanup of old checkpoints by age"""
|
||||||
|
checkpoint_dir = tmp_path / "checkpoints"
|
||||||
|
checkpoint_dir.mkdir(parents=True)
|
||||||
|
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
|
||||||
|
|
||||||
|
# Create old and new checkpoints
|
||||||
|
old_time = datetime.now() - timedelta(hours=48)
|
||||||
|
new_time = datetime.now() - timedelta(hours=1)
|
||||||
|
|
||||||
|
old_cp = checkpoint_dir / "checkpoint_Project_old.ppz"
|
||||||
|
new_cp = checkpoint_dir / "checkpoint_Project_new.ppz"
|
||||||
|
old_cp.touch()
|
||||||
|
new_cp.touch()
|
||||||
|
|
||||||
|
old_metadata = {"project_name": "Project", "timestamp": old_time.isoformat()}
|
||||||
|
new_metadata = {"project_name": "Project", "timestamp": new_time.isoformat()}
|
||||||
|
|
||||||
|
with open(old_cp.with_suffix(".json"), "w") as f:
|
||||||
|
json.dump(old_metadata, f)
|
||||||
|
with open(new_cp.with_suffix(".json"), "w") as f:
|
||||||
|
json.dump(new_metadata, f)
|
||||||
|
|
||||||
|
manager = AutosaveManager()
|
||||||
|
manager.cleanup_old_checkpoints(max_age_hours=24)
|
||||||
|
|
||||||
|
# Only new checkpoint should remain
|
||||||
|
remaining = list(checkpoint_dir.glob("checkpoint_*.ppz"))
|
||||||
|
assert len(remaining) == 1
|
||||||
|
assert "new" in remaining[0].name
|
||||||
|
|
||||||
|
def test_cleanup_old_checkpoints_by_count(self, tmp_path, monkeypatch):
|
||||||
|
"""Test cleanup of checkpoints by count"""
|
||||||
|
checkpoint_dir = tmp_path / "checkpoints"
|
||||||
|
checkpoint_dir.mkdir(parents=True)
|
||||||
|
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
|
||||||
|
|
||||||
|
# Create many recent checkpoints
|
||||||
|
for i in range(5):
|
||||||
|
timestamp = datetime.now() - timedelta(hours=i)
|
||||||
|
cp = checkpoint_dir / f"checkpoint_Project_{i:02d}.ppz"
|
||||||
|
cp.touch()
|
||||||
|
metadata = {"project_name": "Project", "timestamp": timestamp.isoformat()}
|
||||||
|
with open(cp.with_suffix(".json"), "w") as f:
|
||||||
|
json.dump(metadata, f)
|
||||||
|
|
||||||
|
manager = AutosaveManager()
|
||||||
|
manager.cleanup_old_checkpoints(max_age_hours=24 * 7, max_count=3)
|
||||||
|
|
||||||
|
# Should only keep 3 most recent
|
||||||
|
remaining = list(checkpoint_dir.glob("checkpoint_*.ppz"))
|
||||||
|
assert len(remaining) == 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestHasCheckpoints:
|
||||||
|
"""Tests for has_checkpoints method"""
|
||||||
|
|
||||||
|
def test_has_checkpoints_true(self, tmp_path, monkeypatch):
|
||||||
|
"""Test has_checkpoints returns True when checkpoints exist"""
|
||||||
|
checkpoint_dir = tmp_path / "checkpoints"
|
||||||
|
checkpoint_dir.mkdir(parents=True)
|
||||||
|
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
|
||||||
|
|
||||||
|
cp = checkpoint_dir / "checkpoint_Test.ppz"
|
||||||
|
cp.touch()
|
||||||
|
|
||||||
|
manager = AutosaveManager()
|
||||||
|
assert manager.has_checkpoints() is True
|
||||||
|
|
||||||
|
def test_has_checkpoints_false(self, tmp_path, monkeypatch):
|
||||||
|
"""Test has_checkpoints returns False when no checkpoints"""
|
||||||
|
checkpoint_dir = tmp_path / "checkpoints"
|
||||||
|
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
|
||||||
|
|
||||||
|
manager = AutosaveManager()
|
||||||
|
assert manager.has_checkpoints() is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetLatestCheckpoint:
|
||||||
|
"""Tests for get_latest_checkpoint method"""
|
||||||
|
|
||||||
|
def test_get_latest_checkpoint(self, tmp_path, monkeypatch):
|
||||||
|
"""Test getting the latest checkpoint"""
|
||||||
|
checkpoint_dir = tmp_path / "checkpoints"
|
||||||
|
checkpoint_dir.mkdir(parents=True)
|
||||||
|
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
|
||||||
|
|
||||||
|
# Create checkpoints with different timestamps
|
||||||
|
for hour in [8, 10, 12]:
|
||||||
|
cp = checkpoint_dir / f"checkpoint_Project_{hour:02d}.ppz"
|
||||||
|
cp.touch()
|
||||||
|
metadata = {"project_name": "Project", "timestamp": f"2024-01-15T{hour:02d}:00:00"}
|
||||||
|
with open(cp.with_suffix(".json"), "w") as f:
|
||||||
|
json.dump(metadata, f)
|
||||||
|
|
||||||
|
manager = AutosaveManager()
|
||||||
|
result = manager.get_latest_checkpoint()
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert "12:00:00" in result[1]["timestamp"]
|
||||||
|
|
||||||
|
def test_get_latest_checkpoint_none(self, tmp_path, monkeypatch):
|
||||||
|
"""Test getting latest checkpoint when none exist"""
|
||||||
|
checkpoint_dir = tmp_path / "checkpoints"
|
||||||
|
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
|
||||||
|
|
||||||
|
manager = AutosaveManager()
|
||||||
|
result = manager.get_latest_checkpoint()
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_get_latest_checkpoint_filtered(self, tmp_path, monkeypatch):
|
||||||
|
"""Test getting latest checkpoint for specific project"""
|
||||||
|
checkpoint_dir = tmp_path / "checkpoints"
|
||||||
|
checkpoint_dir.mkdir(parents=True)
|
||||||
|
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
|
||||||
|
|
||||||
|
# Create checkpoints for different projects
|
||||||
|
for name, hour in [("ProjectA", 10), ("ProjectB", 12), ("ProjectA", 8)]:
|
||||||
|
cp = checkpoint_dir / f"checkpoint_{name}_{hour:02d}.ppz"
|
||||||
|
cp.touch()
|
||||||
|
metadata = {"project_name": name, "timestamp": f"2024-01-15T{hour:02d}:00:00"}
|
||||||
|
with open(cp.with_suffix(".json"), "w") as f:
|
||||||
|
json.dump(metadata, f)
|
||||||
|
|
||||||
|
manager = AutosaveManager()
|
||||||
|
result = manager.get_latest_checkpoint("ProjectA")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result[1]["project_name"] == "ProjectA"
|
||||||
|
assert "10:00:00" in result[1]["timestamp"] # Latest for ProjectA
|
||||||
@@ -249,7 +249,7 @@ class TestDialogMethods:
|
|||||||
qtbot.addWidget(window)
|
qtbot.addWidget(window)
|
||||||
|
|
||||||
mock_critical = Mock()
|
mock_critical = Mock()
|
||||||
monkeypatch.setattr(QMessageBox, 'critical', mock_critical)
|
monkeypatch.setattr(QMessageBox, "critical", mock_critical)
|
||||||
|
|
||||||
window.show_error("Error Title", "Error message")
|
window.show_error("Error Title", "Error message")
|
||||||
|
|
||||||
@@ -260,7 +260,7 @@ class TestDialogMethods:
|
|||||||
qtbot.addWidget(window)
|
qtbot.addWidget(window)
|
||||||
|
|
||||||
mock_warning = Mock()
|
mock_warning = Mock()
|
||||||
monkeypatch.setattr(QMessageBox, 'warning', mock_warning)
|
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
|
||||||
|
|
||||||
window.show_warning("Warning Title", "Warning message")
|
window.show_warning("Warning Title", "Warning message")
|
||||||
|
|
||||||
@@ -271,7 +271,7 @@ class TestDialogMethods:
|
|||||||
qtbot.addWidget(window)
|
qtbot.addWidget(window)
|
||||||
|
|
||||||
mock_info = Mock()
|
mock_info = Mock()
|
||||||
monkeypatch.setattr(QMessageBox, 'information', mock_info)
|
monkeypatch.setattr(QMessageBox, "information", mock_info)
|
||||||
|
|
||||||
window.show_info("Info Title", "Info message")
|
window.show_info("Info Title", "Info message")
|
||||||
|
|
||||||
@@ -309,7 +309,7 @@ class TestRequirePage:
|
|||||||
window._gl_widget = Mock()
|
window._gl_widget = Mock()
|
||||||
|
|
||||||
mock_warning = Mock()
|
mock_warning = Mock()
|
||||||
monkeypatch.setattr(QMessageBox, 'warning', mock_warning)
|
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
|
||||||
|
|
||||||
result = window.require_page(show_warning=True)
|
result = window.require_page(show_warning=True)
|
||||||
|
|
||||||
@@ -366,7 +366,7 @@ class TestRequireSelection:
|
|||||||
window._gl_widget = gl_widget
|
window._gl_widget = gl_widget
|
||||||
|
|
||||||
mock_info = Mock()
|
mock_info = Mock()
|
||||||
monkeypatch.setattr(QMessageBox, 'information', mock_info)
|
monkeypatch.setattr(QMessageBox, "information", mock_info)
|
||||||
|
|
||||||
result = window.require_selection(min_count=1, show_warning=True)
|
result = window.require_selection(min_count=1, show_warning=True)
|
||||||
|
|
||||||
@@ -386,7 +386,7 @@ class TestRequireSelection:
|
|||||||
window._gl_widget = gl_widget
|
window._gl_widget = gl_widget
|
||||||
|
|
||||||
mock_info = Mock()
|
mock_info = Mock()
|
||||||
monkeypatch.setattr(QMessageBox, 'information', mock_info)
|
monkeypatch.setattr(QMessageBox, "information", mock_info)
|
||||||
|
|
||||||
result = window.require_selection(min_count=3, show_warning=True)
|
result = window.require_selection(min_count=3, show_warning=True)
|
||||||
|
|
||||||
|
|||||||
+83
-115
@@ -16,7 +16,7 @@ from pyPhotoAlbum.commands import (
|
|||||||
ChangeZOrderCommand,
|
ChangeZOrderCommand,
|
||||||
StateChangeCommand,
|
StateChangeCommand,
|
||||||
CommandHistory,
|
CommandHistory,
|
||||||
_normalize_asset_path
|
_normalize_asset_path,
|
||||||
)
|
)
|
||||||
from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
|
from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
|
||||||
from pyPhotoAlbum.page_layout import PageLayout
|
from pyPhotoAlbum.page_layout import PageLayout
|
||||||
@@ -107,9 +107,9 @@ class TestAddElementCommand:
|
|||||||
|
|
||||||
data = cmd.serialize()
|
data = cmd.serialize()
|
||||||
|
|
||||||
assert data['type'] == 'add_element'
|
assert data["type"] == "add_element"
|
||||||
assert 'element' in data
|
assert "element" in data
|
||||||
assert data['executed'] is True
|
assert data["executed"] is True
|
||||||
|
|
||||||
def test_add_element_with_asset_manager(self):
|
def test_add_element_with_asset_manager(self):
|
||||||
"""Test add element with asset manager reference"""
|
"""Test add element with asset manager reference"""
|
||||||
@@ -164,8 +164,8 @@ class TestDeleteElementCommand:
|
|||||||
cmd = DeleteElementCommand(layout, element)
|
cmd = DeleteElementCommand(layout, element)
|
||||||
data = cmd.serialize()
|
data = cmd.serialize()
|
||||||
|
|
||||||
assert data['type'] == 'delete_element'
|
assert data["type"] == "delete_element"
|
||||||
assert 'element' in data
|
assert "element" in data
|
||||||
|
|
||||||
|
|
||||||
class TestMoveElementCommand:
|
class TestMoveElementCommand:
|
||||||
@@ -198,9 +198,9 @@ class TestMoveElementCommand:
|
|||||||
cmd = MoveElementCommand(element, old_position=(100, 100), new_position=(200, 200))
|
cmd = MoveElementCommand(element, old_position=(100, 100), new_position=(200, 200))
|
||||||
data = cmd.serialize()
|
data = cmd.serialize()
|
||||||
|
|
||||||
assert data['type'] == 'move_element'
|
assert data["type"] == "move_element"
|
||||||
assert data['old_position'] == (100, 100)
|
assert data["old_position"] == (100, 100)
|
||||||
assert data['new_position'] == (200, 200)
|
assert data["new_position"] == (200, 200)
|
||||||
|
|
||||||
|
|
||||||
class TestResizeElementCommand:
|
class TestResizeElementCommand:
|
||||||
@@ -211,11 +211,7 @@ class TestResizeElementCommand:
|
|||||||
element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150)
|
element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150)
|
||||||
|
|
||||||
cmd = ResizeElementCommand(
|
cmd = ResizeElementCommand(
|
||||||
element,
|
element, old_position=(100, 100), old_size=(200, 150), new_position=(100, 100), new_size=(300, 225)
|
||||||
old_position=(100, 100),
|
|
||||||
old_size=(200, 150),
|
|
||||||
new_position=(100, 100),
|
|
||||||
new_size=(300, 225)
|
|
||||||
)
|
)
|
||||||
cmd.execute()
|
cmd.execute()
|
||||||
|
|
||||||
@@ -226,11 +222,7 @@ class TestResizeElementCommand:
|
|||||||
element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150)
|
element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150)
|
||||||
|
|
||||||
cmd = ResizeElementCommand(
|
cmd = ResizeElementCommand(
|
||||||
element,
|
element, old_position=(100, 100), old_size=(200, 150), new_position=(100, 100), new_size=(300, 225)
|
||||||
old_position=(100, 100),
|
|
||||||
old_size=(200, 150),
|
|
||||||
new_position=(100, 100),
|
|
||||||
new_size=(300, 225)
|
|
||||||
)
|
)
|
||||||
cmd.execute()
|
cmd.execute()
|
||||||
|
|
||||||
@@ -243,11 +235,7 @@ class TestResizeElementCommand:
|
|||||||
element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150)
|
element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150)
|
||||||
|
|
||||||
cmd = ResizeElementCommand(
|
cmd = ResizeElementCommand(
|
||||||
element,
|
element, old_position=(100, 100), old_size=(200, 150), new_position=(90, 90), new_size=(220, 165)
|
||||||
old_position=(100, 100),
|
|
||||||
old_size=(200, 150),
|
|
||||||
new_position=(90, 90),
|
|
||||||
new_size=(220, 165)
|
|
||||||
)
|
)
|
||||||
cmd.execute()
|
cmd.execute()
|
||||||
|
|
||||||
@@ -298,9 +286,9 @@ class TestRotateElementCommand:
|
|||||||
cmd = RotateElementCommand(element, old_rotation=0, new_rotation=45)
|
cmd = RotateElementCommand(element, old_rotation=0, new_rotation=45)
|
||||||
data = cmd.serialize()
|
data = cmd.serialize()
|
||||||
|
|
||||||
assert data['type'] == 'rotate_element'
|
assert data["type"] == "rotate_element"
|
||||||
assert data['old_rotation'] == 0
|
assert data["old_rotation"] == 0
|
||||||
assert data['new_rotation'] == 45
|
assert data["new_rotation"] == 45
|
||||||
|
|
||||||
|
|
||||||
class TestAdjustImageCropCommand:
|
class TestAdjustImageCropCommand:
|
||||||
@@ -310,32 +298,25 @@ class TestAdjustImageCropCommand:
|
|||||||
"""Test adjusting image crop"""
|
"""Test adjusting image crop"""
|
||||||
element = ImageData(
|
element = ImageData(
|
||||||
image_path="/test.jpg",
|
image_path="/test.jpg",
|
||||||
x=100, y=100,
|
x=100,
|
||||||
width=200, height=150,
|
y=100,
|
||||||
crop_info={'x': 0.0, 'y': 0.0, 'width': 1.0, 'height': 1.0}
|
width=200,
|
||||||
|
height=150,
|
||||||
|
crop_info={"x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0},
|
||||||
)
|
)
|
||||||
|
|
||||||
new_crop = {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8}
|
new_crop = {"x": 0.1, "y": 0.1, "width": 0.8, "height": 0.8}
|
||||||
cmd = AdjustImageCropCommand(
|
cmd = AdjustImageCropCommand(element, old_crop_info=element.crop_info.copy(), new_crop_info=new_crop)
|
||||||
element,
|
|
||||||
old_crop_info=element.crop_info.copy(),
|
|
||||||
new_crop_info=new_crop
|
|
||||||
)
|
|
||||||
cmd.execute()
|
cmd.execute()
|
||||||
|
|
||||||
assert element.crop_info == new_crop
|
assert element.crop_info == new_crop
|
||||||
|
|
||||||
def test_adjust_crop_undo(self):
|
def test_adjust_crop_undo(self):
|
||||||
"""Test undoing crop adjustment"""
|
"""Test undoing crop adjustment"""
|
||||||
old_crop = {'x': 0.0, 'y': 0.0, 'width': 1.0, 'height': 1.0}
|
old_crop = {"x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0}
|
||||||
element = ImageData(
|
element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150, crop_info=old_crop.copy())
|
||||||
image_path="/test.jpg",
|
|
||||||
x=100, y=100,
|
|
||||||
width=200, height=150,
|
|
||||||
crop_info=old_crop.copy()
|
|
||||||
)
|
|
||||||
|
|
||||||
new_crop = {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8}
|
new_crop = {"x": 0.1, "y": 0.1, "width": 0.8, "height": 0.8}
|
||||||
cmd = AdjustImageCropCommand(element, old_crop_info=old_crop, new_crop_info=new_crop)
|
cmd = AdjustImageCropCommand(element, old_crop_info=old_crop, new_crop_info=new_crop)
|
||||||
cmd.execute()
|
cmd.execute()
|
||||||
|
|
||||||
@@ -399,10 +380,7 @@ class TestResizeElementsCommand:
|
|||||||
element2.size = (300, 300)
|
element2.size = (300, 300)
|
||||||
|
|
||||||
# Command expects list of (element, old_position, old_size) tuples
|
# Command expects list of (element, old_position, old_size) tuples
|
||||||
changes = [
|
changes = [(element1, (100, 100), (100, 100)), (element2, (200, 200), (150, 150))]
|
||||||
(element1, (100, 100), (100, 100)),
|
|
||||||
(element2, (200, 200), (150, 150))
|
|
||||||
]
|
|
||||||
|
|
||||||
cmd = ResizeElementsCommand(changes)
|
cmd = ResizeElementsCommand(changes)
|
||||||
cmd.execute()
|
cmd.execute()
|
||||||
@@ -421,10 +399,7 @@ class TestResizeElementsCommand:
|
|||||||
element2.size = (300, 300)
|
element2.size = (300, 300)
|
||||||
|
|
||||||
# Command expects list of (element, old_position, old_size) tuples
|
# Command expects list of (element, old_position, old_size) tuples
|
||||||
changes = [
|
changes = [(element1, (100, 100), (100, 100)), (element2, (200, 200), (150, 150))]
|
||||||
(element1, (100, 100), (100, 100)),
|
|
||||||
(element2, (200, 200), (150, 150))
|
|
||||||
]
|
|
||||||
|
|
||||||
cmd = ResizeElementsCommand(changes)
|
cmd = ResizeElementsCommand(changes)
|
||||||
cmd.execute()
|
cmd.execute()
|
||||||
@@ -476,81 +451,68 @@ class TestStateChangeCommand:
|
|||||||
|
|
||||||
def test_state_change_undo(self):
|
def test_state_change_undo(self):
|
||||||
"""Test undoing state change"""
|
"""Test undoing state change"""
|
||||||
element = TextBoxData(
|
element = TextBoxData(text_content="Old Text", x=100, y=100, width=200, height=100)
|
||||||
text_content="Old Text",
|
|
||||||
x=100, y=100,
|
|
||||||
width=200, height=100
|
|
||||||
)
|
|
||||||
|
|
||||||
# Define restore function
|
# Define restore function
|
||||||
def restore_state(state):
|
def restore_state(state):
|
||||||
element.text_content = state['text_content']
|
element.text_content = state["text_content"]
|
||||||
|
|
||||||
old_state = {'text_content': 'Old Text'}
|
old_state = {"text_content": "Old Text"}
|
||||||
new_state = {'text_content': 'New Text'}
|
new_state = {"text_content": "New Text"}
|
||||||
|
|
||||||
# Apply new state first
|
# Apply new state first
|
||||||
element.text_content = 'New Text'
|
element.text_content = "New Text"
|
||||||
|
|
||||||
cmd = StateChangeCommand(
|
cmd = StateChangeCommand(
|
||||||
description="Change text",
|
description="Change text", restore_func=restore_state, before_state=old_state, after_state=new_state
|
||||||
restore_func=restore_state,
|
|
||||||
before_state=old_state,
|
|
||||||
after_state=new_state
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Undo should restore old state
|
# Undo should restore old state
|
||||||
cmd.undo()
|
cmd.undo()
|
||||||
assert element.text_content == 'Old Text'
|
assert element.text_content == "Old Text"
|
||||||
|
|
||||||
def test_state_change_redo(self):
|
def test_state_change_redo(self):
|
||||||
"""Test redoing state change"""
|
"""Test redoing state change"""
|
||||||
element = TextBoxData(
|
element = TextBoxData(text_content="Old Text", x=100, y=100, width=200, height=100)
|
||||||
text_content="Old Text",
|
|
||||||
x=100, y=100,
|
|
||||||
width=200, height=100
|
|
||||||
)
|
|
||||||
|
|
||||||
# Define restore function
|
# Define restore function
|
||||||
def restore_state(state):
|
def restore_state(state):
|
||||||
element.text_content = state['text_content']
|
element.text_content = state["text_content"]
|
||||||
|
|
||||||
old_state = {'text_content': 'Old Text'}
|
old_state = {"text_content": "Old Text"}
|
||||||
new_state = {'text_content': 'New Text'}
|
new_state = {"text_content": "New Text"}
|
||||||
|
|
||||||
# Apply new state first
|
# Apply new state first
|
||||||
element.text_content = 'New Text'
|
element.text_content = "New Text"
|
||||||
|
|
||||||
cmd = StateChangeCommand(
|
cmd = StateChangeCommand(
|
||||||
description="Change text",
|
description="Change text", restore_func=restore_state, before_state=old_state, after_state=new_state
|
||||||
restore_func=restore_state,
|
|
||||||
before_state=old_state,
|
|
||||||
after_state=new_state
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Undo then redo
|
# Undo then redo
|
||||||
cmd.undo()
|
cmd.undo()
|
||||||
assert element.text_content == 'Old Text'
|
assert element.text_content == "Old Text"
|
||||||
|
|
||||||
cmd.redo()
|
cmd.redo()
|
||||||
assert element.text_content == 'New Text'
|
assert element.text_content == "New Text"
|
||||||
|
|
||||||
def test_state_change_serialization(self):
|
def test_state_change_serialization(self):
|
||||||
"""Test serializing state change command"""
|
"""Test serializing state change command"""
|
||||||
|
|
||||||
def restore_func(state):
|
def restore_func(state):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
cmd = StateChangeCommand(
|
cmd = StateChangeCommand(
|
||||||
description="Test operation",
|
description="Test operation",
|
||||||
restore_func=restore_func,
|
restore_func=restore_func,
|
||||||
before_state={'test': 'before'},
|
before_state={"test": "before"},
|
||||||
after_state={'test': 'after'}
|
after_state={"test": "after"},
|
||||||
)
|
)
|
||||||
|
|
||||||
data = cmd.serialize()
|
data = cmd.serialize()
|
||||||
|
|
||||||
assert data['type'] == 'state_change'
|
assert data["type"] == "state_change"
|
||||||
assert data['description'] == 'Test operation'
|
assert data["description"] == "Test operation"
|
||||||
|
|
||||||
|
|
||||||
class TestCommandHistory:
|
class TestCommandHistory:
|
||||||
@@ -656,7 +618,7 @@ class TestCommandHistory:
|
|||||||
layout = PageLayout(width=210, height=297)
|
layout = PageLayout(width=210, height=297)
|
||||||
|
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
element = ImageData(image_path=f"/test{i}.jpg", x=i*10, y=i*10, width=100, height=100)
|
element = ImageData(image_path=f"/test{i}.jpg", x=i * 10, y=i * 10, width=100, height=100)
|
||||||
history.execute(AddElementCommand(layout, element))
|
history.execute(AddElementCommand(layout, element))
|
||||||
|
|
||||||
# Should only have 3 commands in history (max_history)
|
# Should only have 3 commands in history (max_history)
|
||||||
@@ -678,8 +640,8 @@ class TestCommandHistory:
|
|||||||
|
|
||||||
# Serialize
|
# Serialize
|
||||||
data = history.serialize()
|
data = history.serialize()
|
||||||
assert len(data['undo_stack']) == 1
|
assert len(data["undo_stack"]) == 1
|
||||||
assert data['undo_stack'][0]['type'] == 'add_element'
|
assert data["undo_stack"][0]["type"] == "add_element"
|
||||||
|
|
||||||
# Create mock project for deserialization
|
# Create mock project for deserialization
|
||||||
mock_project = Mock()
|
mock_project = Mock()
|
||||||
@@ -734,7 +696,7 @@ class TestCommandHistory:
|
|||||||
|
|
||||||
# Manually build serialized history data
|
# Manually build serialized history data
|
||||||
data = {
|
data = {
|
||||||
'undo_stack': [
|
"undo_stack": [
|
||||||
cmd1.serialize(),
|
cmd1.serialize(),
|
||||||
cmd2.serialize(),
|
cmd2.serialize(),
|
||||||
cmd3.serialize(),
|
cmd3.serialize(),
|
||||||
@@ -745,8 +707,8 @@ class TestCommandHistory:
|
|||||||
cmd8.serialize(),
|
cmd8.serialize(),
|
||||||
cmd9.serialize(),
|
cmd9.serialize(),
|
||||||
],
|
],
|
||||||
'redo_stack': [],
|
"redo_stack": [],
|
||||||
'max_history': 100
|
"max_history": 100,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create mock project
|
# Create mock project
|
||||||
@@ -758,28 +720,29 @@ class TestCommandHistory:
|
|||||||
new_history.deserialize(data, mock_project)
|
new_history.deserialize(data, mock_project)
|
||||||
|
|
||||||
assert len(new_history.undo_stack) == 9
|
assert len(new_history.undo_stack) == 9
|
||||||
assert new_history.undo_stack[0].__class__.__name__ == 'AddElementCommand'
|
assert new_history.undo_stack[0].__class__.__name__ == "AddElementCommand"
|
||||||
assert new_history.undo_stack[1].__class__.__name__ == 'DeleteElementCommand'
|
assert new_history.undo_stack[1].__class__.__name__ == "DeleteElementCommand"
|
||||||
assert new_history.undo_stack[2].__class__.__name__ == 'MoveElementCommand'
|
assert new_history.undo_stack[2].__class__.__name__ == "MoveElementCommand"
|
||||||
assert new_history.undo_stack[3].__class__.__name__ == 'ResizeElementCommand'
|
assert new_history.undo_stack[3].__class__.__name__ == "ResizeElementCommand"
|
||||||
assert new_history.undo_stack[4].__class__.__name__ == 'RotateElementCommand'
|
assert new_history.undo_stack[4].__class__.__name__ == "RotateElementCommand"
|
||||||
assert new_history.undo_stack[5].__class__.__name__ == 'AdjustImageCropCommand'
|
assert new_history.undo_stack[5].__class__.__name__ == "AdjustImageCropCommand"
|
||||||
assert new_history.undo_stack[6].__class__.__name__ == 'AlignElementsCommand'
|
assert new_history.undo_stack[6].__class__.__name__ == "AlignElementsCommand"
|
||||||
assert new_history.undo_stack[7].__class__.__name__ == 'ResizeElementsCommand'
|
assert new_history.undo_stack[7].__class__.__name__ == "ResizeElementsCommand"
|
||||||
assert new_history.undo_stack[8].__class__.__name__ == 'ChangeZOrderCommand'
|
assert new_history.undo_stack[8].__class__.__name__ == "ChangeZOrderCommand"
|
||||||
|
|
||||||
def test_history_deserialize_unknown_command_type(self):
|
def test_history_deserialize_unknown_command_type(self):
|
||||||
"""Test deserializing unknown command type returns None and continues"""
|
"""Test deserializing unknown command type returns None and continues"""
|
||||||
history = CommandHistory()
|
history = CommandHistory()
|
||||||
mock_project = Mock()
|
mock_project = Mock()
|
||||||
|
mock_project.pages = []
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'undo_stack': [
|
"undo_stack": [
|
||||||
{'type': 'unknown_command', 'data': 'test'},
|
{"type": "unknown_command", "data": "test"},
|
||||||
{'type': 'add_element', 'element': ImageData().serialize(), 'executed': True}
|
{"type": "add_element", "element": ImageData().serialize(), "executed": True},
|
||||||
],
|
],
|
||||||
'redo_stack': [],
|
"redo_stack": [],
|
||||||
'max_history': 100
|
"max_history": 100,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Should not raise exception, just skip unknown command
|
# Should not raise exception, just skip unknown command
|
||||||
@@ -787,7 +750,7 @@ class TestCommandHistory:
|
|||||||
|
|
||||||
# Should only have the valid command
|
# Should only have the valid command
|
||||||
assert len(history.undo_stack) == 1
|
assert len(history.undo_stack) == 1
|
||||||
assert history.undo_stack[0].__class__.__name__ == 'AddElementCommand'
|
assert history.undo_stack[0].__class__.__name__ == "AddElementCommand"
|
||||||
|
|
||||||
def test_history_deserialize_malformed_command(self):
|
def test_history_deserialize_malformed_command(self):
|
||||||
"""Test deserializing malformed command handles exception gracefully"""
|
"""Test deserializing malformed command handles exception gracefully"""
|
||||||
@@ -795,13 +758,17 @@ class TestCommandHistory:
|
|||||||
mock_project = Mock()
|
mock_project = Mock()
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'undo_stack': [
|
"undo_stack": [
|
||||||
{'type': 'add_element'}, # Missing required 'element' field
|
{"type": "add_element"}, # Missing required 'element' field
|
||||||
{'type': 'move_element', 'element': ImageData().serialize(),
|
{
|
||||||
'old_position': (0, 0), 'new_position': (10, 10)}
|
"type": "move_element",
|
||||||
|
"element": ImageData().serialize(),
|
||||||
|
"old_position": (0, 0),
|
||||||
|
"new_position": (10, 10),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
'redo_stack': [],
|
"redo_stack": [],
|
||||||
'max_history': 100
|
"max_history": 100,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Should not raise exception, just skip malformed command
|
# Should not raise exception, just skip malformed command
|
||||||
@@ -809,7 +776,7 @@ class TestCommandHistory:
|
|||||||
|
|
||||||
# Should only have the valid command
|
# Should only have the valid command
|
||||||
assert len(history.undo_stack) == 1
|
assert len(history.undo_stack) == 1
|
||||||
assert history.undo_stack[0].__class__.__name__ == 'MoveElementCommand'
|
assert history.undo_stack[0].__class__.__name__ == "MoveElementCommand"
|
||||||
|
|
||||||
def test_history_serialize_deserialize_with_redo_stack(self):
|
def test_history_serialize_deserialize_with_redo_stack(self):
|
||||||
"""Test serializing and deserializing with items in redo stack"""
|
"""Test serializing and deserializing with items in redo stack"""
|
||||||
@@ -826,11 +793,12 @@ class TestCommandHistory:
|
|||||||
|
|
||||||
# Serialize
|
# Serialize
|
||||||
data = history.serialize()
|
data = history.serialize()
|
||||||
assert len(data['undo_stack']) == 1
|
assert len(data["undo_stack"]) == 1
|
||||||
assert len(data['redo_stack']) == 1
|
assert len(data["redo_stack"]) == 1
|
||||||
|
|
||||||
# Deserialize
|
# Deserialize
|
||||||
mock_project = Mock()
|
mock_project = Mock()
|
||||||
|
mock_project.pages = []
|
||||||
new_history = CommandHistory()
|
new_history = CommandHistory()
|
||||||
new_history.deserialize(data, mock_project)
|
new_history.deserialize(data, mock_project)
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class TestGetSelectedElementsList:
|
|||||||
class TestDistributeHorizontally:
|
class TestDistributeHorizontally:
|
||||||
"""Test distribute_horizontally method"""
|
"""Test distribute_horizontally method"""
|
||||||
|
|
||||||
@patch('pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager')
|
@patch("pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager")
|
||||||
def test_distribute_horizontally_success(self, mock_manager, qtbot):
|
def test_distribute_horizontally_success(self, mock_manager, qtbot):
|
||||||
window = TestDistributionWindow()
|
window = TestDistributionWindow()
|
||||||
qtbot.addWidget(window)
|
qtbot.addWidget(window)
|
||||||
@@ -70,7 +70,7 @@ class TestDistributeHorizontally:
|
|||||||
mock_manager.distribute_horizontally.return_value = [
|
mock_manager.distribute_horizontally.return_value = [
|
||||||
(element1, (0, 0)),
|
(element1, (0, 0)),
|
||||||
(element2, (150, 0)),
|
(element2, (150, 0)),
|
||||||
(element3, (500, 0))
|
(element3, (500, 0)),
|
||||||
]
|
]
|
||||||
|
|
||||||
window.distribute_horizontally()
|
window.distribute_horizontally()
|
||||||
@@ -98,7 +98,7 @@ class TestDistributeHorizontally:
|
|||||||
class TestDistributeVertically:
|
class TestDistributeVertically:
|
||||||
"""Test distribute_vertically method"""
|
"""Test distribute_vertically method"""
|
||||||
|
|
||||||
@patch('pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager')
|
@patch("pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager")
|
||||||
def test_distribute_vertically_success(self, mock_manager, qtbot):
|
def test_distribute_vertically_success(self, mock_manager, qtbot):
|
||||||
window = TestDistributionWindow()
|
window = TestDistributionWindow()
|
||||||
qtbot.addWidget(window)
|
qtbot.addWidget(window)
|
||||||
@@ -112,7 +112,7 @@ class TestDistributeVertically:
|
|||||||
mock_manager.distribute_vertically.return_value = [
|
mock_manager.distribute_vertically.return_value = [
|
||||||
(element1, (0, 0)),
|
(element1, (0, 0)),
|
||||||
(element2, (0, 150)),
|
(element2, (0, 150)),
|
||||||
(element3, (0, 500))
|
(element3, (0, 500)),
|
||||||
]
|
]
|
||||||
|
|
||||||
window.distribute_vertically()
|
window.distribute_vertically()
|
||||||
@@ -125,7 +125,7 @@ class TestDistributeVertically:
|
|||||||
class TestSpaceHorizontally:
|
class TestSpaceHorizontally:
|
||||||
"""Test space_horizontally method"""
|
"""Test space_horizontally method"""
|
||||||
|
|
||||||
@patch('pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager')
|
@patch("pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager")
|
||||||
def test_space_horizontally_success(self, mock_manager, qtbot):
|
def test_space_horizontally_success(self, mock_manager, qtbot):
|
||||||
window = TestDistributionWindow()
|
window = TestDistributionWindow()
|
||||||
qtbot.addWidget(window)
|
qtbot.addWidget(window)
|
||||||
@@ -136,11 +136,7 @@ class TestSpaceHorizontally:
|
|||||||
|
|
||||||
window.gl_widget.selected_elements = {element1, element2, element3}
|
window.gl_widget.selected_elements = {element1, element2, element3}
|
||||||
|
|
||||||
mock_manager.space_horizontally.return_value = [
|
mock_manager.space_horizontally.return_value = [(element1, (0, 0)), (element2, (100, 0)), (element3, (200, 0))]
|
||||||
(element1, (0, 0)),
|
|
||||||
(element2, (100, 0)),
|
|
||||||
(element3, (200, 0))
|
|
||||||
]
|
|
||||||
|
|
||||||
window.space_horizontally()
|
window.space_horizontally()
|
||||||
|
|
||||||
@@ -152,7 +148,7 @@ class TestSpaceHorizontally:
|
|||||||
class TestSpaceVertically:
|
class TestSpaceVertically:
|
||||||
"""Test space_vertically method"""
|
"""Test space_vertically method"""
|
||||||
|
|
||||||
@patch('pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager')
|
@patch("pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager")
|
||||||
def test_space_vertically_success(self, mock_manager, qtbot):
|
def test_space_vertically_success(self, mock_manager, qtbot):
|
||||||
window = TestDistributionWindow()
|
window = TestDistributionWindow()
|
||||||
qtbot.addWidget(window)
|
qtbot.addWidget(window)
|
||||||
@@ -163,11 +159,7 @@ class TestSpaceVertically:
|
|||||||
|
|
||||||
window.gl_widget.selected_elements = {element1, element2, element3}
|
window.gl_widget.selected_elements = {element1, element2, element3}
|
||||||
|
|
||||||
mock_manager.space_vertically.return_value = [
|
mock_manager.space_vertically.return_value = [(element1, (0, 0)), (element2, (0, 100)), (element3, (0, 200))]
|
||||||
(element1, (0, 0)),
|
|
||||||
(element2, (0, 100)),
|
|
||||||
(element3, (0, 200))
|
|
||||||
]
|
|
||||||
|
|
||||||
window.space_vertically()
|
window.space_vertically()
|
||||||
|
|
||||||
@@ -178,7 +170,7 @@ class TestSpaceVertically:
|
|||||||
class TestDistributionCommandPattern:
|
class TestDistributionCommandPattern:
|
||||||
"""Test distribution operations with command pattern"""
|
"""Test distribution operations with command pattern"""
|
||||||
|
|
||||||
@patch('pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager')
|
@patch("pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager")
|
||||||
def test_distribution_creates_command(self, mock_manager, qtbot):
|
def test_distribution_creates_command(self, mock_manager, qtbot):
|
||||||
window = TestDistributionWindow()
|
window = TestDistributionWindow()
|
||||||
qtbot.addWidget(window)
|
qtbot.addWidget(window)
|
||||||
@@ -192,7 +184,7 @@ class TestDistributionCommandPattern:
|
|||||||
mock_manager.distribute_horizontally.return_value = [
|
mock_manager.distribute_horizontally.return_value = [
|
||||||
(element1, (0, 0)),
|
(element1, (0, 0)),
|
||||||
(element2, (100, 0)),
|
(element2, (100, 0)),
|
||||||
(element3, (200, 0))
|
(element3, (200, 0)),
|
||||||
]
|
]
|
||||||
|
|
||||||
assert not window.project.history.can_undo()
|
assert not window.project.history.can_undo()
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class TestEditWindow(EditOperationsMixin, QMainWindow):
|
|||||||
return len(self.gl_widget.selected_elements) >= min_count
|
return len(self.gl_widget.selected_elements) >= min_count
|
||||||
|
|
||||||
def get_current_page(self):
|
def get_current_page(self):
|
||||||
if hasattr(self, '_current_page'):
|
if hasattr(self, "_current_page"):
|
||||||
return self._current_page
|
return self._current_page
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from pyPhotoAlbum.page_layout import PageLayout
|
|||||||
# Create test widget combining necessary mixins
|
# Create test widget combining necessary mixins
|
||||||
class TestManipulationWidget(ElementManipulationMixin, ElementSelectionMixin, QOpenGLWidget):
|
class TestManipulationWidget(ElementManipulationMixin, ElementSelectionMixin, QOpenGLWidget):
|
||||||
"""Test widget combining manipulation and selection mixins"""
|
"""Test widget combining manipulation and selection mixins"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._page_renderers = []
|
self._page_renderers = []
|
||||||
@@ -36,11 +37,7 @@ class TestElementManipulationInitialization:
|
|||||||
assert widget.rotation_mode is False
|
assert widget.rotation_mode is False
|
||||||
assert widget.rotation_start_angle is None
|
assert widget.rotation_start_angle is None
|
||||||
assert widget.rotation_snap_angle == 15
|
assert widget.rotation_snap_angle == 15
|
||||||
assert widget.snap_state == {
|
assert widget.snap_state == {"is_snapped": False, "last_position": None, "last_size": None}
|
||||||
'is_snapped': False,
|
|
||||||
'last_position': None,
|
|
||||||
'last_size': None
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_rotation_mode_is_mutable(self, qtbot):
|
def test_rotation_mode_is_mutable(self, qtbot):
|
||||||
"""Test that rotation mode can be toggled"""
|
"""Test that rotation mode can be toggled"""
|
||||||
@@ -72,7 +69,7 @@ class TestResizeElementNoSnap:
|
|||||||
|
|
||||||
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
|
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
|
||||||
widget.selected_element = elem
|
widget.selected_element = elem
|
||||||
widget.resize_handle = 'se'
|
widget.resize_handle = "se"
|
||||||
widget.resize_start_pos = (100, 100)
|
widget.resize_start_pos = (100, 100)
|
||||||
widget.resize_start_size = (200, 150)
|
widget.resize_start_size = (200, 150)
|
||||||
|
|
||||||
@@ -89,7 +86,7 @@ class TestResizeElementNoSnap:
|
|||||||
|
|
||||||
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
|
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
|
||||||
widget.selected_element = elem
|
widget.selected_element = elem
|
||||||
widget.resize_handle = 'nw'
|
widget.resize_handle = "nw"
|
||||||
widget.resize_start_pos = (100, 100)
|
widget.resize_start_pos = (100, 100)
|
||||||
widget.resize_start_size = (200, 150)
|
widget.resize_start_size = (200, 150)
|
||||||
|
|
||||||
@@ -106,7 +103,7 @@ class TestResizeElementNoSnap:
|
|||||||
|
|
||||||
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
|
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
|
||||||
widget.selected_element = elem
|
widget.selected_element = elem
|
||||||
widget.resize_handle = 'ne'
|
widget.resize_handle = "ne"
|
||||||
widget.resize_start_pos = (100, 100)
|
widget.resize_start_pos = (100, 100)
|
||||||
widget.resize_start_size = (200, 150)
|
widget.resize_start_size = (200, 150)
|
||||||
|
|
||||||
@@ -123,7 +120,7 @@ class TestResizeElementNoSnap:
|
|||||||
|
|
||||||
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
|
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
|
||||||
widget.selected_element = elem
|
widget.selected_element = elem
|
||||||
widget.resize_handle = 'sw'
|
widget.resize_handle = "sw"
|
||||||
widget.resize_start_pos = (100, 100)
|
widget.resize_start_pos = (100, 100)
|
||||||
widget.resize_start_size = (200, 150)
|
widget.resize_start_size = (200, 150)
|
||||||
|
|
||||||
@@ -140,7 +137,7 @@ class TestResizeElementNoSnap:
|
|||||||
|
|
||||||
elem = ImageData(image_path="test.jpg", x=100, y=100, width=50, height=50)
|
elem = ImageData(image_path="test.jpg", x=100, y=100, width=50, height=50)
|
||||||
widget.selected_element = elem
|
widget.selected_element = elem
|
||||||
widget.resize_handle = 'se'
|
widget.resize_handle = "se"
|
||||||
widget.resize_start_pos = (100, 100)
|
widget.resize_start_pos = (100, 100)
|
||||||
widget.resize_start_size = (50, 50)
|
widget.resize_start_size = (50, 50)
|
||||||
|
|
||||||
@@ -157,7 +154,7 @@ class TestResizeElementNoSnap:
|
|||||||
|
|
||||||
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
|
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
|
||||||
widget.selected_element = elem
|
widget.selected_element = elem
|
||||||
widget.resize_handle = 'se'
|
widget.resize_handle = "se"
|
||||||
# Don't set resize_start_pos or resize_start_size
|
# Don't set resize_start_pos or resize_start_size
|
||||||
|
|
||||||
original_pos = elem.position
|
original_pos = elem.position
|
||||||
@@ -185,7 +182,7 @@ class TestResizeElementWithSnap:
|
|||||||
elem._parent_page = page
|
elem._parent_page = page
|
||||||
|
|
||||||
widget.selected_element = elem
|
widget.selected_element = elem
|
||||||
widget.resize_handle = 'se'
|
widget.resize_handle = "se"
|
||||||
widget.resize_start_pos = (100, 100)
|
widget.resize_start_pos = (100, 100)
|
||||||
widget.resize_start_size = (200, 150)
|
widget.resize_start_size = (200, 150)
|
||||||
|
|
||||||
@@ -208,7 +205,7 @@ class TestResizeElementWithSnap:
|
|||||||
params = call_args[0][0]
|
params = call_args[0][0]
|
||||||
assert params.dx == 50
|
assert params.dx == 50
|
||||||
assert params.dy == 30
|
assert params.dy == 30
|
||||||
assert params.resize_handle == 'se'
|
assert params.resize_handle == "se"
|
||||||
|
|
||||||
# Verify element was updated
|
# Verify element was updated
|
||||||
assert elem.size == (250, 180)
|
assert elem.size == (250, 180)
|
||||||
@@ -220,7 +217,7 @@ class TestResizeElementWithSnap:
|
|||||||
|
|
||||||
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
|
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
|
||||||
widget.selected_element = elem
|
widget.selected_element = elem
|
||||||
widget.resize_handle = 'se'
|
widget.resize_handle = "se"
|
||||||
widget.resize_start_pos = (100, 100)
|
widget.resize_start_pos = (100, 100)
|
||||||
widget.resize_start_size = (200, 150)
|
widget.resize_start_size = (200, 150)
|
||||||
|
|
||||||
@@ -241,7 +238,7 @@ class TestResizeElementWithSnap:
|
|||||||
elem._parent_page = page
|
elem._parent_page = page
|
||||||
|
|
||||||
widget.selected_element = elem
|
widget.selected_element = elem
|
||||||
widget.resize_handle = 'se'
|
widget.resize_handle = "se"
|
||||||
widget.resize_start_pos = (100, 100)
|
widget.resize_start_pos = (100, 100)
|
||||||
widget.resize_start_size = (50, 50)
|
widget.resize_start_size = (50, 50)
|
||||||
|
|
||||||
@@ -344,20 +341,20 @@ class TestManipulationStateManagement:
|
|||||||
widget = TestManipulationWidget()
|
widget = TestManipulationWidget()
|
||||||
qtbot.addWidget(widget)
|
qtbot.addWidget(widget)
|
||||||
|
|
||||||
assert 'is_snapped' in widget.snap_state
|
assert "is_snapped" in widget.snap_state
|
||||||
assert 'last_position' in widget.snap_state
|
assert "last_position" in widget.snap_state
|
||||||
assert 'last_size' in widget.snap_state
|
assert "last_size" in widget.snap_state
|
||||||
|
|
||||||
def test_resize_state_can_be_set(self, qtbot):
|
def test_resize_state_can_be_set(self, qtbot):
|
||||||
"""Test resize state variables can be set"""
|
"""Test resize state variables can be set"""
|
||||||
widget = TestManipulationWidget()
|
widget = TestManipulationWidget()
|
||||||
qtbot.addWidget(widget)
|
qtbot.addWidget(widget)
|
||||||
|
|
||||||
widget.resize_handle = 'nw'
|
widget.resize_handle = "nw"
|
||||||
widget.resize_start_pos = (10, 20)
|
widget.resize_start_pos = (10, 20)
|
||||||
widget.resize_start_size = (100, 200)
|
widget.resize_start_size = (100, 200)
|
||||||
|
|
||||||
assert widget.resize_handle == 'nw'
|
assert widget.resize_handle == "nw"
|
||||||
assert widget.resize_start_pos == (10, 20)
|
assert widget.resize_start_pos == (10, 20)
|
||||||
assert widget.resize_start_size == (100, 200)
|
assert widget.resize_start_size == (100, 200)
|
||||||
|
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ class TestElementMaximizer:
|
|||||||
def test_maximize_empty_elements(self):
|
def test_maximize_empty_elements(self):
|
||||||
"""Test maximize with empty element list."""
|
"""Test maximize with empty element list."""
|
||||||
from pyPhotoAlbum.alignment import AlignmentManager
|
from pyPhotoAlbum.alignment import AlignmentManager
|
||||||
|
|
||||||
result = AlignmentManager.maximize_pattern([], (200.0, 200.0))
|
result = AlignmentManager.maximize_pattern([], (200.0, 200.0))
|
||||||
assert result == []
|
assert result == []
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user