Compare commits
41
Commits
3bfe2fa654
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15d8ca8c05 | ||
|
|
1fe44e7d8a | ||
|
|
fd0758dd6c | ||
|
|
5763fa629e | ||
|
|
3092388226 | ||
|
|
cdd5215eb4 | ||
|
|
e112437372 | ||
|
|
f96200c799 | ||
|
|
f0aa005d8c | ||
|
|
293b568772 | ||
|
|
3a2d8b05db | ||
|
|
54cc78783a | ||
|
|
cf27d9ebee | ||
|
|
6a791b1397 | ||
|
|
254a95d83c | ||
|
|
c1ee894e7b | ||
|
|
8f9f387848 | ||
|
|
c66724c190 | ||
|
|
a7f32e73b9 | ||
|
|
8f1e906884 | ||
|
|
80d7d291f3 | ||
|
|
2a087f0c9d | ||
|
|
b18a780a33 | ||
|
|
558b7be01a | ||
|
|
f6ed11b0bc | ||
|
|
3b2301b547 | ||
|
|
d911f00c93 | ||
|
|
40e709a29a | ||
|
|
35b740496d | ||
|
|
fae9e5bd2b | ||
|
|
d7786ede80 | ||
|
|
45268cdfe4 | ||
|
|
e8d9eaca51 | ||
|
|
fc672bcdff | ||
|
|
73c504abb7 | ||
|
|
56ea7d5ea9 | ||
|
|
e52cb7334a | ||
|
|
2fc99a3292 | ||
|
|
6755549dfd | ||
|
|
0d698a83b4 | ||
|
|
e93a789524 |
@@ -0,0 +1,35 @@
|
||||
# Virtual environments
|
||||
venv/
|
||||
.venv/
|
||||
pyPhotoAlbum/venv/
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
dist/
|
||||
eggs/
|
||||
*.egg-info/
|
||||
*.egg
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Git
|
||||
.git/
|
||||
|
||||
# Test/coverage
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
.tox/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -1,183 +0,0 @@
|
||||
name: Python CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master, develop ]
|
||||
paths-ignore:
|
||||
- 'coverage*.svg'
|
||||
- 'README.md'
|
||||
pull_request:
|
||||
branches: [ main, master, develop ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
# Install package in development mode with dev dependencies
|
||||
pip install -e ".[dev]"
|
||||
# Install additional test packages
|
||||
pip install coverage-badge interrogate
|
||||
|
||||
- name: Download initial failed badges
|
||||
run: |
|
||||
echo "Downloading initial failed badges..."
|
||||
|
||||
# Create cov_info directory first
|
||||
mkdir -p cov_info
|
||||
|
||||
# Download failed badges as defaults
|
||||
curl -o cov_info/coverage.svg "https://img.shields.io/badge/coverage-failed-red.svg"
|
||||
curl -o cov_info/coverage-docs.svg "https://img.shields.io/badge/docs-failed-red.svg"
|
||||
|
||||
echo "Initial failed badges created:"
|
||||
ls -la cov_info/coverage*.svg
|
||||
|
||||
- name: Run tests with pytest
|
||||
id: pytest
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Run tests with coverage
|
||||
# Check if xvfb-run is available, use it if present
|
||||
if command -v xvfb-run &> /dev/null; then
|
||||
echo "Using xvfb-run for headless Qt testing"
|
||||
xvfb-run -a python -m pytest tests/ -v --cov=pyPhotoAlbum --cov-report=term-missing --cov-report=json --cov-report=html --cov-report=xml
|
||||
else
|
||||
echo "xvfb-run not found, running with QT_QPA_PLATFORM=offscreen only"
|
||||
echo "For better Qt support, install: sudo apt-get install xvfb"
|
||||
python -m pytest tests/ -v --cov=pyPhotoAlbum --cov-report=term-missing --cov-report=json --cov-report=html --cov-report=xml
|
||||
fi
|
||||
env:
|
||||
QT_QPA_PLATFORM: offscreen
|
||||
|
||||
- name: Check documentation coverage
|
||||
id: docs
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Generate documentation coverage report
|
||||
interrogate -v --ignore-init-method --ignore-init-module --ignore-magic --ignore-private --ignore-property-decorators --ignore-semiprivate --fail-under=80 pyPhotoAlbum/
|
||||
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
# Stop the build if there are Python syntax errors or undefined names
|
||||
flake8 pyPhotoAlbum --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||
# Exit-zero treats all errors as warnings
|
||||
flake8 pyPhotoAlbum --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
||||
|
||||
- name: Create coverage info directory
|
||||
if: always()
|
||||
run: |
|
||||
mkdir -p cov_info
|
||||
echo "Created cov_info directory for coverage data"
|
||||
|
||||
- name: Update test coverage badge on success
|
||||
if: steps.pytest.outcome == 'success' && always()
|
||||
run: |
|
||||
echo "Tests passed! Generating successful coverage badge..."
|
||||
|
||||
if [ -f coverage.json ]; then
|
||||
coverage-badge -o cov_info/coverage.svg -f
|
||||
echo "✅ Test coverage badge updated with actual results"
|
||||
else
|
||||
echo "⚠️ No coverage.json found, keeping failed badge"
|
||||
fi
|
||||
|
||||
- name: Update docs coverage badge on success
|
||||
if: steps.docs.outcome == 'success' && always()
|
||||
run: |
|
||||
echo "Docs check passed! Generating successful docs badge..."
|
||||
|
||||
# Remove existing badge first to avoid overwrite error
|
||||
rm -f cov_info/coverage-docs.svg
|
||||
interrogate --generate-badge cov_info/coverage-docs.svg pyPhotoAlbum/
|
||||
echo "✅ Docs coverage badge updated with actual results"
|
||||
|
||||
- name: Generate coverage reports
|
||||
if: steps.pytest.outcome == 'success'
|
||||
run: |
|
||||
# Generate coverage summary for README
|
||||
python -c "
|
||||
import json
|
||||
import os
|
||||
# Read coverage data
|
||||
if os.path.exists('coverage.json'):
|
||||
with open('coverage.json', 'r') as f:
|
||||
coverage_data = json.load(f)
|
||||
total_coverage = round(coverage_data['totals']['percent_covered'], 1)
|
||||
# Create coverage summary file in cov_info directory
|
||||
with open('cov_info/coverage-summary.txt', 'w') as f:
|
||||
f.write(f'{total_coverage}%')
|
||||
print(f'Test Coverage: {total_coverage}%')
|
||||
covered_lines = coverage_data['totals']['covered_lines']
|
||||
total_lines = coverage_data['totals']['num_statements']
|
||||
print(f'Lines Covered: {covered_lines}/{total_lines}')
|
||||
else:
|
||||
print('No coverage data found')
|
||||
"
|
||||
|
||||
# Copy other coverage files to cov_info
|
||||
if [ -f coverage.json ]; then cp coverage.json cov_info/; fi
|
||||
if [ -f coverage.xml ]; then cp coverage.xml cov_info/; fi
|
||||
if [ -d htmlcov ]; then cp -r htmlcov cov_info/; fi
|
||||
|
||||
- name: Final badge status
|
||||
if: always()
|
||||
run: |
|
||||
echo "=== FINAL BADGE STATUS ==="
|
||||
echo "Test outcome: ${{ steps.pytest.outcome }}"
|
||||
echo "Docs outcome: ${{ steps.docs.outcome }}"
|
||||
|
||||
if [ -f cov_info/coverage.svg ]; then
|
||||
echo "✅ Test coverage badge: $(ls -lh cov_info/coverage.svg)"
|
||||
else
|
||||
echo "❌ Test coverage badge: MISSING"
|
||||
fi
|
||||
|
||||
if [ -f cov_info/coverage-docs.svg ]; then
|
||||
echo "✅ Docs coverage badge: $(ls -lh cov_info/coverage-docs.svg)"
|
||||
else
|
||||
echo "❌ Docs coverage badge: MISSING"
|
||||
fi
|
||||
|
||||
echo "Coverage info directory contents:"
|
||||
ls -la cov_info/ 2>/dev/null || echo "No cov_info directory found"
|
||||
|
||||
- name: Upload coverage artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: coverage-reports
|
||||
path: |
|
||||
cov_info/
|
||||
|
||||
- name: Commit badges to badges branch
|
||||
if: github.ref == 'refs/heads/master'
|
||||
run: |
|
||||
git config --local user.email "action@gitea.local"
|
||||
git config --local user.name "Gitea Action"
|
||||
|
||||
# Set the remote URL to use the token
|
||||
git remote set-url origin https://${{ secrets.PUSH_TOKEN }}@gitea.tourolle.paris/dtourolle/pyPhotoAlbum.git
|
||||
|
||||
# Create a new orphan branch for badges (this discards any existing badges branch)
|
||||
git checkout --orphan badges
|
||||
|
||||
# Remove all files except cov_info
|
||||
find . -maxdepth 1 -not -name '.git' -not -name 'cov_info' -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
# Add only the coverage info directory
|
||||
git add -f cov_info/
|
||||
|
||||
# Always commit (force overwrite)
|
||||
echo "Force updating badges branch with new coverage data..."
|
||||
git commit -m "Update coverage badges [skip ci]"
|
||||
git push -f origin badges
|
||||
@@ -4,34 +4,26 @@ on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
runs-on: linux/amd64
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/pyphotoalbum-ci:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install flake8 black mypy
|
||||
|
||||
|
||||
- name: Run flake8
|
||||
run: |
|
||||
# Stop the build if there are Python syntax errors or undefined names
|
||||
flake8 pyPhotoAlbum --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||
# Exit-zero treats all errors as warnings
|
||||
flake8 pyPhotoAlbum --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
||||
|
||||
|
||||
- name: Check formatting with black
|
||||
run: |
|
||||
black --check pyPhotoAlbum
|
||||
continue-on-error: true
|
||||
|
||||
|
||||
- name: Type check with mypy
|
||||
run: |
|
||||
mypy pyPhotoAlbum --ignore-missing-imports
|
||||
|
||||
+93
-22
@@ -1,36 +1,107 @@
|
||||
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:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.9', '3.10', '3.11']
|
||||
|
||||
runs-on: linux/amd64
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/pyphotoalbum-ci:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install Python dependencies
|
||||
|
||||
- name: Install project
|
||||
run: pip3 install -e . --no-deps --break-system-packages
|
||||
|
||||
- name: Download initial failed badges
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[dev]"
|
||||
|
||||
mkdir -p cov_info
|
||||
curl -o cov_info/coverage.svg "https://img.shields.io/badge/coverage-failed-red.svg"
|
||||
curl -o cov_info/coverage-docs.svg "https://img.shields.io/badge/docs-failed-red.svg"
|
||||
|
||||
- name: Run tests with coverage
|
||||
id: pytest
|
||||
continue-on-error: true
|
||||
run: |
|
||||
xvfb-run -a pytest --cov=pyPhotoAlbum --cov-report=xml --cov-report=term-missing
|
||||
pytest --cov=pyPhotoAlbum --cov-report=xml --cov-report=json --cov-report=html --cov-report=term-missing
|
||||
env:
|
||||
QT_QPA_PLATFORM: offscreen
|
||||
|
||||
- name: Upload coverage reports
|
||||
if: matrix.python-version == '3.11'
|
||||
uses: codecov/codecov-action@v3
|
||||
|
||||
- name: Check documentation coverage
|
||||
id: docs
|
||||
continue-on-error: true
|
||||
run: |
|
||||
interrogate -v --ignore-init-method --ignore-init-module --ignore-magic --ignore-private --ignore-property-decorators --ignore-semiprivate --fail-under=80 pyPhotoAlbum/
|
||||
|
||||
- name: Update test coverage badge on success
|
||||
if: steps.pytest.outcome == 'success' && always()
|
||||
run: |
|
||||
if [ -f coverage.json ]; then
|
||||
coverage-badge -o cov_info/coverage.svg -f
|
||||
echo "✅ Test coverage badge updated"
|
||||
fi
|
||||
|
||||
- name: Update docs coverage badge on success
|
||||
if: steps.docs.outcome == 'success' && always()
|
||||
run: |
|
||||
rm -f cov_info/coverage-docs.svg
|
||||
interrogate --generate-badge cov_info/coverage-docs.svg pyPhotoAlbum/
|
||||
echo "✅ Docs coverage badge updated"
|
||||
|
||||
- name: Generate coverage reports
|
||||
if: steps.pytest.outcome == 'success'
|
||||
run: |
|
||||
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:
|
||||
file: ./coverage.xml
|
||||
fail_ci_if_error: false
|
||||
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"
|
||||
|
||||
git remote set-url origin https://${{ secrets.PUSH_TOKEN }}@gitea.tourolle.paris/dtourolle/pyPhotoAlbum.git
|
||||
|
||||
git checkout --orphan badges
|
||||
|
||||
find . -maxdepth 1 -not -name '.git' -not -name 'cov_info' -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
git add -f cov_info/
|
||||
|
||||
git commit -m "Update coverage badges [skip ci]"
|
||||
git push -f origin badges
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
# Coverage Badges Integration
|
||||
|
||||
This document explains how to integrate the coverage badges generated by the CI workflow into your README.
|
||||
|
||||
## How It Works
|
||||
|
||||
The Python CI workflow automatically:
|
||||
1. Runs tests with coverage reporting
|
||||
2. Checks documentation coverage with interrogate
|
||||
3. Generates coverage badges
|
||||
4. Commits badges to a separate `badges` branch
|
||||
|
||||
## Using the Badges in README
|
||||
|
||||
Once the workflow has run successfully on the `master` branch, you can add the following badges to your README.md:
|
||||
|
||||
### Test Coverage Badge
|
||||
|
||||
```markdown
|
||||

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

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

|
||||

|
||||
|
||||
A Python application for designing photo albums and exporting them to PDF.
|
||||
```
|
||||
|
||||
## Workflow Details
|
||||
|
||||
- **Workflow File**: `.gitea/workflows/ci.yml`
|
||||
- **Trigger**: Pushes to `main`, `master`, or `develop` branches
|
||||
- **Runner**: Self-hosted
|
||||
- **Badge Branch**: `badges` (automatically created/updated)
|
||||
- **Badge Location**: `cov_info/` directory in badges branch
|
||||
|
||||
## Requirements
|
||||
|
||||
The workflow requires a `PUSH_TOKEN` secret to be configured in your Gitea repository settings. This token allows the workflow to push to the badges branch.
|
||||
|
||||
### Setting Up the PUSH_TOKEN
|
||||
|
||||
1. Go to your Gitea profile settings
|
||||
2. Navigate to Applications → Generate New Token
|
||||
3. Give it a descriptive name (e.g., "CI Badges Token")
|
||||
4. Select the `repository` scope
|
||||
5. Generate the token
|
||||
6. Go to your repository → Settings → Secrets
|
||||
7. Add a new secret named `PUSH_TOKEN` with the token value
|
||||
|
||||
## Coverage Reports
|
||||
|
||||
In addition to badges, the workflow also generates:
|
||||
- `coverage.json` - Machine-readable coverage data
|
||||
- `coverage.xml` - XML format coverage report
|
||||
- `htmlcov/` - HTML coverage report
|
||||
- `coverage-summary.txt` - Simple text summary of coverage percentage
|
||||
|
||||
All these files are available as artifacts after each workflow run and are stored in the `badges` branch under `cov_info/`.
|
||||
@@ -0,0 +1,67 @@
|
||||
# CI test image for pyPhotoAlbum
|
||||
# Build: docker build -f Dockerfile.ci -t gitea.tourolle.paris/dtourolle/pyphotoalbum-ci:latest .
|
||||
# Push: docker push gitea.tourolle.paris/dtourolle/pyphotoalbum-ci:latest
|
||||
FROM ubuntu:24.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
--no-install-recommends \
|
||||
# Python
|
||||
python3 \
|
||||
python3-pip \
|
||||
# PyQt6 / OpenGL runtime deps
|
||||
libgl1 \
|
||||
libglib2.0-0 \
|
||||
libegl1 \
|
||||
libfontconfig1 \
|
||||
libfreetype6 \
|
||||
libdbus-1-3 \
|
||||
# XCB / display (needed even with offscreen platform)
|
||||
libx11-6 \
|
||||
libx11-xcb1 \
|
||||
libxcb1 \
|
||||
libxcb-cursor0 \
|
||||
libxcb-glx0 \
|
||||
libxcb-icccm4 \
|
||||
libxcb-image0 \
|
||||
libxcb-keysyms1 \
|
||||
libxcb-randr0 \
|
||||
libxcb-render0 \
|
||||
libxcb-render-util0 \
|
||||
libxcb-shape0 \
|
||||
libxcb-shm0 \
|
||||
libxcb-sync1 \
|
||||
libxcb-xfixes0 \
|
||||
libxcb-xinerama0 \
|
||||
libxcb-xkb1 \
|
||||
libxkbcommon0 \
|
||||
libxkbcommon-x11-0 \
|
||||
# Misc tools used in workflows
|
||||
curl \
|
||||
git \
|
||||
nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install all Python dependencies so CI runs don't need to download anything
|
||||
RUN pip3 install --break-system-packages --no-cache-dir \
|
||||
# Runtime deps
|
||||
PyQt6 \
|
||||
PyOpenGL \
|
||||
numpy \
|
||||
Pillow \
|
||||
reportlab \
|
||||
lxml \
|
||||
pypdf \
|
||||
# Dev/test deps
|
||||
pytest \
|
||||
pytest-qt \
|
||||
pytest-cov \
|
||||
pytest-mock \
|
||||
pdfplumber \
|
||||
flake8 \
|
||||
black \
|
||||
mypy \
|
||||
coverage-badge \
|
||||
interrogate \
|
||||
setuptools
|
||||
@@ -0,0 +1,63 @@
|
||||
# Dockerfile for testing pyPhotoAlbum installation on Debian
|
||||
FROM debian:bookworm
|
||||
|
||||
# Avoid interactive prompts
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Install system dependencies (same as install-debian.sh)
|
||||
RUN apt-get update && apt-get install -y \
|
||||
sudo \
|
||||
curl \
|
||||
python3 \
|
||||
python3-venv \
|
||||
python3-pip \
|
||||
libgl1-mesa-dev \
|
||||
libglu1-mesa-dev \
|
||||
libxcb-xinerama0 \
|
||||
libxcb-cursor0 \
|
||||
libxkbcommon0 \
|
||||
libdbus-1-3 \
|
||||
libegl1 \
|
||||
libfontconfig1 \
|
||||
libfreetype6 \
|
||||
libx11-6 \
|
||||
libx11-xcb1 \
|
||||
libxcb1 \
|
||||
libxcb-glx0 \
|
||||
libxcb-icccm4 \
|
||||
libxcb-image0 \
|
||||
libxcb-keysyms1 \
|
||||
libxcb-randr0 \
|
||||
libxcb-render0 \
|
||||
libxcb-render-util0 \
|
||||
libxcb-shape0 \
|
||||
libxcb-shm0 \
|
||||
libxcb-sync1 \
|
||||
libxcb-xfixes0 \
|
||||
libxcb-xkb1 \
|
||||
libxkbcommon-x11-0 \
|
||||
libglib2.0-0 \
|
||||
libgtk-3-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create a non-root user for testing
|
||||
RUN useradd -m -s /bin/bash testuser && \
|
||||
echo "testuser ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
|
||||
|
||||
# Copy project files
|
||||
WORKDIR /home/testuser/pyPhotoAlbum
|
||||
COPY . .
|
||||
|
||||
# Fix ownership
|
||||
RUN chown -R testuser:testuser /home/testuser/pyPhotoAlbum
|
||||
|
||||
# Switch to test user
|
||||
USER testuser
|
||||
|
||||
# Create venv and install
|
||||
RUN python3 -m venv venv && \
|
||||
./venv/bin/pip install --upgrade pip && \
|
||||
./venv/bin/pip install -e .
|
||||
|
||||
# Default command - run the app
|
||||
CMD ["./launch-pyphotoalbum.sh"]
|
||||
@@ -0,0 +1,541 @@
|
||||
# Project Merge & Conflict Resolution Feature
|
||||
|
||||
## Overview
|
||||
|
||||
pyPhotoAlbum v3.0 introduces comprehensive merge conflict resolution support, enabling multiple users to edit the same album and merge their changes intelligently. The system uses UUIDs, timestamps, and a project ID to track changes and resolve conflicts.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Key Features](#key-features)
|
||||
- [How It Works](#how-it-works)
|
||||
- [File Format Changes (v3.0)](#file-format-changes-v30)
|
||||
- [User Guide](#user-guide)
|
||||
- [Developer Guide](#developer-guide)
|
||||
- [Testing](#testing)
|
||||
- [Migration from v2.0](#migration-from-v20)
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. **Project ID-Based Merge Detection**
|
||||
- Each project has a unique `project_id` (UUID)
|
||||
- **Same project_id** → Merge with conflict resolution
|
||||
- **Different project_id** → Concatenate (combine all pages)
|
||||
|
||||
### 2. **UUID-Based Element Tracking**
|
||||
- Every page and element has a stable UUID
|
||||
- Elements can be tracked even when page numbers or z-order changes
|
||||
- Enables reliable conflict detection across versions
|
||||
|
||||
### 3. **Timestamp-Based Conflict Resolution**
|
||||
- All changes tracked with `created` and `last_modified` timestamps (ISO 8601 UTC)
|
||||
- Automatic "Latest Wins" strategy available
|
||||
- Manual conflict resolution through visual dialog
|
||||
|
||||
### 4. **Soft Delete Support**
|
||||
- Deleted items marked with `deleted` flag and `deleted_at` timestamp
|
||||
- Prevents resurrection conflicts
|
||||
- Tombstone pattern ensures deleted items stay deleted
|
||||
|
||||
### 5. **Visual Merge Dialog**
|
||||
- Side-by-side comparison of conflicting changes
|
||||
- Page previews and element details
|
||||
- Multiple resolution strategies:
|
||||
- **Latest Wins**: Most recent change wins (automatic)
|
||||
- **Always Use Yours**: Keep all local changes
|
||||
- **Always Use Theirs**: Accept all remote changes
|
||||
- **Manual**: Choose per-conflict
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
### Merge Workflow
|
||||
|
||||
```
|
||||
1. User clicks "Merge Projects" in File ribbon tab
|
||||
↓
|
||||
2. Select .ppz file to merge
|
||||
↓
|
||||
3. System compares project_ids
|
||||
├─→ Same ID: Detect conflicts → Show merge dialog
|
||||
└─→ Different ID: Ask to concatenate
|
||||
↓
|
||||
4. User resolves conflicts (if any)
|
||||
↓
|
||||
5. Merged project becomes current project
|
||||
↓
|
||||
6. User saves merged project
|
||||
```
|
||||
|
||||
### Conflict Detection
|
||||
|
||||
The system detects three types of conflicts:
|
||||
|
||||
#### 1. **Page-Level Conflicts**
|
||||
- Page modified in both versions
|
||||
- Page deleted in one, modified in other
|
||||
- Page properties changed (size, type, etc.)
|
||||
|
||||
#### 2. **Element-Level Conflicts**
|
||||
- Element modified in both versions (position, size, rotation, content)
|
||||
- Element deleted in one, modified in other
|
||||
- Element properties changed differently
|
||||
|
||||
#### 3. **Project-Level Conflicts**
|
||||
- Settings changed in both (page size, DPI, cover settings, etc.)
|
||||
|
||||
### Automatic Conflict Resolution
|
||||
|
||||
**Non-conflicting changes** are automatically merged:
|
||||
- Page 1 modified in version A, Page 2 modified in version B → Keep both
|
||||
- New pages added at different positions → Merge both sets
|
||||
- Different elements modified → Keep all modifications
|
||||
|
||||
**Conflicting changes** require resolution:
|
||||
- Same element modified in both versions
|
||||
- Element/page deleted in one but modified in other
|
||||
|
||||
---
|
||||
|
||||
## File Format Changes (v3.0)
|
||||
|
||||
### What's New in v3.0
|
||||
|
||||
#### Project Level
|
||||
```json
|
||||
{
|
||||
"data_version": "3.0",
|
||||
"project_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"created": "2025-01-22T10:30:00.123456+00:00",
|
||||
"last_modified": "2025-01-22T14:45:12.789012+00:00",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
#### Page Level
|
||||
```json
|
||||
{
|
||||
"page_number": 1,
|
||||
"uuid": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
|
||||
"created": "2025-01-22T10:30:00.123456+00:00",
|
||||
"last_modified": "2025-01-22T11:15:30.456789+00:00",
|
||||
"deleted": false,
|
||||
"deleted_at": null,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
#### Element Level
|
||||
```json
|
||||
{
|
||||
"type": "image",
|
||||
"uuid": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
|
||||
"created": "2025-01-22T10:30:00.123456+00:00",
|
||||
"last_modified": "2025-01-22T13:20:45.123456+00:00",
|
||||
"deleted": false,
|
||||
"deleted_at": null,
|
||||
"position": [10, 10],
|
||||
"size": [100, 100],
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### Backwards Compatibility
|
||||
|
||||
- **v3.0 can read v2.0 and v1.0 files** with automatic migration
|
||||
- **v2.0/v1.0 cannot read v3.0 files** (breaking change)
|
||||
- Migration automatically generates UUIDs and timestamps for old files
|
||||
|
||||
---
|
||||
|
||||
## User Guide
|
||||
|
||||
### How to Merge Two Album Versions
|
||||
|
||||
1. **Open your current album** in pyPhotoAlbum
|
||||
|
||||
2. **Click "Merge Projects"** in the File tab of the ribbon
|
||||
|
||||
3. **Select the other album file** (.ppz) to merge
|
||||
|
||||
4. **System analyzes the projects:**
|
||||
- If they're the same album (same project_id):
|
||||
- Shows conflicts requiring resolution
|
||||
- Auto-merges non-conflicting changes
|
||||
- If they're different albums:
|
||||
- Asks if you want to combine all pages
|
||||
|
||||
5. **Resolve conflicts** (if merging same album):
|
||||
- View side-by-side comparison
|
||||
- Choose "Use Your Version" or "Use Other Version" for each conflict
|
||||
- Or click "Auto-Resolve All" with a strategy:
|
||||
- **Latest Wins**: Keeps most recently modified version
|
||||
- **Always Use Yours**: Keeps all your changes
|
||||
- **Always Use Theirs**: Accepts all their changes
|
||||
|
||||
6. **Click "Apply Merge"** to complete the merge
|
||||
|
||||
7. **Save the merged album** when ready
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Save before merging** - The system will prompt you, but it's good practice
|
||||
|
||||
2. **Use cloud sync carefully** - If using Dropbox/Google Drive:
|
||||
- Each person should have their own working copy
|
||||
- Merge explicitly rather than relying on cloud sync conflicts
|
||||
|
||||
3. **Communicate with collaborators** - Agree on who edits which pages to minimize conflicts
|
||||
|
||||
4. **Review the merge** - Check the merged result before saving
|
||||
|
||||
5. **Keep backups** - The autosave system creates checkpoints, but manual backups are recommended
|
||||
|
||||
### Common Scenarios
|
||||
|
||||
#### Scenario 1: You and a Friend Edit Different Pages
|
||||
- **Result**: Auto-merge ✅
|
||||
- No conflicts, both sets of changes preserved
|
||||
|
||||
#### Scenario 2: You Both Edit the Same Image Position
|
||||
- **Result**: Conflict resolution needed ⚠️
|
||||
- You choose which position to keep
|
||||
|
||||
#### Scenario 3: You Delete an Image, They Move It
|
||||
- **Result**: Conflict resolution needed ⚠️
|
||||
- You choose: keep it deleted or use their moved version
|
||||
|
||||
#### Scenario 4: Combining Two Different Albums
|
||||
- **Result**: Concatenation
|
||||
- All pages from both albums combined into one
|
||||
|
||||
---
|
||||
|
||||
## Developer Guide
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
pyPhotoAlbum/
|
||||
├── models.py # BaseLayoutElement with UUID/timestamp support
|
||||
├── project.py # Project and Page with UUID/timestamp support
|
||||
├── version_manager.py # v3.0 migration logic
|
||||
├── project_serializer.py # Save/load with v3.0 support
|
||||
├── merge_manager.py # Core merge conflict detection & resolution
|
||||
├── merge_dialog.py # Qt UI for visual conflict resolution
|
||||
└── mixins/operations/
|
||||
└── merge_ops.py # Ribbon integration & workflow
|
||||
```
|
||||
|
||||
### Key Classes
|
||||
|
||||
#### MergeManager
|
||||
```python
|
||||
from pyPhotoAlbum.merge_manager import MergeManager, MergeStrategy
|
||||
|
||||
manager = MergeManager()
|
||||
|
||||
# Check if projects should be merged or concatenated
|
||||
should_merge = manager.should_merge_projects(project_a_data, project_b_data)
|
||||
|
||||
# Detect conflicts
|
||||
conflicts = manager.detect_conflicts(our_data, their_data)
|
||||
|
||||
# Auto-resolve
|
||||
resolutions = manager.auto_resolve_conflicts(MergeStrategy.LATEST_WINS)
|
||||
|
||||
# Apply merge
|
||||
merged_data = manager.apply_resolutions(our_data, their_data, resolutions)
|
||||
```
|
||||
|
||||
#### Data Model Updates
|
||||
|
||||
```python
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
from pyPhotoAlbum.project import Page, Project
|
||||
|
||||
# All elements now have:
|
||||
element = ImageData(...)
|
||||
element.uuid # Auto-generated UUID
|
||||
element.created # ISO 8601 timestamp
|
||||
element.last_modified # ISO 8601 timestamp
|
||||
element.deleted # Boolean flag
|
||||
element.deleted_at # Timestamp when deleted
|
||||
|
||||
# Mark as modified
|
||||
element.mark_modified() # Updates last_modified
|
||||
|
||||
# Mark as deleted
|
||||
element.mark_deleted() # Sets deleted=True, deleted_at=now
|
||||
|
||||
# Same for pages and projects
|
||||
page.mark_modified()
|
||||
project.mark_modified()
|
||||
```
|
||||
|
||||
### Adding Merge Support to Custom Elements
|
||||
|
||||
If you create custom element types, ensure they:
|
||||
|
||||
1. **Inherit from BaseLayoutElement**
|
||||
```python
|
||||
class MyCustomElement(BaseLayoutElement):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs) # Initializes UUID and timestamps
|
||||
# Your custom fields here
|
||||
```
|
||||
|
||||
2. **Call `_deserialize_base_fields()` first in deserialize**
|
||||
```python
|
||||
def deserialize(self, data: Dict[str, Any]):
|
||||
self._deserialize_base_fields(data) # Load UUID/timestamps
|
||||
# Load your custom fields
|
||||
```
|
||||
|
||||
3. **Include base fields in serialize**
|
||||
```python
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
data = {
|
||||
"type": "mycustom",
|
||||
# Your custom fields
|
||||
}
|
||||
data.update(self._serialize_base_fields()) # Add UUID/timestamps
|
||||
return data
|
||||
```
|
||||
|
||||
4. **Call `mark_modified()` when changed**
|
||||
```python
|
||||
def set_my_property(self, value):
|
||||
self.my_property = value
|
||||
self.mark_modified() # Update timestamp
|
||||
```
|
||||
|
||||
### Migration System
|
||||
|
||||
To add a new migration (e.g., v3.0 to v4.0):
|
||||
|
||||
```python
|
||||
# In version_manager.py
|
||||
|
||||
@DataMigration.register_migration("3.0", "4.0")
|
||||
def migrate_3_0_to_4_0(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Migrate from version 3.0 to 4.0.
|
||||
|
||||
Main changes:
|
||||
- Add new fields
|
||||
- Update structures
|
||||
"""
|
||||
# Perform migration
|
||||
data['new_field'] = default_value
|
||||
|
||||
# Update version
|
||||
data['data_version'] = "4.0"
|
||||
|
||||
return data
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
Run the provided test scripts:
|
||||
|
||||
```bash
|
||||
# Test v2.0 → v3.0 migration
|
||||
python test_migration.py
|
||||
|
||||
# Test merge functionality
|
||||
python test_merge.py
|
||||
```
|
||||
|
||||
Expected output: All tests should pass with ✅
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Testing Checklist
|
||||
|
||||
#### Test 1: Basic Migration
|
||||
- [ ] Open a v2.0 project
|
||||
- [ ] Verify it loads without errors
|
||||
- [ ] Check console for "Migration 2.0 → 3.0" message
|
||||
- [ ] Save the project
|
||||
- [ ] Verify saved version is 3.0
|
||||
|
||||
#### Test 2: Same Project Merge
|
||||
- [ ] Create a project, save it
|
||||
- [ ] Open the file twice in different instances
|
||||
- [ ] Modify same element in both
|
||||
- [ ] Merge them
|
||||
- [ ] Verify conflict dialog appears
|
||||
- [ ] Resolve conflict
|
||||
- [ ] Verify merged result
|
||||
|
||||
#### Test 3: Different Project Concatenation
|
||||
- [ ] Create two different projects
|
||||
- [ ] Try to merge them
|
||||
- [ ] Verify concatenation option appears
|
||||
- [ ] Verify combined project has all pages
|
||||
|
||||
#### Test 4: Auto-Merge Non-Conflicting
|
||||
- [ ] Create project with 2 pages
|
||||
- [ ] Version A: Edit page 1
|
||||
- [ ] Version B: Edit page 2
|
||||
- [ ] Merge
|
||||
- [ ] Verify auto-merge without conflicts
|
||||
- [ ] Verify both edits preserved
|
||||
|
||||
### Automated Testing
|
||||
|
||||
Run the test scripts:
|
||||
|
||||
```bash
|
||||
cd /home/dtourolle/Development/pyPhotoAlbum
|
||||
|
||||
# Migration test
|
||||
./test_migration.py
|
||||
|
||||
# Merge test
|
||||
./test_merge.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration from v2.0
|
||||
|
||||
### Automatic Migration
|
||||
|
||||
When you open a v2.0 project in v3.0, it will automatically:
|
||||
|
||||
1. Generate a unique `project_id`
|
||||
2. Generate `uuid` for all pages and elements
|
||||
3. Set `created` and `last_modified` to current time
|
||||
4. Add `deleted` and `deleted_at` fields (all set to False/None)
|
||||
5. Update `data_version` to "3.0"
|
||||
|
||||
### Migration Output Example
|
||||
|
||||
```
|
||||
Migration 2.0 → 3.0: Adding UUIDs, timestamps, and project_id
|
||||
Generated project_id: 550e8400-e29b-41d4-a716-446655440000
|
||||
Migrated 5 pages to v3.0
|
||||
Migration completed successfully
|
||||
```
|
||||
|
||||
### After Migration
|
||||
|
||||
- **Save the project** to persist the migration
|
||||
- The migrated file can **only be opened in v3.0+**
|
||||
- Keep a backup of v2.0 file if you need v2.0 compatibility
|
||||
|
||||
### Rollback
|
||||
|
||||
If you need to rollback to v2.0:
|
||||
1. Don't save after opening in v3.0
|
||||
2. Close without saving
|
||||
3. Open original v2.0 file in v2.0
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Merge Dialog Won't Appear
|
||||
|
||||
**Problem**: Clicking "Merge Projects" does nothing
|
||||
|
||||
**Solutions**:
|
||||
- Check both projects are v3.0 (or were migrated)
|
||||
- Verify projects have the same `project_id`
|
||||
- Check console for error messages
|
||||
|
||||
### Can't Resolve Conflicts
|
||||
|
||||
**Problem**: "Apply Merge" button is grayed out
|
||||
|
||||
**Solutions**:
|
||||
- Make a resolution choice for each conflict
|
||||
- Or click "Auto-Resolve All" first
|
||||
|
||||
### Changes Not Preserved
|
||||
|
||||
**Problem**: After merge, some changes are missing
|
||||
|
||||
**Solutions**:
|
||||
- Check which resolution strategy you used
|
||||
- "Latest Wins" prefers most recent modifications
|
||||
- Review each conflict manually if needed
|
||||
|
||||
### Project Won't Load
|
||||
|
||||
**Problem**: "Incompatible file version" error
|
||||
|
||||
**Solutions**:
|
||||
- This is a v2.0 or v1.0 file
|
||||
- Migration should happen automatically
|
||||
- If not, check version_manager.py for errors
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
### Q: Can I merge more than two projects at once?
|
||||
**A:** Not directly. Merge two at a time, then merge the result with a third.
|
||||
|
||||
### Q: What happens to undo history after merge?
|
||||
**A:** Undo history is session-specific and not preserved during merge. Save before merging.
|
||||
|
||||
### Q: Can I see what changed before merging?
|
||||
**A:** The merge dialog shows changed elements with timestamps. Future versions may add detailed diff view.
|
||||
|
||||
### Q: Is merge atomic?
|
||||
**A:** No. If you cancel during conflict resolution, no changes are made. Once you click "Apply Merge", the changes are applied to the current project.
|
||||
|
||||
### Q: Can I merge projects from different versions?
|
||||
**A:** Yes! v2.0 and v1.0 projects are automatically migrated to v3.0 before merging.
|
||||
|
||||
### Q: What if two people add the same image?
|
||||
**A:** If the image has the same filename and is added to different pages, both instances are kept. If added to the same location on the same page, it becomes a conflict.
|
||||
|
||||
### Q: Can I programmatically merge projects?
|
||||
**A:** Yes! See the Developer Guide section for `MergeManager` API usage.
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements for future versions:
|
||||
|
||||
1. **Three-way merge** - Use base version for better conflict resolution
|
||||
2. **Merge history tracking** - Log all merges performed
|
||||
3. **Partial merge** - Merge only specific pages
|
||||
4. **Cloud collaboration** - Real-time collaborative editing
|
||||
5. **Merge preview** - Show full diff before applying
|
||||
6. **Asset conflict handling** - Better handling of duplicate assets
|
||||
7. **Conflict visualization** - Visual overlay showing changes
|
||||
|
||||
---
|
||||
|
||||
## Version History
|
||||
|
||||
### v3.0 (2025-01-22)
|
||||
- ✨ Initial merge conflict resolution feature
|
||||
- ✨ UUID and timestamp tracking
|
||||
- ✨ Project ID-based merge detection
|
||||
- ✨ Visual merge dialog
|
||||
- ✨ Automatic migration from v2.0
|
||||
- ✨ Soft delete support
|
||||
|
||||
---
|
||||
|
||||
## Credits
|
||||
|
||||
Merge system designed and implemented with the following principles:
|
||||
- **UUID stability** - Elements tracked across versions
|
||||
- **Timestamp precision** - ISO 8601 UTC for reliable ordering
|
||||
- **Backwards compatibility** - Seamless migration from v2.0
|
||||
- **User-friendly** - Visual conflict resolution
|
||||
- **Developer-friendly** - Clean API, well-documented
|
||||
|
||||
For questions or issues, please file a bug report in the project repository.
|
||||
@@ -1,6 +1,10 @@
|
||||
# pyPhotoAlbum
|
||||
|
||||
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
|
||||
|
||||
@@ -31,19 +35,8 @@ cd pyPhotoAlbum
|
||||
./install.sh
|
||||
```
|
||||
|
||||
**For GNOME users:** See [GNOME_QUICKSTART.md](GNOME_QUICKSTART.md) for desktop integration.
|
||||
|
||||
**For detailed instructions:** See [INSTALLATION.md](INSTALLATION.md)
|
||||
|
||||
### Requirements
|
||||
|
||||
- Python 3.9 or higher
|
||||
- PyQt6
|
||||
- PyOpenGL
|
||||
- Pillow
|
||||
- ReportLab
|
||||
- lxml
|
||||
|
||||
### Distribution Packages
|
||||
|
||||
**Fedora (RPM):**
|
||||
@@ -59,25 +52,12 @@ makepkg -si
|
||||
|
||||
See [INSTALLATION.md](INSTALLATION.md) for complete instructions.
|
||||
|
||||
### Install for Development
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://gitea.tourolle.paris/dtourolle/pyPhotoAlbum.git
|
||||
cd pyPhotoAlbum
|
||||
|
||||
# Create virtual environment
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# Install with development dependencies
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Running the Application
|
||||
|
||||
After installation, launch pyPhotoAlbum:
|
||||
|
||||
```bash
|
||||
pyphotoalbum
|
||||
```
|
||||
@@ -88,600 +68,44 @@ Or run directly from source:
|
||||
python pyPhotoAlbum/main.py
|
||||
```
|
||||
|
||||
### Basic Usage Example
|
||||
### Basic Workflow
|
||||
|
||||
```python
|
||||
from pyPhotoAlbum.project import Project, Page
|
||||
from pyPhotoAlbum.page_layout import PageLayout
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
1. **Create a New Project** - Choose your page size (A4, Letter, etc.) and DPI
|
||||
2. **Add Pages** - Start with blank pages or use templates
|
||||
3. **Add Images** - Drag and drop images from your file browser onto pages
|
||||
4. **Arrange & Edit** - Move, resize, rotate, and crop images to your liking
|
||||
5. **Save Your Work** - Projects are saved as .ppz files (ZIP archives)
|
||||
6. **Export to PDF** - Generate high-quality PDFs ready for printing
|
||||
|
||||
# Create a new project
|
||||
project = Project(name="My Photo Album")
|
||||
project.page_size_mm = (210, 297) # A4 size
|
||||
project.working_dpi = 300
|
||||
## Using Templates
|
||||
|
||||
# Create a page with an image
|
||||
layout = PageLayout(width=210, height=297)
|
||||
image = ImageData(
|
||||
image_path="photos/vacation.jpg",
|
||||
x=10.0,
|
||||
y=10.0,
|
||||
width=190.0,
|
||||
height=140.0
|
||||
)
|
||||
layout.add_element(image)
|
||||
pyPhotoAlbum includes a template system to help you quickly create consistent layouts:
|
||||
|
||||
# Add page to project
|
||||
page = Page(layout=layout, page_number=1)
|
||||
project.add_page(page)
|
||||
- **Built-in Templates**: Grid layouts, single large image, and more
|
||||
- **Custom Templates**: Save your favorite layouts as templates
|
||||
- **Flexible Application**: Apply templates to new or existing pages
|
||||
|
||||
# Save project
|
||||
from pyPhotoAlbum.project_serializer import save_to_zip
|
||||
success, error = save_to_zip(project, "my_album.ppz")
|
||||
```
|
||||
## Architecture Highlights
|
||||
|
||||
## Architecture
|
||||
pyPhotoAlbum is built with clean, maintainable design patterns:
|
||||
|
||||
### GLWidget Mixin Architecture
|
||||
### Mixin-Based Composition
|
||||
|
||||
The main OpenGL widget uses a **mixin-based architecture** for maintainability and testability. The monolithic 1,368-line `gl_widget.py` has been refactored into 9 focused mixins averaging 89 lines each:
|
||||
The main OpenGL widget is composed of **12 specialized mixins** instead of one monolithic class:
|
||||
- Each mixin handles a single responsibility (viewport, rendering, selection, etc.)
|
||||
- Average ~90 lines per mixin for maintainability
|
||||
- Easy to test in isolation with comprehensive unit tests
|
||||
- Clean separation of concerns throughout the codebase
|
||||
|
||||
```python
|
||||
class GLWidget(
|
||||
ViewportMixin, # Zoom & pan state
|
||||
RenderingMixin, # OpenGL rendering
|
||||
AssetDropMixin, # Drag-and-drop
|
||||
PageNavigationMixin, # Page detection
|
||||
ImagePanMixin, # Image cropping
|
||||
ElementManipulationMixin, # Resize & rotate
|
||||
ElementSelectionMixin, # Hit detection
|
||||
MouseInteractionMixin, # Event routing
|
||||
UndoableInteractionMixin, # Undo/redo
|
||||
QOpenGLWidget # Qt base class
|
||||
):
|
||||
"""Clean orchestration with minimal boilerplate"""
|
||||
```
|
||||
### Declarative UI with Decorators
|
||||
|
||||
**Benefits:**
|
||||
- Each mixin has a single, clear responsibility
|
||||
- 89 comprehensive unit tests with 69-97% coverage per mixin
|
||||
- Easy to test in isolation with mock dependencies
|
||||
- Clear separation of concerns
|
||||
- Maintainable codebase (average 89 lines per mixin)
|
||||
The ribbon interface is **auto-generated from decorator metadata**:
|
||||
- `@ribbon_action` - Automatically creates ribbon buttons from method metadata
|
||||
- `@undoable_operation` - Automatically captures state for undo/redo
|
||||
- `@dialog_action` - Separates dialog presentation from business logic
|
||||
- No manual UI wiring required - just add decorators to your methods
|
||||
|
||||
See [REFACTORING_COMPLETE.md](REFACTORING_COMPLETE.md) for details on the refactoring process.
|
||||
|
||||
### Core Components
|
||||
|
||||
#### Models (`models.py`)
|
||||
|
||||
Base classes for layout elements:
|
||||
|
||||
```python
|
||||
# Image element with crop support
|
||||
image = ImageData(
|
||||
image_path="photo.jpg",
|
||||
x=10, y=20,
|
||||
width=200, height=150,
|
||||
rotation=0,
|
||||
z_index=0,
|
||||
crop_info=(0, 0, 1, 1) # (x_min, y_min, x_max, y_max)
|
||||
)
|
||||
|
||||
# Text box element
|
||||
textbox = TextBoxData(
|
||||
text_content="My Caption",
|
||||
font_settings={"family": "Arial", "size": 14, "color": (0, 0, 0)},
|
||||
alignment="center",
|
||||
x=10, y=180,
|
||||
width=200, height=30
|
||||
)
|
||||
|
||||
# Placeholder for templates
|
||||
placeholder = PlaceholderData(
|
||||
placeholder_type="image",
|
||||
x=10, y=10,
|
||||
width=100, height=100
|
||||
)
|
||||
```
|
||||
|
||||
#### Project Structure (`project.py`)
|
||||
|
||||
```python
|
||||
# Project contains multiple pages
|
||||
project = Project(name="Album", folder_path="/path/to/project")
|
||||
|
||||
# Each page has a layout with elements
|
||||
page = Page(layout=PageLayout(), page_number=1)
|
||||
page.layout.add_element(image)
|
||||
|
||||
project.add_page(page)
|
||||
```
|
||||
|
||||
#### Asset Management (`asset_manager.py`)
|
||||
|
||||
Automatic asset handling with reference counting:
|
||||
|
||||
```python
|
||||
# Import an image into the project
|
||||
asset_path = project.asset_manager.import_asset("photo.jpg")
|
||||
# Returns: "assets/photo_001.jpg" (relative path)
|
||||
|
||||
# Assets are automatically copied to project folder
|
||||
# Reference counting tracks usage across pages
|
||||
# Cleanup happens automatically when elements are deleted
|
||||
```
|
||||
|
||||
#### Command System (`commands.py`)
|
||||
|
||||
Undo/redo support for all operations:
|
||||
|
||||
```python
|
||||
# Commands are automatically created for operations
|
||||
from pyPhotoAlbum.commands import AddElementCommand, MoveElementCommand
|
||||
|
||||
# Add element (undoable)
|
||||
cmd = AddElementCommand(page.layout, image, project.asset_manager)
|
||||
project.history.execute(cmd)
|
||||
|
||||
# Move element (undoable)
|
||||
cmd = MoveElementCommand(image, old_pos=(10, 10), new_pos=(20, 20))
|
||||
project.history.execute(cmd)
|
||||
|
||||
# Undo/redo
|
||||
project.history.undo()
|
||||
project.history.redo()
|
||||
```
|
||||
|
||||
### Layout System
|
||||
|
||||
#### PageLayout (`page_layout.py`)
|
||||
|
||||
Manages elements on a page:
|
||||
|
||||
```python
|
||||
layout = PageLayout(width=210, height=297) # A4 in mm
|
||||
|
||||
# Add multiple elements
|
||||
layout.add_element(image1)
|
||||
layout.add_element(image2)
|
||||
layout.add_element(textbox)
|
||||
|
||||
# Elements are rendered in z_index order
|
||||
# Serialize/deserialize for saving
|
||||
data = layout.serialize()
|
||||
layout2 = PageLayout()
|
||||
layout2.deserialize(data)
|
||||
```
|
||||
|
||||
#### Alignment Tools (`alignment.py`)
|
||||
|
||||
Precise element positioning:
|
||||
|
||||
```python
|
||||
from pyPhotoAlbum.alignment import AlignmentManager
|
||||
|
||||
# Align multiple elements to the left
|
||||
changes = AlignmentManager.align_left(selected_elements)
|
||||
for element, new_position in changes:
|
||||
element.position = new_position
|
||||
|
||||
# Distribute elements evenly
|
||||
changes = AlignmentManager.distribute_horizontally(selected_elements)
|
||||
|
||||
# Make elements the same size
|
||||
changes = AlignmentManager.make_same_size(selected_elements)
|
||||
for element, new_position, new_size in changes:
|
||||
element.position = new_position
|
||||
element.size = new_size
|
||||
```
|
||||
|
||||
### Template System
|
||||
|
||||
#### Creating Templates
|
||||
|
||||
```python
|
||||
from pyPhotoAlbum.template_manager import TemplateManager, Template
|
||||
|
||||
manager = TemplateManager()
|
||||
|
||||
# Create template from existing page
|
||||
template = manager.create_template_from_page(
|
||||
page=current_page,
|
||||
name="My Grid Layout",
|
||||
description="2x2 photo grid"
|
||||
)
|
||||
|
||||
# Save template
|
||||
manager.save_template(template)
|
||||
```
|
||||
|
||||
#### Using Templates
|
||||
|
||||
```python
|
||||
# List available templates
|
||||
templates = manager.list_templates()
|
||||
# Returns: ["Grid_2x2", "Single_Large", "My Grid Layout", ...]
|
||||
|
||||
# Create new page from template
|
||||
new_page = manager.create_page_from_template(
|
||||
template_name="Grid_2x2",
|
||||
target_page_size=(210, 297),
|
||||
page_number=5
|
||||
)
|
||||
|
||||
# Apply template to existing page
|
||||
manager.apply_template_to_page(
|
||||
template=template,
|
||||
target_page=existing_page,
|
||||
mode="replace", # or "reflow"
|
||||
scaling="proportional" # or "stretch" or "center"
|
||||
)
|
||||
```
|
||||
|
||||
### Project Serialization
|
||||
|
||||
#### Save/Load Projects
|
||||
|
||||
Projects are saved as ZIP archives (.ppz) containing:
|
||||
- `project.json` - Project metadata and structure
|
||||
- `assets/` - All referenced images
|
||||
|
||||
```python
|
||||
from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip
|
||||
|
||||
# Save project
|
||||
success, error = save_to_zip(project, "album.ppz")
|
||||
if not success:
|
||||
print(f"Error saving: {error}")
|
||||
|
||||
# Load project
|
||||
loaded_project, error = load_from_zip("album.ppz")
|
||||
if loaded_project:
|
||||
print(f"Loaded: {loaded_project.name}")
|
||||
else:
|
||||
print(f"Error loading: {error}")
|
||||
|
||||
# Get project info without loading
|
||||
from pyPhotoAlbum.project_serializer import get_project_info
|
||||
info = get_project_info("album.ppz")
|
||||
print(f"Name: {info['name']}, Pages: {info['page_count']}")
|
||||
```
|
||||
|
||||
#### Project Structure
|
||||
|
||||
```python
|
||||
{
|
||||
"name": "My Album",
|
||||
"serialization_version": "1.0",
|
||||
"page_size_mm": [210, 297],
|
||||
"working_dpi": 300,
|
||||
"export_dpi": 300,
|
||||
"pages": [
|
||||
{
|
||||
"page_number": 1,
|
||||
"is_double_spread": false,
|
||||
"layout": {
|
||||
"width": 210,
|
||||
"height": 297,
|
||||
"elements": [...]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### PDF Export
|
||||
|
||||
```python
|
||||
from pyPhotoAlbum.pdf_exporter import PDFExporter
|
||||
|
||||
# Create exporter
|
||||
exporter = PDFExporter(project, export_dpi=300)
|
||||
|
||||
# Export with progress callback
|
||||
def progress_callback(current, total):
|
||||
print(f"Exporting page {current}/{total}")
|
||||
|
||||
success, errors = exporter.export(
|
||||
output_path="album.pdf",
|
||||
progress_callback=progress_callback
|
||||
)
|
||||
|
||||
if success:
|
||||
print("PDF exported successfully")
|
||||
else:
|
||||
print(f"Errors: {errors}")
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Run Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Run with coverage
|
||||
pytest --cov=pyPhotoAlbum --cov-report=html
|
||||
|
||||
# Run specific test file
|
||||
pytest tests/test_models.py
|
||||
|
||||
# Run with verbose output
|
||||
pytest -v
|
||||
```
|
||||
|
||||
### Test Structure
|
||||
|
||||
```
|
||||
tests/
|
||||
├── __init__.py
|
||||
├── conftest.py # Shared fixtures
|
||||
├── test_models.py # Model serialization tests
|
||||
├── test_project.py # Project and page tests
|
||||
├── test_project_serialization.py # Save/load tests
|
||||
├── test_page_renderer.py # Rendering tests
|
||||
├── test_pdf_export.py # PDF export tests
|
||||
├── test_gl_widget_fixtures.py # Shared GL widget test fixtures
|
||||
├── test_viewport_mixin.py # Viewport mixin tests
|
||||
├── test_element_selection_mixin.py # Selection mixin tests
|
||||
├── test_element_manipulation_mixin.py # Manipulation mixin tests
|
||||
├── test_image_pan_mixin.py # Image pan mixin tests
|
||||
├── test_page_navigation_mixin.py # Page navigation mixin tests
|
||||
└── test_asset_drop_mixin.py # Asset drop mixin tests
|
||||
```
|
||||
|
||||
### Example Test Cases
|
||||
|
||||
From `tests/test_models.py`:
|
||||
|
||||
```python
|
||||
def test_image_serialization():
|
||||
"""Test ImageData serialization"""
|
||||
img = ImageData(
|
||||
image_path="test.jpg",
|
||||
x=15.0, y=25.0,
|
||||
width=180.0, height=120.0,
|
||||
rotation=30.0,
|
||||
z_index=3
|
||||
)
|
||||
|
||||
# Serialize
|
||||
data = img.serialize()
|
||||
assert data["type"] == "image"
|
||||
assert data["position"] == (15.0, 25.0)
|
||||
|
||||
# Deserialize
|
||||
img2 = ImageData()
|
||||
img2.deserialize(data)
|
||||
assert img2.position == img.position
|
||||
```
|
||||
|
||||
From `tests/test_project_serialization.py`:
|
||||
|
||||
```python
|
||||
def test_save_and_load_project(tmp_path):
|
||||
"""Test complete save/load cycle"""
|
||||
# Create project with pages
|
||||
project = Project(name="Test")
|
||||
page = Page(layout=PageLayout(), page_number=1)
|
||||
project.add_page(page)
|
||||
|
||||
# Save
|
||||
zip_path = tmp_path / "project.ppz"
|
||||
success, error = save_to_zip(project, str(zip_path))
|
||||
assert success is True
|
||||
|
||||
# Load
|
||||
loaded, error = load_from_zip(str(zip_path))
|
||||
assert loaded.name == "Test"
|
||||
assert len(loaded.pages) == 1
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
pyPhotoAlbum/
|
||||
├── __init__.py
|
||||
├── main.py # Application entry point
|
||||
├── models.py # Data models (ImageData, TextBoxData, etc.)
|
||||
├── project.py # Project and Page classes
|
||||
├── page_layout.py # Page layout management
|
||||
├── page_renderer.py # OpenGL rendering
|
||||
├── gl_widget.py # Main OpenGL widget (mixin orchestration)
|
||||
├── project_serializer.py # Save/load functionality
|
||||
├── asset_manager.py # Asset handling
|
||||
├── commands.py # Undo/redo system
|
||||
├── template_manager.py # Template system
|
||||
├── pdf_exporter.py # PDF export
|
||||
├── alignment.py # Alignment tools
|
||||
├── snapping.py # Snapping system
|
||||
├── decorators.py # UI decorators
|
||||
├── ribbon_widget.py # Ribbon interface
|
||||
├── ribbon_builder.py # Ribbon configuration
|
||||
├── mixins/ # Mixin architecture
|
||||
│ ├── __init__.py
|
||||
│ ├── base.py # Base mixin class
|
||||
│ ├── viewport.py # Zoom and pan management
|
||||
│ ├── rendering.py # OpenGL rendering pipeline
|
||||
│ ├── asset_drop.py # Drag-and-drop functionality
|
||||
│ ├── page_navigation.py # Page detection and ghost pages
|
||||
│ ├── image_pan.py # Image cropping within frames
|
||||
│ ├── element_manipulation.py # Resize and rotate
|
||||
│ ├── element_selection.py # Hit detection and selection
|
||||
│ ├── mouse_interaction.py # Mouse event coordination
|
||||
│ ├── interaction_undo.py # Undo/redo integration
|
||||
│ └── operations/ # Operation mixins
|
||||
│ ├── element_ops.py
|
||||
│ ├── page_ops.py
|
||||
│ ├── file_ops.py
|
||||
│ ├── view_ops.py
|
||||
│ ├── edit_ops.py
|
||||
│ ├── template_ops.py
|
||||
│ ├── alignment_ops.py
|
||||
│ ├── distribution_ops.py
|
||||
│ └── size_ops.py
|
||||
└── templates/ # Built-in templates
|
||||
├── Grid_2x2.json
|
||||
└── Single_Large.json
|
||||
|
||||
tests/ # Unit tests (312 tests, 29% coverage)
|
||||
examples/ # Usage examples
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Code Style
|
||||
|
||||
The project uses:
|
||||
- **Black** for code formatting (line length: 120)
|
||||
- **Flake8** for linting
|
||||
- **MyPy** for type checking
|
||||
|
||||
```bash
|
||||
# Format code
|
||||
black pyPhotoAlbum tests
|
||||
|
||||
# Run linter
|
||||
flake8 pyPhotoAlbum tests
|
||||
|
||||
# Type checking
|
||||
mypy pyPhotoAlbum
|
||||
```
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
GitHub Actions / Gitea Actions workflows:
|
||||
- Run tests on Python 3.9, 3.10, 3.11
|
||||
- Check code quality with linters
|
||||
- Generate coverage reports
|
||||
|
||||
### Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Write tests for new features
|
||||
4. Ensure all tests pass
|
||||
5. Submit a pull request
|
||||
|
||||
## Usage Examples
|
||||
|
||||
See the `examples/` directory for complete working examples:
|
||||
|
||||
- `basic_usage.py` - Creating projects and adding images
|
||||
- `template_example.py` - Working with templates
|
||||
- `generate_screenshots.py` - Creating documentation screenshots
|
||||
|
||||
Run examples:
|
||||
|
||||
```bash
|
||||
cd examples
|
||||
python basic_usage.py
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Key Classes
|
||||
|
||||
#### BaseLayoutElement (Abstract)
|
||||
Base class for all layout elements.
|
||||
|
||||
**Methods:**
|
||||
- `render()` - Render element using OpenGL
|
||||
- `serialize() -> Dict` - Convert to dictionary
|
||||
- `deserialize(data: Dict)` - Load from dictionary
|
||||
|
||||
**Attributes:**
|
||||
- `position: Tuple[float, float]` - (x, y) in mm
|
||||
- `size: Tuple[float, float]` - (width, height) in mm
|
||||
- `rotation: float` - Rotation angle in degrees
|
||||
- `z_index: int` - Layer order
|
||||
|
||||
#### ImageData
|
||||
Image element with crop support.
|
||||
|
||||
**Constructor:**
|
||||
```python
|
||||
ImageData(
|
||||
image_path: str = "",
|
||||
crop_info: Tuple = (0, 0, 1, 1),
|
||||
x: float = 0,
|
||||
y: float = 0,
|
||||
width: float = 100,
|
||||
height: float = 100,
|
||||
rotation: float = 0,
|
||||
z_index: int = 0
|
||||
)
|
||||
```
|
||||
|
||||
#### TextBoxData
|
||||
Text element with formatting.
|
||||
|
||||
**Constructor:**
|
||||
```python
|
||||
TextBoxData(
|
||||
text_content: str = "",
|
||||
font_settings: Dict = None,
|
||||
alignment: str = "left",
|
||||
x: float = 0,
|
||||
y: float = 0,
|
||||
width: float = 100,
|
||||
height: float = 100
|
||||
)
|
||||
```
|
||||
|
||||
**Font Settings:**
|
||||
```python
|
||||
{
|
||||
"family": "Arial",
|
||||
"size": 12,
|
||||
"color": (0, 0, 0) # RGB tuple
|
||||
}
|
||||
```
|
||||
|
||||
#### Project
|
||||
Main project container.
|
||||
|
||||
**Methods:**
|
||||
- `add_page(page: Page)` - Add page to project
|
||||
- `remove_page(page: Page)` - Remove page
|
||||
- `serialize() -> Dict` - Save to dictionary
|
||||
- `deserialize(data: Dict)` - Load from dictionary
|
||||
|
||||
**Attributes:**
|
||||
- `name: str` - Project name
|
||||
- `pages: List[Page]` - List of pages
|
||||
- `page_size_mm: Tuple[float, float]` - Page dimensions
|
||||
- `working_dpi: int` - Display DPI
|
||||
- `export_dpi: int` - Export DPI
|
||||
- `asset_manager: AssetManager` - Asset handler
|
||||
- `history: CommandHistory` - Undo/redo history
|
||||
|
||||
#### Page
|
||||
Single page in project.
|
||||
|
||||
**Constructor:**
|
||||
```python
|
||||
Page(
|
||||
layout: PageLayout = None,
|
||||
page_number: int = 1,
|
||||
is_double_spread: bool = False
|
||||
)
|
||||
```
|
||||
|
||||
#### PageLayout
|
||||
Element container for a page.
|
||||
|
||||
**Methods:**
|
||||
- `add_element(element: BaseLayoutElement)` - Add element
|
||||
- `remove_element(element: BaseLayoutElement)` - Remove element
|
||||
- `render(dpi: int)` - Render all elements
|
||||
|
||||
**Attributes:**
|
||||
- `elements: List[BaseLayoutElement]` - Page elements
|
||||
- `width: float` - Page width in mm
|
||||
- `height: float` - Page height in mm
|
||||
This approach keeps UI concerns separate from business logic and makes the codebase easier to maintain and extend.
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
@@ -700,12 +124,6 @@ Element container for a page.
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
|
||||
## Links
|
||||
|
||||
- Documentation: [Link to docs]
|
||||
- Issue Tracker: [Link to issues]
|
||||
- Changelog: [Link to changelog]
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Built with:
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
# GLWidget Refactoring - COMPLETE! ✅
|
||||
|
||||
## Summary
|
||||
|
||||
Successfully refactored [gl_widget.py](pyPhotoAlbum/gl_widget.py) from **1,368 lines** into a clean **mixin-based architecture** with **9 focused mixins** totaling **~800 lines** of well-tested, maintainable code.
|
||||
|
||||
## Results
|
||||
|
||||
### Test Coverage
|
||||
- ✅ **449 tests passing** (was 223 originally)
|
||||
- **+226 new tests** added for mixins, commands, undo, and operations
|
||||
- **0 failures** - complete backwards compatibility maintained
|
||||
- Overall project coverage: **50%** (up from 6%) 🎉
|
||||
|
||||
### Code Metrics
|
||||
|
||||
**Before:**
|
||||
- 1,368 lines in one monolithic file
|
||||
- 27 methods
|
||||
- 25+ state variables
|
||||
- 13 conflated responsibilities
|
||||
|
||||
**After:**
|
||||
- 85 lines in [gl_widget.py](pyPhotoAlbum/gl_widget.py:1-85) (orchestration only)
|
||||
- ~800 lines total across 9 focused mixins
|
||||
- Each mixin averages 89 lines
|
||||
- Clear separation of concerns
|
||||
|
||||
### Extracted Mixins
|
||||
|
||||
| Mixin | Lines | Tests | Coverage | Purpose |
|
||||
|-------|-------|-------|----------|---------|
|
||||
| [ViewportMixin](pyPhotoAlbum/mixins/viewport.py:1-32) | 32 | 11 | 75% | Zoom and pan management |
|
||||
| [ElementSelectionMixin](pyPhotoAlbum/mixins/element_selection.py:1-78) | 78 | 21 | 69% | Element hit detection & selection |
|
||||
| [ElementManipulationMixin](pyPhotoAlbum/mixins/element_manipulation.py:1-71) | 71 | 18 | 97% | Resize, rotate, transfer |
|
||||
| [ImagePanMixin](pyPhotoAlbum/mixins/image_pan.py:1-39) | 39 | 12 | 95% | Image cropping within frames |
|
||||
| [PageNavigationMixin](pyPhotoAlbum/mixins/page_navigation.py:1-103) | 103 | 16 | 86% | Page detection & ghost pages |
|
||||
| [AssetDropMixin](pyPhotoAlbum/mixins/asset_drop.py:1-74) | 74 | 11 | 81% | Drag-and-drop file handling |
|
||||
| [MouseInteractionMixin](pyPhotoAlbum/mixins/mouse_interaction.py:1-189) | 189 | 18 | 65% | Mouse event coordination |
|
||||
| [RenderingMixin](pyPhotoAlbum/mixins/rendering.py:1-194) | 194 | - | - | OpenGL rendering pipeline |
|
||||
| [UndoableInteractionMixin](pyPhotoAlbum/mixins/interaction_undo.py:1-104) | 104 | 22 | 100% | Undo/redo integration |
|
||||
|
||||
**Total:** 884 lines extracted, 147 tests added
|
||||
|
||||
## Architecture
|
||||
|
||||
### New GLWidget Structure
|
||||
|
||||
```python
|
||||
class GLWidget(
|
||||
ViewportMixin, # Zoom & pan state
|
||||
RenderingMixin, # OpenGL rendering
|
||||
AssetDropMixin, # Drag-and-drop
|
||||
PageNavigationMixin, # Page detection
|
||||
ImagePanMixin, # Image cropping
|
||||
ElementManipulationMixin, # Resize & rotate
|
||||
ElementSelectionMixin, # Hit detection
|
||||
MouseInteractionMixin, # Event routing
|
||||
UndoableInteractionMixin, # Undo/redo
|
||||
QOpenGLWidget # Qt base class
|
||||
):
|
||||
"""Clean orchestration with minimal boilerplate"""
|
||||
```
|
||||
|
||||
### Method Resolution Order (MRO)
|
||||
|
||||
The mixin order is carefully designed:
|
||||
1. **ViewportMixin** - Provides fundamental state (zoom, pan)
|
||||
2. **RenderingMixin** - Uses viewport for rendering
|
||||
3. **AssetDropMixin** - Depends on page navigation
|
||||
4. **PageNavigationMixin** - Provides page detection
|
||||
5. **ImagePanMixin** - Needs viewport and selection
|
||||
6. **ElementManipulationMixin** - Needs selection
|
||||
7. **ElementSelectionMixin** - Core element operations
|
||||
8. **MouseInteractionMixin** - Coordinates all above
|
||||
9. **UndoableInteractionMixin** - Adds undo to interactions
|
||||
|
||||
## Benefits Achieved
|
||||
|
||||
### 1. **Maintainability**
|
||||
- Each mixin has a single, clear responsibility
|
||||
- Average mixin size: 89 lines (easy to understand)
|
||||
- Self-contained functionality with minimal coupling
|
||||
|
||||
### 2. **Testability**
|
||||
- 89 new unit tests for previously untested code
|
||||
- Mixins can be tested in isolation
|
||||
- Mock dependencies easily
|
||||
- High coverage (69-97% per mixin)
|
||||
|
||||
### 3. **Reusability**
|
||||
- Mixins can be composed in different ways
|
||||
- Easy to add new functionality by creating new mixins
|
||||
- Pattern established for future refactoring
|
||||
|
||||
### 4. **Backwards Compatibility**
|
||||
- All 223 original tests still pass
|
||||
- No breaking changes to public API
|
||||
- `selected_element` property maintained for compatibility
|
||||
- Zero regressions
|
||||
|
||||
### 5. **Code Quality**
|
||||
- Type hints added throughout
|
||||
- Comprehensive docstrings
|
||||
- Clear naming conventions
|
||||
- Consistent patterns
|
||||
|
||||
## Files Changed
|
||||
|
||||
### Created
|
||||
- [pyPhotoAlbum/mixins/viewport.py](pyPhotoAlbum/mixins/viewport.py)
|
||||
- [pyPhotoAlbum/mixins/element_selection.py](pyPhotoAlbum/mixins/element_selection.py)
|
||||
- [pyPhotoAlbum/mixins/element_manipulation.py](pyPhotoAlbum/mixins/element_manipulation.py)
|
||||
- [pyPhotoAlbum/mixins/image_pan.py](pyPhotoAlbum/mixins/image_pan.py)
|
||||
- [pyPhotoAlbum/mixins/page_navigation.py](pyPhotoAlbum/mixins/page_navigation.py)
|
||||
- [pyPhotoAlbum/mixins/asset_drop.py](pyPhotoAlbum/mixins/asset_drop.py)
|
||||
- [pyPhotoAlbum/mixins/mouse_interaction.py](pyPhotoAlbum/mixins/mouse_interaction.py)
|
||||
- [pyPhotoAlbum/mixins/rendering.py](pyPhotoAlbum/mixins/rendering.py)
|
||||
- [tests/test_viewport_mixin.py](tests/test_viewport_mixin.py)
|
||||
- [tests/test_element_selection_mixin.py](tests/test_element_selection_mixin.py)
|
||||
- [tests/test_element_manipulation_mixin.py](tests/test_element_manipulation_mixin.py)
|
||||
- [tests/test_image_pan_mixin.py](tests/test_image_pan_mixin.py)
|
||||
- [tests/test_page_navigation_mixin.py](tests/test_page_navigation_mixin.py)
|
||||
- [tests/test_asset_drop_mixin.py](tests/test_asset_drop_mixin.py)
|
||||
- [tests/test_mouse_interaction_mixin.py](tests/test_mouse_interaction_mixin.py)
|
||||
- [tests/test_gl_widget_integration.py](tests/test_gl_widget_integration.py)
|
||||
- [tests/test_gl_widget_fixtures.py](tests/test_gl_widget_fixtures.py)
|
||||
- [tests/test_commands.py](tests/test_commands.py) - 39 tests for command pattern (Phase 2)
|
||||
|
||||
### Modified
|
||||
- [pyPhotoAlbum/gl_widget.py](pyPhotoAlbum/gl_widget.py) - Reduced from 1,368 → 85 lines
|
||||
- [pyPhotoAlbum/commands.py](pyPhotoAlbum/commands.py) - Coverage improved from 26% → 59%
|
||||
|
||||
### Archived
|
||||
- [pyPhotoAlbum/gl_widget_old.py](pyPhotoAlbum/gl_widget_old.py) - Original backup
|
||||
|
||||
## Bug Fixes During Refactoring
|
||||
|
||||
1. **None project checks** - Added null safety in ViewportMixin, ElementSelectionMixin, PageNavigationMixin, AssetDropMixin
|
||||
2. **Floating point precision** - Fixed tolerance issues in image pan tests
|
||||
3. **Mock decorator paths** - Corrected @patch paths in page navigation tests
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Each mixin follows this proven pattern:
|
||||
|
||||
1. **Initialization tests** - Verify default state
|
||||
2. **Functionality tests** - Test core methods
|
||||
3. **Edge case tests** - Null checks, boundary conditions
|
||||
4. **Integration tests** - Verify mixin interactions
|
||||
|
||||
Example coverage breakdown:
|
||||
- **ElementManipulationMixin**: 97% (2 of 71 lines uncovered)
|
||||
- **ImagePanMixin**: 95% (2 of 39 lines uncovered)
|
||||
- **PageNavigationMixin**: 86% (14 of 103 lines uncovered)
|
||||
- **AssetDropMixin**: 81% (14 of 74 lines uncovered)
|
||||
- **MouseInteractionMixin**: 65% (67 of 189 lines uncovered)
|
||||
|
||||
## Phase 2: Command Pattern Testing (Medium Effort)
|
||||
|
||||
After completing the GLWidget refactoring, we continued with Phase 2 to improve test coverage for the command pattern implementation.
|
||||
|
||||
### Command Tests Added
|
||||
- **39 comprehensive tests** for [commands.py](pyPhotoAlbum/commands.py)
|
||||
- Coverage improved from **26% → 59%** (+33 percentage points)
|
||||
- Tests cover all command types:
|
||||
- `_normalize_asset_path` helper (4 tests)
|
||||
- `AddElementCommand` (5 tests)
|
||||
- `DeleteElementCommand` (3 tests)
|
||||
- `MoveElementCommand` (3 tests)
|
||||
- `ResizeElementCommand` (3 tests)
|
||||
- `RotateElementCommand` (3 tests)
|
||||
- `AdjustImageCropCommand` (2 tests)
|
||||
- `AlignElementsCommand` (2 tests)
|
||||
- `ResizeElementsCommand` (2 tests)
|
||||
- `ChangeZOrderCommand` (2 tests)
|
||||
- `StateChangeCommand` (3 tests)
|
||||
- `CommandHistory` (7 tests)
|
||||
|
||||
### Key Test Patterns
|
||||
Each command follows this test structure:
|
||||
1. **Execute tests** - Verify command execution changes state correctly
|
||||
2. **Undo tests** - Verify undo restores previous state
|
||||
3. **Redo tests** - Verify redo re-applies changes
|
||||
4. **Serialization tests** - Verify command can be serialized/deserialized
|
||||
5. **Asset management tests** - Verify reference counting for image assets
|
||||
6. **History management tests** - Verify undo/redo stack behavior
|
||||
|
||||
### Coverage Improvements by File
|
||||
- [pyPhotoAlbum/commands.py](pyPhotoAlbum/commands.py): 26% → 59% (+33%)
|
||||
- Overall project: 38% → 40% (+2%)
|
||||
|
||||
## Phase 3: InteractionUndo Testing (High Value)
|
||||
|
||||
After completing Phase 2, we continued with Phase 3 to achieve 100% coverage for the undo/redo interaction tracking system.
|
||||
|
||||
### InteractionUndo Tests Added
|
||||
- **22 comprehensive tests** for [interaction_undo.py](pyPhotoAlbum/mixins/interaction_undo.py)
|
||||
- Coverage improved from **42% → 100%** (+58 percentage points)
|
||||
- Tests cover all interaction types:
|
||||
- Initialization (1 test)
|
||||
- Begin Move (2 tests)
|
||||
- Begin Resize (1 test)
|
||||
- Begin Rotate (1 test)
|
||||
- Begin Image Pan (2 tests)
|
||||
- End Interaction (9 tests - all command types)
|
||||
- Clear State (2 tests)
|
||||
- Cancel Interaction (1 test)
|
||||
- Edge Cases (3 tests)
|
||||
|
||||
### Key Test Patterns
|
||||
Each interaction follows this test structure:
|
||||
1. **Begin tests** - Verify state capture (position, size, rotation, crop)
|
||||
2. **End tests** - Verify command creation and execution
|
||||
3. **Significance tests** - Verify tiny changes don't create commands
|
||||
4. **Error handling tests** - Verify graceful handling of edge cases
|
||||
|
||||
### Coverage Improvements by File
|
||||
- [pyPhotoAlbum/mixins/interaction_undo.py](pyPhotoAlbum/mixins/interaction_undo.py): 42% → 100% (+58%)
|
||||
- Overall project: 40% → 41% (+1%)
|
||||
|
||||
## Phase 4: Operations Mixins Testing (Easy Wins)
|
||||
|
||||
After completing Phase 3, we continued with Phase 4 to test operations mixins that were all at 0% coverage.
|
||||
|
||||
### Operations Mixin Tests Added
|
||||
- **40 comprehensive tests** for 3 operations mixins
|
||||
- Coverage improvements:
|
||||
- `zorder_ops.py`: 0% → 92% (+92%, 17 tests)
|
||||
- `alignment_ops.py`: 0% → 93% (+93%, 12 tests)
|
||||
- `element_ops.py`: 0% → 96% (+96%, 11 tests)
|
||||
|
||||
### Key Operations Tested
|
||||
**Z-Order Operations (17 tests):**
|
||||
- Bring to Front / Send to Back
|
||||
- Bring Forward / Send Backward
|
||||
- Swap Order
|
||||
- Command pattern integration
|
||||
- Edge cases (no selection, already at position, etc.)
|
||||
|
||||
**Alignment Operations (12 tests):**
|
||||
- Align Left / Right / Top / Bottom
|
||||
- Align Horizontal Center / Vertical Center
|
||||
- Command pattern integration
|
||||
- Minimum selection checks (requires 2+ elements)
|
||||
|
||||
**Element Operations (11 tests):**
|
||||
- Add Image (with asset management)
|
||||
- Add Text Box
|
||||
- Add Placeholder
|
||||
- Image scaling for large images
|
||||
- File dialog integration
|
||||
- Error handling
|
||||
|
||||
### Coverage Improvements by File
|
||||
- [pyPhotoAlbum/mixins/operations/zorder_ops.py](pyPhotoAlbum/mixins/operations/zorder_ops.py): 0% → 92% (+92%)
|
||||
- [pyPhotoAlbum/mixins/operations/alignment_ops.py](pyPhotoAlbum/mixins/operations/alignment_ops.py): 0% → 93% (+93%)
|
||||
- [pyPhotoAlbum/mixins/operations/element_ops.py](pyPhotoAlbum/mixins/operations/element_ops.py): 0% → 96% (+96%)
|
||||
- **Overall project: 41% → 50%** (+9%) 🎉
|
||||
|
||||
## Next Steps (Optional)
|
||||
|
||||
While the refactoring is complete and Phases 2-4 are done, future improvements could include:
|
||||
|
||||
1. **Phase 5: Remaining operations mixins** - 7 files at 5-26% coverage (distribution, size, edit, view, template, page, file)
|
||||
2. **Add tests for RenderingMixin** - Visual testing is challenging but possible (currently at 5%)
|
||||
3. **Improve MouseInteractionMixin coverage** - Currently at 65%, could add tests for rotation and resize modes
|
||||
4. **Improve ElementSelectionMixin coverage** - Currently at 69%, could add complex selection tests
|
||||
5. **Performance profiling** - Ensure mixin overhead is negligible
|
||||
6. **Documentation** - Add architecture diagrams and mixin interaction guide
|
||||
|
||||
## Conclusion
|
||||
|
||||
The refactoring successfully achieved all goals:
|
||||
|
||||
✅ Broke up monolithic 1,368-line file
|
||||
✅ Created maintainable mixin architecture
|
||||
✅ Added 226 comprehensive tests across 4 phases
|
||||
✅ Maintained 100% backwards compatibility
|
||||
✅ Established pattern for future refactoring
|
||||
✅ Improved overall code quality
|
||||
✅ **Increased test coverage from 6% to 50%** - major milestone! 🎉
|
||||
|
||||
The codebase is now significantly more maintainable, testable, and extensible.
|
||||
|
||||
---
|
||||
|
||||
**Completed:** 2025-11-11
|
||||
**Time invested:** ~40 hours
|
||||
**Lines refactored:** 1,368 → 85 + (9 mixins × ~89 avg lines)
|
||||
**Tests added:** 226 (125 for mixins, 39 for commands, 22 for undo, 40 for operations)
|
||||
**Tests passing:** 449/449 ✅
|
||||
**Coverage:** 6% → 50% (+44%)
|
||||
@@ -0,0 +1,205 @@
|
||||
# Test Suite Analysis
|
||||
|
||||
## Overview
|
||||
**Total Test Files**: 43
|
||||
**Total Tests**: ~650
|
||||
**Test Collection Status**: ✅ All tests collect successfully
|
||||
|
||||
---
|
||||
|
||||
## Test Categories
|
||||
|
||||
### 1. ✅ **Proper Unit Tests** (Core Business Logic)
|
||||
These test pure logic with no external dependencies. Good unit tests!
|
||||
|
||||
| File | Tests | Description |
|
||||
|------|-------|-------------|
|
||||
| `test_alignment.py` | 43 | Pure alignment algorithm logic (bounds, distribute, spacing) |
|
||||
| `test_commands.py` | 39 | Command pattern implementation (with mocks) |
|
||||
| `test_snapping.py` | 30 | Snapping algorithm logic |
|
||||
| `test_page_layout.py` | 28 | Layout management logic |
|
||||
| `test_models.py` | 27 | Data model serialization/deserialization |
|
||||
| `test_zorder.py` | 18 | Z-order management logic |
|
||||
| `test_project.py` | 21 | Project lifecycle operations |
|
||||
| `test_project_serialization.py` | 21 | Serialization correctness |
|
||||
| `test_rotation_serialization.py` | 8 | Rotation data handling |
|
||||
| `test_merge.py` | 3 | Merge conflict resolution logic |
|
||||
|
||||
**Total**: ~258 tests
|
||||
**Status**: ✅ These are good unit tests!
|
||||
|
||||
---
|
||||
|
||||
### 2. ⚠️ **Integration Tests with Mocks** (UI Components)
|
||||
These test Qt widgets/mixins with mocked dependencies. Somewhat integration-y but still automated.
|
||||
|
||||
| File | Tests | Description |
|
||||
|------|-------|-------------|
|
||||
| `test_template_manager.py` | 35 | Template management with Qt |
|
||||
| `test_base_mixin.py` | 31 | Application state mixin (Qt + mocks) |
|
||||
| `test_view_ops_mixin.py` | 29 | View operations mixin (Qt + mocks) |
|
||||
| `test_element_selection_mixin.py` | 26 | Selection handling (Qt + mocks) |
|
||||
| `test_viewport_mixin.py` | 23 | Viewport rendering (Qt + mocks) |
|
||||
| `test_page_renderer.py` | 22 | Page rendering logic |
|
||||
| `test_interaction_undo_mixin.py` | 22 | Undo/redo system (Qt + mocks) |
|
||||
| `test_edit_ops_mixin.py` | 19 | Edit operations (Qt + mocks) |
|
||||
| `test_mouse_interaction_mixin.py` | 18 | Mouse event handling (Qt + mocks) |
|
||||
| `test_gl_widget_integration.py` | 18 | OpenGL widget integration (Qt + mocks) |
|
||||
| `test_element_manipulation_mixin.py` | 18 | Element manipulation (Qt + mocks) |
|
||||
| `test_zorder_ops_mixin.py` | 17 | Z-order operations mixin (Qt + mocks) |
|
||||
| `test_page_ops_mixin.py` | 17 | Page operations mixin (Qt + mocks) |
|
||||
| `test_page_navigation_mixin.py` | 16 | Page navigation (Qt + mocks) |
|
||||
| `test_size_ops_mixin.py` | 14 | Size operations mixin (Qt + mocks) |
|
||||
| `test_pdf_export.py` | 13 | PDF export functionality |
|
||||
| `test_image_pan_mixin.py` | 12 | Image panning (Qt + mocks) |
|
||||
| `test_alignment_ops_mixin.py` | 12 | Alignment ops mixin (Qt + mocks) |
|
||||
| `test_embedded_templates.py` | 11 | Template embedding |
|
||||
| `test_element_ops_mixin.py` | 11 | Element operations (Qt + mocks) |
|
||||
| `test_asset_drop_mixin.py` | 11 | Drag & drop handling (Qt + mocks) |
|
||||
| `test_distribution_ops_mixin.py` | 7 | Distribution operations (Qt + mocks) |
|
||||
| `test_multiselect.py` | 2 | Multi-selection (Qt + mocks) |
|
||||
| `test_loading_widget.py` | 2 | Loading UI widget (Qt) |
|
||||
|
||||
**Total**: ~405 tests
|
||||
**Status**: ⚠️ Proper tests but integration-heavy (Qt widgets)
|
||||
|
||||
---
|
||||
|
||||
### 3. ❌ **Not Really Tests** (Manual/Interactive Tests)
|
||||
These are scripts that were dumped into the test directory but aren't proper automated tests:
|
||||
|
||||
| File | Tests | Type | Issue |
|
||||
|------|-------|------|-------|
|
||||
| `test_drop_bug.py` | 1 | Manual test | References `/home/dtourolle/Pictures/` - hardcoded user path! |
|
||||
| `test_async_nonblocking.py` | 1 | Interactive GUI | Requires Qt event loop, crashes in CI |
|
||||
| `test_asset_loading.py` | 1 | Manual test | Requires `/home/dtourolle/Nextcloud/Photo Gallery/gr58/Album_pytool.ppz` |
|
||||
| `test_album6_compatibility.py` | 1 | Manual test | Requires `/home/dtourolle/Nextcloud/Photo Gallery/gr58/Album6.ppz` |
|
||||
| `test_version_roundtrip.py` | 1 | Demo script | Just converted to proper test - now OK! |
|
||||
| `test_page_setup.py` | 1 | Interactive | Requires Qt window |
|
||||
| `test_migration.py` | 1 | Manual test | Tests migration but not fully automated |
|
||||
| `test_heal_function.py` | 1 | Manual test | Interactive asset healing |
|
||||
| `test_zip_embedding.py` | 1 | Demo script | Content embedding demo |
|
||||
|
||||
**Total**: 9 "tests"
|
||||
**Status**: ❌ These should be:
|
||||
- Moved to `examples/` or `scripts/` directory, OR
|
||||
- Converted to proper automated tests with fixtures/mocks
|
||||
|
||||
---
|
||||
|
||||
### 4. 🔧 **Test Infrastructure**
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `test_gl_widget_fixtures.py` | Pytest fixtures for OpenGL testing (0 tests, just fixtures) |
|
||||
|
||||
---
|
||||
|
||||
## Problems Found
|
||||
|
||||
### 🔴 **Critical Issues**
|
||||
|
||||
1. **Hardcoded absolute paths** in tests:
|
||||
- `test_drop_bug.py`: `/home/dtourolle/Pictures/some_photo.jpg`
|
||||
- `test_asset_loading.py`: `/home/dtourolle/Nextcloud/Photo Gallery/gr58/Album_pytool.ppz`
|
||||
- `test_album6_compatibility.py`: `/home/dtourolle/Nextcloud/Photo Gallery/gr58/Album6.ppz`
|
||||
|
||||
2. **Interactive tests in CI**:
|
||||
- `test_async_nonblocking.py` - Creates Qt application and runs event loop
|
||||
- `test_page_setup.py` - Interactive GUI window
|
||||
- `test_loading_widget.py` - Interactive loading widget
|
||||
|
||||
3. **API mismatch** (FIXED):
|
||||
- ✅ `test_version_roundtrip.py` - Was using old `load_from_zip()` API
|
||||
- ✅ `test_asset_loading.py` - Was using old `load_from_zip()` API
|
||||
|
||||
### 🟡 **Medium Issues**
|
||||
|
||||
4. **Tests that look like demos**:
|
||||
- `test_heal_function.py` - Prints results but doesn't assert much
|
||||
- `test_zip_embedding.py` - More of a demo than a test
|
||||
- `test_migration.py` - Tests migration but could be more thorough
|
||||
|
||||
### 🟢 **Minor Issues**
|
||||
|
||||
5. **Test file naming**:
|
||||
- Some files have generic names like `test_multiselect.py` (2 tests)
|
||||
- Could be more descriptive
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
### Short Term (Fix Immediately)
|
||||
|
||||
1. **Mark problematic tests to skip on CI**:
|
||||
```python
|
||||
@pytest.mark.skip(reason="Requires user-specific files")
|
||||
def test_album6_compatibility():
|
||||
...
|
||||
```
|
||||
|
||||
2. **Add skip conditions for missing files**:
|
||||
```python
|
||||
@pytest.mark.skipif(not os.path.exists(TEST_FILE), reason="Test file not found")
|
||||
```
|
||||
|
||||
3. **Fix the crashing test**:
|
||||
- `test_async_nonblocking.py` needs `@pytest.mark.gui` or similar
|
||||
- Or mark as `@pytest.mark.skip` for now
|
||||
|
||||
### Medium Term (Cleanup)
|
||||
|
||||
4. **Move non-tests out of tests directory**:
|
||||
```
|
||||
tests/ → Keep only real automated tests
|
||||
examples/ → Move interactive demos here
|
||||
scripts/ → Move manual test scripts here
|
||||
```
|
||||
|
||||
5. **Create proper fixtures for file-based tests**:
|
||||
- Use `pytest.fixture` to create temporary test files
|
||||
- Don't rely on user's home directory
|
||||
|
||||
6. **Add proper test markers**:
|
||||
```python
|
||||
@pytest.mark.unit # Pure logic, no dependencies
|
||||
@pytest.mark.integration # Needs Qt, database, etc.
|
||||
@pytest.mark.slow # Takes >1 second
|
||||
@pytest.mark.gui # Needs display/X server
|
||||
```
|
||||
|
||||
### Long Term (Architecture)
|
||||
|
||||
7. **Separate test types**:
|
||||
```
|
||||
tests/unit/ # Pure unit tests (fast, no deps)
|
||||
tests/integration/ # Integration tests (Qt, mocks)
|
||||
tests/e2e/ # End-to-end tests (slow, full stack)
|
||||
```
|
||||
|
||||
8. **Add CI configuration**:
|
||||
```yaml
|
||||
# Run fast unit tests on every commit
|
||||
# Run integration tests on PR
|
||||
# Run GUI tests manually only
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Category | Count | Quality |
|
||||
|----------|-------|---------|
|
||||
| ✅ Good Unit Tests | ~258 | Excellent |
|
||||
| ⚠️ Integration Tests | ~405 | Good (but heavy) |
|
||||
| ❌ Not Real Tests | ~9 | Need fixing |
|
||||
| 🔧 Infrastructure | 1 | Good |
|
||||
| **Total** | **~673** | **Mixed** |
|
||||
|
||||
**Bottom Line**:
|
||||
- ~66% of tests are solid (unit + integration with mocks)
|
||||
- ~34% are integration tests that rely heavily on Qt
|
||||
- ~1.3% are broken/manual tests that need cleanup
|
||||
|
||||
The test suite is generally good, but needs cleanup of the manual/interactive tests that were dumped into the tests directory.
|
||||
Executable
+265
@@ -0,0 +1,265 @@
|
||||
#!/bin/bash
|
||||
# Debian/Ubuntu installation script for pyPhotoAlbum
|
||||
# Creates a virtual environment and installs all dependencies
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
print_info() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
print_warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
print_step() {
|
||||
echo -e "${BLUE}[STEP]${NC} $1"
|
||||
}
|
||||
|
||||
# Get the directory where this script is located
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VENV_DIR="$SCRIPT_DIR/venv"
|
||||
INSTALL_DIR="$HOME/.local"
|
||||
BIN_DIR="$INSTALL_DIR/bin"
|
||||
|
||||
echo "========================================"
|
||||
echo " pyPhotoAlbum Debian Installation "
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Check if running on Debian/Ubuntu
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
if [[ "$ID" != "debian" && "$ID" != "ubuntu" && "$ID_LIKE" != *"debian"* && "$ID_LIKE" != *"ubuntu"* ]]; then
|
||||
print_warn "This script is designed for Debian/Ubuntu-based systems."
|
||||
print_warn "Detected: $PRETTY_NAME"
|
||||
read -p "Continue anyway? [y/N]: " continue_choice
|
||||
if [[ ! "$continue_choice" =~ ^[Yy]$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
print_info "Detected: $PRETTY_NAME"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for required files
|
||||
if [ ! -f "$SCRIPT_DIR/pyproject.toml" ]; then
|
||||
print_error "pyproject.toml not found. Please run this script from the project root."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 1: Install system dependencies
|
||||
print_step "Installing system dependencies..."
|
||||
echo ""
|
||||
|
||||
# Check if we need sudo
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
SUDO="sudo"
|
||||
else
|
||||
SUDO=""
|
||||
fi
|
||||
|
||||
$SUDO apt update
|
||||
|
||||
# Install Python and venv support
|
||||
print_info "Installing Python and venv support..."
|
||||
$SUDO apt install -y python3 python3-venv python3-pip
|
||||
|
||||
# Install system libraries required for PyQt6 and OpenGL
|
||||
print_info "Installing Qt6 and OpenGL libraries..."
|
||||
$SUDO apt install -y \
|
||||
libgl1-mesa-dev \
|
||||
libglu1-mesa-dev \
|
||||
libxcb-xinerama0 \
|
||||
libxcb-cursor0 \
|
||||
libxkbcommon0 \
|
||||
libdbus-1-3 \
|
||||
libegl1 \
|
||||
libfontconfig1 \
|
||||
libfreetype6 \
|
||||
libx11-6 \
|
||||
libx11-xcb1 \
|
||||
libxcb1 \
|
||||
libxcb-glx0 \
|
||||
libxcb-icccm4 \
|
||||
libxcb-image0 \
|
||||
libxcb-keysyms1 \
|
||||
libxcb-randr0 \
|
||||
libxcb-render0 \
|
||||
libxcb-render-util0 \
|
||||
libxcb-shape0 \
|
||||
libxcb-shm0 \
|
||||
libxcb-sync1 \
|
||||
libxcb-xfixes0 \
|
||||
libxcb-xkb1 \
|
||||
libxkbcommon-x11-0 \
|
||||
libglib2.0-0 \
|
||||
libgtk-3-0 || print_warn "Some packages may not be available, continuing..."
|
||||
|
||||
echo ""
|
||||
|
||||
# Step 2: Create virtual environment
|
||||
print_step "Creating virtual environment..."
|
||||
echo ""
|
||||
|
||||
if [ -d "$VENV_DIR" ]; then
|
||||
print_warn "Virtual environment already exists at $VENV_DIR"
|
||||
read -p "Remove and recreate? [y/N]: " recreate_choice
|
||||
if [[ "$recreate_choice" =~ ^[Yy]$ ]]; then
|
||||
print_info "Removing existing virtual environment..."
|
||||
rm -rf "$VENV_DIR"
|
||||
else
|
||||
print_info "Using existing virtual environment..."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
print_info "Creating virtual environment at $VENV_DIR..."
|
||||
python3 -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
# Activate virtual environment
|
||||
source "$VENV_DIR/bin/activate"
|
||||
|
||||
# Upgrade pip
|
||||
print_info "Upgrading pip..."
|
||||
pip install --upgrade pip
|
||||
|
||||
echo ""
|
||||
|
||||
# Step 3: Install Python dependencies
|
||||
print_step "Installing Python dependencies..."
|
||||
echo ""
|
||||
|
||||
print_info "Installing pyPhotoAlbum and its dependencies..."
|
||||
pip install -e "$SCRIPT_DIR"
|
||||
|
||||
echo ""
|
||||
|
||||
# Step 4: Create launcher script
|
||||
print_step "Creating launcher script..."
|
||||
echo ""
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
|
||||
cat > "$BIN_DIR/pyphotoalbum" << EOF
|
||||
#!/bin/bash
|
||||
# pyPhotoAlbum launcher script
|
||||
# Activates the virtual environment and runs the application
|
||||
|
||||
SCRIPT_DIR="$SCRIPT_DIR"
|
||||
VENV_DIR="$VENV_DIR"
|
||||
|
||||
# Activate venv and run
|
||||
source "\$VENV_DIR/bin/activate"
|
||||
exec python "\$SCRIPT_DIR/pyPhotoAlbum/main.py" "\$@"
|
||||
EOF
|
||||
|
||||
chmod +x "$BIN_DIR/pyphotoalbum"
|
||||
print_info "Launcher script created at $BIN_DIR/pyphotoalbum"
|
||||
|
||||
echo ""
|
||||
|
||||
# Step 5: Install desktop integration
|
||||
print_step "Installing desktop integration..."
|
||||
echo ""
|
||||
|
||||
DESKTOP_DIR="$HOME/.local/share/applications"
|
||||
ICON_DIR="$HOME/.local/share/icons/hicolor"
|
||||
|
||||
mkdir -p "$DESKTOP_DIR"
|
||||
mkdir -p "$ICON_DIR/256x256/apps"
|
||||
|
||||
# Create desktop file with correct path
|
||||
cat > "$DESKTOP_DIR/pyphotoalbum.desktop" << EOF
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=pyPhotoAlbum
|
||||
GenericName=Photo Album Designer
|
||||
Comment=Design photo albums and export them to PDF
|
||||
Exec=$BIN_DIR/pyphotoalbum %F
|
||||
Icon=pyphotoalbum
|
||||
Terminal=false
|
||||
Categories=Graphics;Photography;Qt;
|
||||
Keywords=photo;album;pdf;design;layout;
|
||||
MimeType=application/x-pyphotoalbum-project;
|
||||
StartupNotify=true
|
||||
StartupWMClass=pyPhotoAlbum
|
||||
Actions=NewProject;
|
||||
|
||||
[Desktop Action NewProject]
|
||||
Name=New Project
|
||||
Exec=$BIN_DIR/pyphotoalbum --new
|
||||
EOF
|
||||
|
||||
print_info "Desktop file created at $DESKTOP_DIR/pyphotoalbum.desktop"
|
||||
|
||||
# Copy icon
|
||||
if [ -f "$SCRIPT_DIR/pyPhotoAlbum/icons/icon.png" ]; then
|
||||
cp "$SCRIPT_DIR/pyPhotoAlbum/icons/icon.png" "$ICON_DIR/256x256/apps/pyphotoalbum.png"
|
||||
print_info "Icon installed"
|
||||
|
||||
# Generate additional icon sizes if ImageMagick is available
|
||||
if command -v convert &> /dev/null || command -v magick &> /dev/null; then
|
||||
for size in 48 64 128; do
|
||||
mkdir -p "$ICON_DIR/${size}x${size}/apps"
|
||||
if command -v magick &> /dev/null; then
|
||||
magick "$SCRIPT_DIR/pyPhotoAlbum/icons/icon.png" -resize ${size}x${size} "$ICON_DIR/${size}x${size}/apps/pyphotoalbum.png" 2>/dev/null
|
||||
else
|
||||
convert "$SCRIPT_DIR/pyPhotoAlbum/icons/icon.png" -resize ${size}x${size} "$ICON_DIR/${size}x${size}/apps/pyphotoalbum.png" 2>/dev/null
|
||||
fi
|
||||
done
|
||||
print_info "Additional icon sizes generated"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Update desktop database
|
||||
if command -v update-desktop-database &> /dev/null; then
|
||||
update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Update icon cache
|
||||
if command -v gtk-update-icon-cache &> /dev/null; then
|
||||
gtk-update-icon-cache -f "$ICON_DIR" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Deactivate venv
|
||||
deactivate
|
||||
|
||||
# Final message
|
||||
echo "========================================"
|
||||
echo -e "${GREEN} Installation complete!${NC}"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
echo "You can now run pyPhotoAlbum by:"
|
||||
echo " 1) Running 'pyphotoalbum' in the terminal"
|
||||
echo " 2) Finding 'pyPhotoAlbum' in your application menu"
|
||||
echo ""
|
||||
|
||||
# Check if ~/.local/bin is in PATH
|
||||
if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
|
||||
print_warn "~/.local/bin is not in your PATH"
|
||||
echo ""
|
||||
echo "Add this to your ~/.bashrc or ~/.profile:"
|
||||
echo " export PATH=\"\$HOME/.local/bin:\$PATH\""
|
||||
echo ""
|
||||
echo "Then run: source ~/.bashrc"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "To run directly from source directory:"
|
||||
echo " $SCRIPT_DIR/launch-pyphotoalbum.sh"
|
||||
echo ""
|
||||
+40
-10
@@ -81,18 +81,24 @@ in_virtualenv() {
|
||||
install_package() {
|
||||
local install_mode=$1
|
||||
|
||||
if [ "$install_mode" = "system" ]; then
|
||||
print_info "Installing pyPhotoAlbum system-wide..."
|
||||
sudo pip install .
|
||||
else
|
||||
if in_virtualenv; then
|
||||
case "$install_mode" in
|
||||
system)
|
||||
print_info "Installing pyPhotoAlbum system-wide..."
|
||||
sudo pip install --upgrade .
|
||||
;;
|
||||
venv)
|
||||
print_info "Installing pyPhotoAlbum in virtual environment..."
|
||||
pip install .
|
||||
else
|
||||
pip install --upgrade .
|
||||
;;
|
||||
user-force)
|
||||
print_info "Installing pyPhotoAlbum for current user (forcing --user)..."
|
||||
pip install --user --upgrade .
|
||||
;;
|
||||
*)
|
||||
print_info "Installing pyPhotoAlbum for current user..."
|
||||
pip install --user .
|
||||
fi
|
||||
fi
|
||||
pip install --user --upgrade .
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Install desktop integration
|
||||
@@ -202,6 +208,30 @@ main() {
|
||||
install_mode="system"
|
||||
fi
|
||||
|
||||
# Check if in virtualenv and warn user
|
||||
if in_virtualenv; then
|
||||
print_warn "Running in a virtual environment"
|
||||
echo "Where do you want to install?"
|
||||
echo "1) Virtual environment (default)"
|
||||
echo "2) User installation (~/.local)"
|
||||
echo "3) System-wide (requires sudo)"
|
||||
echo ""
|
||||
read -p "Enter your choice [1-3]: " venv_choice
|
||||
|
||||
case "$venv_choice" in
|
||||
2)
|
||||
install_mode="user-force"
|
||||
;;
|
||||
3)
|
||||
install_mode="system"
|
||||
;;
|
||||
*)
|
||||
install_mode="venv"
|
||||
;;
|
||||
esac
|
||||
echo ""
|
||||
fi
|
||||
|
||||
print_info "Installation mode: $install_mode"
|
||||
echo ""
|
||||
|
||||
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
# pyPhotoAlbum launch script
|
||||
# Runs the application from the project directory using the local venv
|
||||
|
||||
# Get the directory where this script is located
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VENV_DIR="$SCRIPT_DIR/venv"
|
||||
|
||||
# Check if venv exists
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
echo "Error: Virtual environment not found at $VENV_DIR"
|
||||
echo "Please run install-debian.sh first to set up the environment."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Activate venv and run the application
|
||||
source "$VENV_DIR/bin/activate"
|
||||
exec python "$SCRIPT_DIR/pyPhotoAlbum/main.py" "$@"
|
||||
@@ -136,7 +136,7 @@ When loading:
|
||||
```python
|
||||
from pyPhotoAlbum.project_serializer import load_from_zip
|
||||
|
||||
project, error = load_from_zip("myalbum.ppz")
|
||||
project = load_from_zip("myalbum.ppz")
|
||||
# Embedded templates are automatically restored
|
||||
|
||||
# Create template manager to access them
|
||||
|
||||
+285
-163
@@ -6,6 +6,263 @@ from typing import List, Tuple
|
||||
from pyPhotoAlbum.models import BaseLayoutElement
|
||||
|
||||
|
||||
class ElementMaximizer:
|
||||
"""
|
||||
Handles element maximization using a crystal growth algorithm.
|
||||
Breaks down the complex maximize_pattern logic into atomic, testable methods.
|
||||
"""
|
||||
|
||||
def __init__(self, elements: List[BaseLayoutElement], page_size: Tuple[float, float], min_gap: float):
|
||||
"""
|
||||
Initialize the maximizer with elements and constraints.
|
||||
|
||||
Args:
|
||||
elements: List of elements to maximize
|
||||
page_size: (width, height) of the page in mm
|
||||
min_gap: Minimum gap to maintain between elements and borders (in mm)
|
||||
"""
|
||||
self.elements = elements
|
||||
self.page_width, self.page_height = page_size
|
||||
self.min_gap = min_gap
|
||||
self.changes: List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]] = []
|
||||
self._record_initial_states()
|
||||
|
||||
def _record_initial_states(self) -> None:
|
||||
"""Record initial positions and sizes for undo functionality."""
|
||||
for elem in self.elements:
|
||||
self.changes.append((elem, elem.position, elem.size))
|
||||
|
||||
def check_collision(self, elem_idx: int, new_size: Tuple[float, float]) -> bool:
|
||||
"""
|
||||
Check if element with new_size would collide with boundaries or other elements.
|
||||
|
||||
Args:
|
||||
elem_idx: Index of the element to check
|
||||
new_size: Proposed new size (width, height)
|
||||
|
||||
Returns:
|
||||
True if collision detected, False otherwise
|
||||
"""
|
||||
elem = self.elements[elem_idx]
|
||||
x, y = elem.position
|
||||
w, h = new_size
|
||||
|
||||
# Check page boundaries
|
||||
if x < self.min_gap or y < self.min_gap:
|
||||
return True
|
||||
if x + w > self.page_width - self.min_gap:
|
||||
return True
|
||||
if y + h > self.page_height - self.min_gap:
|
||||
return True
|
||||
|
||||
# Check collision with other elements
|
||||
for i, other in enumerate(self.elements):
|
||||
if i == elem_idx:
|
||||
continue
|
||||
|
||||
other_x, other_y = other.position
|
||||
other_w, other_h = other.size
|
||||
|
||||
# Calculate distances between rectangles
|
||||
horizontal_gap = max(
|
||||
other_x - (x + w), x - (other_x + other_w) # Other is to the right # Other is to the left
|
||||
)
|
||||
|
||||
vertical_gap = max(other_y - (y + h), y - (other_y + other_h)) # Other is below # Other is above
|
||||
|
||||
# If rectangles overlap or are too close in both dimensions
|
||||
if horizontal_gap < self.min_gap and vertical_gap < self.min_gap:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def find_max_scale(
|
||||
self,
|
||||
elem_idx: int,
|
||||
current_scale: float,
|
||||
max_search_scale: float = 3.0,
|
||||
tolerance: float = 0.001,
|
||||
max_iterations: int = 20,
|
||||
) -> float:
|
||||
"""
|
||||
Use binary search to find the maximum scale factor for an element.
|
||||
|
||||
Args:
|
||||
elem_idx: Index of the element
|
||||
current_scale: Current scale factor
|
||||
max_search_scale: Maximum scale to search up to (relative to current_scale)
|
||||
tolerance: Convergence tolerance for binary search
|
||||
max_iterations: Maximum binary search iterations
|
||||
|
||||
Returns:
|
||||
Maximum scale factor that doesn't cause collision
|
||||
"""
|
||||
old_size = self.changes[elem_idx][2]
|
||||
|
||||
# Binary search for maximum scale
|
||||
low, high = current_scale, current_scale * max_search_scale
|
||||
best_scale = current_scale
|
||||
|
||||
for _ in range(max_iterations):
|
||||
mid = (low + high) / 2.0
|
||||
test_size = (old_size[0] * mid, old_size[1] * mid)
|
||||
|
||||
if self.check_collision(elem_idx, test_size):
|
||||
high = mid
|
||||
else:
|
||||
best_scale = mid
|
||||
low = mid
|
||||
|
||||
if high - low < tolerance:
|
||||
break
|
||||
|
||||
return best_scale
|
||||
|
||||
def grow_iteration(self, scales: List[float], growth_rate: float) -> bool:
|
||||
"""
|
||||
Perform one iteration of the growth algorithm.
|
||||
|
||||
Args:
|
||||
scales: Current scale factors for each element
|
||||
growth_rate: Percentage to grow each iteration (0.05 = 5%)
|
||||
|
||||
Returns:
|
||||
True if any element grew, False otherwise
|
||||
"""
|
||||
any_growth = False
|
||||
|
||||
for i, elem in enumerate(self.elements):
|
||||
old_size = self.changes[i][2]
|
||||
|
||||
# Try to grow this element
|
||||
new_scale = scales[i] * (1.0 + growth_rate)
|
||||
new_size = (old_size[0] * new_scale, old_size[1] * new_scale)
|
||||
|
||||
if not self.check_collision(i, new_size):
|
||||
scales[i] = new_scale
|
||||
elem.size = new_size
|
||||
any_growth = True
|
||||
else:
|
||||
# Can't grow uniformly, try to find maximum possible scale
|
||||
max_scale = self.find_max_scale(i, scales[i])
|
||||
if max_scale > scales[i]:
|
||||
scales[i] = max_scale
|
||||
elem.size = (old_size[0] * max_scale, old_size[1] * max_scale)
|
||||
any_growth = True
|
||||
|
||||
return any_growth
|
||||
|
||||
def check_element_collision(self, elem: BaseLayoutElement, new_pos: Tuple[float, float]) -> bool:
|
||||
"""
|
||||
Check if moving an element to new_pos would cause collision with other elements.
|
||||
|
||||
Args:
|
||||
elem: The element to check
|
||||
new_pos: Proposed new position (x, y)
|
||||
|
||||
Returns:
|
||||
True if collision detected, False otherwise
|
||||
"""
|
||||
x, y = new_pos
|
||||
w, h = elem.size
|
||||
|
||||
for other in self.elements:
|
||||
if other is elem:
|
||||
continue
|
||||
ox, oy = other.position
|
||||
ow, oh = other.size
|
||||
|
||||
# Check if rectangles overlap (with min_gap consideration)
|
||||
if (
|
||||
abs((x + w / 2) - (ox + ow / 2)) < (w + ow) / 2 + self.min_gap
|
||||
and abs((y + h / 2) - (oy + oh / 2)) < (h + oh) / 2 + self.min_gap
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def center_element_horizontally(self, elem: BaseLayoutElement) -> None:
|
||||
"""
|
||||
Micro-adjust element position to center horizontally in available space.
|
||||
|
||||
Args:
|
||||
elem: Element to center
|
||||
"""
|
||||
x, y = elem.position
|
||||
w, h = elem.size
|
||||
|
||||
# Calculate available space on each side
|
||||
space_left = x - self.min_gap
|
||||
space_right = (self.page_width - self.min_gap) - (x + w)
|
||||
|
||||
if space_left >= 0 and space_right >= 0:
|
||||
adjust_x = (space_right - space_left) / 4.0 # Gentle centering
|
||||
new_x = max(self.min_gap, min(self.page_width - w - self.min_gap, x + adjust_x))
|
||||
|
||||
# Verify this doesn't cause collision
|
||||
old_pos = elem.position
|
||||
new_pos = (new_x, y)
|
||||
|
||||
if not self.check_element_collision(elem, new_pos):
|
||||
elem.position = new_pos
|
||||
|
||||
def center_element_vertically(self, elem: BaseLayoutElement) -> None:
|
||||
"""
|
||||
Micro-adjust element position to center vertically in available space.
|
||||
|
||||
Args:
|
||||
elem: Element to center
|
||||
"""
|
||||
x, y = elem.position
|
||||
w, h = elem.size
|
||||
|
||||
# Calculate available space on each side
|
||||
space_top = y - self.min_gap
|
||||
space_bottom = (self.page_height - self.min_gap) - (y + h)
|
||||
|
||||
if space_top >= 0 and space_bottom >= 0:
|
||||
adjust_y = (space_bottom - space_top) / 4.0
|
||||
new_y = max(self.min_gap, min(self.page_height - h - self.min_gap, y + adjust_y))
|
||||
|
||||
# Verify this doesn't cause collision
|
||||
old_pos = elem.position
|
||||
new_pos = (x, new_y)
|
||||
|
||||
if not self.check_element_collision(elem, new_pos):
|
||||
elem.position = new_pos
|
||||
|
||||
def center_elements(self) -> None:
|
||||
"""Center all elements slightly within their constrained space."""
|
||||
for elem in self.elements:
|
||||
self.center_element_horizontally(elem)
|
||||
self.center_element_vertically(elem)
|
||||
|
||||
def maximize(
|
||||
self, max_iterations: int = 100, growth_rate: float = 0.05
|
||||
) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
|
||||
"""
|
||||
Execute the maximization algorithm.
|
||||
|
||||
Args:
|
||||
max_iterations: Maximum number of growth iterations
|
||||
growth_rate: Percentage to grow each iteration (0.05 = 5%)
|
||||
|
||||
Returns:
|
||||
List of (element, old_position, old_size) tuples for undo
|
||||
"""
|
||||
scales = [1.0] * len(self.elements)
|
||||
|
||||
# Growth algorithm - iterative expansion
|
||||
for _ in range(max_iterations):
|
||||
if not self.grow_iteration(scales, growth_rate):
|
||||
break
|
||||
|
||||
# Center elements slightly within their constrained space
|
||||
self.center_elements()
|
||||
|
||||
return self.changes
|
||||
|
||||
|
||||
class AlignmentManager:
|
||||
"""Manages alignment and distribution operations on multiple elements"""
|
||||
|
||||
@@ -114,7 +371,9 @@ class AlignmentManager:
|
||||
return changes
|
||||
|
||||
@staticmethod
|
||||
def align_horizontal_center(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
|
||||
def align_horizontal_center(
|
||||
elements: List[BaseLayoutElement],
|
||||
) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
|
||||
"""
|
||||
Align all elements to horizontal center.
|
||||
|
||||
@@ -162,7 +421,9 @@ class AlignmentManager:
|
||||
return changes
|
||||
|
||||
@staticmethod
|
||||
def make_same_size(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
|
||||
def make_same_size(
|
||||
elements: List[BaseLayoutElement],
|
||||
) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
|
||||
"""
|
||||
Make all elements the same size as the first element.
|
||||
|
||||
@@ -184,7 +445,9 @@ class AlignmentManager:
|
||||
return changes
|
||||
|
||||
@staticmethod
|
||||
def make_same_width(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
|
||||
def make_same_width(
|
||||
elements: List[BaseLayoutElement],
|
||||
) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
|
||||
"""
|
||||
Make all elements the same width as the first element.
|
||||
|
||||
@@ -206,7 +469,9 @@ class AlignmentManager:
|
||||
return changes
|
||||
|
||||
@staticmethod
|
||||
def make_same_height(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
|
||||
def make_same_height(
|
||||
elements: List[BaseLayoutElement],
|
||||
) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
|
||||
"""
|
||||
Make all elements the same height as the first element.
|
||||
|
||||
@@ -228,7 +493,9 @@ class AlignmentManager:
|
||||
return changes
|
||||
|
||||
@staticmethod
|
||||
def distribute_horizontally(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
|
||||
def distribute_horizontally(
|
||||
elements: List[BaseLayoutElement],
|
||||
) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
|
||||
"""
|
||||
Distribute elements evenly across horizontal span.
|
||||
|
||||
@@ -362,7 +629,9 @@ class AlignmentManager:
|
||||
return changes
|
||||
|
||||
@staticmethod
|
||||
def fit_to_page_width(element: BaseLayoutElement, page_width: float) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
|
||||
def fit_to_page_width(
|
||||
element: BaseLayoutElement, page_width: float
|
||||
) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
|
||||
"""
|
||||
Resize element to fit page width while maintaining aspect ratio.
|
||||
|
||||
@@ -387,7 +656,9 @@ class AlignmentManager:
|
||||
return (element, old_pos, old_size)
|
||||
|
||||
@staticmethod
|
||||
def fit_to_page_height(element: BaseLayoutElement, page_height: float) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
|
||||
def fit_to_page_height(
|
||||
element: BaseLayoutElement, page_height: float
|
||||
) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
|
||||
"""
|
||||
Resize element to fit page height while maintaining aspect ratio.
|
||||
|
||||
@@ -412,7 +683,9 @@ class AlignmentManager:
|
||||
return (element, old_pos, old_size)
|
||||
|
||||
@staticmethod
|
||||
def fit_to_page(element: BaseLayoutElement, page_width: float, page_height: float) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
|
||||
def fit_to_page(
|
||||
element: BaseLayoutElement, page_width: float, page_height: float
|
||||
) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
|
||||
"""
|
||||
Resize element to fit within page dimensions while maintaining aspect ratio.
|
||||
|
||||
@@ -451,7 +724,7 @@ class AlignmentManager:
|
||||
page_size: Tuple[float, float],
|
||||
min_gap: float = 2.0,
|
||||
max_iterations: int = 100,
|
||||
growth_rate: float = 0.05
|
||||
growth_rate: float = 0.05,
|
||||
) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
|
||||
"""
|
||||
Maximize element sizes using a crystal growth algorithm.
|
||||
@@ -470,166 +743,15 @@ class AlignmentManager:
|
||||
if not elements:
|
||||
return []
|
||||
|
||||
page_width, page_height = page_size
|
||||
changes = []
|
||||
|
||||
# Record initial states
|
||||
for elem in elements:
|
||||
changes.append((elem, elem.position, elem.size))
|
||||
|
||||
# Helper function to check if element would collide with boundaries or other elements
|
||||
def check_collision(elem_idx: int, new_size: Tuple[float, float]) -> bool:
|
||||
elem = elements[elem_idx]
|
||||
x, y = elem.position
|
||||
w, h = new_size
|
||||
|
||||
# Check page boundaries
|
||||
if x < min_gap or y < min_gap:
|
||||
return True
|
||||
if x + w > page_width - min_gap:
|
||||
return True
|
||||
if y + h > page_height - min_gap:
|
||||
return True
|
||||
|
||||
# Check collision with other elements
|
||||
for i, other in enumerate(elements):
|
||||
if i == elem_idx:
|
||||
continue
|
||||
|
||||
other_x, other_y = other.position
|
||||
other_w, other_h = other.size
|
||||
|
||||
# Calculate distances between rectangles
|
||||
horizontal_gap = max(
|
||||
other_x - (x + w), # Other is to the right
|
||||
x - (other_x + other_w) # Other is to the left
|
||||
)
|
||||
|
||||
vertical_gap = max(
|
||||
other_y - (y + h), # Other is below
|
||||
y - (other_y + other_h) # Other is above
|
||||
)
|
||||
|
||||
# If rectangles overlap or are too close in both dimensions
|
||||
if horizontal_gap < min_gap and vertical_gap < min_gap:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
# Helper function to get the maximum scale factor for an element
|
||||
def get_max_scale(elem_idx: int, current_scale: float) -> float:
|
||||
elem = elements[elem_idx]
|
||||
old_size = changes[elem_idx][2]
|
||||
|
||||
# Binary search for maximum scale
|
||||
low, high = current_scale, current_scale * 3.0
|
||||
best_scale = current_scale
|
||||
|
||||
for _ in range(20): # Binary search iterations
|
||||
mid = (low + high) / 2.0
|
||||
test_size = (old_size[0] * mid, old_size[1] * mid)
|
||||
|
||||
if check_collision(elem_idx, test_size):
|
||||
high = mid
|
||||
else:
|
||||
best_scale = mid
|
||||
low = mid
|
||||
|
||||
if high - low < 0.001:
|
||||
break
|
||||
|
||||
return best_scale
|
||||
|
||||
# Growth algorithm - iterative expansion
|
||||
scales = [1.0] * len(elements)
|
||||
|
||||
for iteration in range(max_iterations):
|
||||
any_growth = False
|
||||
|
||||
for i, elem in enumerate(elements):
|
||||
old_size = changes[i][2]
|
||||
|
||||
# Try to grow this element
|
||||
new_scale = scales[i] * (1.0 + growth_rate)
|
||||
new_size = (old_size[0] * new_scale, old_size[1] * new_scale)
|
||||
|
||||
if not check_collision(i, new_size):
|
||||
scales[i] = new_scale
|
||||
elem.size = new_size
|
||||
any_growth = True
|
||||
else:
|
||||
# Can't grow uniformly, try to find maximum possible scale
|
||||
max_scale = get_max_scale(i, scales[i])
|
||||
if max_scale > scales[i]:
|
||||
scales[i] = max_scale
|
||||
elem.size = (old_size[0] * max_scale, old_size[1] * max_scale)
|
||||
any_growth = True
|
||||
|
||||
# If no element could grow, we're done
|
||||
if not any_growth:
|
||||
break
|
||||
|
||||
# Optional: Center elements slightly within their constrained space
|
||||
for elem in elements:
|
||||
x, y = elem.position
|
||||
w, h = elem.size
|
||||
|
||||
# Calculate available space on each side
|
||||
space_left = x - min_gap
|
||||
space_right = (page_width - min_gap) - (x + w)
|
||||
space_top = y - min_gap
|
||||
space_bottom = (page_height - min_gap) - (y + h)
|
||||
|
||||
# Micro-adjust position to center in available space
|
||||
if space_left >= 0 and space_right >= 0:
|
||||
adjust_x = (space_right - space_left) / 4.0 # Gentle centering
|
||||
new_x = max(min_gap, min(page_width - w - min_gap, x + adjust_x))
|
||||
|
||||
# Verify this doesn't cause collision
|
||||
old_pos = elem.position
|
||||
elem.position = (new_x, y)
|
||||
collision = False
|
||||
for other in elements:
|
||||
if other is elem:
|
||||
continue
|
||||
ox, oy = other.position
|
||||
ow, oh = other.size
|
||||
if (abs((new_x + w/2) - (ox + ow/2)) < (w + ow)/2 + min_gap and
|
||||
abs((y + h/2) - (oy + oh/2)) < (h + oh)/2 + min_gap):
|
||||
collision = True
|
||||
break
|
||||
|
||||
if collision:
|
||||
elem.position = old_pos
|
||||
|
||||
if space_top >= 0 and space_bottom >= 0:
|
||||
adjust_y = (space_bottom - space_top) / 4.0
|
||||
new_y = max(min_gap, min(page_height - h - min_gap, y + adjust_y))
|
||||
|
||||
old_pos = elem.position
|
||||
elem.position = (elem.position[0], new_y)
|
||||
collision = False
|
||||
for other in elements:
|
||||
if other is elem:
|
||||
continue
|
||||
ox, oy = other.position
|
||||
ow, oh = other.size
|
||||
if (abs((elem.position[0] + w/2) - (ox + ow/2)) < (w + ow)/2 + min_gap and
|
||||
abs((new_y + h/2) - (oy + oh/2)) < (h + oh)/2 + min_gap):
|
||||
collision = True
|
||||
break
|
||||
|
||||
if collision:
|
||||
elem.position = old_pos
|
||||
|
||||
return changes
|
||||
maximizer = ElementMaximizer(elements, page_size, min_gap)
|
||||
return maximizer.maximize(max_iterations, growth_rate)
|
||||
|
||||
@staticmethod
|
||||
def expand_to_bounds(
|
||||
element: BaseLayoutElement,
|
||||
page_size: Tuple[float, float],
|
||||
other_elements: List[BaseLayoutElement],
|
||||
min_gap: float = 10.0
|
||||
min_gap: float = 10.0,
|
||||
) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
|
||||
"""
|
||||
Expand a single element until it is min_gap away from page edges or other elements.
|
||||
|
||||
@@ -3,11 +3,19 @@ Asset healing dialog for reconnecting missing images
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from typing import List, Dict, Set
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||
QListWidget, QListWidgetItem, QFileDialog, QGroupBox,
|
||||
QMessageBox
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QFileDialog,
|
||||
QGroupBox,
|
||||
QMessageBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
@@ -78,24 +86,38 @@ class AssetHealDialog(QDialog):
|
||||
self.setLayout(layout)
|
||||
|
||||
def _scan_missing_assets(self):
|
||||
"""Scan project for missing assets"""
|
||||
"""Scan project for missing assets - only assets in project's assets folder are valid"""
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
self.missing_assets.clear()
|
||||
self.missing_list.clear()
|
||||
|
||||
# Check all pages for missing images
|
||||
# Check all pages for images that need healing
|
||||
# Images MUST be in the project's assets folder - absolute paths or external paths need healing
|
||||
for page in self.project.pages:
|
||||
for element in page.layout.elements:
|
||||
if isinstance(element, ImageData) and element.image_path:
|
||||
# Check if path exists
|
||||
if os.path.isabs(element.image_path):
|
||||
full_path = element.image_path
|
||||
else:
|
||||
full_path = os.path.join(self.project.folder_path, element.image_path)
|
||||
needs_healing = False
|
||||
reason = ""
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
# Absolute paths need healing (should be relative to assets/)
|
||||
if os.path.isabs(element.image_path):
|
||||
needs_healing = True
|
||||
reason = "absolute path"
|
||||
# Paths not starting with assets/ need healing
|
||||
elif not element.image_path.startswith("assets/"):
|
||||
needs_healing = True
|
||||
reason = "not in assets folder"
|
||||
else:
|
||||
# Relative path in assets/ - check if file exists
|
||||
full_path = os.path.join(self.project.folder_path, element.image_path)
|
||||
if not os.path.exists(full_path):
|
||||
needs_healing = True
|
||||
reason = "file missing"
|
||||
|
||||
if needs_healing:
|
||||
self.missing_assets.add(element.image_path)
|
||||
print(f"Asset needs healing: {element.image_path} ({reason})")
|
||||
|
||||
# Display missing assets
|
||||
if self.missing_assets:
|
||||
@@ -109,10 +131,7 @@ class AssetHealDialog(QDialog):
|
||||
def _add_search_path(self):
|
||||
"""Add a search path"""
|
||||
directory = QFileDialog.getExistingDirectory(
|
||||
self,
|
||||
"Select Search Path for Assets",
|
||||
"",
|
||||
QFileDialog.Option.ShowDirsOnly
|
||||
self, "Select Search Path for Assets", "", QFileDialog.Option.ShowDirsOnly
|
||||
)
|
||||
|
||||
if directory:
|
||||
@@ -128,22 +147,14 @@ class AssetHealDialog(QDialog):
|
||||
self.search_list.takeItem(current_row)
|
||||
|
||||
def _attempt_healing(self):
|
||||
"""Attempt to heal missing assets using search paths"""
|
||||
"""Attempt to heal missing assets by resolving stored paths and using search paths"""
|
||||
from pyPhotoAlbum.models import ImageData, set_asset_resolution_context
|
||||
|
||||
if not self.search_paths:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"No Search Paths",
|
||||
"Please add at least one search path before attempting to heal assets."
|
||||
)
|
||||
return
|
||||
|
||||
healed_count = 0
|
||||
imported_count = 0
|
||||
still_missing = []
|
||||
|
||||
# Update asset resolution context with search paths
|
||||
# Update asset resolution context with search paths (for rendering after heal)
|
||||
set_asset_resolution_context(self.project.folder_path, self.search_paths)
|
||||
|
||||
# Build mapping of missing paths to elements
|
||||
@@ -161,19 +172,34 @@ class AssetHealDialog(QDialog):
|
||||
found_path = None
|
||||
filename = os.path.basename(asset_path)
|
||||
|
||||
# Search in each search path
|
||||
for search_path in self.search_paths:
|
||||
# Try direct match
|
||||
candidate = os.path.join(search_path, filename)
|
||||
if os.path.exists(candidate):
|
||||
found_path = candidate
|
||||
break
|
||||
# FIRST: Try to resolve the stored path directly from project folder
|
||||
# This handles paths like "../../home/user/Photos/image.jpg"
|
||||
if not os.path.isabs(asset_path):
|
||||
resolved = os.path.normpath(os.path.join(self.project.folder_path, asset_path))
|
||||
if os.path.exists(resolved):
|
||||
found_path = resolved
|
||||
print(f"Resolved relative path: {asset_path} → {resolved}")
|
||||
|
||||
# Try with same relative path
|
||||
candidate = os.path.join(search_path, asset_path)
|
||||
if os.path.exists(candidate):
|
||||
found_path = candidate
|
||||
break
|
||||
# SECOND: If it's an absolute path, check if it exists directly
|
||||
if not found_path and os.path.isabs(asset_path):
|
||||
if os.path.exists(asset_path):
|
||||
found_path = asset_path
|
||||
print(f"Found at absolute path: {asset_path}")
|
||||
|
||||
# THIRD: Search in user-provided search paths
|
||||
if not found_path:
|
||||
for search_path in self.search_paths:
|
||||
# Try direct match by filename
|
||||
candidate = os.path.join(search_path, filename)
|
||||
if os.path.exists(candidate):
|
||||
found_path = candidate
|
||||
break
|
||||
|
||||
# Try with same relative path structure
|
||||
candidate = os.path.join(search_path, asset_path)
|
||||
if os.path.exists(candidate):
|
||||
found_path = candidate
|
||||
break
|
||||
|
||||
if found_path:
|
||||
healed_count += 1
|
||||
@@ -181,12 +207,11 @@ class AssetHealDialog(QDialog):
|
||||
# Check if the found file needs to be imported
|
||||
# (i.e., it's not already in the assets folder)
|
||||
needs_import = True
|
||||
if not os.path.isabs(asset_path) and asset_path.startswith('assets/'):
|
||||
if not os.path.isabs(asset_path) and asset_path.startswith("assets/"):
|
||||
# It's already a relative assets path, just missing from disk
|
||||
# Copy it to the correct location
|
||||
dest_path = os.path.join(self.project.folder_path, asset_path)
|
||||
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
|
||||
import shutil
|
||||
shutil.copy2(found_path, dest_path)
|
||||
print(f"Restored: {asset_path} from {found_path}")
|
||||
else:
|
||||
@@ -223,5 +248,8 @@ class AssetHealDialog(QDialog):
|
||||
|
||||
QMessageBox.information(self, "Healing Results", message)
|
||||
|
||||
# Reset asset resolution context to project folder only (no search paths for rendering)
|
||||
set_asset_resolution_context(self.project.folder_path)
|
||||
|
||||
# Rescan to update the list
|
||||
self._scan_missing_assets()
|
||||
|
||||
+305
-28
@@ -2,56 +2,82 @@
|
||||
Asset management system for pyPhotoAlbum with automatic reference counting
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
from typing import Dict, Optional
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def compute_file_md5(file_path: str) -> Optional[str]:
|
||||
"""
|
||||
Compute MD5 hash of a file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
|
||||
Returns:
|
||||
MD5 hash as hex string, or None if file doesn't exist
|
||||
"""
|
||||
if not os.path.exists(file_path):
|
||||
return None
|
||||
|
||||
hash_md5 = hashlib.md5()
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(8192), b""):
|
||||
hash_md5.update(chunk)
|
||||
return hash_md5.hexdigest()
|
||||
except Exception as e:
|
||||
print(f"AssetManager: Error computing MD5 for {file_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
class AssetManager:
|
||||
"""Manages project assets with automatic reference counting and cleanup"""
|
||||
|
||||
|
||||
def __init__(self, project_folder: str):
|
||||
"""
|
||||
Initialize AssetManager.
|
||||
|
||||
|
||||
Args:
|
||||
project_folder: Root folder for the project
|
||||
"""
|
||||
self.project_folder = project_folder
|
||||
self.assets_folder = os.path.join(project_folder, "assets")
|
||||
self.reference_counts: Dict[str, int] = {} # {relative_path: count}
|
||||
|
||||
self.asset_hashes: Dict[str, str] = {} # {relative_path: md5_hash}
|
||||
|
||||
# Create assets folder if it doesn't exist
|
||||
os.makedirs(self.assets_folder, exist_ok=True)
|
||||
|
||||
|
||||
def import_asset(self, source_path: str) -> str:
|
||||
"""
|
||||
Import an asset into the project by copying it to the assets folder.
|
||||
|
||||
|
||||
Args:
|
||||
source_path: Path to the source file
|
||||
|
||||
|
||||
Returns:
|
||||
Relative path to the imported asset (e.g., "assets/photo_001.jpg")
|
||||
"""
|
||||
if not os.path.exists(source_path):
|
||||
raise FileNotFoundError(f"Source file not found: {source_path}")
|
||||
|
||||
|
||||
# Get filename and extension
|
||||
filename = os.path.basename(source_path)
|
||||
name, ext = os.path.splitext(filename)
|
||||
|
||||
|
||||
# Find a unique filename if there's a collision
|
||||
counter = 1
|
||||
dest_filename = filename
|
||||
dest_path = os.path.join(self.assets_folder, dest_filename)
|
||||
|
||||
|
||||
while os.path.exists(dest_path):
|
||||
dest_filename = f"{name}_{counter:03d}{ext}"
|
||||
dest_path = os.path.join(self.assets_folder, dest_filename)
|
||||
counter += 1
|
||||
|
||||
|
||||
# Copy the file
|
||||
shutil.copy2(source_path, dest_path)
|
||||
|
||||
@@ -65,17 +91,17 @@ class AssetManager:
|
||||
|
||||
# Return relative path for storage in elements
|
||||
return relative_path
|
||||
|
||||
|
||||
def acquire_reference(self, asset_path: str):
|
||||
"""
|
||||
Increment the reference count for an asset.
|
||||
|
||||
|
||||
Args:
|
||||
asset_path: Relative path to the asset
|
||||
"""
|
||||
if not asset_path:
|
||||
return
|
||||
|
||||
|
||||
if asset_path in self.reference_counts:
|
||||
self.reference_counts[asset_path] += 1
|
||||
print(f"AssetManager: Acquired reference to {asset_path} (refs={self.reference_counts[asset_path]})")
|
||||
@@ -87,25 +113,25 @@ class AssetManager:
|
||||
print(f"AssetManager: Acquired reference to existing asset {asset_path} (refs=1)")
|
||||
else:
|
||||
print(f"AssetManager: Warning - asset not found: {asset_path}")
|
||||
|
||||
|
||||
def release_reference(self, asset_path: str):
|
||||
"""
|
||||
Decrement the reference count for an asset.
|
||||
If count reaches zero, delete the asset file.
|
||||
|
||||
|
||||
Args:
|
||||
asset_path: Relative path to the asset
|
||||
"""
|
||||
if not asset_path:
|
||||
return
|
||||
|
||||
|
||||
if asset_path not in self.reference_counts:
|
||||
print(f"AssetManager: Warning - attempting to release unknown asset: {asset_path}")
|
||||
return
|
||||
|
||||
|
||||
self.reference_counts[asset_path] -= 1
|
||||
print(f"AssetManager: Released reference to {asset_path} (refs={self.reference_counts[asset_path]})")
|
||||
|
||||
|
||||
if self.reference_counts[asset_path] <= 0:
|
||||
# No more references - safe to delete
|
||||
full_path = os.path.join(self.project_folder, asset_path)
|
||||
@@ -116,38 +142,289 @@ class AssetManager:
|
||||
del self.reference_counts[asset_path]
|
||||
except Exception as e:
|
||||
print(f"AssetManager: Error deleting asset {asset_path}: {e}")
|
||||
|
||||
|
||||
def get_absolute_path(self, relative_path: str) -> str:
|
||||
"""
|
||||
Convert a relative asset path to an absolute path.
|
||||
|
||||
|
||||
Args:
|
||||
relative_path: Relative path from project folder
|
||||
|
||||
|
||||
Returns:
|
||||
Absolute path to the asset
|
||||
"""
|
||||
return os.path.join(self.project_folder, relative_path)
|
||||
|
||||
|
||||
def get_reference_count(self, asset_path: str) -> int:
|
||||
"""
|
||||
Get the current reference count for an asset.
|
||||
|
||||
|
||||
Args:
|
||||
asset_path: Relative path to the asset
|
||||
|
||||
|
||||
Returns:
|
||||
Reference count (0 if not tracked)
|
||||
"""
|
||||
return self.reference_counts.get(asset_path, 0)
|
||||
|
||||
|
||||
def serialize(self) -> Dict:
|
||||
"""Serialize asset manager state"""
|
||||
return {
|
||||
"reference_counts": self.reference_counts
|
||||
"reference_counts": self.reference_counts,
|
||||
"asset_hashes": self.asset_hashes,
|
||||
}
|
||||
|
||||
|
||||
def deserialize(self, data: Dict):
|
||||
"""Deserialize asset manager state"""
|
||||
self.reference_counts = data.get("reference_counts", {})
|
||||
self.asset_hashes = data.get("asset_hashes", {})
|
||||
print(f"AssetManager: Loaded {len(self.reference_counts)} asset references")
|
||||
|
||||
def compute_asset_hash(self, asset_path: str) -> Optional[str]:
|
||||
"""
|
||||
Compute and cache the MD5 hash for an asset.
|
||||
|
||||
Args:
|
||||
asset_path: Relative path to the asset
|
||||
|
||||
Returns:
|
||||
MD5 hash as hex string, or None if computation fails
|
||||
"""
|
||||
full_path = self.get_absolute_path(asset_path)
|
||||
md5_hash = compute_file_md5(full_path)
|
||||
if md5_hash:
|
||||
self.asset_hashes[asset_path] = md5_hash
|
||||
return md5_hash
|
||||
|
||||
def compute_all_hashes(self) -> Dict[str, str]:
|
||||
"""
|
||||
Compute MD5 hashes for all assets in the assets folder.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping relative paths to MD5 hashes
|
||||
"""
|
||||
self.asset_hashes.clear()
|
||||
|
||||
if not os.path.exists(self.assets_folder):
|
||||
return self.asset_hashes
|
||||
|
||||
for root, dirs, files in os.walk(self.assets_folder):
|
||||
for filename in files:
|
||||
file_path = os.path.join(root, filename)
|
||||
relative_path = os.path.relpath(file_path, self.project_folder)
|
||||
md5_hash = compute_file_md5(file_path)
|
||||
if md5_hash:
|
||||
self.asset_hashes[relative_path] = md5_hash
|
||||
|
||||
print(f"AssetManager: Computed hashes for {len(self.asset_hashes)} assets")
|
||||
return self.asset_hashes
|
||||
|
||||
def find_duplicates(self) -> Dict[str, List[str]]:
|
||||
"""
|
||||
Find duplicate assets based on MD5 hash.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping MD5 hash to list of asset paths with that hash.
|
||||
Only includes hashes with more than one file.
|
||||
"""
|
||||
# Compute hashes if not already done
|
||||
if not self.asset_hashes:
|
||||
self.compute_all_hashes()
|
||||
|
||||
# Group assets by hash
|
||||
hash_to_paths: Dict[str, List[str]] = {}
|
||||
for path, md5_hash in self.asset_hashes.items():
|
||||
if md5_hash not in hash_to_paths:
|
||||
hash_to_paths[md5_hash] = []
|
||||
hash_to_paths[md5_hash].append(path)
|
||||
|
||||
# Filter to only duplicates (more than one file with same hash)
|
||||
duplicates = {h: paths for h, paths in hash_to_paths.items() if len(paths) > 1}
|
||||
|
||||
if duplicates:
|
||||
total_dups = sum(len(paths) - 1 for paths in duplicates.values())
|
||||
print(f"AssetManager: Found {total_dups} duplicate files in {len(duplicates)} groups")
|
||||
|
||||
return duplicates
|
||||
|
||||
def deduplicate_assets(self, update_references_callback=None) -> Tuple[int, int]:
|
||||
"""
|
||||
Remove duplicate assets, keeping one canonical copy and updating references.
|
||||
|
||||
Args:
|
||||
update_references_callback: Optional callback function that takes
|
||||
(old_path, new_path) to update external references (e.g., ImageData elements)
|
||||
|
||||
Returns:
|
||||
Tuple of (files_removed, bytes_saved)
|
||||
"""
|
||||
duplicates = self.find_duplicates()
|
||||
if not duplicates:
|
||||
print("AssetManager: No duplicates found")
|
||||
return (0, 0)
|
||||
|
||||
files_removed = 0
|
||||
bytes_saved = 0
|
||||
|
||||
for md5_hash, paths in duplicates.items():
|
||||
# Sort paths to get consistent canonical path (first alphabetically)
|
||||
paths.sort()
|
||||
canonical_path = paths[0]
|
||||
|
||||
# Remove duplicates and update references
|
||||
for dup_path in paths[1:]:
|
||||
full_dup_path = self.get_absolute_path(dup_path)
|
||||
|
||||
# Get file size before deletion
|
||||
try:
|
||||
file_size = os.path.getsize(full_dup_path)
|
||||
except OSError:
|
||||
file_size = 0
|
||||
|
||||
# Update references if callback provided
|
||||
if update_references_callback:
|
||||
update_references_callback(dup_path, canonical_path)
|
||||
|
||||
# Transfer reference count to canonical path
|
||||
if dup_path in self.reference_counts:
|
||||
dup_refs = self.reference_counts[dup_path]
|
||||
if canonical_path in self.reference_counts:
|
||||
self.reference_counts[canonical_path] += dup_refs
|
||||
else:
|
||||
self.reference_counts[canonical_path] = dup_refs
|
||||
del self.reference_counts[dup_path]
|
||||
|
||||
# Delete the duplicate file
|
||||
try:
|
||||
if os.path.exists(full_dup_path):
|
||||
os.remove(full_dup_path)
|
||||
files_removed += 1
|
||||
bytes_saved += file_size
|
||||
print(f"AssetManager: Removed duplicate {dup_path} (kept {canonical_path})")
|
||||
except Exception as e:
|
||||
print(f"AssetManager: Error removing duplicate {dup_path}: {e}")
|
||||
|
||||
# Remove from hash tracking
|
||||
if dup_path in self.asset_hashes:
|
||||
del self.asset_hashes[dup_path]
|
||||
|
||||
print(f"AssetManager: Deduplication complete - removed {files_removed} files, saved {bytes_saved} bytes")
|
||||
return (files_removed, bytes_saved)
|
||||
|
||||
def get_duplicate_stats(self) -> Tuple[int, int, int]:
|
||||
"""
|
||||
Get statistics about duplicate assets without modifying anything.
|
||||
|
||||
Returns:
|
||||
Tuple of (duplicate_groups, total_duplicate_files, estimated_bytes_to_save)
|
||||
"""
|
||||
duplicates = self.find_duplicates()
|
||||
if not duplicates:
|
||||
return (0, 0, 0)
|
||||
|
||||
duplicate_groups = len(duplicates)
|
||||
total_duplicate_files = sum(len(paths) - 1 for paths in duplicates.values())
|
||||
|
||||
# Calculate bytes that would be saved
|
||||
bytes_to_save = 0
|
||||
for paths in duplicates.values():
|
||||
# Skip the first (canonical) file, count size of the rest
|
||||
for dup_path in paths[1:]:
|
||||
full_path = self.get_absolute_path(dup_path)
|
||||
try:
|
||||
bytes_to_save += os.path.getsize(full_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return (duplicate_groups, total_duplicate_files, bytes_to_save)
|
||||
|
||||
def find_unused_assets(self) -> List[str]:
|
||||
"""
|
||||
Find assets that exist in the assets folder but have no references.
|
||||
|
||||
Returns:
|
||||
List of relative paths to unused assets
|
||||
"""
|
||||
unused: list[str] = []
|
||||
|
||||
if not os.path.exists(self.assets_folder):
|
||||
return unused
|
||||
|
||||
# Get all files in assets folder
|
||||
for root, dirs, files in os.walk(self.assets_folder):
|
||||
for filename in files:
|
||||
file_path = os.path.join(root, filename)
|
||||
relative_path = os.path.relpath(file_path, self.project_folder)
|
||||
|
||||
# Check if this asset has any references
|
||||
ref_count = self.reference_counts.get(relative_path, 0)
|
||||
if ref_count <= 0:
|
||||
unused.append(relative_path)
|
||||
|
||||
if unused:
|
||||
print(f"AssetManager: Found {len(unused)} unused assets")
|
||||
|
||||
return unused
|
||||
|
||||
def get_unused_stats(self) -> Tuple[int, int]:
|
||||
"""
|
||||
Get statistics about unused assets without modifying anything.
|
||||
|
||||
Returns:
|
||||
Tuple of (unused_file_count, total_bytes)
|
||||
"""
|
||||
unused = self.find_unused_assets()
|
||||
if not unused:
|
||||
return (0, 0)
|
||||
|
||||
total_bytes = 0
|
||||
for asset_path in unused:
|
||||
full_path = self.get_absolute_path(asset_path)
|
||||
try:
|
||||
total_bytes += os.path.getsize(full_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return (len(unused), total_bytes)
|
||||
|
||||
def remove_unused_assets(self) -> Tuple[int, int]:
|
||||
"""
|
||||
Remove all unused assets from the assets folder.
|
||||
|
||||
Returns:
|
||||
Tuple of (files_removed, bytes_freed)
|
||||
"""
|
||||
unused = self.find_unused_assets()
|
||||
if not unused:
|
||||
print("AssetManager: No unused assets to remove")
|
||||
return (0, 0)
|
||||
|
||||
files_removed = 0
|
||||
bytes_freed = 0
|
||||
|
||||
for asset_path in unused:
|
||||
full_path = self.get_absolute_path(asset_path)
|
||||
|
||||
try:
|
||||
file_size = os.path.getsize(full_path)
|
||||
except OSError:
|
||||
file_size = 0
|
||||
|
||||
try:
|
||||
if os.path.exists(full_path):
|
||||
os.remove(full_path)
|
||||
files_removed += 1
|
||||
bytes_freed += file_size
|
||||
print(f"AssetManager: Removed unused asset {asset_path}")
|
||||
|
||||
# Clean up tracking
|
||||
if asset_path in self.reference_counts:
|
||||
del self.reference_counts[asset_path]
|
||||
if asset_path in self.asset_hashes:
|
||||
del self.asset_hashes[asset_path]
|
||||
|
||||
except Exception as e:
|
||||
print(f"AssetManager: Error removing unused asset {asset_path}: {e}")
|
||||
|
||||
print(f"AssetManager: Removed {files_removed} unused assets, freed {bytes_freed} bytes")
|
||||
return (files_removed, bytes_freed)
|
||||
|
||||
+186
-108
@@ -12,29 +12,72 @@ import asyncio
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from enum import IntEnum
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable, Dict, Any, Tuple
|
||||
from typing import Optional, Callable, Dict, Any, Tuple, Union
|
||||
from concurrent.futures import Future
|
||||
from collections import OrderedDict
|
||||
import threading
|
||||
|
||||
from PIL import Image
|
||||
from PyQt6.QtCore import QObject, pyqtSignal
|
||||
|
||||
from pyPhotoAlbum.image_utils import convert_to_rgba, resize_to_fit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoadPriority(Enum):
|
||||
class LoadPriority(IntEnum):
|
||||
"""Priority levels for load requests."""
|
||||
LOW = 0 # Offscreen, not visible
|
||||
NORMAL = 1 # Potentially visible soon
|
||||
HIGH = 2 # Visible on screen
|
||||
URGENT = 3 # User is actively interacting with
|
||||
|
||||
LOW = 0 # Offscreen, not visible
|
||||
NORMAL = 1 # Potentially visible soon
|
||||
HIGH = 2 # Visible on screen
|
||||
URGENT = 3 # User is actively interacting with
|
||||
|
||||
|
||||
def get_image_dimensions(image_path: str, max_size: Optional[int] = None) -> Optional[Tuple[int, int]]:
|
||||
"""
|
||||
Extract image dimensions without loading the full image.
|
||||
|
||||
Uses PIL's lazy loading to read only the header, making this a fast
|
||||
operation suitable for UI code that needs dimensions before async loading.
|
||||
|
||||
Args:
|
||||
image_path: Path to the image file (absolute or relative)
|
||||
max_size: Optional maximum dimension - if provided, dimensions are
|
||||
scaled down proportionally to fit within this limit
|
||||
|
||||
Returns:
|
||||
Tuple of (width, height) or None if the image cannot be read
|
||||
|
||||
Example:
|
||||
# Get raw dimensions
|
||||
dims = get_image_dimensions("/path/to/image.jpg")
|
||||
|
||||
# Get dimensions scaled to fit within 300px
|
||||
dims = get_image_dimensions("/path/to/image.jpg", max_size=300)
|
||||
"""
|
||||
try:
|
||||
with Image.open(image_path) as img:
|
||||
width, height = img.size
|
||||
|
||||
if max_size and (width > max_size or height > max_size):
|
||||
scale = min(max_size / width, max_size / height)
|
||||
width = int(width * scale)
|
||||
height = int(height * scale)
|
||||
|
||||
return (width, height)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not extract dimensions for {image_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(order=True)
|
||||
class LoadRequest:
|
||||
"""Request to load and process an image."""
|
||||
|
||||
priority: LoadPriority = field(compare=True)
|
||||
request_id: int = field(compare=True) # Tie-breaker for same priority
|
||||
path: Path = field(compare=False)
|
||||
@@ -68,7 +111,7 @@ class ImageCache:
|
||||
"""Estimate memory size of PIL image in bytes."""
|
||||
# PIL images are typically width * height * bytes_per_pixel
|
||||
# RGBA = 4 bytes, RGB = 3 bytes, L = 1 byte
|
||||
mode_sizes = {'RGBA': 4, 'RGB': 3, 'L': 1, 'LA': 2}
|
||||
mode_sizes = {"RGBA": 4, "RGB": 3, "L": 1, "LA": 2}
|
||||
bytes_per_pixel = mode_sizes.get(img.mode, 4)
|
||||
return img.width * img.height * bytes_per_pixel
|
||||
|
||||
@@ -121,8 +164,7 @@ class ImageCache:
|
||||
self.current_memory_bytes -= old_size
|
||||
|
||||
# Evict LRU items if needed
|
||||
while (self.current_memory_bytes + img_size > self.max_memory_bytes
|
||||
and len(self._cache) > 0):
|
||||
while self.current_memory_bytes + img_size > self.max_memory_bytes and len(self._cache) > 0:
|
||||
evicted_key, (evicted_img, evicted_size) = self._cache.popitem(last=False)
|
||||
self.current_memory_bytes -= evicted_size
|
||||
logger.debug(f"Cache EVICT: {evicted_key} ({evicted_size / 1024 / 1024:.1f}MB)")
|
||||
@@ -131,10 +173,12 @@ class ImageCache:
|
||||
self._cache[key] = (img.copy(), img_size)
|
||||
self.current_memory_bytes += img_size
|
||||
|
||||
logger.debug(f"Cache PUT: {key} ({img_size / 1024 / 1024:.1f}MB) "
|
||||
f"[Total: {self.current_memory_bytes / 1024 / 1024:.1f}MB / "
|
||||
f"{self.max_memory_bytes / 1024 / 1024:.1f}MB, "
|
||||
f"Items: {len(self._cache)}]")
|
||||
logger.debug(
|
||||
f"Cache PUT: {key} ({img_size / 1024 / 1024:.1f}MB) "
|
||||
f"[Total: {self.current_memory_bytes / 1024 / 1024:.1f}MB / "
|
||||
f"{self.max_memory_bytes / 1024 / 1024:.1f}MB, "
|
||||
f"Items: {len(self._cache)}]"
|
||||
)
|
||||
|
||||
def clear(self):
|
||||
"""Clear entire cache."""
|
||||
@@ -147,10 +191,10 @@ class ImageCache:
|
||||
"""Get cache statistics."""
|
||||
with self._lock:
|
||||
return {
|
||||
'items': len(self._cache),
|
||||
'memory_mb': self.current_memory_bytes / 1024 / 1024,
|
||||
'max_memory_mb': self.max_memory_bytes / 1024 / 1024,
|
||||
'utilization': (self.current_memory_bytes / self.max_memory_bytes) * 100
|
||||
"items": len(self._cache),
|
||||
"memory_mb": self.current_memory_bytes / 1024 / 1024,
|
||||
"max_memory_mb": self.max_memory_bytes / 1024 / 1024,
|
||||
"utilization": (self.current_memory_bytes / self.max_memory_bytes) * 100,
|
||||
}
|
||||
|
||||
|
||||
@@ -170,7 +214,7 @@ class AsyncImageLoader(QObject):
|
||||
|
||||
# Signals for Qt integration
|
||||
image_loaded = pyqtSignal(object, object, object) # (path, image, user_data)
|
||||
load_failed = pyqtSignal(object, str, object) # (path, error_msg, user_data)
|
||||
load_failed = pyqtSignal(object, str, object) # (path, error_msg, user_data)
|
||||
|
||||
def __init__(self, cache: Optional[ImageCache] = None, max_workers: int = 4):
|
||||
"""
|
||||
@@ -184,11 +228,10 @@ class AsyncImageLoader(QObject):
|
||||
|
||||
self.cache = cache or ImageCache()
|
||||
self.max_workers = max_workers
|
||||
self.executor = ThreadPoolExecutor(max_workers=max_workers,
|
||||
thread_name_prefix="ImageLoader")
|
||||
self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="ImageLoader")
|
||||
|
||||
# Priority queue and tracking
|
||||
self._queue: asyncio.PriorityQueue = None # Created when event loop starts
|
||||
self._queue: Optional[asyncio.PriorityQueue[Any]] = None # Created when event loop starts
|
||||
self._pending_requests: Dict[Path, LoadRequest] = {}
|
||||
self._active_tasks: Dict[Path, asyncio.Task] = {}
|
||||
self._next_request_id = 0
|
||||
@@ -208,9 +251,9 @@ class AsyncImageLoader(QObject):
|
||||
return
|
||||
|
||||
self._shutdown = False
|
||||
self._loop_thread = threading.Thread(target=self._run_event_loop,
|
||||
daemon=True,
|
||||
name="AsyncImageLoader-EventLoop")
|
||||
self._loop_thread = threading.Thread(
|
||||
target=self._run_event_loop, daemon=True, name="AsyncImageLoader-EventLoop"
|
||||
)
|
||||
self._loop_thread.start()
|
||||
logger.info("AsyncImageLoader event loop started")
|
||||
|
||||
@@ -222,9 +265,14 @@ class AsyncImageLoader(QObject):
|
||||
logger.info("Stopping AsyncImageLoader...")
|
||||
self._shutdown = True
|
||||
|
||||
# Cancel all active tasks
|
||||
# Cancel all active tasks and wait for them to finish
|
||||
if self._loop and not self._loop.is_closed():
|
||||
asyncio.run_coroutine_threadsafe(self._cancel_all_tasks(), self._loop)
|
||||
future = asyncio.run_coroutine_threadsafe(self._cancel_all_tasks(), self._loop)
|
||||
try:
|
||||
# Wait for cancellation to complete with timeout
|
||||
future.result(timeout=2.0)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error during task cancellation: {e}")
|
||||
|
||||
# Stop the event loop
|
||||
self._loop.call_soon_threadsafe(self._loop.stop)
|
||||
@@ -302,6 +350,10 @@ class AsyncImageLoader(QObject):
|
||||
target_size = request.target_size
|
||||
|
||||
try:
|
||||
# Check if shutting down
|
||||
if self._shutdown:
|
||||
return
|
||||
|
||||
# Check cache first
|
||||
cached_img = self.cache.get(path, target_size)
|
||||
if cached_img is not None:
|
||||
@@ -311,12 +363,11 @@ class AsyncImageLoader(QObject):
|
||||
|
||||
# Load in thread pool (I/O bound)
|
||||
loop = asyncio.get_event_loop()
|
||||
img = await loop.run_in_executor(
|
||||
self.executor,
|
||||
self._load_and_process_image,
|
||||
path,
|
||||
target_size
|
||||
)
|
||||
img = await loop.run_in_executor(self.executor, self._load_and_process_image, path, target_size)
|
||||
|
||||
# Check again if shutting down before emitting
|
||||
if self._shutdown:
|
||||
return
|
||||
|
||||
# Cache result
|
||||
self.cache.put(path, img, target_size)
|
||||
@@ -326,9 +377,16 @@ class AsyncImageLoader(QObject):
|
||||
|
||||
logger.debug(f"Loaded: {path} (size: {img.size})")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
# Task was cancelled during shutdown - this is expected
|
||||
logger.debug(f"Load cancelled for {path}")
|
||||
raise # Re-raise to properly cancel the task
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load {path}: {e}", exc_info=True)
|
||||
self._emit_failed(path, str(e), request.user_data)
|
||||
# Only emit error if not shutting down
|
||||
if not self._shutdown:
|
||||
logger.error(f"Failed to load {path}: {e}", exc_info=True)
|
||||
self._emit_failed(path, str(e), request.user_data)
|
||||
|
||||
finally:
|
||||
# Cleanup tracking
|
||||
@@ -336,7 +394,7 @@ class AsyncImageLoader(QObject):
|
||||
self._pending_requests.pop(path, None)
|
||||
self._active_tasks.pop(path, None)
|
||||
|
||||
def _load_and_process_image(self, path: Path, target_size: Optional[Tuple[int, int]]) -> Image.Image:
|
||||
def _load_and_process_image(self, path: Path, target_size: Optional[Tuple[int, int]]) -> "Image.Image":
|
||||
"""
|
||||
Load image from disk and process (runs in thread pool).
|
||||
|
||||
@@ -347,39 +405,48 @@ class AsyncImageLoader(QObject):
|
||||
Returns:
|
||||
Processed PIL Image
|
||||
"""
|
||||
# Load image
|
||||
img = Image.open(path)
|
||||
|
||||
# Convert to RGBA for consistency
|
||||
if img.mode != 'RGBA':
|
||||
img = img.convert('RGBA')
|
||||
img: Image.Image = Image.open(path)
|
||||
img = convert_to_rgba(img)
|
||||
|
||||
# Downsample if target size specified (preserving aspect ratio)
|
||||
if target_size:
|
||||
max_size = target_size[0] # Assume square target (2048, 2048)
|
||||
if img.width > max_size or img.height > max_size:
|
||||
# Calculate scale to fit within max_size while preserving aspect ratio
|
||||
scale = min(max_size / img.width, max_size / img.height)
|
||||
new_width = int(img.width * scale)
|
||||
new_height = int(img.height * scale)
|
||||
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||
logger.debug(f"Downsampled {path}: {img.size} -> ({new_width}, {new_height})")
|
||||
original_size = img.size
|
||||
img = resize_to_fit(img, max_size)
|
||||
if img.size != original_size:
|
||||
logger.debug(f"Downsampled {path}: {original_size} -> {img.size}")
|
||||
|
||||
return img
|
||||
|
||||
def _emit_loaded(self, path: Path, img: Image.Image, user_data: Any):
|
||||
"""Emit image_loaded signal (thread-safe)."""
|
||||
self.image_loaded.emit(path, img, user_data)
|
||||
# Check if object is still valid before emitting
|
||||
if self._shutdown:
|
||||
return
|
||||
try:
|
||||
self.image_loaded.emit(path, img, user_data)
|
||||
except RuntimeError as e:
|
||||
# Object was deleted - log but don't crash
|
||||
logger.debug(f"Could not emit image_loaded for {path}: {e}")
|
||||
|
||||
def _emit_failed(self, path: Path, error_msg: str, user_data: Any):
|
||||
"""Emit load_failed signal (thread-safe)."""
|
||||
self.load_failed.emit(path, error_msg, user_data)
|
||||
# Check if object is still valid before emitting
|
||||
if self._shutdown:
|
||||
return
|
||||
try:
|
||||
self.load_failed.emit(path, error_msg, user_data)
|
||||
except RuntimeError as e:
|
||||
# Object was deleted - log but don't crash
|
||||
logger.debug(f"Could not emit load_failed for {path}: {e}")
|
||||
|
||||
def request_load(self,
|
||||
path: Path,
|
||||
priority: LoadPriority = LoadPriority.NORMAL,
|
||||
target_size: Optional[Tuple[int, int]] = None,
|
||||
user_data: Any = None) -> bool:
|
||||
def request_load(
|
||||
self,
|
||||
path: Path,
|
||||
priority: LoadPriority = LoadPriority.NORMAL,
|
||||
target_size: Optional[Tuple[int, int]] = None,
|
||||
user_data: Any = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Request image load with specified priority.
|
||||
|
||||
@@ -410,7 +477,7 @@ class AsyncImageLoader(QObject):
|
||||
request_id=self._next_request_id,
|
||||
path=path,
|
||||
target_size=target_size,
|
||||
user_data=user_data
|
||||
user_data=user_data,
|
||||
)
|
||||
self._next_request_id += 1
|
||||
|
||||
@@ -418,10 +485,8 @@ class AsyncImageLoader(QObject):
|
||||
self._pending_requests[path] = request
|
||||
|
||||
# Submit to queue (thread-safe)
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self._queue.put(request),
|
||||
self._loop
|
||||
)
|
||||
assert self._queue is not None
|
||||
asyncio.run_coroutine_threadsafe(self._queue.put(request), self._loop)
|
||||
|
||||
logger.debug(f"Queued load: {path} (priority: {priority.name})")
|
||||
return True
|
||||
@@ -458,9 +523,9 @@ class AsyncImageLoader(QObject):
|
||||
"""Get loader statistics."""
|
||||
with self._lock:
|
||||
return {
|
||||
'pending': len(self._pending_requests),
|
||||
'active': len(self._active_tasks),
|
||||
'cache': self.cache.get_stats()
|
||||
"pending": len(self._pending_requests),
|
||||
"active": len(self._active_tasks),
|
||||
"cache": self.cache.get_stats(),
|
||||
}
|
||||
|
||||
|
||||
@@ -481,8 +546,8 @@ class AsyncPDFGenerator(QObject):
|
||||
|
||||
# Signals for Qt integration
|
||||
progress_updated = pyqtSignal(int, int, str) # (current, total, message)
|
||||
export_complete = pyqtSignal(bool, list) # (success, warnings)
|
||||
export_failed = pyqtSignal(str) # (error_message)
|
||||
export_complete = pyqtSignal(bool, list) # (success, warnings)
|
||||
export_failed = pyqtSignal(str) # (error_message)
|
||||
|
||||
def __init__(self, image_cache: Optional[ImageCache] = None, max_workers: int = 2):
|
||||
"""
|
||||
@@ -496,13 +561,12 @@ class AsyncPDFGenerator(QObject):
|
||||
|
||||
self.image_cache = image_cache or ImageCache()
|
||||
self.max_workers = max_workers
|
||||
self.executor = ThreadPoolExecutor(max_workers=max_workers,
|
||||
thread_name_prefix="PDFGenerator")
|
||||
self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="PDFGenerator")
|
||||
|
||||
# Export state
|
||||
self._current_export: Optional[asyncio.Task] = None
|
||||
self._current_export: Optional[Future[Any]] = None
|
||||
self._cancel_requested = False
|
||||
self._lock = threading.Lock()
|
||||
self._lock = threading.RLock() # Use RLock to allow re-entrant locking
|
||||
self._shutdown = False
|
||||
|
||||
# Event loop for async operations
|
||||
@@ -518,9 +582,9 @@ class AsyncPDFGenerator(QObject):
|
||||
return
|
||||
|
||||
self._shutdown = False
|
||||
self._loop_thread = threading.Thread(target=self._run_event_loop,
|
||||
daemon=True,
|
||||
name="AsyncPDFGenerator-EventLoop")
|
||||
self._loop_thread = threading.Thread(
|
||||
target=self._run_event_loop, daemon=True, name="AsyncPDFGenerator-EventLoop"
|
||||
)
|
||||
self._loop_thread.start()
|
||||
logger.info("AsyncPDFGenerator event loop started")
|
||||
|
||||
@@ -585,8 +649,7 @@ class AsyncPDFGenerator(QObject):
|
||||
|
||||
# Submit export task
|
||||
self._current_export = asyncio.run_coroutine_threadsafe(
|
||||
self._export_pdf_async(project, output_path, export_dpi),
|
||||
self._loop
|
||||
self._export_pdf_async(project, output_path, export_dpi), self._loop
|
||||
)
|
||||
|
||||
logger.info(f"PDF export started: {output_path}")
|
||||
@@ -618,41 +681,53 @@ class AsyncPDFGenerator(QObject):
|
||||
|
||||
# Progress callback wrapper
|
||||
def progress_callback(current, total, message):
|
||||
if self._cancel_requested:
|
||||
if self._cancel_requested or self._shutdown:
|
||||
return False # Signal cancellation
|
||||
self.progress_updated.emit(current, total, message)
|
||||
try:
|
||||
self.progress_updated.emit(current, total, message)
|
||||
except RuntimeError as e:
|
||||
# Object was deleted - log but don't crash
|
||||
logger.debug(f"Could not emit progress_updated: {e}")
|
||||
return False
|
||||
return True
|
||||
|
||||
# Run export in thread pool
|
||||
loop = asyncio.get_event_loop()
|
||||
success, warnings = await loop.run_in_executor(
|
||||
self.executor,
|
||||
self._export_with_cache,
|
||||
exporter,
|
||||
output_path,
|
||||
progress_callback
|
||||
self.executor, self._export_with_cache, exporter, output_path, progress_callback
|
||||
)
|
||||
|
||||
# Emit completion signal
|
||||
if not self._cancel_requested:
|
||||
self.export_complete.emit(success, warnings)
|
||||
logger.info(f"PDF export completed: {output_path} (warnings: {len(warnings)})")
|
||||
if not self._cancel_requested and not self._shutdown:
|
||||
try:
|
||||
self.export_complete.emit(success, warnings)
|
||||
logger.info(f"PDF export completed: {output_path} (warnings: {len(warnings)})")
|
||||
except RuntimeError as e:
|
||||
logger.debug(f"Could not emit export_complete: {e}")
|
||||
else:
|
||||
logger.info("PDF export cancelled")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("PDF export cancelled by user")
|
||||
self.export_failed.emit("Export cancelled")
|
||||
if not self._shutdown:
|
||||
try:
|
||||
self.export_failed.emit("Export cancelled")
|
||||
except RuntimeError as e:
|
||||
logger.debug(f"Could not emit export_failed: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"PDF export failed: {e}", exc_info=True)
|
||||
self.export_failed.emit(str(e))
|
||||
if not self._shutdown:
|
||||
try:
|
||||
self.export_failed.emit(str(e))
|
||||
except RuntimeError as e:
|
||||
logger.debug(f"Could not emit export_failed: {e}")
|
||||
|
||||
finally:
|
||||
with self._lock:
|
||||
self._current_export = None
|
||||
|
||||
def _export_with_cache(self, exporter, output_path: str, progress_callback) -> Tuple[bool, list]:
|
||||
def _export_with_cache(self, exporter: Any, output_path: str, progress_callback: Any) -> Tuple[bool, list[Any]]:
|
||||
"""
|
||||
Run PDF export with image cache integration.
|
||||
|
||||
@@ -671,24 +746,31 @@ class AsyncPDFGenerator(QObject):
|
||||
|
||||
# Patch Image.open to use cache
|
||||
def cached_open(path, *args, **kwargs):
|
||||
# Try cache first
|
||||
# Note: We cache the unrotated image so rotation can be applied per-element
|
||||
cached_img = self.image_cache.get(Path(path))
|
||||
if cached_img:
|
||||
logger.debug(f"PDF using cached image: {path}")
|
||||
return cached_img
|
||||
# Only use cache for file paths, not BytesIO or other file-like objects
|
||||
is_file_path = isinstance(path, (str, Path))
|
||||
|
||||
# Load and cache (unrotated - rotation is applied per-element)
|
||||
img = original_open(path, *args, **kwargs)
|
||||
if img.mode != 'RGBA':
|
||||
img = img.convert('RGBA')
|
||||
self.image_cache.put(Path(path), img)
|
||||
return img
|
||||
if is_file_path:
|
||||
# Try cache first
|
||||
# Note: We cache the unrotated image so rotation can be applied per-element
|
||||
path_obj = Path(path) if isinstance(path, str) else path
|
||||
cached_img = self.image_cache.get(path_obj)
|
||||
if cached_img:
|
||||
logger.debug(f"PDF using cached image: {path}")
|
||||
return cached_img
|
||||
|
||||
# Load and cache (unrotated - rotation is applied per-element)
|
||||
img = original_open(path, *args, **kwargs)
|
||||
img = convert_to_rgba(img)
|
||||
self.image_cache.put(path_obj, img)
|
||||
return img
|
||||
else:
|
||||
# For BytesIO and other file-like objects, just use original open
|
||||
return original_open(path, *args, **kwargs)
|
||||
|
||||
# Temporarily patch Image.open
|
||||
try:
|
||||
Image.open = cached_open
|
||||
return exporter.export(output_path, progress_callback)
|
||||
Image.open = cached_open # type: ignore[assignment]
|
||||
return exporter.export(output_path, progress_callback) # type: ignore[no-any-return]
|
||||
finally:
|
||||
# Restore original
|
||||
Image.open = original_open
|
||||
@@ -696,13 +778,9 @@ class AsyncPDFGenerator(QObject):
|
||||
def is_exporting(self) -> bool:
|
||||
"""Check if export is currently in progress."""
|
||||
with self._lock:
|
||||
return (self._current_export is not None
|
||||
and not self._current_export.done())
|
||||
return self._current_export is not None and not self._current_export.done()
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""Get generator statistics."""
|
||||
with self._lock:
|
||||
return {
|
||||
'exporting': self.is_exporting(),
|
||||
'cache': self.image_cache.get_stats()
|
||||
}
|
||||
return {"exporting": self.is_exporting(), "cache": self.image_cache.get_stats()}
|
||||
|
||||
@@ -18,7 +18,7 @@ from pyPhotoAlbum.version_manager import (
|
||||
CURRENT_DATA_VERSION,
|
||||
check_version_compatibility,
|
||||
VersionCompatibility,
|
||||
DataMigration
|
||||
DataMigration,
|
||||
)
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ class AsyncProjectLoader(QThread):
|
||||
self.progress_updated.emit(10, 100, "Extracting project files...")
|
||||
|
||||
# Extract ZIP contents with progress
|
||||
with zipfile.ZipFile(self.zip_path, 'r') as zipf:
|
||||
with zipfile.ZipFile(self.zip_path, "r") as zipf:
|
||||
file_list = zipf.namelist()
|
||||
total_files = len(file_list)
|
||||
|
||||
@@ -91,10 +91,7 @@ class AsyncProjectLoader(QThread):
|
||||
# Update progress every 10 files or on last file
|
||||
if i % 10 == 0 or i == total_files - 1:
|
||||
progress = 10 + int((i / total_files) * 30) # 10-40%
|
||||
self.progress_updated.emit(
|
||||
progress, 100,
|
||||
f"Extracting files... ({i + 1}/{total_files})"
|
||||
)
|
||||
self.progress_updated.emit(progress, 100, f"Extracting files... ({i + 1}/{total_files})")
|
||||
|
||||
if self._cancelled:
|
||||
return
|
||||
@@ -103,12 +100,12 @@ class AsyncProjectLoader(QThread):
|
||||
self.progress_updated.emit(45, 100, "Loading project data...")
|
||||
|
||||
# Load project.json
|
||||
project_json_path = os.path.join(extract_to, 'project.json')
|
||||
project_json_path = os.path.join(extract_to, "project.json")
|
||||
if not os.path.exists(project_json_path):
|
||||
self.load_failed.emit("Invalid project file: project.json not found")
|
||||
return
|
||||
|
||||
with open(project_json_path, 'r') as f:
|
||||
with open(project_json_path, "r") as f:
|
||||
project_data = json.load(f)
|
||||
|
||||
if self._cancelled:
|
||||
@@ -118,7 +115,7 @@ class AsyncProjectLoader(QThread):
|
||||
self.progress_updated.emit(55, 100, "Checking version compatibility...")
|
||||
|
||||
# Check version compatibility
|
||||
file_version = project_data.get('data_version', project_data.get('serialization_version', '1.0'))
|
||||
file_version = project_data.get("data_version", project_data.get("serialization_version", "1.0"))
|
||||
|
||||
is_compatible, error_msg = check_version_compatibility(file_version, self.zip_path)
|
||||
if not is_compatible:
|
||||
@@ -141,7 +138,7 @@ class AsyncProjectLoader(QThread):
|
||||
self.progress_updated.emit(70, 100, "Creating project...")
|
||||
|
||||
# Create new project
|
||||
project_name = project_data.get('name', 'Untitled Project')
|
||||
project_name = project_data.get("name", "Untitled Project")
|
||||
project = Project(name=project_name, folder_path=extract_to)
|
||||
|
||||
# Deserialize project data
|
||||
@@ -169,8 +166,8 @@ class AsyncProjectLoader(QThread):
|
||||
self.progress_updated.emit(95, 100, "Setting up asset resolution...")
|
||||
|
||||
# Set asset resolution context
|
||||
zip_directory = os.path.dirname(os.path.abspath(self.zip_path))
|
||||
set_asset_resolution_context(extract_to, additional_search_paths=[zip_directory])
|
||||
# Only set project folder - search paths are reserved for healing functionality
|
||||
set_asset_resolution_context(extract_to)
|
||||
|
||||
if self._cancelled:
|
||||
return
|
||||
@@ -197,14 +194,14 @@ class AsyncProjectLoader(QThread):
|
||||
original_path = element.image_path
|
||||
|
||||
# Skip if already a simple relative path
|
||||
if not os.path.isabs(original_path) and not original_path.startswith('./projects/'):
|
||||
if not os.path.isabs(original_path) and not original_path.startswith("./projects/"):
|
||||
continue
|
||||
|
||||
# Pattern 1: "./projects/XXX/assets/filename.jpg" -> "assets/filename.jpg"
|
||||
if '/assets/' in original_path:
|
||||
parts = original_path.split('/assets/')
|
||||
if "/assets/" in original_path:
|
||||
parts = original_path.split("/assets/")
|
||||
if len(parts) == 2:
|
||||
new_path = os.path.join('assets', parts[1])
|
||||
new_path = os.path.join("assets", parts[1])
|
||||
element.image_path = new_path
|
||||
normalized_count += 1
|
||||
continue
|
||||
@@ -222,9 +219,9 @@ class AsyncProjectLoader(QThread):
|
||||
print(f"Normalized {normalized_count} asset paths")
|
||||
|
||||
|
||||
def load_from_zip_async(zip_path: str, extract_to: Optional[str] = None,
|
||||
progress_callback=None, complete_callback=None,
|
||||
error_callback=None) -> AsyncProjectLoader:
|
||||
def load_from_zip_async(
|
||||
zip_path: str, extract_to: Optional[str] = None, progress_callback=None, complete_callback=None, error_callback=None
|
||||
) -> AsyncProjectLoader:
|
||||
"""
|
||||
Load a project from a ZIP file asynchronously.
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
Autosave and checkpoint management for pyPhotoAlbum.
|
||||
|
||||
This module provides automatic checkpoint creation and recovery functionality
|
||||
to prevent data loss from crashes or unexpected exits.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Optional, List, Tuple
|
||||
from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip
|
||||
|
||||
|
||||
class AutosaveManager:
|
||||
"""Manages autosave checkpoints for projects."""
|
||||
|
||||
CHECKPOINT_DIR = Path.home() / ".pyphotoalbum" / "checkpoints"
|
||||
CHECKPOINT_PREFIX = "checkpoint_"
|
||||
CHECKPOINT_EXTENSION = ".ppz"
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the autosave manager."""
|
||||
self._ensure_checkpoint_directory()
|
||||
|
||||
def _ensure_checkpoint_directory(self):
|
||||
"""Ensure the checkpoint directory exists."""
|
||||
self.CHECKPOINT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _get_checkpoint_path(self, project_name: str, timestamp: Optional[datetime] = None) -> Path:
|
||||
"""
|
||||
Get the path for a checkpoint file.
|
||||
|
||||
Args:
|
||||
project_name: Name of the project
|
||||
timestamp: Optional timestamp, defaults to current time
|
||||
|
||||
Returns:
|
||||
Path to the checkpoint file
|
||||
"""
|
||||
if timestamp is None:
|
||||
timestamp = datetime.now()
|
||||
|
||||
# Sanitize project name for filename
|
||||
safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in project_name)
|
||||
timestamp_str = timestamp.strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"{self.CHECKPOINT_PREFIX}{safe_name}_{timestamp_str}{self.CHECKPOINT_EXTENSION}"
|
||||
|
||||
return self.CHECKPOINT_DIR / filename
|
||||
|
||||
def create_checkpoint(self, project) -> Tuple[bool, str]:
|
||||
"""
|
||||
Create a checkpoint for the given project.
|
||||
|
||||
Args:
|
||||
project: Project instance to checkpoint
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, message: str)
|
||||
"""
|
||||
try:
|
||||
checkpoint_path = self._get_checkpoint_path(project.name)
|
||||
success, message = save_to_zip(project, str(checkpoint_path))
|
||||
|
||||
if success:
|
||||
# Also save metadata about this checkpoint
|
||||
self._save_checkpoint_metadata(project, checkpoint_path)
|
||||
return True, f"Checkpoint created: {checkpoint_path.name}"
|
||||
else:
|
||||
return False, f"Checkpoint failed: {message}"
|
||||
|
||||
except Exception as e:
|
||||
return False, f"Checkpoint error: {str(e)}"
|
||||
|
||||
def _save_checkpoint_metadata(self, project, checkpoint_path: Path):
|
||||
"""
|
||||
Save metadata about a checkpoint.
|
||||
|
||||
Args:
|
||||
project: Project instance
|
||||
checkpoint_path: Path to the checkpoint file
|
||||
"""
|
||||
metadata = {
|
||||
"project_name": project.name,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"checkpoint_path": str(checkpoint_path),
|
||||
"original_path": getattr(project, "file_path", None),
|
||||
}
|
||||
|
||||
metadata_path = checkpoint_path.with_suffix(".json")
|
||||
with open(metadata_path, "w") as f:
|
||||
json.dump(metadata, f, indent=2)
|
||||
|
||||
def list_checkpoints(self, project_name: Optional[str] = None) -> List[Tuple[Path, dict]]:
|
||||
"""
|
||||
List available checkpoints.
|
||||
|
||||
Args:
|
||||
project_name: Optional filter by project name
|
||||
|
||||
Returns:
|
||||
List of tuples (checkpoint_path, metadata)
|
||||
"""
|
||||
checkpoints = []
|
||||
|
||||
for checkpoint_file in self.CHECKPOINT_DIR.glob(f"{self.CHECKPOINT_PREFIX}*{self.CHECKPOINT_EXTENSION}"):
|
||||
metadata_file = checkpoint_file.with_suffix(".json")
|
||||
|
||||
# Try to load metadata
|
||||
metadata = {}
|
||||
if metadata_file.exists():
|
||||
try:
|
||||
with open(metadata_file, "r") as f:
|
||||
metadata = json.load(f)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Filter by project name if specified
|
||||
if project_name is None or metadata.get("project_name") == project_name:
|
||||
checkpoints.append((checkpoint_file, metadata))
|
||||
|
||||
# Sort by timestamp (newest first)
|
||||
checkpoints.sort(key=lambda x: x[1].get("timestamp", ""), reverse=True)
|
||||
return checkpoints
|
||||
|
||||
def load_checkpoint(self, checkpoint_path: Path):
|
||||
"""
|
||||
Load a project from a checkpoint.
|
||||
|
||||
Args:
|
||||
checkpoint_path: Path to the checkpoint file
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, project or error_message)
|
||||
"""
|
||||
try:
|
||||
project = load_from_zip(str(checkpoint_path))
|
||||
return True, project
|
||||
except Exception as e:
|
||||
return False, f"Failed to load checkpoint: {str(e)}"
|
||||
|
||||
def delete_checkpoint(self, checkpoint_path: Path) -> bool:
|
||||
"""
|
||||
Delete a checkpoint file and its metadata.
|
||||
|
||||
Args:
|
||||
checkpoint_path: Path to the checkpoint file
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
try:
|
||||
# Delete checkpoint file
|
||||
if checkpoint_path.exists():
|
||||
checkpoint_path.unlink()
|
||||
|
||||
# Delete metadata file
|
||||
metadata_path = checkpoint_path.with_suffix(".json")
|
||||
if metadata_path.exists():
|
||||
metadata_path.unlink()
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error deleting checkpoint: {e}")
|
||||
return False
|
||||
|
||||
def delete_all_checkpoints(self, project_name: Optional[str] = None):
|
||||
"""
|
||||
Delete all checkpoints, optionally filtered by project name.
|
||||
|
||||
Args:
|
||||
project_name: Optional filter by project name
|
||||
"""
|
||||
checkpoints = self.list_checkpoints(project_name)
|
||||
for checkpoint_path, _ in checkpoints:
|
||||
self.delete_checkpoint(checkpoint_path)
|
||||
|
||||
def cleanup_old_checkpoints(self, max_age_hours: int = 24 * 7, max_count: int = 50):
|
||||
"""
|
||||
Clean up old checkpoints to prevent unlimited growth.
|
||||
|
||||
Args:
|
||||
max_age_hours: Maximum age in hours (default: 7 days)
|
||||
max_count: Maximum number of checkpoints to keep per project
|
||||
"""
|
||||
now = datetime.now()
|
||||
checkpoints_by_project: Dict[str, List[Tuple[Path, dict]]] = {}
|
||||
|
||||
# Group checkpoints by project
|
||||
for checkpoint_path, metadata in self.list_checkpoints():
|
||||
project_name = metadata.get("project_name", "unknown")
|
||||
if project_name not in checkpoints_by_project:
|
||||
checkpoints_by_project[project_name] = []
|
||||
checkpoints_by_project[project_name].append((checkpoint_path, metadata))
|
||||
|
||||
# Clean up each project's checkpoints
|
||||
for project_name, checkpoints in checkpoints_by_project.items():
|
||||
# Sort by timestamp (newest first)
|
||||
checkpoints.sort(key=lambda x: x[1].get("timestamp", ""), reverse=True)
|
||||
|
||||
for idx, (checkpoint_path, metadata) in enumerate(checkpoints):
|
||||
# Delete if too old
|
||||
timestamp_str = metadata.get("timestamp")
|
||||
if timestamp_str:
|
||||
try:
|
||||
timestamp = datetime.fromisoformat(timestamp_str)
|
||||
age = now - timestamp
|
||||
if age > timedelta(hours=max_age_hours):
|
||||
self.delete_checkpoint(checkpoint_path)
|
||||
continue
|
||||
except:
|
||||
pass
|
||||
|
||||
# Delete if beyond max count
|
||||
if idx >= max_count:
|
||||
self.delete_checkpoint(checkpoint_path)
|
||||
|
||||
def has_checkpoints(self, project_name: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Check if there are any checkpoints available.
|
||||
|
||||
Args:
|
||||
project_name: Optional filter by project name
|
||||
|
||||
Returns:
|
||||
True if checkpoints exist
|
||||
"""
|
||||
return len(self.list_checkpoints(project_name)) > 0
|
||||
|
||||
def get_latest_checkpoint(self, project_name: Optional[str] = None) -> Optional[Tuple[Path, dict]]:
|
||||
"""
|
||||
Get the most recent checkpoint.
|
||||
|
||||
Args:
|
||||
project_name: Optional filter by project name
|
||||
|
||||
Returns:
|
||||
Tuple of (checkpoint_path, metadata) or None
|
||||
"""
|
||||
checkpoints = self.list_checkpoints(project_name)
|
||||
if checkpoints:
|
||||
return checkpoints[0] # Already sorted newest first
|
||||
return None
|
||||
+238
-302
@@ -2,6 +2,7 @@
|
||||
Command pattern implementation for undo/redo functionality
|
||||
"""
|
||||
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Any, List, Optional
|
||||
from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData
|
||||
@@ -10,111 +11,120 @@ from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, T
|
||||
def _normalize_asset_path(image_path: str, asset_manager) -> str:
|
||||
"""
|
||||
Convert absolute path to relative for asset manager.
|
||||
|
||||
|
||||
Args:
|
||||
image_path: Image path (absolute or relative)
|
||||
asset_manager: AssetManager instance
|
||||
|
||||
|
||||
Returns:
|
||||
Relative path suitable for asset manager
|
||||
"""
|
||||
if not asset_manager or not image_path:
|
||||
return image_path
|
||||
|
||||
import os
|
||||
|
||||
if os.path.isabs(image_path):
|
||||
return os.path.relpath(image_path, asset_manager.project_folder)
|
||||
return image_path
|
||||
|
||||
|
||||
def _deserialize_element(elem_data: Dict[str, Any]) -> BaseLayoutElement:
|
||||
"""
|
||||
Deserialize element data into the appropriate element type.
|
||||
|
||||
Args:
|
||||
elem_data: Dictionary containing serialized element data with 'type' key
|
||||
|
||||
Returns:
|
||||
Deserialized element instance (ImageData, PlaceholderData, or TextBoxData)
|
||||
|
||||
Raises:
|
||||
ValueError: If element type is unknown
|
||||
"""
|
||||
elem_type = elem_data.get("type")
|
||||
|
||||
element: BaseLayoutElement
|
||||
if elem_type == "image":
|
||||
element = ImageData()
|
||||
elif elem_type == "placeholder":
|
||||
element = PlaceholderData()
|
||||
elif elem_type == "textbox":
|
||||
element = TextBoxData()
|
||||
else:
|
||||
raise ValueError(f"Unknown element type: {elem_type}")
|
||||
|
||||
element.deserialize(elem_data)
|
||||
return element
|
||||
|
||||
|
||||
class Command(ABC):
|
||||
"""Abstract base class for all commands"""
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def execute(self):
|
||||
"""Execute the command"""
|
||||
pass
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def undo(self):
|
||||
"""Undo the command"""
|
||||
pass
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def redo(self):
|
||||
"""Redo the command (default implementation calls execute)"""
|
||||
self.execute()
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
"""Serialize command to dictionary for saving"""
|
||||
pass
|
||||
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def deserialize(data: Dict[str, Any], project) -> 'Command':
|
||||
def deserialize(data: Dict[str, Any], project) -> "Command":
|
||||
"""Deserialize command from dictionary"""
|
||||
pass
|
||||
|
||||
|
||||
class AddElementCommand(Command):
|
||||
"""Command for adding an element to a page"""
|
||||
|
||||
|
||||
def __init__(self, page_layout, element: BaseLayoutElement, asset_manager=None):
|
||||
self.page_layout = page_layout
|
||||
self.element = element
|
||||
self.executed = False
|
||||
self.asset_manager = asset_manager
|
||||
|
||||
|
||||
# Acquire reference to asset when command is created
|
||||
if self.asset_manager and isinstance(self.element, ImageData) and self.element.image_path:
|
||||
rel_path = _normalize_asset_path(self.element.image_path, self.asset_manager)
|
||||
self.asset_manager.acquire_reference(rel_path)
|
||||
|
||||
|
||||
def execute(self):
|
||||
"""Add the element to the page"""
|
||||
if not self.executed:
|
||||
self.page_layout.add_element(self.element)
|
||||
self.executed = True
|
||||
|
||||
|
||||
def undo(self):
|
||||
"""Remove the element from the page"""
|
||||
if self.executed:
|
||||
self.page_layout.remove_element(self.element)
|
||||
self.executed = False
|
||||
|
||||
|
||||
def redo(self):
|
||||
"""Re-add the element"""
|
||||
self.execute()
|
||||
|
||||
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary"""
|
||||
return {
|
||||
"type": "add_element",
|
||||
"element": self.element.serialize(),
|
||||
"executed": self.executed
|
||||
}
|
||||
|
||||
return {"type": "add_element", "element": self.element.serialize(), "executed": self.executed}
|
||||
|
||||
@staticmethod
|
||||
def deserialize(data: Dict[str, Any], project) -> 'AddElementCommand':
|
||||
def deserialize(data: Dict[str, Any], project) -> "AddElementCommand":
|
||||
"""Deserialize from dictionary"""
|
||||
# Reconstruct element from serialized data
|
||||
elem_data = data["element"]
|
||||
elem_type = elem_data.get("type")
|
||||
|
||||
if elem_type == "image":
|
||||
element = ImageData()
|
||||
elif elem_type == "placeholder":
|
||||
element = PlaceholderData()
|
||||
elif elem_type == "textbox":
|
||||
element = TextBoxData()
|
||||
else:
|
||||
raise ValueError(f"Unknown element type: {elem_type}")
|
||||
|
||||
element.deserialize(elem_data)
|
||||
|
||||
# Note: We need to find the correct page_layout
|
||||
# This will be handled by the CommandHistory deserializer
|
||||
element = _deserialize_element(data["element"])
|
||||
# Note: page_layout will be handled by the CommandHistory deserializer
|
||||
cmd = AddElementCommand(None, element)
|
||||
cmd.executed = data.get("executed", False)
|
||||
return cmd
|
||||
@@ -122,59 +132,42 @@ class AddElementCommand(Command):
|
||||
|
||||
class DeleteElementCommand(Command):
|
||||
"""Command for deleting an element from a page"""
|
||||
|
||||
|
||||
def __init__(self, page_layout, element: BaseLayoutElement, asset_manager=None):
|
||||
self.page_layout = page_layout
|
||||
self.element = element
|
||||
self.executed = False
|
||||
self.asset_manager = asset_manager
|
||||
|
||||
|
||||
# Acquire reference to asset to keep it alive while in undo history
|
||||
if self.asset_manager and isinstance(self.element, ImageData) and self.element.image_path:
|
||||
rel_path = _normalize_asset_path(self.element.image_path, self.asset_manager)
|
||||
self.asset_manager.acquire_reference(rel_path)
|
||||
|
||||
|
||||
def execute(self):
|
||||
"""Remove the element from the page"""
|
||||
if not self.executed:
|
||||
self.page_layout.remove_element(self.element)
|
||||
self.executed = True
|
||||
|
||||
|
||||
def undo(self):
|
||||
"""Re-add the element to the page"""
|
||||
if self.executed:
|
||||
self.page_layout.add_element(self.element)
|
||||
self.executed = False
|
||||
|
||||
|
||||
def redo(self):
|
||||
"""Re-remove the element"""
|
||||
self.execute()
|
||||
|
||||
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary"""
|
||||
return {
|
||||
"type": "delete_element",
|
||||
"element": self.element.serialize(),
|
||||
"executed": self.executed
|
||||
}
|
||||
|
||||
return {"type": "delete_element", "element": self.element.serialize(), "executed": self.executed}
|
||||
|
||||
@staticmethod
|
||||
def deserialize(data: Dict[str, Any], project) -> 'DeleteElementCommand':
|
||||
def deserialize(data: Dict[str, Any], project) -> "DeleteElementCommand":
|
||||
"""Deserialize from dictionary"""
|
||||
elem_data = data["element"]
|
||||
elem_type = elem_data.get("type")
|
||||
|
||||
if elem_type == "image":
|
||||
element = ImageData()
|
||||
elif elem_type == "placeholder":
|
||||
element = PlaceholderData()
|
||||
elif elem_type == "textbox":
|
||||
element = TextBoxData()
|
||||
else:
|
||||
raise ValueError(f"Unknown element type: {elem_type}")
|
||||
|
||||
element.deserialize(elem_data)
|
||||
|
||||
element = _deserialize_element(data["element"])
|
||||
cmd = DeleteElementCommand(None, element)
|
||||
cmd.executed = data.get("executed", False)
|
||||
return cmd
|
||||
@@ -182,82 +175,66 @@ class DeleteElementCommand(Command):
|
||||
|
||||
class MoveElementCommand(Command):
|
||||
"""Command for moving an element"""
|
||||
|
||||
|
||||
def __init__(self, element: BaseLayoutElement, old_position: tuple, new_position: tuple):
|
||||
self.element = element
|
||||
self.old_position = old_position
|
||||
self.new_position = new_position
|
||||
|
||||
|
||||
def execute(self):
|
||||
"""Move element to new position"""
|
||||
self.element.position = self.new_position
|
||||
|
||||
|
||||
def undo(self):
|
||||
"""Move element back to old position"""
|
||||
self.element.position = self.old_position
|
||||
|
||||
|
||||
def redo(self):
|
||||
"""Move element to new position again"""
|
||||
self.execute()
|
||||
|
||||
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary"""
|
||||
return {
|
||||
"type": "move_element",
|
||||
"element": self.element.serialize(),
|
||||
"old_position": self.old_position,
|
||||
"new_position": self.new_position
|
||||
"new_position": self.new_position,
|
||||
}
|
||||
|
||||
|
||||
@staticmethod
|
||||
def deserialize(data: Dict[str, Any], project) -> 'MoveElementCommand':
|
||||
def deserialize(data: Dict[str, Any], project) -> "MoveElementCommand":
|
||||
"""Deserialize from dictionary"""
|
||||
elem_data = data["element"]
|
||||
elem_type = elem_data.get("type")
|
||||
|
||||
if elem_type == "image":
|
||||
element = ImageData()
|
||||
elif elem_type == "placeholder":
|
||||
element = PlaceholderData()
|
||||
elif elem_type == "textbox":
|
||||
element = TextBoxData()
|
||||
else:
|
||||
raise ValueError(f"Unknown element type: {elem_type}")
|
||||
|
||||
element.deserialize(elem_data)
|
||||
|
||||
return MoveElementCommand(
|
||||
element,
|
||||
tuple(data["old_position"]),
|
||||
tuple(data["new_position"])
|
||||
)
|
||||
element = _deserialize_element(data["element"])
|
||||
return MoveElementCommand(element, tuple(data["old_position"]), tuple(data["new_position"]))
|
||||
|
||||
|
||||
class ResizeElementCommand(Command):
|
||||
"""Command for resizing an element"""
|
||||
|
||||
def __init__(self, element: BaseLayoutElement, old_position: tuple, old_size: tuple,
|
||||
new_position: tuple, new_size: tuple):
|
||||
|
||||
def __init__(
|
||||
self, element: BaseLayoutElement, old_position: tuple, old_size: tuple, new_position: tuple, new_size: tuple
|
||||
):
|
||||
self.element = element
|
||||
self.old_position = old_position
|
||||
self.old_size = old_size
|
||||
self.new_position = new_position
|
||||
self.new_size = new_size
|
||||
|
||||
|
||||
def execute(self):
|
||||
"""Resize element to new size"""
|
||||
self.element.position = self.new_position
|
||||
self.element.size = self.new_size
|
||||
|
||||
|
||||
def undo(self):
|
||||
"""Resize element back to old size"""
|
||||
self.element.position = self.old_position
|
||||
self.element.size = self.old_size
|
||||
|
||||
|
||||
def redo(self):
|
||||
"""Resize element to new size again"""
|
||||
self.execute()
|
||||
|
||||
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary"""
|
||||
return {
|
||||
@@ -266,32 +243,19 @@ class ResizeElementCommand(Command):
|
||||
"old_position": self.old_position,
|
||||
"old_size": self.old_size,
|
||||
"new_position": self.new_position,
|
||||
"new_size": self.new_size
|
||||
"new_size": self.new_size,
|
||||
}
|
||||
|
||||
|
||||
@staticmethod
|
||||
def deserialize(data: Dict[str, Any], project) -> 'ResizeElementCommand':
|
||||
def deserialize(data: Dict[str, Any], project) -> "ResizeElementCommand":
|
||||
"""Deserialize from dictionary"""
|
||||
elem_data = data["element"]
|
||||
elem_type = elem_data.get("type")
|
||||
|
||||
if elem_type == "image":
|
||||
element = ImageData()
|
||||
elif elem_type == "placeholder":
|
||||
element = PlaceholderData()
|
||||
elif elem_type == "textbox":
|
||||
element = TextBoxData()
|
||||
else:
|
||||
raise ValueError(f"Unknown element type: {elem_type}")
|
||||
|
||||
element.deserialize(elem_data)
|
||||
|
||||
element = _deserialize_element(data["element"])
|
||||
return ResizeElementCommand(
|
||||
element,
|
||||
tuple(data["old_position"]),
|
||||
tuple(data["old_size"]),
|
||||
tuple(data["new_position"]),
|
||||
tuple(data["new_size"])
|
||||
tuple(data["new_size"]),
|
||||
)
|
||||
|
||||
|
||||
@@ -308,7 +272,7 @@ class RotateElementCommand(Command):
|
||||
self.old_size = element.size
|
||||
|
||||
# For ImageData, store the old PIL rotation state
|
||||
if hasattr(element, 'pil_rotation_90'):
|
||||
if hasattr(element, "pil_rotation_90"):
|
||||
self.old_pil_rotation = element.pil_rotation_90
|
||||
else:
|
||||
self.old_pil_rotation = None
|
||||
@@ -344,9 +308,9 @@ class RotateElementCommand(Command):
|
||||
self.element.position = (center_x - h / 2, center_y - w / 2)
|
||||
|
||||
# Clear the texture so it will be reloaded with the new rotation
|
||||
if hasattr(self.element, '_texture_id'):
|
||||
if hasattr(self.element, "_texture_id"):
|
||||
del self.element._texture_id
|
||||
if hasattr(self.element, '_async_load_requested'):
|
||||
if hasattr(self.element, "_async_load_requested"):
|
||||
self.element._async_load_requested = False
|
||||
|
||||
# Keep visual rotation at 0
|
||||
@@ -376,109 +340,88 @@ class RotateElementCommand(Command):
|
||||
# For ImageData, restore PIL rotation and clear texture
|
||||
if isinstance(self.element, ImageData) and self.old_pil_rotation is not None:
|
||||
self.element.pil_rotation_90 = self.old_pil_rotation
|
||||
if hasattr(self.element, '_texture_id'):
|
||||
if hasattr(self.element, "_texture_id"):
|
||||
self.element._texture_id = None
|
||||
self.element._async_load_requested = False
|
||||
|
||||
def redo(self):
|
||||
"""Rotate element to new angle again"""
|
||||
self.execute()
|
||||
|
||||
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary"""
|
||||
return {
|
||||
"type": "rotate_element",
|
||||
"element": self.element.serialize(),
|
||||
"old_rotation": self.old_rotation,
|
||||
"new_rotation": self.new_rotation
|
||||
"new_rotation": self.new_rotation,
|
||||
}
|
||||
|
||||
|
||||
@staticmethod
|
||||
def deserialize(data: Dict[str, Any], project) -> 'RotateElementCommand':
|
||||
def deserialize(data: Dict[str, Any], project) -> "RotateElementCommand":
|
||||
"""Deserialize from dictionary"""
|
||||
elem_data = data["element"]
|
||||
elem_type = elem_data.get("type")
|
||||
|
||||
if elem_type == "image":
|
||||
element = ImageData()
|
||||
elif elem_type == "placeholder":
|
||||
element = PlaceholderData()
|
||||
elif elem_type == "textbox":
|
||||
element = TextBoxData()
|
||||
else:
|
||||
raise ValueError(f"Unknown element type: {elem_type}")
|
||||
|
||||
element.deserialize(elem_data)
|
||||
|
||||
return RotateElementCommand(
|
||||
element,
|
||||
data["old_rotation"],
|
||||
data["new_rotation"]
|
||||
)
|
||||
element = _deserialize_element(data["element"])
|
||||
return RotateElementCommand(element, data["old_rotation"], data["new_rotation"])
|
||||
|
||||
|
||||
class AdjustImageCropCommand(Command):
|
||||
"""Command for adjusting image crop/pan within frame"""
|
||||
|
||||
|
||||
def __init__(self, element: ImageData, old_crop_info: tuple, new_crop_info: tuple):
|
||||
self.element = element
|
||||
self.old_crop_info = old_crop_info
|
||||
self.new_crop_info = new_crop_info
|
||||
|
||||
|
||||
def execute(self):
|
||||
"""Apply new crop info"""
|
||||
self.element.crop_info = self.new_crop_info
|
||||
|
||||
|
||||
def undo(self):
|
||||
"""Restore old crop info"""
|
||||
self.element.crop_info = self.old_crop_info
|
||||
|
||||
|
||||
def redo(self):
|
||||
"""Apply new crop info again"""
|
||||
self.execute()
|
||||
|
||||
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary"""
|
||||
return {
|
||||
"type": "adjust_image_crop",
|
||||
"element": self.element.serialize(),
|
||||
"old_crop_info": self.old_crop_info,
|
||||
"new_crop_info": self.new_crop_info
|
||||
"new_crop_info": self.new_crop_info,
|
||||
}
|
||||
|
||||
|
||||
@staticmethod
|
||||
def deserialize(data: Dict[str, Any], project) -> 'AdjustImageCropCommand':
|
||||
def deserialize(data: Dict[str, Any], project) -> "AdjustImageCropCommand":
|
||||
"""Deserialize from dictionary"""
|
||||
elem_data = data["element"]
|
||||
element = ImageData()
|
||||
element.deserialize(elem_data)
|
||||
|
||||
return AdjustImageCropCommand(
|
||||
element,
|
||||
tuple(data["old_crop_info"]),
|
||||
tuple(data["new_crop_info"])
|
||||
)
|
||||
|
||||
return AdjustImageCropCommand(element, tuple(data["old_crop_info"]), tuple(data["new_crop_info"]))
|
||||
|
||||
|
||||
class AlignElementsCommand(Command):
|
||||
"""Command for aligning multiple elements"""
|
||||
|
||||
|
||||
def __init__(self, changes: List[tuple]):
|
||||
"""
|
||||
Args:
|
||||
changes: List of (element, old_position) tuples
|
||||
"""
|
||||
self.changes = changes
|
||||
|
||||
|
||||
def execute(self):
|
||||
"""Positions have already been set by AlignmentManager"""
|
||||
pass
|
||||
|
||||
|
||||
def undo(self):
|
||||
"""Restore old positions"""
|
||||
for element, old_position in self.changes:
|
||||
element.position = old_position
|
||||
|
||||
|
||||
def redo(self):
|
||||
"""Re-apply alignment (positions are stored in current state)"""
|
||||
# Store current positions and restore them
|
||||
@@ -488,47 +431,31 @@ class AlignElementsCommand(Command):
|
||||
# Then re-apply new positions
|
||||
for element, new_position in new_positions:
|
||||
element.position = new_position
|
||||
|
||||
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary"""
|
||||
return {
|
||||
"type": "align_elements",
|
||||
"changes": [
|
||||
{
|
||||
"element": elem.serialize(),
|
||||
"old_position": old_pos
|
||||
}
|
||||
for elem, old_pos in self.changes
|
||||
]
|
||||
"changes": [{"element": elem.serialize(), "old_position": old_pos} for elem, old_pos in self.changes],
|
||||
}
|
||||
|
||||
|
||||
@staticmethod
|
||||
def deserialize(data: Dict[str, Any], project) -> 'AlignElementsCommand':
|
||||
def deserialize(data: Dict[str, Any], project) -> "AlignElementsCommand":
|
||||
"""Deserialize from dictionary"""
|
||||
changes = []
|
||||
for change_data in data.get("changes", []):
|
||||
elem_data = change_data["element"]
|
||||
elem_type = elem_data.get("type")
|
||||
|
||||
if elem_type == "image":
|
||||
element = ImageData()
|
||||
elif elem_type == "placeholder":
|
||||
element = PlaceholderData()
|
||||
elif elem_type == "textbox":
|
||||
element = TextBoxData()
|
||||
else:
|
||||
try:
|
||||
element = _deserialize_element(change_data["element"])
|
||||
old_position = tuple(change_data["old_position"])
|
||||
changes.append((element, old_position))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
element.deserialize(elem_data)
|
||||
old_position = tuple(change_data["old_position"])
|
||||
changes.append((element, old_position))
|
||||
|
||||
return AlignElementsCommand(changes)
|
||||
|
||||
|
||||
class ResizeElementsCommand(Command):
|
||||
"""Command for resizing multiple elements"""
|
||||
|
||||
|
||||
def __init__(self, changes: List[tuple]):
|
||||
"""
|
||||
Args:
|
||||
@@ -536,131 +463,101 @@ class ResizeElementsCommand(Command):
|
||||
"""
|
||||
self.changes = changes
|
||||
self.new_states = [(elem, elem.position, elem.size) for elem, _, _ in changes]
|
||||
|
||||
|
||||
def execute(self):
|
||||
"""Sizes have already been set by AlignmentManager"""
|
||||
pass
|
||||
|
||||
|
||||
def undo(self):
|
||||
"""Restore old positions and sizes"""
|
||||
for element, old_position, old_size in self.changes:
|
||||
element.position = old_position
|
||||
element.size = old_size
|
||||
|
||||
|
||||
def redo(self):
|
||||
"""Re-apply new sizes"""
|
||||
for element, new_position, new_size in self.new_states:
|
||||
element.position = new_position
|
||||
element.size = new_size
|
||||
|
||||
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary"""
|
||||
return {
|
||||
"type": "resize_elements",
|
||||
"changes": [
|
||||
{
|
||||
"element": elem.serialize(),
|
||||
"old_position": old_pos,
|
||||
"old_size": old_size
|
||||
}
|
||||
{"element": elem.serialize(), "old_position": old_pos, "old_size": old_size}
|
||||
for elem, old_pos, old_size in self.changes
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@staticmethod
|
||||
def deserialize(data: Dict[str, Any], project) -> 'ResizeElementsCommand':
|
||||
def deserialize(data: Dict[str, Any], project) -> "ResizeElementsCommand":
|
||||
"""Deserialize from dictionary"""
|
||||
changes = []
|
||||
for change_data in data.get("changes", []):
|
||||
elem_data = change_data["element"]
|
||||
elem_type = elem_data.get("type")
|
||||
|
||||
if elem_type == "image":
|
||||
element = ImageData()
|
||||
elif elem_type == "placeholder":
|
||||
element = PlaceholderData()
|
||||
elif elem_type == "textbox":
|
||||
element = TextBoxData()
|
||||
else:
|
||||
try:
|
||||
element = _deserialize_element(change_data["element"])
|
||||
old_position = tuple(change_data["old_position"])
|
||||
old_size = tuple(change_data["old_size"])
|
||||
changes.append((element, old_position, old_size))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
element.deserialize(elem_data)
|
||||
old_position = tuple(change_data["old_position"])
|
||||
old_size = tuple(change_data["old_size"])
|
||||
changes.append((element, old_position, old_size))
|
||||
|
||||
return ResizeElementsCommand(changes)
|
||||
|
||||
|
||||
class ChangeZOrderCommand(Command):
|
||||
"""Command for changing element z-order (list position)"""
|
||||
|
||||
|
||||
def __init__(self, page_layout, element: BaseLayoutElement, old_index: int, new_index: int):
|
||||
self.page_layout = page_layout
|
||||
self.element = element
|
||||
self.old_index = old_index
|
||||
self.new_index = new_index
|
||||
|
||||
|
||||
def execute(self):
|
||||
"""Move element to new position in list"""
|
||||
elements = self.page_layout.elements
|
||||
if self.element in elements:
|
||||
elements.remove(self.element)
|
||||
elements.insert(self.new_index, self.element)
|
||||
|
||||
|
||||
def undo(self):
|
||||
"""Move element back to old position in list"""
|
||||
elements = self.page_layout.elements
|
||||
if self.element in elements:
|
||||
elements.remove(self.element)
|
||||
elements.insert(self.old_index, self.element)
|
||||
|
||||
|
||||
def redo(self):
|
||||
"""Move element to new position again"""
|
||||
self.execute()
|
||||
|
||||
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary"""
|
||||
return {
|
||||
"type": "change_zorder",
|
||||
"element": self.element.serialize(),
|
||||
"old_index": self.old_index,
|
||||
"new_index": self.new_index
|
||||
"new_index": self.new_index,
|
||||
}
|
||||
|
||||
|
||||
@staticmethod
|
||||
def deserialize(data: Dict[str, Any], project) -> 'ChangeZOrderCommand':
|
||||
def deserialize(data: Dict[str, Any], project) -> "ChangeZOrderCommand":
|
||||
"""Deserialize from dictionary"""
|
||||
elem_data = data["element"]
|
||||
elem_type = elem_data.get("type")
|
||||
|
||||
if elem_type == "image":
|
||||
element = ImageData()
|
||||
elif elem_type == "placeholder":
|
||||
element = PlaceholderData()
|
||||
elif elem_type == "textbox":
|
||||
element = TextBoxData()
|
||||
else:
|
||||
raise ValueError(f"Unknown element type: {elem_type}")
|
||||
|
||||
element.deserialize(elem_data)
|
||||
|
||||
element = _deserialize_element(data["element"])
|
||||
return ChangeZOrderCommand(
|
||||
None, # page_layout will be set by CommandHistory
|
||||
element,
|
||||
data["old_index"],
|
||||
data["new_index"]
|
||||
None, element, data["old_index"], data["new_index"] # page_layout will be set by CommandHistory
|
||||
)
|
||||
|
||||
|
||||
class StateChangeCommand(Command):
|
||||
"""
|
||||
Generic command for operations that change state.
|
||||
|
||||
|
||||
This command captures before/after snapshots of state and can restore them.
|
||||
Used by the @undoable_operation decorator.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, description: str, restore_func, before_state: Any, after_state: Any = None):
|
||||
"""
|
||||
Args:
|
||||
@@ -673,31 +570,28 @@ class StateChangeCommand(Command):
|
||||
self.restore_func = restore_func
|
||||
self.before_state = before_state
|
||||
self.after_state = after_state
|
||||
|
||||
|
||||
def execute(self):
|
||||
"""State is already applied, just store after_state if not set"""
|
||||
# After state is captured by decorator after operation runs
|
||||
pass
|
||||
|
||||
|
||||
def undo(self):
|
||||
"""Restore to before state"""
|
||||
self.restore_func(self.before_state)
|
||||
|
||||
|
||||
def redo(self):
|
||||
"""Restore to after state"""
|
||||
self.restore_func(self.after_state)
|
||||
|
||||
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary"""
|
||||
# For now, state change commands are not serialized
|
||||
# This could be enhanced later if needed
|
||||
return {
|
||||
"type": "state_change",
|
||||
"description": self.description
|
||||
}
|
||||
|
||||
return {"type": "state_change", "description": self.description}
|
||||
|
||||
@staticmethod
|
||||
def deserialize(data: Dict[str, Any], project) -> 'StateChangeCommand':
|
||||
def deserialize(data: Dict[str, Any], project) -> "StateChangeCommand":
|
||||
"""Deserialize from dictionary"""
|
||||
# Not implemented - would need to serialize state
|
||||
raise NotImplementedError("StateChangeCommand deserialization not yet supported")
|
||||
@@ -705,72 +599,86 @@ class StateChangeCommand(Command):
|
||||
|
||||
class CommandHistory:
|
||||
"""Manages undo/redo command history"""
|
||||
|
||||
def __init__(self, max_history: int = 100, asset_manager=None):
|
||||
|
||||
def __init__(self, max_history: int = 100, asset_manager=None, project=None):
|
||||
self.undo_stack: List[Command] = []
|
||||
self.redo_stack: List[Command] = []
|
||||
self.max_history = max_history
|
||||
self.asset_manager = asset_manager
|
||||
|
||||
self.project = project # Reference to project for dirty flag tracking
|
||||
|
||||
def execute(self, command: Command):
|
||||
"""Execute a command and add it to history"""
|
||||
command.execute()
|
||||
|
||||
|
||||
# When clearing redo stack, release asset references
|
||||
for cmd in self.redo_stack:
|
||||
self._release_command_assets(cmd)
|
||||
self.redo_stack.clear()
|
||||
|
||||
|
||||
self.undo_stack.append(command)
|
||||
|
||||
|
||||
# Mark project as dirty
|
||||
if self.project:
|
||||
self.project.mark_dirty()
|
||||
|
||||
# Limit history size - release assets from old commands
|
||||
if len(self.undo_stack) > self.max_history:
|
||||
old_cmd = self.undo_stack.pop(0)
|
||||
self._release_command_assets(old_cmd)
|
||||
|
||||
|
||||
def _release_command_assets(self, command: Command):
|
||||
"""Release asset references held by a command"""
|
||||
if not self.asset_manager:
|
||||
return
|
||||
|
||||
|
||||
# Release asset references for commands that hold them
|
||||
if isinstance(command, (AddElementCommand, DeleteElementCommand)):
|
||||
if isinstance(command.element, ImageData) and command.element.image_path:
|
||||
# Convert absolute path to relative for asset manager
|
||||
import os
|
||||
asset_path = command.element.image_path
|
||||
if os.path.isabs(asset_path):
|
||||
asset_path = os.path.relpath(asset_path, self.asset_manager.project_folder)
|
||||
self.asset_manager.release_reference(asset_path)
|
||||
|
||||
|
||||
def undo(self) -> bool:
|
||||
"""Undo the last command"""
|
||||
if not self.can_undo():
|
||||
return False
|
||||
|
||||
|
||||
command = self.undo_stack.pop()
|
||||
command.undo()
|
||||
self.redo_stack.append(command)
|
||||
|
||||
# Mark project as dirty
|
||||
if self.project:
|
||||
self.project.mark_dirty()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def redo(self) -> bool:
|
||||
"""Redo the last undone command"""
|
||||
if not self.can_redo():
|
||||
return False
|
||||
|
||||
|
||||
command = self.redo_stack.pop()
|
||||
command.redo()
|
||||
self.undo_stack.append(command)
|
||||
|
||||
# Mark project as dirty
|
||||
if self.project:
|
||||
self.project.mark_dirty()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def can_undo(self) -> bool:
|
||||
"""Check if undo is available"""
|
||||
return len(self.undo_stack) > 0
|
||||
|
||||
|
||||
def can_redo(self) -> bool:
|
||||
"""Check if redo is available"""
|
||||
return len(self.redo_stack) > 0
|
||||
|
||||
|
||||
def clear(self):
|
||||
"""Clear all history and release asset references"""
|
||||
# Release all asset references
|
||||
@@ -778,62 +686,90 @@ class CommandHistory:
|
||||
self._release_command_assets(cmd)
|
||||
for cmd in self.redo_stack:
|
||||
self._release_command_assets(cmd)
|
||||
|
||||
|
||||
self.undo_stack.clear()
|
||||
self.redo_stack.clear()
|
||||
|
||||
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
"""Serialize history to dictionary"""
|
||||
return {
|
||||
"undo_stack": [cmd.serialize() for cmd in self.undo_stack],
|
||||
"redo_stack": [cmd.serialize() for cmd in self.redo_stack],
|
||||
"max_history": self.max_history
|
||||
"max_history": self.max_history,
|
||||
}
|
||||
|
||||
|
||||
def deserialize(self, data: Dict[str, Any], project):
|
||||
"""Deserialize history from dictionary"""
|
||||
self.max_history = data.get("max_history", 100)
|
||||
|
||||
|
||||
# Deserialize undo stack
|
||||
self.undo_stack = []
|
||||
for cmd_data in data.get("undo_stack", []):
|
||||
cmd = self._deserialize_command(cmd_data, project)
|
||||
if cmd:
|
||||
# Fix up page_layout references for commands that need them
|
||||
self._fixup_page_layout(cmd, project)
|
||||
self.undo_stack.append(cmd)
|
||||
|
||||
|
||||
# Deserialize redo stack
|
||||
self.redo_stack = []
|
||||
for cmd_data in data.get("redo_stack", []):
|
||||
cmd = self._deserialize_command(cmd_data, project)
|
||||
if cmd:
|
||||
# Fix up page_layout references for commands that need them
|
||||
self._fixup_page_layout(cmd, project)
|
||||
self.redo_stack.append(cmd)
|
||||
|
||||
|
||||
def _fixup_page_layout(self, cmd: Command, project):
|
||||
"""
|
||||
Fix up page_layout references after deserialization.
|
||||
|
||||
Commands like AddElementCommand store page_layout as None during
|
||||
deserialization because the page_layout object doesn't exist yet.
|
||||
This method finds the correct page_layout based on the element.
|
||||
"""
|
||||
# Check if command has a page_layout attribute that's None
|
||||
if not hasattr(cmd, "page_layout") or cmd.page_layout is not None:
|
||||
return
|
||||
|
||||
# Try to find the page containing this element
|
||||
if hasattr(cmd, "element") and cmd.element:
|
||||
element = cmd.element
|
||||
for page in project.pages:
|
||||
if element in page.layout.elements:
|
||||
cmd.page_layout = page.layout
|
||||
return
|
||||
# Element not found in any page - use first page as fallback
|
||||
# This can happen for newly added elements not yet in a page
|
||||
if project.pages:
|
||||
cmd.page_layout = project.pages[0].layout
|
||||
|
||||
# Command type registry for deserialization
|
||||
_COMMAND_DESERIALIZERS = {
|
||||
"add_element": AddElementCommand.deserialize,
|
||||
"delete_element": DeleteElementCommand.deserialize,
|
||||
"move_element": MoveElementCommand.deserialize,
|
||||
"resize_element": ResizeElementCommand.deserialize,
|
||||
"rotate_element": RotateElementCommand.deserialize,
|
||||
"align_elements": AlignElementsCommand.deserialize,
|
||||
"resize_elements": ResizeElementsCommand.deserialize,
|
||||
"change_zorder": ChangeZOrderCommand.deserialize,
|
||||
"adjust_image_crop": AdjustImageCropCommand.deserialize,
|
||||
}
|
||||
|
||||
def _deserialize_command(self, data: Dict[str, Any], project) -> Optional[Command]:
|
||||
"""Deserialize a single command"""
|
||||
"""Deserialize a single command using registry pattern"""
|
||||
cmd_type = data.get("type")
|
||||
|
||||
if cmd_type is None:
|
||||
return None
|
||||
|
||||
deserializer = self._COMMAND_DESERIALIZERS.get(cmd_type)
|
||||
if not deserializer:
|
||||
print(f"Warning: Unknown command type: {cmd_type}")
|
||||
return None
|
||||
|
||||
try:
|
||||
if cmd_type == "add_element":
|
||||
return AddElementCommand.deserialize(data, project)
|
||||
elif cmd_type == "delete_element":
|
||||
return DeleteElementCommand.deserialize(data, project)
|
||||
elif cmd_type == "move_element":
|
||||
return MoveElementCommand.deserialize(data, project)
|
||||
elif cmd_type == "resize_element":
|
||||
return ResizeElementCommand.deserialize(data, project)
|
||||
elif cmd_type == "rotate_element":
|
||||
return RotateElementCommand.deserialize(data, project)
|
||||
elif cmd_type == "align_elements":
|
||||
return AlignElementsCommand.deserialize(data, project)
|
||||
elif cmd_type == "resize_elements":
|
||||
return ResizeElementsCommand.deserialize(data, project)
|
||||
elif cmd_type == "change_zorder":
|
||||
return ChangeZOrderCommand.deserialize(data, project)
|
||||
elif cmd_type == "adjust_image_crop":
|
||||
return AdjustImageCropCommand.deserialize(data, project)
|
||||
else:
|
||||
print(f"Warning: Unknown command type: {cmd_type}")
|
||||
return None
|
||||
return deserializer(data, project)
|
||||
except Exception as e:
|
||||
print(f"Error deserializing command: {e}")
|
||||
return None
|
||||
|
||||
+176
-83
@@ -2,17 +2,18 @@
|
||||
Decorator system for pyPhotoAlbum ribbon UI
|
||||
"""
|
||||
|
||||
import copy
|
||||
from functools import wraps
|
||||
from typing import Optional, Callable
|
||||
from typing import Any, Optional, Callable
|
||||
|
||||
|
||||
class RibbonAction:
|
||||
"""
|
||||
Decorator to mark methods as ribbon actions.
|
||||
|
||||
|
||||
This decorator stores metadata about UI actions that should appear in the ribbon.
|
||||
The metadata is used to auto-generate the ribbon configuration.
|
||||
|
||||
|
||||
Example:
|
||||
@RibbonAction(
|
||||
label="New",
|
||||
@@ -25,7 +26,7 @@ class RibbonAction:
|
||||
def new_project(self):
|
||||
...
|
||||
"""
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
label: str,
|
||||
@@ -36,11 +37,11 @@ class RibbonAction:
|
||||
shortcut: Optional[str] = None,
|
||||
requires_page: bool = False,
|
||||
requires_selection: bool = False,
|
||||
min_selection: int = 0
|
||||
min_selection: int = 0,
|
||||
):
|
||||
"""
|
||||
Initialize the ribbon action decorator.
|
||||
|
||||
|
||||
Args:
|
||||
label: Button label text
|
||||
tooltip: Tooltip text shown on hover
|
||||
@@ -61,35 +62,36 @@ class RibbonAction:
|
||||
self.requires_page = requires_page
|
||||
self.requires_selection = requires_selection
|
||||
self.min_selection = min_selection
|
||||
|
||||
|
||||
def __call__(self, func: Callable) -> Callable:
|
||||
"""
|
||||
Decorate the function with ribbon action metadata.
|
||||
|
||||
|
||||
Args:
|
||||
func: The function to decorate
|
||||
|
||||
|
||||
Returns:
|
||||
The decorated function with metadata attached
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
return func(*args, **kwargs)
|
||||
|
||||
|
||||
# Store metadata on wrapper function
|
||||
wrapper._ribbon_action = {
|
||||
'label': self.label,
|
||||
'tooltip': self.tooltip,
|
||||
'tab': self.tab,
|
||||
'group': self.group,
|
||||
'icon': self.icon,
|
||||
'shortcut': self.shortcut,
|
||||
'action': func.__name__,
|
||||
'requires_page': self.requires_page,
|
||||
'requires_selection': self.requires_selection,
|
||||
'min_selection': self.min_selection
|
||||
wrapper._ribbon_action = { # type: ignore[attr-defined]
|
||||
"label": self.label,
|
||||
"tooltip": self.tooltip,
|
||||
"tab": self.tab,
|
||||
"group": self.group,
|
||||
"icon": self.icon,
|
||||
"shortcut": self.shortcut,
|
||||
"action": func.__name__,
|
||||
"requires_page": self.requires_page,
|
||||
"requires_selection": self.requires_selection,
|
||||
"min_selection": self.min_selection,
|
||||
}
|
||||
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@@ -102,13 +104,13 @@ def ribbon_action(
|
||||
shortcut: Optional[str] = None,
|
||||
requires_page: bool = False,
|
||||
requires_selection: bool = False,
|
||||
min_selection: int = 0
|
||||
min_selection: int = 0,
|
||||
) -> Callable:
|
||||
"""
|
||||
Convenience function for the RibbonAction decorator.
|
||||
|
||||
|
||||
This provides a lowercase function-style interface to the decorator.
|
||||
|
||||
|
||||
Args:
|
||||
label: Button label text
|
||||
tooltip: Tooltip text shown on hover
|
||||
@@ -119,7 +121,7 @@ def ribbon_action(
|
||||
requires_page: Whether this action requires an active page
|
||||
requires_selection: Whether this action requires selected elements
|
||||
min_selection: Minimum number of selected elements required
|
||||
|
||||
|
||||
Returns:
|
||||
RibbonAction decorator instance
|
||||
"""
|
||||
@@ -132,17 +134,17 @@ def ribbon_action(
|
||||
shortcut=shortcut,
|
||||
requires_page=requires_page,
|
||||
requires_selection=requires_selection,
|
||||
min_selection=min_selection
|
||||
min_selection=min_selection,
|
||||
)
|
||||
|
||||
|
||||
class NumericalInput:
|
||||
"""
|
||||
Decorator to mark methods that require numerical width/height inputs.
|
||||
|
||||
|
||||
This decorator stores metadata about numerical input fields that should
|
||||
be presented in dialogs for methods that work with page dimensions.
|
||||
|
||||
|
||||
Example:
|
||||
@numerical_input(
|
||||
fields=[
|
||||
@@ -153,49 +155,48 @@ class NumericalInput:
|
||||
def set_page_size(self, width, height):
|
||||
...
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, fields: list):
|
||||
"""
|
||||
Initialize the numerical input decorator.
|
||||
|
||||
|
||||
Args:
|
||||
fields: List of tuples, each containing:
|
||||
(param_name, label, unit, min_value, max_value)
|
||||
"""
|
||||
self.fields = fields
|
||||
|
||||
|
||||
def __call__(self, func: Callable) -> Callable:
|
||||
"""
|
||||
Decorate the function with numerical input metadata.
|
||||
|
||||
|
||||
Args:
|
||||
func: The function to decorate
|
||||
|
||||
|
||||
Returns:
|
||||
The decorated function with metadata attached
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
return func(*args, **kwargs)
|
||||
|
||||
|
||||
# Store metadata on wrapper function
|
||||
wrapper._numerical_input = {
|
||||
'fields': self.fields
|
||||
}
|
||||
|
||||
wrapper._numerical_input = {"fields": self.fields} # type: ignore[attr-defined]
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def numerical_input(fields: list) -> Callable:
|
||||
"""
|
||||
Convenience function for the NumericalInput decorator.
|
||||
|
||||
|
||||
This provides a lowercase function-style interface to the decorator.
|
||||
|
||||
|
||||
Args:
|
||||
fields: List of tuples, each containing:
|
||||
(param_name, label, unit, min_value, max_value)
|
||||
|
||||
|
||||
Returns:
|
||||
NumericalInput decorator instance
|
||||
"""
|
||||
@@ -205,10 +206,10 @@ def numerical_input(fields: list) -> Callable:
|
||||
class UndoableOperation:
|
||||
"""
|
||||
Decorator to automatically create undo/redo commands for operations.
|
||||
|
||||
|
||||
This decorator captures state before and after an operation, then creates
|
||||
a StateChangeCommand for undo/redo functionality.
|
||||
|
||||
|
||||
Example:
|
||||
@undoable_operation(capture='page_elements')
|
||||
def apply_template(self):
|
||||
@@ -216,11 +217,11 @@ class UndoableOperation:
|
||||
self.template_manager.apply_template(...)
|
||||
# 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.
|
||||
|
||||
|
||||
Args:
|
||||
capture: What to capture for undo/redo:
|
||||
- 'page_elements': Capture elements of current page
|
||||
@@ -229,100 +230,192 @@ class UndoableOperation:
|
||||
"""
|
||||
self.capture = capture
|
||||
self.description = description
|
||||
|
||||
|
||||
def __call__(self, func: Callable) -> Callable:
|
||||
"""
|
||||
Decorate the function with automatic undo/redo.
|
||||
|
||||
|
||||
Args:
|
||||
func: The function to decorate
|
||||
|
||||
|
||||
Returns:
|
||||
The decorated function
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(self_instance, *args, **kwargs):
|
||||
# Get description
|
||||
description = self.description or func.__name__.replace('_', ' ').title()
|
||||
|
||||
description = self.description or func.__name__.replace("_", " ").title()
|
||||
|
||||
# Capture before state
|
||||
before_state = self._capture_state(self_instance, self.capture)
|
||||
|
||||
|
||||
# Execute the operation
|
||||
result = func(self_instance, *args, **kwargs)
|
||||
|
||||
# Capture after state
|
||||
|
||||
# Capture after state
|
||||
after_state = self._capture_state(self_instance, self.capture)
|
||||
|
||||
|
||||
# Create restore function
|
||||
def restore_state(state):
|
||||
self._restore_state(self_instance, self.capture, state)
|
||||
# Update view after restoring
|
||||
if hasattr(self_instance, 'update_view'):
|
||||
if hasattr(self_instance, "update_view"):
|
||||
self_instance.update_view()
|
||||
|
||||
|
||||
# Create and execute command
|
||||
from pyPhotoAlbum.commands import StateChangeCommand
|
||||
|
||||
cmd = StateChangeCommand(description, restore_state, before_state, after_state)
|
||||
|
||||
if hasattr(self_instance, 'project') and hasattr(self_instance.project, 'history'):
|
||||
|
||||
if hasattr(self_instance, "project") and hasattr(self_instance.project, "history"):
|
||||
self_instance.project.history.execute(cmd)
|
||||
print(f"Undoable operation '{description}' executed")
|
||||
|
||||
|
||||
return result
|
||||
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def _capture_state(self, instance, capture_type: str):
|
||||
"""Capture current state based on capture type"""
|
||||
if capture_type == 'page_elements':
|
||||
if capture_type == "page_elements":
|
||||
# Capture elements from current page
|
||||
current_page = instance.get_current_page() if hasattr(instance, 'get_current_page') else None
|
||||
current_page = instance.get_current_page() if hasattr(instance, "get_current_page") else None
|
||||
if current_page:
|
||||
# Deep copy elements
|
||||
import copy
|
||||
return [copy.deepcopy(elem.serialize()) for elem in current_page.layout.elements]
|
||||
return []
|
||||
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _restore_state(self, instance, capture_type: str, state):
|
||||
"""Restore state based on capture type"""
|
||||
if capture_type == 'page_elements':
|
||||
if capture_type == "page_elements":
|
||||
# Restore elements to current page
|
||||
current_page = instance.get_current_page() if hasattr(instance, 'get_current_page') else None
|
||||
current_page = instance.get_current_page() if hasattr(instance, "get_current_page") else None
|
||||
if current_page and state is not None:
|
||||
# Clear existing elements
|
||||
current_page.layout.elements.clear()
|
||||
|
||||
|
||||
# Restore elements from serialized state
|
||||
from pyPhotoAlbum.models import ImageData, PlaceholderData, TextBoxData
|
||||
from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData
|
||||
|
||||
for elem_data in state:
|
||||
elem_type = elem_data.get('type')
|
||||
if elem_type == 'image':
|
||||
elem_type = elem_data.get("type")
|
||||
elem: BaseLayoutElement
|
||||
if elem_type == "image":
|
||||
elem = ImageData()
|
||||
elif elem_type == 'placeholder':
|
||||
elif elem_type == "placeholder":
|
||||
elem = PlaceholderData()
|
||||
elif elem_type == 'textbox':
|
||||
elif elem_type == "textbox":
|
||||
elem = TextBoxData()
|
||||
else:
|
||||
continue
|
||||
|
||||
|
||||
elem.deserialize(elem_data)
|
||||
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.
|
||||
|
||||
|
||||
This provides a lowercase function-style interface to the decorator.
|
||||
|
||||
|
||||
Args:
|
||||
capture: What to capture for undo/redo
|
||||
description: Human-readable description of the operation
|
||||
|
||||
|
||||
Returns:
|
||||
UndoableOperation decorator instance
|
||||
"""
|
||||
return UndoableOperation(capture=capture, description=description)
|
||||
|
||||
|
||||
class DialogAction:
|
||||
"""
|
||||
Decorator to mark methods that should open a dialog.
|
||||
|
||||
This decorator automatically handles dialog creation and result processing,
|
||||
separating UI presentation from business logic.
|
||||
|
||||
Example:
|
||||
@dialog_action(dialog_class=PageSetupDialog)
|
||||
def page_setup(self, values):
|
||||
# Just implement the business logic
|
||||
# Dialog presentation is handled automatically
|
||||
self.apply_page_setup(values)
|
||||
"""
|
||||
|
||||
def __init__(self, dialog_class: type, requires_pages: bool = True):
|
||||
"""
|
||||
Initialize the dialog action decorator.
|
||||
|
||||
Args:
|
||||
dialog_class: The dialog class to instantiate
|
||||
requires_pages: Whether this action requires pages to exist
|
||||
"""
|
||||
self.dialog_class = dialog_class
|
||||
self.requires_pages = requires_pages
|
||||
|
||||
def __call__(self, func: Callable) -> Callable:
|
||||
"""
|
||||
Decorate the function with automatic dialog handling.
|
||||
|
||||
Args:
|
||||
func: The function to decorate (receives dialog values)
|
||||
|
||||
Returns:
|
||||
The decorated function
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(self_instance, *args, **kwargs):
|
||||
# Check preconditions
|
||||
if self.requires_pages and not self_instance.project.pages:
|
||||
return
|
||||
|
||||
# Get initial page index if available
|
||||
initial_page_index = 0
|
||||
if hasattr(self_instance, "_get_most_visible_page_index"):
|
||||
initial_page_index = self_instance._get_most_visible_page_index()
|
||||
|
||||
# Create and show dialog
|
||||
from pyPhotoAlbum.mixins.dialog_mixin import DialogMixin
|
||||
|
||||
# Create dialog
|
||||
dialog = self.dialog_class(
|
||||
parent=self_instance, project=self_instance.project, initial_page_index=initial_page_index, **kwargs
|
||||
)
|
||||
|
||||
# Show dialog and get result
|
||||
from PyQt6.QtWidgets import QDialog
|
||||
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
# Get values from dialog
|
||||
if hasattr(dialog, "get_values"):
|
||||
values = dialog.get_values()
|
||||
# Call the decorated function with values
|
||||
return func(self_instance, values, *args, **kwargs)
|
||||
else:
|
||||
return func(self_instance, *args, **kwargs)
|
||||
|
||||
return None
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def dialog_action(dialog_class: type, requires_pages: bool = True) -> Callable:
|
||||
"""
|
||||
Convenience function for the DialogAction decorator.
|
||||
|
||||
This provides a lowercase function-style interface to the decorator.
|
||||
|
||||
Args:
|
||||
dialog_class: The dialog class to instantiate
|
||||
requires_pages: Whether this action requires pages to exist
|
||||
|
||||
Returns:
|
||||
DialogAction decorator instance
|
||||
"""
|
||||
return DialogAction(dialog_class=dialog_class, requires_pages=requires_pages)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Dialog classes for pyPhotoAlbum
|
||||
|
||||
This package contains reusable dialog classes that encapsulate
|
||||
UI presentation logic separately from business logic.
|
||||
"""
|
||||
|
||||
from .page_setup_dialog import PageSetupDialog
|
||||
from .print_settings_dialog import PrintSettingsDialog
|
||||
|
||||
__all__ = ["PageSetupDialog", "PrintSettingsDialog"]
|
||||
@@ -0,0 +1,352 @@
|
||||
"""
|
||||
Frame picker dialog for pyPhotoAlbum
|
||||
|
||||
Dialog for selecting decorative frames to apply to images.
|
||||
"""
|
||||
|
||||
from typing import Optional, Tuple
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QTabWidget,
|
||||
QWidget,
|
||||
QGridLayout,
|
||||
QScrollArea,
|
||||
QFrame,
|
||||
QGroupBox,
|
||||
QCheckBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
from PyQt6.QtGui import QPainter, QColor, QPen
|
||||
|
||||
from pyPhotoAlbum.frame_manager import get_frame_manager, FrameCategory, FrameDefinition, FrameType
|
||||
|
||||
|
||||
class FramePreviewWidget(QFrame):
|
||||
"""Widget that shows a preview of a frame"""
|
||||
|
||||
clicked = pyqtSignal(str) # Emits frame name when clicked
|
||||
|
||||
def __init__(self, frame: FrameDefinition, parent=None):
|
||||
super().__init__(parent)
|
||||
self.frame = frame
|
||||
self.selected = False
|
||||
self.setFixedSize(100, 100)
|
||||
self.setFrameStyle(QFrame.Shape.Box)
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
|
||||
def paintEvent(self, event):
|
||||
super().paintEvent(event)
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
|
||||
# Background
|
||||
if self.selected:
|
||||
painter.fillRect(self.rect(), QColor(200, 220, 255))
|
||||
else:
|
||||
painter.fillRect(self.rect(), QColor(245, 245, 245))
|
||||
|
||||
# Draw a simple preview of the frame style
|
||||
margin = 15
|
||||
rect = self.rect().adjusted(margin, margin, -margin, -margin)
|
||||
|
||||
# Draw "photo" placeholder
|
||||
painter.fillRect(rect, QColor(180, 200, 220))
|
||||
|
||||
# Draw frame preview based on type
|
||||
pen = QPen(QColor(80, 80, 80))
|
||||
pen.setWidth(2)
|
||||
painter.setPen(pen)
|
||||
|
||||
if self.frame.frame_type.value == "corners":
|
||||
# Draw corner decorations
|
||||
corner_size = 12
|
||||
x, y, w, h = rect.x(), rect.y(), rect.width(), rect.height()
|
||||
|
||||
# Top-left
|
||||
painter.drawLine(x, y + corner_size, x, y)
|
||||
painter.drawLine(x, y, x + corner_size, y)
|
||||
|
||||
# Top-right
|
||||
painter.drawLine(x + w - corner_size, y, x + w, y)
|
||||
painter.drawLine(x + w, y, x + w, y + corner_size)
|
||||
|
||||
# Bottom-right
|
||||
painter.drawLine(x + w, y + h - corner_size, x + w, y + h)
|
||||
painter.drawLine(x + w, y + h, x + w - corner_size, y + h)
|
||||
|
||||
# Bottom-left
|
||||
painter.drawLine(x + corner_size, y + h, x, y + h)
|
||||
painter.drawLine(x, y + h, x, y + h - corner_size)
|
||||
|
||||
else:
|
||||
# Draw full border
|
||||
painter.drawRect(rect.adjusted(-3, -3, 3, 3))
|
||||
painter.drawRect(rect)
|
||||
|
||||
# Draw frame name
|
||||
painter.setPen(QColor(0, 0, 0))
|
||||
text_rect = self.rect().adjusted(0, 0, 0, 0)
|
||||
text_rect.setTop(self.rect().bottom() - 20)
|
||||
painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, self.frame.display_name)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
self.clicked.emit(self.frame.name)
|
||||
|
||||
def set_selected(self, selected: bool):
|
||||
self.selected = selected
|
||||
self.update()
|
||||
|
||||
|
||||
class FramePickerDialog(QDialog):
|
||||
"""Dialog for selecting a decorative frame"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent,
|
||||
current_frame: Optional[str] = None,
|
||||
current_color: Tuple[int, int, int] = (0, 0, 0),
|
||||
current_corners: Tuple[bool, bool, bool, bool] = (True, True, True, True),
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Select Frame")
|
||||
self.setMinimumSize(500, 500)
|
||||
|
||||
self.selected_frame: Optional[str] = current_frame
|
||||
self.frame_color = current_color
|
||||
self.frame_corners = current_corners # (TL, TR, BR, BL)
|
||||
self.frame_widgets: dict[str, FramePreviewWidget] = {}
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Tab widget for categories
|
||||
self.tab_widget = QTabWidget()
|
||||
|
||||
# All frames tab
|
||||
all_tab = self._create_category_tab(None)
|
||||
self.tab_widget.addTab(all_tab, "All")
|
||||
|
||||
# Category tabs
|
||||
for category in FrameCategory:
|
||||
tab = self._create_category_tab(category)
|
||||
self.tab_widget.addTab(tab, category.value.title())
|
||||
|
||||
layout.addWidget(self.tab_widget)
|
||||
|
||||
# Selected frame info
|
||||
info_group = QGroupBox("Selected Frame")
|
||||
info_layout = QVBoxLayout(info_group)
|
||||
|
||||
# Frame name and color row
|
||||
name_color_layout = QHBoxLayout()
|
||||
self.selected_label = QLabel("None")
|
||||
name_color_layout.addWidget(self.selected_label)
|
||||
|
||||
# Color button
|
||||
from pyPhotoAlbum.dialogs.style_dialogs import ColorButton
|
||||
|
||||
name_color_layout.addWidget(QLabel("Color:"))
|
||||
self.color_btn = ColorButton(self.frame_color)
|
||||
name_color_layout.addWidget(self.color_btn)
|
||||
name_color_layout.addStretch()
|
||||
info_layout.addLayout(name_color_layout)
|
||||
|
||||
# Corner selection (for corner-type frames)
|
||||
self.corners_group = QGroupBox("Corner Decorations")
|
||||
corners_layout = QGridLayout(self.corners_group)
|
||||
|
||||
# Create a visual grid for corner checkboxes
|
||||
self.corner_tl = QCheckBox("Top-Left")
|
||||
self.corner_tl.setChecked(self.frame_corners[0])
|
||||
self.corner_tl.stateChanged.connect(self._update_corners)
|
||||
|
||||
self.corner_tr = QCheckBox("Top-Right")
|
||||
self.corner_tr.setChecked(self.frame_corners[1])
|
||||
self.corner_tr.stateChanged.connect(self._update_corners)
|
||||
|
||||
self.corner_br = QCheckBox("Bottom-Right")
|
||||
self.corner_br.setChecked(self.frame_corners[2])
|
||||
self.corner_br.stateChanged.connect(self._update_corners)
|
||||
|
||||
self.corner_bl = QCheckBox("Bottom-Left")
|
||||
self.corner_bl.setChecked(self.frame_corners[3])
|
||||
self.corner_bl.stateChanged.connect(self._update_corners)
|
||||
|
||||
corners_layout.addWidget(self.corner_tl, 0, 0)
|
||||
corners_layout.addWidget(self.corner_tr, 0, 1)
|
||||
corners_layout.addWidget(self.corner_bl, 1, 0)
|
||||
corners_layout.addWidget(self.corner_br, 1, 1)
|
||||
|
||||
# Quick selection buttons
|
||||
quick_btns_layout = QHBoxLayout()
|
||||
all_btn = QPushButton("All")
|
||||
all_btn.clicked.connect(self._select_all_corners)
|
||||
none_btn = QPushButton("None")
|
||||
none_btn.clicked.connect(self._select_no_corners)
|
||||
diag_btn = QPushButton("Diagonal")
|
||||
diag_btn.clicked.connect(self._select_diagonal_corners)
|
||||
quick_btns_layout.addWidget(all_btn)
|
||||
quick_btns_layout.addWidget(none_btn)
|
||||
quick_btns_layout.addWidget(diag_btn)
|
||||
quick_btns_layout.addStretch()
|
||||
corners_layout.addLayout(quick_btns_layout, 2, 0, 1, 2)
|
||||
|
||||
info_layout.addWidget(self.corners_group)
|
||||
|
||||
layout.addWidget(info_group)
|
||||
|
||||
# Update corners group visibility based on frame type
|
||||
self._update_corners_visibility()
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
clear_btn = QPushButton("No Frame")
|
||||
clear_btn.clicked.connect(self._clear_selection)
|
||||
button_layout.addWidget(clear_btn)
|
||||
|
||||
button_layout.addStretch()
|
||||
|
||||
ok_btn = QPushButton("OK")
|
||||
ok_btn.clicked.connect(self.accept)
|
||||
button_layout.addWidget(ok_btn)
|
||||
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
button_layout.addWidget(cancel_btn)
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
# Update selection display
|
||||
self._update_selection_display()
|
||||
|
||||
def _create_category_tab(self, category: Optional[FrameCategory]) -> QWidget:
|
||||
"""Create a tab for a frame category"""
|
||||
widget = QWidget()
|
||||
layout = QVBoxLayout(widget)
|
||||
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
|
||||
content = QWidget()
|
||||
grid = QGridLayout(content)
|
||||
grid.setSpacing(10)
|
||||
|
||||
frame_manager = get_frame_manager()
|
||||
|
||||
if category:
|
||||
frames = frame_manager.get_frames_by_category(category)
|
||||
else:
|
||||
frames = frame_manager.get_all_frames()
|
||||
|
||||
row, col = 0, 0
|
||||
max_cols = 4
|
||||
|
||||
for frame in frames:
|
||||
preview = FramePreviewWidget(frame)
|
||||
preview.clicked.connect(self._on_frame_clicked)
|
||||
|
||||
if frame.name == self.selected_frame:
|
||||
preview.set_selected(True)
|
||||
|
||||
grid.addWidget(preview, row, col)
|
||||
self.frame_widgets[frame.name] = preview
|
||||
|
||||
col += 1
|
||||
if col >= max_cols:
|
||||
col = 0
|
||||
row += 1
|
||||
|
||||
# Add stretch at the bottom
|
||||
grid.setRowStretch(row + 1, 1)
|
||||
|
||||
scroll.setWidget(content)
|
||||
layout.addWidget(scroll)
|
||||
|
||||
return widget
|
||||
|
||||
def _on_frame_clicked(self, frame_name: str):
|
||||
"""Handle frame selection"""
|
||||
# Deselect previous
|
||||
if self.selected_frame and self.selected_frame in self.frame_widgets:
|
||||
self.frame_widgets[self.selected_frame].set_selected(False)
|
||||
|
||||
# Select new
|
||||
self.selected_frame = frame_name
|
||||
if frame_name in self.frame_widgets:
|
||||
self.frame_widgets[frame_name].set_selected(True)
|
||||
|
||||
self._update_selection_display()
|
||||
self._update_corners_visibility()
|
||||
|
||||
def _clear_selection(self):
|
||||
"""Clear frame selection"""
|
||||
if self.selected_frame and self.selected_frame in self.frame_widgets:
|
||||
self.frame_widgets[self.selected_frame].set_selected(False)
|
||||
self.selected_frame = None
|
||||
self._update_selection_display()
|
||||
self._update_corners_visibility()
|
||||
|
||||
def _update_selection_display(self):
|
||||
"""Update the selected frame label"""
|
||||
if self.selected_frame:
|
||||
frame = get_frame_manager().get_frame(self.selected_frame)
|
||||
if frame:
|
||||
self.selected_label.setText(f"{frame.display_name} - {frame.description}")
|
||||
else:
|
||||
self.selected_label.setText(self.selected_frame)
|
||||
else:
|
||||
self.selected_label.setText("None")
|
||||
|
||||
def _update_corners(self):
|
||||
"""Update corner selection from checkboxes"""
|
||||
self.frame_corners = (
|
||||
self.corner_tl.isChecked(),
|
||||
self.corner_tr.isChecked(),
|
||||
self.corner_br.isChecked(),
|
||||
self.corner_bl.isChecked(),
|
||||
)
|
||||
|
||||
def _update_corners_visibility(self):
|
||||
"""Show/hide corners group based on selected frame type"""
|
||||
if self.selected_frame:
|
||||
frame = get_frame_manager().get_frame(self.selected_frame)
|
||||
if frame and frame.frame_type == FrameType.CORNERS:
|
||||
self.corners_group.setVisible(True)
|
||||
return
|
||||
self.corners_group.setVisible(False)
|
||||
|
||||
def _select_all_corners(self):
|
||||
"""Select all corners"""
|
||||
self.corner_tl.setChecked(True)
|
||||
self.corner_tr.setChecked(True)
|
||||
self.corner_br.setChecked(True)
|
||||
self.corner_bl.setChecked(True)
|
||||
self._update_corners()
|
||||
|
||||
def _select_no_corners(self):
|
||||
"""Deselect all corners"""
|
||||
self.corner_tl.setChecked(False)
|
||||
self.corner_tr.setChecked(False)
|
||||
self.corner_br.setChecked(False)
|
||||
self.corner_bl.setChecked(False)
|
||||
self._update_corners()
|
||||
|
||||
def _select_diagonal_corners(self):
|
||||
"""Select diagonal corners (TL and BR)"""
|
||||
self.corner_tl.setChecked(True)
|
||||
self.corner_tr.setChecked(False)
|
||||
self.corner_br.setChecked(True)
|
||||
self.corner_bl.setChecked(False)
|
||||
self._update_corners()
|
||||
|
||||
def get_values(self) -> Tuple[Optional[str], Tuple[int, int, int], Tuple[bool, bool, bool, bool]]:
|
||||
"""Get selected frame name, color, and corner configuration"""
|
||||
return self.selected_frame, self.color_btn.get_color(), self.frame_corners
|
||||
@@ -0,0 +1,340 @@
|
||||
"""
|
||||
Page Setup Dialog for pyPhotoAlbum
|
||||
|
||||
Encapsulates all UI logic for page setup configuration,
|
||||
separating presentation from business logic.
|
||||
"""
|
||||
|
||||
import math
|
||||
from typing import Optional, Dict, Any
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QDoubleSpinBox,
|
||||
QSpinBox,
|
||||
QPushButton,
|
||||
QGroupBox,
|
||||
QComboBox,
|
||||
QCheckBox,
|
||||
QRadioButton,
|
||||
QButtonGroup,
|
||||
)
|
||||
from pyPhotoAlbum.project import Project
|
||||
|
||||
|
||||
class PageSetupDialog(QDialog):
|
||||
"""
|
||||
Dialog for configuring page settings.
|
||||
|
||||
This dialog handles all UI presentation logic for page setup,
|
||||
including page size, DPI settings, and cover configuration.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, project: Project, initial_page_index: int = 0):
|
||||
"""
|
||||
Initialize the page setup dialog.
|
||||
|
||||
Args:
|
||||
parent: Parent widget
|
||||
project: Project instance containing pages and settings
|
||||
initial_page_index: Index of page to initially select
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.project = project
|
||||
self.initial_page_index = initial_page_index
|
||||
|
||||
self._setup_ui()
|
||||
self._connect_signals()
|
||||
self._initialize_values()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Create and layout all UI components."""
|
||||
self.setWindowTitle("Page Setup")
|
||||
self.setMinimumWidth(450)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Page selection group
|
||||
self._page_select_group = self._create_page_selection_group()
|
||||
layout.addWidget(self._page_select_group)
|
||||
|
||||
# Cover settings group
|
||||
self._cover_group = self._create_cover_settings_group()
|
||||
layout.addWidget(self._cover_group)
|
||||
|
||||
# Page size group
|
||||
self._size_group = self._create_page_size_group()
|
||||
layout.addWidget(self._size_group)
|
||||
|
||||
# DPI settings group
|
||||
self._dpi_group = self._create_dpi_settings_group()
|
||||
layout.addWidget(self._dpi_group)
|
||||
|
||||
# Buttons
|
||||
button_layout = self._create_button_layout()
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def _create_page_selection_group(self) -> QGroupBox:
|
||||
"""Create the page selection group."""
|
||||
group = QGroupBox("Select Page")
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Page combo box
|
||||
self.page_combo = QComboBox()
|
||||
for i, page in enumerate(self.project.pages):
|
||||
page_label = self.project.get_page_display_name(page)
|
||||
if page.is_double_spread and not page.is_cover:
|
||||
page_label += " (Double Spread)"
|
||||
if page.manually_sized:
|
||||
page_label += " *"
|
||||
self.page_combo.addItem(page_label, i)
|
||||
layout.addWidget(self.page_combo)
|
||||
|
||||
# Info label
|
||||
info_label = QLabel("* = Manually sized page")
|
||||
info_label.setStyleSheet("font-size: 9pt; color: gray;")
|
||||
layout.addWidget(info_label)
|
||||
|
||||
group.setLayout(layout)
|
||||
return group
|
||||
|
||||
def _create_cover_settings_group(self) -> QGroupBox:
|
||||
"""Create the cover settings group."""
|
||||
group = QGroupBox("Cover Settings")
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Cover checkbox
|
||||
self.cover_checkbox = QCheckBox("Designate as Cover")
|
||||
self.cover_checkbox.setToolTip("Mark this page as the book cover with wrap-around front/spine/back")
|
||||
layout.addWidget(self.cover_checkbox)
|
||||
|
||||
# Paper thickness
|
||||
thickness_layout = QHBoxLayout()
|
||||
thickness_layout.addWidget(QLabel("Paper Thickness:"))
|
||||
self.thickness_spinbox = QDoubleSpinBox()
|
||||
self.thickness_spinbox.setRange(0.05, 1.0)
|
||||
self.thickness_spinbox.setSingleStep(0.05)
|
||||
self.thickness_spinbox.setValue(self.project.paper_thickness_mm)
|
||||
self.thickness_spinbox.setSuffix(" mm")
|
||||
self.thickness_spinbox.setToolTip("Thickness of paper for spine calculation")
|
||||
thickness_layout.addWidget(self.thickness_spinbox)
|
||||
layout.addLayout(thickness_layout)
|
||||
|
||||
# Bleed margin
|
||||
bleed_layout = QHBoxLayout()
|
||||
bleed_layout.addWidget(QLabel("Bleed Margin:"))
|
||||
self.bleed_spinbox = QDoubleSpinBox()
|
||||
self.bleed_spinbox.setRange(0, 10)
|
||||
self.bleed_spinbox.setSingleStep(0.5)
|
||||
self.bleed_spinbox.setValue(self.project.cover_bleed_mm)
|
||||
self.bleed_spinbox.setSuffix(" mm")
|
||||
self.bleed_spinbox.setToolTip("Extra margin around cover for printing bleed")
|
||||
bleed_layout.addWidget(self.bleed_spinbox)
|
||||
layout.addLayout(bleed_layout)
|
||||
|
||||
# Calculated spine width display
|
||||
self.spine_info_label = QLabel()
|
||||
self.spine_info_label.setStyleSheet("font-size: 9pt; color: #0066cc; padding: 5px;")
|
||||
self.spine_info_label.setWordWrap(True)
|
||||
layout.addWidget(self.spine_info_label)
|
||||
|
||||
group.setLayout(layout)
|
||||
return group
|
||||
|
||||
def _create_page_size_group(self) -> QGroupBox:
|
||||
"""Create the page size group."""
|
||||
group = QGroupBox("Page Size")
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Width
|
||||
width_layout = QHBoxLayout()
|
||||
width_layout.addWidget(QLabel("Width:"))
|
||||
self.width_spinbox = QDoubleSpinBox()
|
||||
self.width_spinbox.setRange(10, 1000)
|
||||
self.width_spinbox.setSuffix(" mm")
|
||||
width_layout.addWidget(self.width_spinbox)
|
||||
layout.addLayout(width_layout)
|
||||
|
||||
# Height
|
||||
height_layout = QHBoxLayout()
|
||||
height_layout.addWidget(QLabel("Height:"))
|
||||
self.height_spinbox = QDoubleSpinBox()
|
||||
self.height_spinbox.setRange(10, 1000)
|
||||
self.height_spinbox.setSuffix(" mm")
|
||||
height_layout.addWidget(self.height_spinbox)
|
||||
layout.addLayout(height_layout)
|
||||
|
||||
# Apply scope radio buttons
|
||||
scope_label = QLabel("Apply to:")
|
||||
layout.addWidget(scope_label)
|
||||
|
||||
self._apply_scope_group = QButtonGroup(self)
|
||||
self.scope_page_only = QRadioButton("This page only")
|
||||
self.scope_non_manual = QRadioButton("All non-manual pages")
|
||||
self.scope_all_pages = QRadioButton("All pages (override manual sizing)")
|
||||
self.scope_page_only.setChecked(True)
|
||||
|
||||
self._apply_scope_group.addButton(self.scope_page_only, 0)
|
||||
self._apply_scope_group.addButton(self.scope_non_manual, 1)
|
||||
self._apply_scope_group.addButton(self.scope_all_pages, 2)
|
||||
|
||||
layout.addWidget(self.scope_page_only)
|
||||
layout.addWidget(self.scope_non_manual)
|
||||
layout.addWidget(self.scope_all_pages)
|
||||
|
||||
group.setLayout(layout)
|
||||
return group
|
||||
|
||||
def _create_dpi_settings_group(self) -> QGroupBox:
|
||||
"""Create the DPI settings group."""
|
||||
group = QGroupBox("DPI Settings")
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Working DPI
|
||||
working_dpi_layout = QHBoxLayout()
|
||||
working_dpi_layout.addWidget(QLabel("Working DPI:"))
|
||||
self.working_dpi_spinbox = QSpinBox()
|
||||
self.working_dpi_spinbox.setRange(72, 1200)
|
||||
self.working_dpi_spinbox.setValue(self.project.working_dpi)
|
||||
working_dpi_layout.addWidget(self.working_dpi_spinbox)
|
||||
layout.addLayout(working_dpi_layout)
|
||||
|
||||
# Export DPI
|
||||
export_dpi_layout = QHBoxLayout()
|
||||
export_dpi_layout.addWidget(QLabel("Export DPI:"))
|
||||
self.export_dpi_spinbox = QSpinBox()
|
||||
self.export_dpi_spinbox.setRange(72, 1200)
|
||||
self.export_dpi_spinbox.setValue(self.project.export_dpi)
|
||||
export_dpi_layout.addWidget(self.export_dpi_spinbox)
|
||||
layout.addLayout(export_dpi_layout)
|
||||
|
||||
group.setLayout(layout)
|
||||
return group
|
||||
|
||||
def _create_button_layout(self) -> QHBoxLayout:
|
||||
"""Create dialog button layout."""
|
||||
layout = QHBoxLayout()
|
||||
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
|
||||
ok_btn = QPushButton("OK")
|
||||
ok_btn.clicked.connect(self.accept)
|
||||
ok_btn.setDefault(True)
|
||||
|
||||
layout.addStretch()
|
||||
layout.addWidget(cancel_btn)
|
||||
layout.addWidget(ok_btn)
|
||||
|
||||
return layout
|
||||
|
||||
def _connect_signals(self):
|
||||
"""Connect widget signals to handlers."""
|
||||
self.page_combo.currentIndexChanged.connect(self._on_page_changed)
|
||||
self.cover_checkbox.stateChanged.connect(self._update_spine_info)
|
||||
self.thickness_spinbox.valueChanged.connect(self._update_spine_info)
|
||||
self.bleed_spinbox.valueChanged.connect(self._update_spine_info)
|
||||
|
||||
def _initialize_values(self):
|
||||
"""Initialize dialog values based on current page."""
|
||||
# Set initial page selection
|
||||
if 0 <= self.initial_page_index < len(self.project.pages):
|
||||
self.page_combo.setCurrentIndex(self.initial_page_index)
|
||||
|
||||
# Trigger initial page change to populate values
|
||||
self._on_page_changed(self.initial_page_index)
|
||||
|
||||
def _on_page_changed(self, index: int):
|
||||
"""
|
||||
Handle page selection change.
|
||||
|
||||
Args:
|
||||
index: Index of selected page
|
||||
"""
|
||||
if index < 0 or index >= len(self.project.pages):
|
||||
return
|
||||
|
||||
selected_page = self.project.pages[index]
|
||||
is_first_page = index == 0
|
||||
|
||||
# Show/hide cover settings based on page selection
|
||||
self._cover_group.setVisible(is_first_page)
|
||||
|
||||
# Update cover checkbox
|
||||
if is_first_page:
|
||||
self.cover_checkbox.setChecked(selected_page.is_cover)
|
||||
self._update_spine_info()
|
||||
|
||||
# Get display width (accounting for double spreads and covers)
|
||||
if selected_page.is_cover:
|
||||
# For covers, show the full calculated width
|
||||
display_width = selected_page.layout.size[0]
|
||||
elif selected_page.is_double_spread:
|
||||
display_width = (
|
||||
selected_page.layout.base_width
|
||||
if hasattr(selected_page.layout, "base_width")
|
||||
else selected_page.layout.size[0] / 2
|
||||
)
|
||||
else:
|
||||
display_width = selected_page.layout.size[0]
|
||||
|
||||
self.width_spinbox.setValue(display_width)
|
||||
self.height_spinbox.setValue(selected_page.layout.size[1])
|
||||
|
||||
# Disable size editing for covers (auto-calculated)
|
||||
is_cover = selected_page.is_cover
|
||||
self.width_spinbox.setEnabled(not is_cover)
|
||||
self.height_spinbox.setEnabled(not is_cover)
|
||||
self.scope_non_manual.setEnabled(not is_cover)
|
||||
self.scope_all_pages.setEnabled(not is_cover)
|
||||
if is_cover:
|
||||
self.scope_page_only.setChecked(True)
|
||||
|
||||
def _update_spine_info(self):
|
||||
"""Update the spine information display."""
|
||||
if self.cover_checkbox.isChecked():
|
||||
# Calculate spine width with current settings
|
||||
content_pages = sum(p.get_page_count() for p in self.project.pages if not p.is_cover)
|
||||
sheets = math.ceil(content_pages / 4)
|
||||
spine_width = sheets * self.thickness_spinbox.value() * 2
|
||||
|
||||
page_width = self.project.page_size_mm[0]
|
||||
total_width = (page_width * 2) + spine_width + (self.bleed_spinbox.value() * 2)
|
||||
|
||||
self.spine_info_label.setText(
|
||||
f"Cover Layout: Front ({page_width:.0f}mm) + "
|
||||
f"Spine ({spine_width:.2f}mm) + "
|
||||
f"Back ({page_width:.0f}mm) + "
|
||||
f"Bleed ({self.bleed_spinbox.value():.1f}mm × 2)\n"
|
||||
f"Total Width: {total_width:.1f}mm | "
|
||||
f"Content Pages: {content_pages} | Sheets: {sheets}"
|
||||
)
|
||||
else:
|
||||
self.spine_info_label.setText("")
|
||||
|
||||
def get_values(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get dialog values.
|
||||
|
||||
Returns:
|
||||
Dictionary containing all dialog values
|
||||
"""
|
||||
selected_index = self.page_combo.currentData()
|
||||
selected_page = self.project.pages[selected_index]
|
||||
|
||||
return {
|
||||
"selected_index": selected_index,
|
||||
"selected_page": selected_page,
|
||||
"is_cover": self.cover_checkbox.isChecked(),
|
||||
"paper_thickness_mm": self.thickness_spinbox.value(),
|
||||
"cover_bleed_mm": self.bleed_spinbox.value(),
|
||||
"width_mm": self.width_spinbox.value(),
|
||||
"height_mm": self.height_spinbox.value(),
|
||||
"working_dpi": self.working_dpi_spinbox.value(),
|
||||
"export_dpi": self.export_dpi_spinbox.value(),
|
||||
"apply_scope": self._apply_scope_group.checkedId(),
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Print Settings Dialog for pyPhotoAlbum
|
||||
|
||||
Project-level bleed and safe-area configuration applied uniformly to all pages.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QDoubleSpinBox,
|
||||
QPushButton,
|
||||
QGroupBox,
|
||||
)
|
||||
from pyPhotoAlbum.project import Project
|
||||
|
||||
|
||||
class PrintSettingsDialog(QDialog):
|
||||
"""Dialog for configuring project-wide print settings (bleed and safe area)."""
|
||||
|
||||
def __init__(self, parent, project: Project):
|
||||
super().__init__(parent)
|
||||
self.project = project
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
self.setWindowTitle("Print Settings")
|
||||
self.setMinimumWidth(340)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
group = QGroupBox("Bleed && Safe Area (applied to all pages)")
|
||||
group_layout = QVBoxLayout()
|
||||
|
||||
# Bleed
|
||||
bleed_layout = QHBoxLayout()
|
||||
bleed_layout.addWidget(QLabel("Bleed Margin:"))
|
||||
self.bleed_spinbox = QDoubleSpinBox()
|
||||
self.bleed_spinbox.setRange(0.0, 20.0)
|
||||
self.bleed_spinbox.setSingleStep(0.5)
|
||||
self.bleed_spinbox.setDecimals(1)
|
||||
self.bleed_spinbox.setSuffix(" mm")
|
||||
self.bleed_spinbox.setValue(self.project.page_bleed_mm)
|
||||
self.bleed_spinbox.setToolTip(
|
||||
"Extra white space added around each page in the exported PDF.\n"
|
||||
"The printer cuts here — 3 mm is standard."
|
||||
)
|
||||
bleed_layout.addWidget(self.bleed_spinbox)
|
||||
group_layout.addLayout(bleed_layout)
|
||||
|
||||
# Safe area
|
||||
safe_layout = QHBoxLayout()
|
||||
safe_layout.addWidget(QLabel("Safe Area:"))
|
||||
self.safe_spinbox = QDoubleSpinBox()
|
||||
self.safe_spinbox.setRange(0.0, 50.0)
|
||||
self.safe_spinbox.setSingleStep(0.5)
|
||||
self.safe_spinbox.setDecimals(1)
|
||||
self.safe_spinbox.setSuffix(" mm")
|
||||
self.safe_spinbox.setValue(self.project.page_safe_area_mm)
|
||||
self.safe_spinbox.setToolTip(
|
||||
"Keep text and important content inside this distance from the cut/trim line.\n"
|
||||
"Shown as a red guide in the editor."
|
||||
)
|
||||
safe_layout.addWidget(self.safe_spinbox)
|
||||
group_layout.addLayout(safe_layout)
|
||||
|
||||
group.setLayout(group_layout)
|
||||
layout.addWidget(group)
|
||||
|
||||
# Buttons
|
||||
btn_layout = QHBoxLayout()
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
ok_btn = QPushButton("OK")
|
||||
ok_btn.clicked.connect(self.accept)
|
||||
ok_btn.setDefault(True)
|
||||
btn_layout.addStretch()
|
||||
btn_layout.addWidget(cancel_btn)
|
||||
btn_layout.addWidget(ok_btn)
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def get_values(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"page_bleed_mm": self.bleed_spinbox.value(),
|
||||
"page_safe_area_mm": self.safe_spinbox.value(),
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
"""
|
||||
Style dialogs for pyPhotoAlbum
|
||||
|
||||
Dialogs for configuring image styling options:
|
||||
- Corner radius
|
||||
- Border (width and color)
|
||||
- Drop shadow
|
||||
"""
|
||||
|
||||
from typing import Tuple
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QSlider,
|
||||
QSpinBox,
|
||||
QDoubleSpinBox,
|
||||
QPushButton,
|
||||
QCheckBox,
|
||||
QColorDialog,
|
||||
QGroupBox,
|
||||
QFormLayout,
|
||||
QWidget,
|
||||
)
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtGui import QColor
|
||||
|
||||
|
||||
class CornerRadiusDialog(QDialog):
|
||||
"""Dialog for setting corner radius"""
|
||||
|
||||
def __init__(self, parent, current_radius: float = 0.0):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Corner Radius")
|
||||
self.setMinimumWidth(300)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Slider with label
|
||||
slider_layout = QHBoxLayout()
|
||||
slider_layout.addWidget(QLabel("Radius:"))
|
||||
|
||||
self.slider = QSlider(Qt.Orientation.Horizontal)
|
||||
self.slider.setMinimum(0)
|
||||
self.slider.setMaximum(50)
|
||||
self.slider.setValue(int(current_radius))
|
||||
self.slider.valueChanged.connect(self._on_slider_changed)
|
||||
slider_layout.addWidget(self.slider)
|
||||
|
||||
self.value_label = QLabel(f"{int(current_radius)}%")
|
||||
self.value_label.setMinimumWidth(40)
|
||||
slider_layout.addWidget(self.value_label)
|
||||
|
||||
layout.addLayout(slider_layout)
|
||||
|
||||
# Preset buttons
|
||||
preset_layout = QHBoxLayout()
|
||||
for value, label in [(0, "None"), (5, "Slight"), (15, "Medium"), (25, "Large"), (50, "Circle")]:
|
||||
btn = QPushButton(label)
|
||||
btn.clicked.connect(lambda checked, v=value: self.slider.setValue(v))
|
||||
preset_layout.addWidget(btn)
|
||||
layout.addLayout(preset_layout)
|
||||
|
||||
# OK/Cancel buttons
|
||||
button_layout = QHBoxLayout()
|
||||
ok_btn = QPushButton("OK")
|
||||
ok_btn.clicked.connect(self.accept)
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
button_layout.addStretch()
|
||||
button_layout.addWidget(ok_btn)
|
||||
button_layout.addWidget(cancel_btn)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
def _on_slider_changed(self, value):
|
||||
self.value_label.setText(f"{value}%")
|
||||
|
||||
def get_value(self) -> float:
|
||||
return float(self.slider.value())
|
||||
|
||||
|
||||
class ColorButton(QPushButton):
|
||||
"""Button that shows a color and opens color picker on click"""
|
||||
|
||||
def __init__(self, color: Tuple[int, int, int], parent=None):
|
||||
super().__init__(parent)
|
||||
self.setFixedSize(40, 25)
|
||||
self._color = color
|
||||
self._update_style()
|
||||
self.clicked.connect(self._pick_color)
|
||||
|
||||
def _update_style(self):
|
||||
r, g, b = self._color
|
||||
self.setStyleSheet(f"background-color: rgb({r}, {g}, {b}); border: 1px solid #666;")
|
||||
|
||||
def _pick_color(self):
|
||||
r, g, b = self._color
|
||||
initial = QColor(r, g, b)
|
||||
color = QColorDialog.getColor(initial, self, "Select Color")
|
||||
if color.isValid():
|
||||
self._color = (color.red(), color.green(), color.blue())
|
||||
self._update_style()
|
||||
|
||||
def get_color(self) -> Tuple[int, int, int]:
|
||||
return self._color
|
||||
|
||||
|
||||
class BorderDialog(QDialog):
|
||||
"""Dialog for configuring border"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent,
|
||||
current_width: float = 0.0,
|
||||
current_color: Tuple[int, int, int] = (0, 0, 0),
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Border Settings")
|
||||
self.setMinimumWidth(300)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Border width
|
||||
width_layout = QHBoxLayout()
|
||||
width_layout.addWidget(QLabel("Width (mm):"))
|
||||
self.width_spin = QDoubleSpinBox()
|
||||
self.width_spin.setRange(0, 20)
|
||||
self.width_spin.setSingleStep(0.5)
|
||||
self.width_spin.setValue(current_width)
|
||||
self.width_spin.setDecimals(1)
|
||||
width_layout.addWidget(self.width_spin)
|
||||
layout.addLayout(width_layout)
|
||||
|
||||
# Border color
|
||||
color_layout = QHBoxLayout()
|
||||
color_layout.addWidget(QLabel("Color:"))
|
||||
self.color_btn = ColorButton(current_color)
|
||||
color_layout.addWidget(self.color_btn)
|
||||
color_layout.addStretch()
|
||||
layout.addLayout(color_layout)
|
||||
|
||||
# Preset buttons
|
||||
preset_layout = QHBoxLayout()
|
||||
presets = [
|
||||
("None", 0, (0, 0, 0)),
|
||||
("Thin Black", 0.5, (0, 0, 0)),
|
||||
("White", 2, (255, 255, 255)),
|
||||
("Gold", 1.5, (212, 175, 55)),
|
||||
]
|
||||
for label, width, color in presets:
|
||||
btn = QPushButton(label)
|
||||
btn.clicked.connect(lambda checked, w=width, c=color: self._apply_preset(w, c))
|
||||
preset_layout.addWidget(btn)
|
||||
layout.addLayout(preset_layout)
|
||||
|
||||
# OK/Cancel buttons
|
||||
button_layout = QHBoxLayout()
|
||||
ok_btn = QPushButton("OK")
|
||||
ok_btn.clicked.connect(self.accept)
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
button_layout.addStretch()
|
||||
button_layout.addWidget(ok_btn)
|
||||
button_layout.addWidget(cancel_btn)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
def _apply_preset(self, width, color):
|
||||
self.width_spin.setValue(width)
|
||||
self.color_btn._color = color
|
||||
self.color_btn._update_style()
|
||||
|
||||
def get_values(self) -> Tuple[float, Tuple[int, int, int]]:
|
||||
return self.width_spin.value(), self.color_btn.get_color()
|
||||
|
||||
|
||||
class ShadowDialog(QDialog):
|
||||
"""Dialog for configuring drop shadow"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent,
|
||||
enabled: bool = False,
|
||||
offset: Tuple[float, float] = (2.0, 2.0),
|
||||
blur: float = 3.0,
|
||||
color: Tuple[int, int, int, int] = (0, 0, 0, 128),
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Shadow Settings")
|
||||
self.setMinimumWidth(350)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Enable checkbox
|
||||
self.enabled_check = QCheckBox("Enable Drop Shadow")
|
||||
self.enabled_check.setChecked(enabled)
|
||||
self.enabled_check.stateChanged.connect(self._update_controls)
|
||||
layout.addWidget(self.enabled_check)
|
||||
|
||||
# Settings group
|
||||
self.settings_group = QGroupBox("Shadow Settings")
|
||||
form = QFormLayout(self.settings_group)
|
||||
|
||||
# Offset X
|
||||
self.offset_x = QDoubleSpinBox()
|
||||
self.offset_x.setRange(-20, 20)
|
||||
self.offset_x.setSingleStep(0.5)
|
||||
self.offset_x.setValue(offset[0])
|
||||
self.offset_x.setDecimals(1)
|
||||
form.addRow("Offset X (mm):", self.offset_x)
|
||||
|
||||
# Offset Y
|
||||
self.offset_y = QDoubleSpinBox()
|
||||
self.offset_y.setRange(-20, 20)
|
||||
self.offset_y.setSingleStep(0.5)
|
||||
self.offset_y.setValue(offset[1])
|
||||
self.offset_y.setDecimals(1)
|
||||
form.addRow("Offset Y (mm):", self.offset_y)
|
||||
|
||||
# Blur
|
||||
self.blur_spin = QDoubleSpinBox()
|
||||
self.blur_spin.setRange(0, 20)
|
||||
self.blur_spin.setSingleStep(0.5)
|
||||
self.blur_spin.setValue(blur)
|
||||
self.blur_spin.setDecimals(1)
|
||||
form.addRow("Blur (mm):", self.blur_spin)
|
||||
|
||||
# Color
|
||||
color_widget = QWidget()
|
||||
color_layout = QHBoxLayout(color_widget)
|
||||
color_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.color_btn = ColorButton(color[:3])
|
||||
color_layout.addWidget(self.color_btn)
|
||||
color_layout.addStretch()
|
||||
form.addRow("Color:", color_widget)
|
||||
|
||||
# Opacity
|
||||
self.opacity_slider = QSlider(Qt.Orientation.Horizontal)
|
||||
self.opacity_slider.setRange(0, 255)
|
||||
self.opacity_slider.setValue(color[3] if len(color) > 3 else 128)
|
||||
opacity_layout = QHBoxLayout()
|
||||
opacity_layout.addWidget(self.opacity_slider)
|
||||
self.opacity_label = QLabel(f"{self.opacity_slider.value()}")
|
||||
self.opacity_label.setMinimumWidth(30)
|
||||
opacity_layout.addWidget(self.opacity_label)
|
||||
self.opacity_slider.valueChanged.connect(lambda v: self.opacity_label.setText(str(v)))
|
||||
form.addRow("Opacity:", opacity_layout)
|
||||
|
||||
layout.addWidget(self.settings_group)
|
||||
|
||||
# Preset buttons
|
||||
preset_layout = QHBoxLayout()
|
||||
presets = [
|
||||
("Subtle", True, (1.0, 1.0), 2.0, (0, 0, 0, 60)),
|
||||
("Normal", True, (2.0, 2.0), 3.0, (0, 0, 0, 100)),
|
||||
("Strong", True, (3.0, 3.0), 5.0, (0, 0, 0, 150)),
|
||||
]
|
||||
for label, en, off, bl, col in presets:
|
||||
btn = QPushButton(label)
|
||||
btn.clicked.connect(lambda checked, e=en, o=off, b=bl, c=col: self._apply_preset(e, o, b, c))
|
||||
preset_layout.addWidget(btn)
|
||||
layout.addLayout(preset_layout)
|
||||
|
||||
# OK/Cancel buttons
|
||||
button_layout = QHBoxLayout()
|
||||
ok_btn = QPushButton("OK")
|
||||
ok_btn.clicked.connect(self.accept)
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
button_layout.addStretch()
|
||||
button_layout.addWidget(ok_btn)
|
||||
button_layout.addWidget(cancel_btn)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
self._update_controls()
|
||||
|
||||
def _update_controls(self):
|
||||
self.settings_group.setEnabled(self.enabled_check.isChecked())
|
||||
|
||||
def _apply_preset(self, enabled, offset, blur, color):
|
||||
self.enabled_check.setChecked(enabled)
|
||||
self.offset_x.setValue(offset[0])
|
||||
self.offset_y.setValue(offset[1])
|
||||
self.blur_spin.setValue(blur)
|
||||
self.color_btn._color = color[:3]
|
||||
self.color_btn._update_style()
|
||||
self.opacity_slider.setValue(color[3] if len(color) > 3 else 128)
|
||||
|
||||
def get_values(self) -> Tuple[bool, Tuple[float, float], float, Tuple[int, int, int, int]]:
|
||||
color_rgb = self.color_btn.get_color()
|
||||
color_rgba = color_rgb + (self.opacity_slider.value(),)
|
||||
return (
|
||||
self.enabled_check.isChecked(),
|
||||
(self.offset_x.value(), self.offset_y.value()),
|
||||
self.blur_spin.value(),
|
||||
color_rgba,
|
||||
)
|
||||
@@ -0,0 +1,943 @@
|
||||
"""
|
||||
Frame manager for pyPhotoAlbum
|
||||
|
||||
Manages decorative frames that can be applied to images:
|
||||
- Loading frame assets (SVG/PNG)
|
||||
- Rendering frames in OpenGL and PDF
|
||||
- Frame categories (modern, vintage)
|
||||
- Color override for SVG frames
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
class FrameCategory(Enum):
|
||||
"""Categories for organizing frames"""
|
||||
|
||||
MODERN = "modern"
|
||||
VINTAGE = "vintage"
|
||||
GEOMETRIC = "geometric"
|
||||
CUSTOM = "custom"
|
||||
|
||||
|
||||
class FrameType(Enum):
|
||||
"""How the frame is structured"""
|
||||
|
||||
CORNERS = "corners" # 4 corner pieces, rotated/mirrored
|
||||
FULL = "full" # Complete frame as single image
|
||||
EDGES = "edges" # Tileable edge pieces
|
||||
|
||||
|
||||
@dataclass
|
||||
class FrameDefinition:
|
||||
"""Definition of a decorative frame"""
|
||||
|
||||
name: str
|
||||
display_name: str
|
||||
category: FrameCategory
|
||||
frame_type: FrameType
|
||||
description: str = ""
|
||||
|
||||
# Asset path (relative to frames/corners directory for CORNERS type)
|
||||
# For CORNERS type: single SVG that gets rotated for each corner
|
||||
asset_path: Optional[str] = None
|
||||
|
||||
# Which corner the SVG asset is designed for: "tl", "tr", "br", "bl"
|
||||
# This determines how to flip for other corners
|
||||
asset_corner: str = "tl"
|
||||
|
||||
# Whether the frame can be tinted with a custom color
|
||||
colorizable: bool = True
|
||||
|
||||
# Default thickness as percentage of shorter image side
|
||||
default_thickness: float = 5.0
|
||||
|
||||
# Cached textures for OpenGL rendering: key = (color, size) tuple
|
||||
_texture_cache: Dict[tuple, int] = field(default_factory=dict, repr=False)
|
||||
_image_cache: Dict[tuple, Image.Image] = field(default_factory=dict, repr=False)
|
||||
|
||||
|
||||
class FrameManager:
|
||||
"""
|
||||
Manages loading and rendering of decorative frames.
|
||||
|
||||
Frames are stored in the frames/ directory with the following structure:
|
||||
frames/
|
||||
corners/
|
||||
floral_corner.svg
|
||||
ornate_corner.svg
|
||||
CREDITS.txt
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.frames: Dict[str, FrameDefinition] = {}
|
||||
self._frames_dir = self._get_frames_directory()
|
||||
self._load_bundled_frames()
|
||||
|
||||
def _get_frames_directory(self) -> Path:
|
||||
"""Get the frames directory path"""
|
||||
app_dir = Path(__file__).parent
|
||||
return app_dir / "frames"
|
||||
|
||||
def _load_bundled_frames(self):
|
||||
"""Load bundled frame definitions"""
|
||||
# Modern frames (programmatic - no SVG assets)
|
||||
self._register_frame(
|
||||
FrameDefinition(
|
||||
name="simple_line",
|
||||
display_name="Simple Line",
|
||||
category=FrameCategory.MODERN,
|
||||
frame_type=FrameType.FULL,
|
||||
description="Clean single-line border",
|
||||
colorizable=True,
|
||||
default_thickness=2.0,
|
||||
)
|
||||
)
|
||||
|
||||
self._register_frame(
|
||||
FrameDefinition(
|
||||
name="double_line",
|
||||
display_name="Double Line",
|
||||
category=FrameCategory.MODERN,
|
||||
frame_type=FrameType.FULL,
|
||||
description="Double parallel lines",
|
||||
colorizable=True,
|
||||
default_thickness=4.0,
|
||||
)
|
||||
)
|
||||
|
||||
# Geometric frames (programmatic)
|
||||
self._register_frame(
|
||||
FrameDefinition(
|
||||
name="geometric_corners",
|
||||
display_name="Geometric Corners",
|
||||
category=FrameCategory.GEOMETRIC,
|
||||
frame_type=FrameType.CORNERS,
|
||||
description="Angular geometric corner decorations",
|
||||
colorizable=True,
|
||||
default_thickness=8.0,
|
||||
)
|
||||
)
|
||||
|
||||
# SVG-based vintage frames
|
||||
# Each SVG is designed for a specific corner position:
|
||||
# corner_decoration.svg -> top left (tl)
|
||||
# corner_ornament.svg -> bottom left (bl)
|
||||
# floral_corner.svg -> bottom left (bl)
|
||||
# floral_flourish.svg -> bottom right (br)
|
||||
# ornate_corner.svg -> top left (tl)
|
||||
# simple_corner.svg -> top left (tl)
|
||||
corners_dir = self._frames_dir / "corners"
|
||||
|
||||
# Floral Corner (designed for bottom-left)
|
||||
if (corners_dir / "floral_corner.svg").exists():
|
||||
self._register_frame(
|
||||
FrameDefinition(
|
||||
name="floral_corner",
|
||||
display_name="Floral Corner",
|
||||
category=FrameCategory.VINTAGE,
|
||||
frame_type=FrameType.CORNERS,
|
||||
description="Decorative floral corner ornament",
|
||||
asset_path="corners/floral_corner.svg",
|
||||
asset_corner="bl",
|
||||
colorizable=True,
|
||||
default_thickness=12.0,
|
||||
)
|
||||
)
|
||||
|
||||
# Floral Flourish (designed for bottom-right)
|
||||
if (corners_dir / "floral_flourish.svg").exists():
|
||||
self._register_frame(
|
||||
FrameDefinition(
|
||||
name="floral_flourish",
|
||||
display_name="Floral Flourish",
|
||||
category=FrameCategory.VINTAGE,
|
||||
frame_type=FrameType.CORNERS,
|
||||
description="Elegant floral flourish design",
|
||||
asset_path="corners/floral_flourish.svg",
|
||||
asset_corner="br",
|
||||
colorizable=True,
|
||||
default_thickness=10.0,
|
||||
)
|
||||
)
|
||||
|
||||
# Ornate Corner (designed for top-left)
|
||||
if (corners_dir / "ornate_corner.svg").exists():
|
||||
self._register_frame(
|
||||
FrameDefinition(
|
||||
name="ornate_corner",
|
||||
display_name="Ornate Corner",
|
||||
category=FrameCategory.VINTAGE,
|
||||
frame_type=FrameType.CORNERS,
|
||||
description="Classic ornate line art corner",
|
||||
asset_path="corners/ornate_corner.svg",
|
||||
asset_corner="tl",
|
||||
colorizable=True,
|
||||
default_thickness=10.0,
|
||||
)
|
||||
)
|
||||
|
||||
# Simple Corner (designed for top-left)
|
||||
if (corners_dir / "simple_corner.svg").exists():
|
||||
self._register_frame(
|
||||
FrameDefinition(
|
||||
name="simple_corner",
|
||||
display_name="Simple Corner",
|
||||
category=FrameCategory.VINTAGE,
|
||||
frame_type=FrameType.CORNERS,
|
||||
description="Simple decorative corner ornament",
|
||||
asset_path="corners/simple_corner.svg",
|
||||
asset_corner="tl",
|
||||
colorizable=True,
|
||||
default_thickness=8.0,
|
||||
)
|
||||
)
|
||||
|
||||
# Corner Decoration (designed for top-left)
|
||||
if (corners_dir / "corner_decoration.svg").exists():
|
||||
self._register_frame(
|
||||
FrameDefinition(
|
||||
name="corner_decoration",
|
||||
display_name="Corner Decoration",
|
||||
category=FrameCategory.VINTAGE,
|
||||
frame_type=FrameType.CORNERS,
|
||||
description="Decorative corner piece",
|
||||
asset_path="corners/corner_decoration.svg",
|
||||
asset_corner="tl",
|
||||
colorizable=True,
|
||||
default_thickness=10.0,
|
||||
)
|
||||
)
|
||||
|
||||
# Corner Ornament (designed for bottom-left)
|
||||
if (corners_dir / "corner_ornament.svg").exists():
|
||||
self._register_frame(
|
||||
FrameDefinition(
|
||||
name="corner_ornament",
|
||||
display_name="Corner Ornament",
|
||||
category=FrameCategory.VINTAGE,
|
||||
frame_type=FrameType.CORNERS,
|
||||
description="Vintage corner ornament design",
|
||||
asset_path="corners/corner_ornament.svg",
|
||||
asset_corner="bl",
|
||||
colorizable=True,
|
||||
default_thickness=10.0,
|
||||
)
|
||||
)
|
||||
|
||||
def _register_frame(self, frame: FrameDefinition):
|
||||
"""Register a frame definition"""
|
||||
self.frames[frame.name] = frame
|
||||
|
||||
def get_frame(self, name: str) -> Optional[FrameDefinition]:
|
||||
"""Get a frame by name"""
|
||||
return self.frames.get(name)
|
||||
|
||||
def get_frames_by_category(self, category: FrameCategory) -> List[FrameDefinition]:
|
||||
"""Get all frames in a category"""
|
||||
return [f for f in self.frames.values() if f.category == category]
|
||||
|
||||
def get_all_frames(self) -> List[FrameDefinition]:
|
||||
"""Get all available frames"""
|
||||
return list(self.frames.values())
|
||||
|
||||
def get_frame_names(self) -> List[str]:
|
||||
"""Get list of all frame names"""
|
||||
return list(self.frames.keys())
|
||||
|
||||
def _load_svg_as_image(
|
||||
self,
|
||||
svg_path: Path,
|
||||
target_size: int,
|
||||
color: Optional[Tuple[int, int, int]] = None,
|
||||
) -> Optional[Image.Image]:
|
||||
"""
|
||||
Load an SVG file and render it to a PIL Image.
|
||||
|
||||
Args:
|
||||
svg_path: Path to the SVG file
|
||||
target_size: Target size in pixels for the corner
|
||||
color: Optional color override as RGB tuple (0-255)
|
||||
|
||||
Returns:
|
||||
PIL Image with alpha channel, or None if loading fails
|
||||
"""
|
||||
try:
|
||||
import cairosvg
|
||||
except ImportError:
|
||||
print("Warning: cairosvg not installed, SVG frames will use fallback rendering")
|
||||
return None
|
||||
|
||||
# Validate svg_path type
|
||||
if not isinstance(svg_path, (str, Path)):
|
||||
print(f"Warning: Invalid svg_path type: {type(svg_path)}, expected Path or str")
|
||||
return None
|
||||
|
||||
# Ensure svg_path is a Path object
|
||||
if isinstance(svg_path, str):
|
||||
svg_path = Path(svg_path)
|
||||
|
||||
if not svg_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
# Read SVG content
|
||||
svg_content = svg_path.read_text()
|
||||
|
||||
# Apply color override if specified
|
||||
if color is not None:
|
||||
svg_content = self._recolor_svg(svg_content, color)
|
||||
|
||||
# Render SVG to PNG bytes
|
||||
png_data = cairosvg.svg2png(
|
||||
bytestring=svg_content.encode("utf-8"),
|
||||
output_width=target_size,
|
||||
output_height=target_size,
|
||||
)
|
||||
|
||||
# Validate png_data type
|
||||
if not isinstance(png_data, bytes):
|
||||
print(f"Warning: cairosvg returned {type(png_data)} instead of bytes")
|
||||
return None
|
||||
|
||||
# Load as PIL Image from bytes buffer
|
||||
buffer = io.BytesIO(png_data)
|
||||
img: Image.Image = Image.open(buffer)
|
||||
if img.mode != "RGBA":
|
||||
img = img.convert("RGBA")
|
||||
|
||||
# Force load the image data to avoid issues with BytesIO going out of scope
|
||||
img.load()
|
||||
|
||||
return img
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
print(f"Error loading SVG {svg_path}: {e}")
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
def _recolor_svg(self, svg_content: str, color: Tuple[int, int, int]) -> str:
|
||||
"""
|
||||
Recolor an SVG by replacing fill and stroke colors.
|
||||
|
||||
Args:
|
||||
svg_content: SVG file content as string
|
||||
color: New color as RGB tuple (0-255)
|
||||
|
||||
Returns:
|
||||
Modified SVG content with new colors
|
||||
"""
|
||||
r, g, b = color
|
||||
hex_color = f"#{r:02x}{g:02x}{b:02x}"
|
||||
rgb_color = f"rgb({r},{g},{b})"
|
||||
|
||||
# Replace common color patterns
|
||||
# Replace fill colors (hex, rgb, named colors)
|
||||
svg_content = re.sub(
|
||||
r'fill\s*[:=]\s*["\']?(?:#[0-9a-fA-F]{3,6}|rgb\([^)]+\)|black|white|none)["\']?',
|
||||
f'fill="{hex_color}"',
|
||||
svg_content,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Replace stroke colors
|
||||
svg_content = re.sub(
|
||||
r'stroke\s*[:=]\s*["\']?(?:#[0-9a-fA-F]{3,6}|rgb\([^)]+\)|black|white)["\']?',
|
||||
f'stroke="{hex_color}"',
|
||||
svg_content,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Replace style-based fill/stroke
|
||||
svg_content = re.sub(
|
||||
r"(fill\s*:\s*)(?:#[0-9a-fA-F]{3,6}|rgb\([^)]+\)|black|white)",
|
||||
f"\\1{hex_color}",
|
||||
svg_content,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
svg_content = re.sub(
|
||||
r"(stroke\s*:\s*)(?:#[0-9a-fA-F]{3,6}|rgb\([^)]+\)|black|white)",
|
||||
f"\\1{hex_color}",
|
||||
svg_content,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
return svg_content
|
||||
|
||||
def _get_corner_image(
|
||||
self,
|
||||
frame: FrameDefinition,
|
||||
corner_size: int,
|
||||
color: Tuple[int, int, int],
|
||||
) -> Optional[Image.Image]:
|
||||
"""
|
||||
Get a corner image, using cache if available.
|
||||
|
||||
Args:
|
||||
frame: Frame definition
|
||||
corner_size: Size in pixels
|
||||
color: Color as RGB tuple
|
||||
|
||||
Returns:
|
||||
PIL Image or None
|
||||
"""
|
||||
cache_key = (color, corner_size)
|
||||
|
||||
if cache_key in frame._image_cache:
|
||||
return frame._image_cache[cache_key]
|
||||
|
||||
if frame.asset_path:
|
||||
try:
|
||||
svg_path = self._frames_dir / frame.asset_path
|
||||
img = self._load_svg_as_image(svg_path, corner_size, color)
|
||||
if img:
|
||||
frame._image_cache[cache_key] = img
|
||||
return img
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
print(f"Error getting corner image for {frame.name}: {e}")
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
def render_frame_opengl(
|
||||
self,
|
||||
frame_name: str,
|
||||
x: float,
|
||||
y: float,
|
||||
width: float,
|
||||
height: float,
|
||||
color: Tuple[int, int, int] = (0, 0, 0),
|
||||
thickness: Optional[float] = None,
|
||||
corners: Optional[Tuple[bool, bool, bool, bool]] = None,
|
||||
):
|
||||
"""
|
||||
Render a decorative frame using OpenGL.
|
||||
|
||||
Args:
|
||||
frame_name: Name of the frame to render
|
||||
x, y: Position of the image
|
||||
width, height: Size of the image
|
||||
color: Frame color as RGB (0-255)
|
||||
thickness: Frame thickness (None = use default)
|
||||
corners: Which corners to render (TL, TR, BR, BL). None = all corners
|
||||
"""
|
||||
frame = self.get_frame(frame_name)
|
||||
if not frame:
|
||||
return
|
||||
|
||||
# Default to all corners if not specified
|
||||
if corners is None:
|
||||
corners = (True, True, True, True)
|
||||
|
||||
from pyPhotoAlbum.gl_imports import (
|
||||
glColor3f,
|
||||
glColor4f,
|
||||
glBegin,
|
||||
glEnd,
|
||||
glVertex2f,
|
||||
GL_LINE_LOOP,
|
||||
glLineWidth,
|
||||
glEnable,
|
||||
glDisable,
|
||||
GL_BLEND,
|
||||
glBlendFunc,
|
||||
GL_SRC_ALPHA,
|
||||
GL_ONE_MINUS_SRC_ALPHA,
|
||||
GL_TEXTURE_2D,
|
||||
glBindTexture,
|
||||
glTexCoord2f,
|
||||
GL_QUADS,
|
||||
)
|
||||
|
||||
# Calculate thickness
|
||||
shorter_side = min(width, height)
|
||||
frame_thickness = thickness if thickness else (shorter_side * frame.default_thickness / 100)
|
||||
|
||||
glEnable(GL_BLEND)
|
||||
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
|
||||
|
||||
# Try to render with SVG asset if available
|
||||
if frame.asset_path and frame.frame_type == FrameType.CORNERS:
|
||||
corner_size = int(frame_thickness * 2)
|
||||
if self._render_svg_corners_gl(frame, x, y, width, height, corner_size, color, corners):
|
||||
glDisable(GL_BLEND)
|
||||
return
|
||||
|
||||
# Fall back to programmatic rendering
|
||||
r, g, b = color[0] / 255.0, color[1] / 255.0, color[2] / 255.0
|
||||
glColor3f(r, g, b)
|
||||
|
||||
if frame.frame_type == FrameType.CORNERS:
|
||||
self._render_corner_frame_gl(x, y, width, height, frame_thickness, frame_name, corners)
|
||||
elif frame.frame_type == FrameType.FULL:
|
||||
self._render_full_frame_gl(x, y, width, height, frame_thickness)
|
||||
|
||||
glDisable(GL_BLEND)
|
||||
|
||||
def _render_svg_corners_gl(
|
||||
self,
|
||||
frame: FrameDefinition,
|
||||
x: float,
|
||||
y: float,
|
||||
w: float,
|
||||
h: float,
|
||||
corner_size: int,
|
||||
color: Tuple[int, int, int],
|
||||
corners: Tuple[bool, bool, bool, bool],
|
||||
) -> bool:
|
||||
"""
|
||||
Render SVG-based corners using OpenGL textures.
|
||||
|
||||
Returns True if rendering was successful, False to fall back to programmatic.
|
||||
"""
|
||||
from pyPhotoAlbum.gl_imports import (
|
||||
glEnable,
|
||||
glDisable,
|
||||
glBindTexture,
|
||||
glTexCoord2f,
|
||||
glVertex2f,
|
||||
glBegin,
|
||||
glEnd,
|
||||
glColor4f,
|
||||
GL_TEXTURE_2D,
|
||||
GL_QUADS,
|
||||
glGenTextures,
|
||||
glTexParameteri,
|
||||
glTexImage2D,
|
||||
GL_TEXTURE_MIN_FILTER,
|
||||
GL_TEXTURE_MAG_FILTER,
|
||||
GL_LINEAR,
|
||||
GL_RGBA,
|
||||
GL_UNSIGNED_BYTE,
|
||||
)
|
||||
|
||||
# Get or create corner image
|
||||
corner_img = self._get_corner_image(frame, corner_size, color)
|
||||
if corner_img is None:
|
||||
return False
|
||||
|
||||
# Create texture if not cached
|
||||
cache_key = (color, corner_size, "texture")
|
||||
if cache_key not in frame._texture_cache:
|
||||
img_data = corner_img.tobytes()
|
||||
texture_id = glGenTextures(1)
|
||||
glBindTexture(GL_TEXTURE_2D, texture_id)
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
|
||||
glTexImage2D(
|
||||
GL_TEXTURE_2D,
|
||||
0,
|
||||
GL_RGBA,
|
||||
corner_img.width,
|
||||
corner_img.height,
|
||||
0,
|
||||
GL_RGBA,
|
||||
GL_UNSIGNED_BYTE,
|
||||
img_data,
|
||||
)
|
||||
frame._texture_cache[cache_key] = texture_id
|
||||
|
||||
texture_id = frame._texture_cache[cache_key]
|
||||
|
||||
# Render corners
|
||||
glEnable(GL_TEXTURE_2D)
|
||||
glBindTexture(GL_TEXTURE_2D, texture_id)
|
||||
glColor4f(1.0, 1.0, 1.0, 1.0) # White to show texture colors
|
||||
|
||||
tl, tr, br, bl = corners
|
||||
cs = float(corner_size)
|
||||
|
||||
# Helper to draw a textured quad with optional flipping
|
||||
# flip_h: flip horizontally, flip_v: flip vertically
|
||||
def draw_corner_quad(cx, cy, flip_h=False, flip_v=False):
|
||||
# Calculate texture coordinates based on flipping
|
||||
u0, u1 = (1, 0) if flip_h else (0, 1)
|
||||
v0, v1 = (1, 0) if flip_v else (0, 1)
|
||||
|
||||
glBegin(GL_QUADS)
|
||||
glTexCoord2f(u0, v0)
|
||||
glVertex2f(cx, cy)
|
||||
glTexCoord2f(u1, v0)
|
||||
glVertex2f(cx + cs, cy)
|
||||
glTexCoord2f(u1, v1)
|
||||
glVertex2f(cx + cs, cy + cs)
|
||||
glTexCoord2f(u0, v1)
|
||||
glVertex2f(cx, cy + cs)
|
||||
glEnd()
|
||||
|
||||
# Calculate flips based on the asset's designed corner vs target corner
|
||||
# Each SVG is designed for a specific corner (asset_corner field)
|
||||
# To render it at a different corner, we flip horizontally and/or vertically
|
||||
#
|
||||
# Corner positions:
|
||||
# tl (top-left) tr (top-right)
|
||||
# bl (bottom-left) br (bottom-right)
|
||||
#
|
||||
# To go from asset corner to target corner:
|
||||
# - flip_h if horizontal position differs (l->r or r->l)
|
||||
# - flip_v if vertical position differs (t->b or b->t)
|
||||
|
||||
asset_corner = frame.asset_corner # e.g., "tl", "bl", "br", "tr"
|
||||
asset_h = asset_corner[1] # 'l' or 'r'
|
||||
asset_v = asset_corner[0] # 't' or 'b'
|
||||
|
||||
def get_flips(target_corner: str) -> Tuple[bool, bool]:
|
||||
"""Calculate flip_h, flip_v to transform from asset_corner to target_corner"""
|
||||
target_h = target_corner[1] # 'l' or 'r'
|
||||
target_v = target_corner[0] # 't' or 'b'
|
||||
flip_h = asset_h != target_h
|
||||
flip_v = asset_v != target_v
|
||||
return flip_h, flip_v
|
||||
|
||||
# Top-left corner
|
||||
if tl:
|
||||
flip_h, flip_v = get_flips("tl")
|
||||
draw_corner_quad(x, y, flip_h=flip_h, flip_v=flip_v)
|
||||
|
||||
# Top-right corner
|
||||
if tr:
|
||||
flip_h, flip_v = get_flips("tr")
|
||||
draw_corner_quad(x + w - cs, y, flip_h=flip_h, flip_v=flip_v)
|
||||
|
||||
# Bottom-right corner
|
||||
if br:
|
||||
flip_h, flip_v = get_flips("br")
|
||||
draw_corner_quad(x + w - cs, y + h - cs, flip_h=flip_h, flip_v=flip_v)
|
||||
|
||||
# Bottom-left corner
|
||||
if bl:
|
||||
flip_h, flip_v = get_flips("bl")
|
||||
draw_corner_quad(x, y + h - cs, flip_h=flip_h, flip_v=flip_v)
|
||||
|
||||
glDisable(GL_TEXTURE_2D)
|
||||
return True
|
||||
|
||||
def _render_corner_frame_gl(
|
||||
self,
|
||||
x: float,
|
||||
y: float,
|
||||
w: float,
|
||||
h: float,
|
||||
thickness: float,
|
||||
frame_name: str,
|
||||
corners: Tuple[bool, bool, bool, bool] = (True, True, True, True),
|
||||
):
|
||||
"""Render corner-style frame decorations (programmatic fallback)."""
|
||||
from pyPhotoAlbum.gl_imports import glBegin, glEnd, glVertex2f, glLineWidth, GL_LINE_STRIP
|
||||
|
||||
corner_size = thickness * 2
|
||||
|
||||
glLineWidth(2.0)
|
||||
|
||||
tl, tr, br, bl = corners
|
||||
|
||||
# Top-left corner
|
||||
if tl:
|
||||
glBegin(GL_LINE_STRIP)
|
||||
glVertex2f(x, y + corner_size)
|
||||
glVertex2f(x, y)
|
||||
glVertex2f(x + corner_size, y)
|
||||
glEnd()
|
||||
|
||||
# Top-right corner
|
||||
if tr:
|
||||
glBegin(GL_LINE_STRIP)
|
||||
glVertex2f(x + w - corner_size, y)
|
||||
glVertex2f(x + w, y)
|
||||
glVertex2f(x + w, y + corner_size)
|
||||
glEnd()
|
||||
|
||||
# Bottom-right corner
|
||||
if br:
|
||||
glBegin(GL_LINE_STRIP)
|
||||
glVertex2f(x + w, y + h - corner_size)
|
||||
glVertex2f(x + w, y + h)
|
||||
glVertex2f(x + w - corner_size, y + h)
|
||||
glEnd()
|
||||
|
||||
# Bottom-left corner
|
||||
if bl:
|
||||
glBegin(GL_LINE_STRIP)
|
||||
glVertex2f(x + corner_size, y + h)
|
||||
glVertex2f(x, y + h)
|
||||
glVertex2f(x, y + h - corner_size)
|
||||
glEnd()
|
||||
|
||||
# Add decorative swirls for vintage frames
|
||||
if "leafy" in frame_name or "ornate" in frame_name or "flourish" in frame_name:
|
||||
self._render_decorative_swirls_gl(x, y, w, h, corner_size, corners)
|
||||
|
||||
glLineWidth(1.0)
|
||||
|
||||
def _render_decorative_swirls_gl(
|
||||
self,
|
||||
x: float,
|
||||
y: float,
|
||||
w: float,
|
||||
h: float,
|
||||
size: float,
|
||||
corners: Tuple[bool, bool, bool, bool] = (True, True, True, True),
|
||||
):
|
||||
"""Render decorative swirl elements at corners (programmatic fallback)."""
|
||||
from pyPhotoAlbum.gl_imports import glBegin, glEnd, glVertex2f, GL_LINE_STRIP
|
||||
import math
|
||||
|
||||
steps = 8
|
||||
radius = size * 0.4
|
||||
|
||||
tl, tr, br, bl = corners
|
||||
|
||||
corner_data = [
|
||||
(tl, x + size * 0.5, y + size * 0.5, math.pi),
|
||||
(tr, x + w - size * 0.5, y + size * 0.5, math.pi * 1.5),
|
||||
(br, x + w - size * 0.5, y + h - size * 0.5, 0),
|
||||
(bl, x + size * 0.5, y + h - size * 0.5, math.pi * 0.5),
|
||||
]
|
||||
|
||||
for enabled, cx, cy, start_angle in corner_data:
|
||||
if not enabled:
|
||||
continue
|
||||
glBegin(GL_LINE_STRIP)
|
||||
for i in range(steps + 1):
|
||||
angle = start_angle + (math.pi * 0.5 * i / steps)
|
||||
px = cx + radius * math.cos(angle)
|
||||
py = cy + radius * math.sin(angle)
|
||||
glVertex2f(px, py)
|
||||
glEnd()
|
||||
|
||||
def _render_full_frame_gl(self, x: float, y: float, w: float, h: float, thickness: float):
|
||||
"""Render full-border frame (programmatic)"""
|
||||
from pyPhotoAlbum.gl_imports import glBegin, glEnd, glVertex2f, GL_LINE_LOOP, glLineWidth
|
||||
|
||||
glLineWidth(max(1.0, thickness * 0.5))
|
||||
glBegin(GL_LINE_LOOP)
|
||||
glVertex2f(x - thickness * 0.5, y - thickness * 0.5)
|
||||
glVertex2f(x + w + thickness * 0.5, y - thickness * 0.5)
|
||||
glVertex2f(x + w + thickness * 0.5, y + h + thickness * 0.5)
|
||||
glVertex2f(x - thickness * 0.5, y + h + thickness * 0.5)
|
||||
glEnd()
|
||||
|
||||
glBegin(GL_LINE_LOOP)
|
||||
glVertex2f(x + thickness * 0.3, y + thickness * 0.3)
|
||||
glVertex2f(x + w - thickness * 0.3, y + thickness * 0.3)
|
||||
glVertex2f(x + w - thickness * 0.3, y + h - thickness * 0.3)
|
||||
glVertex2f(x + thickness * 0.3, y + h - thickness * 0.3)
|
||||
glEnd()
|
||||
|
||||
glLineWidth(1.0)
|
||||
|
||||
def render_frame_pdf(
|
||||
self,
|
||||
canvas,
|
||||
frame_name: str,
|
||||
x_pt: float,
|
||||
y_pt: float,
|
||||
width_pt: float,
|
||||
height_pt: float,
|
||||
color: Tuple[int, int, int] = (0, 0, 0),
|
||||
thickness_pt: Optional[float] = None,
|
||||
corners: Optional[Tuple[bool, bool, bool, bool]] = None,
|
||||
):
|
||||
"""
|
||||
Render a decorative frame on a PDF canvas.
|
||||
|
||||
Args:
|
||||
canvas: ReportLab canvas
|
||||
frame_name: Name of the frame to render
|
||||
x_pt, y_pt: Position in points
|
||||
width_pt, height_pt: Size in points
|
||||
color: Frame color as RGB (0-255)
|
||||
thickness_pt: Frame thickness in points (None = use default)
|
||||
corners: Which corners to render (TL, TR, BR, BL). None = all corners
|
||||
"""
|
||||
frame = self.get_frame(frame_name)
|
||||
if not frame:
|
||||
return
|
||||
|
||||
if corners is None:
|
||||
corners = (True, True, True, True)
|
||||
|
||||
shorter_side = min(width_pt, height_pt)
|
||||
frame_thickness = thickness_pt if thickness_pt else (shorter_side * frame.default_thickness / 100)
|
||||
|
||||
r, g, b = color[0] / 255.0, color[1] / 255.0, color[2] / 255.0
|
||||
|
||||
canvas.saveState()
|
||||
canvas.setStrokeColorRGB(r, g, b)
|
||||
canvas.setLineWidth(max(0.5, frame_thickness * 0.3))
|
||||
|
||||
# Try SVG rendering for PDF
|
||||
if frame.asset_path and frame.frame_type == FrameType.CORNERS:
|
||||
corner_size_pt = frame_thickness * 2
|
||||
if self._render_svg_corners_pdf(
|
||||
canvas, frame, x_pt, y_pt, width_pt, height_pt, corner_size_pt, color, corners
|
||||
):
|
||||
canvas.restoreState()
|
||||
return
|
||||
|
||||
# Fall back to programmatic
|
||||
if frame.frame_type == FrameType.CORNERS:
|
||||
self._render_corner_frame_pdf(canvas, x_pt, y_pt, width_pt, height_pt, frame_thickness, frame_name, corners)
|
||||
elif frame.frame_type == FrameType.FULL:
|
||||
self._render_full_frame_pdf(canvas, x_pt, y_pt, width_pt, height_pt, frame_thickness)
|
||||
|
||||
canvas.restoreState()
|
||||
|
||||
def _render_svg_corners_pdf(
|
||||
self,
|
||||
canvas,
|
||||
frame: FrameDefinition,
|
||||
x: float,
|
||||
y: float,
|
||||
w: float,
|
||||
h: float,
|
||||
corner_size_pt: float,
|
||||
color: Tuple[int, int, int],
|
||||
corners: Tuple[bool, bool, bool, bool],
|
||||
) -> bool:
|
||||
"""Render SVG corners on PDF canvas. Returns True if successful."""
|
||||
from reportlab.lib.utils import ImageReader
|
||||
|
||||
# Get corner image at high resolution for PDF
|
||||
corner_size_px = int(corner_size_pt * 4) # 4x for PDF quality
|
||||
if corner_size_px < 1:
|
||||
corner_size_px = 1
|
||||
corner_img = self._get_corner_image(frame, corner_size_px, color)
|
||||
if corner_img is None:
|
||||
return False
|
||||
|
||||
tl, tr, br, bl = corners
|
||||
cs = corner_size_pt
|
||||
|
||||
# For PDF, we use PIL to flip the image rather than canvas transformations
|
||||
# This is more reliable across different PDF renderers
|
||||
def get_flipped_image(target_corner: str) -> Image.Image:
|
||||
"""Get image flipped appropriately for the target corner"""
|
||||
asset_corner = frame.asset_corner
|
||||
asset_h = asset_corner[1] # 'l' or 'r'
|
||||
asset_v = asset_corner[0] # 't' or 'b'
|
||||
target_h = target_corner[1]
|
||||
target_v = target_corner[0]
|
||||
|
||||
img = corner_img.copy()
|
||||
|
||||
# Flip horizontally if h position differs
|
||||
if asset_h != target_h:
|
||||
img = img.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
|
||||
|
||||
# Flip vertically if v position differs
|
||||
if asset_v != target_v:
|
||||
img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
|
||||
|
||||
return img
|
||||
|
||||
# Note: PDF Y-axis is bottom-up, so corners are positioned differently
|
||||
# Top-left in screen coordinates = high Y in PDF
|
||||
if tl:
|
||||
img = get_flipped_image("tl")
|
||||
img_reader = ImageReader(img)
|
||||
canvas.drawImage(img_reader, x, y + h - cs, cs, cs, mask="auto")
|
||||
|
||||
# Top-right
|
||||
if tr:
|
||||
img = get_flipped_image("tr")
|
||||
img_reader = ImageReader(img)
|
||||
canvas.drawImage(img_reader, x + w - cs, y + h - cs, cs, cs, mask="auto")
|
||||
|
||||
# Bottom-right
|
||||
if br:
|
||||
img = get_flipped_image("br")
|
||||
img_reader = ImageReader(img)
|
||||
canvas.drawImage(img_reader, x + w - cs, y, cs, cs, mask="auto")
|
||||
|
||||
# Bottom-left
|
||||
if bl:
|
||||
img = get_flipped_image("bl")
|
||||
img_reader = ImageReader(img)
|
||||
canvas.drawImage(img_reader, x, y, cs, cs, mask="auto")
|
||||
|
||||
return True
|
||||
|
||||
def _render_corner_frame_pdf(
|
||||
self,
|
||||
canvas,
|
||||
x: float,
|
||||
y: float,
|
||||
w: float,
|
||||
h: float,
|
||||
thickness: float,
|
||||
frame_name: str,
|
||||
corners: Tuple[bool, bool, bool, bool] = (True, True, True, True),
|
||||
):
|
||||
"""Render corner-style frame on PDF (programmatic fallback)."""
|
||||
corner_size = thickness * 2
|
||||
tl, tr, br, bl = corners
|
||||
|
||||
path = canvas.beginPath()
|
||||
|
||||
if tl:
|
||||
path.moveTo(x, y + h - corner_size)
|
||||
path.lineTo(x, y + h)
|
||||
path.lineTo(x + corner_size, y + h)
|
||||
|
||||
if tr:
|
||||
path.moveTo(x + w - corner_size, y + h)
|
||||
path.lineTo(x + w, y + h)
|
||||
path.lineTo(x + w, y + h - corner_size)
|
||||
|
||||
if br:
|
||||
path.moveTo(x + w, y + corner_size)
|
||||
path.lineTo(x + w, y)
|
||||
path.lineTo(x + w - corner_size, y)
|
||||
|
||||
if bl:
|
||||
path.moveTo(x + corner_size, y)
|
||||
path.lineTo(x, y)
|
||||
path.lineTo(x, y + corner_size)
|
||||
|
||||
canvas.drawPath(path, stroke=1, fill=0)
|
||||
|
||||
def _render_full_frame_pdf(self, canvas, x: float, y: float, w: float, h: float, thickness: float):
|
||||
"""Render full-border frame on PDF"""
|
||||
canvas.rect(
|
||||
x - thickness * 0.5,
|
||||
y - thickness * 0.5,
|
||||
w + thickness,
|
||||
h + thickness,
|
||||
stroke=1,
|
||||
fill=0,
|
||||
)
|
||||
|
||||
canvas.rect(
|
||||
x + thickness * 0.3,
|
||||
y + thickness * 0.3,
|
||||
w - thickness * 0.6,
|
||||
h - thickness * 0.6,
|
||||
stroke=1,
|
||||
fill=0,
|
||||
)
|
||||
|
||||
|
||||
# Global frame manager instance
|
||||
_frame_manager: Optional[FrameManager] = None
|
||||
|
||||
|
||||
def get_frame_manager() -> FrameManager:
|
||||
"""Get the global frame manager instance"""
|
||||
global _frame_manager
|
||||
if _frame_manager is None:
|
||||
_frame_manager = FrameManager()
|
||||
return _frame_manager
|
||||
@@ -0,0 +1,23 @@
|
||||
Decorative Frame Assets - Credits and Licenses
|
||||
===============================================
|
||||
|
||||
All decorative corner SVG assets in this directory are sourced from FreeSVG.org
|
||||
and are released under the Creative Commons Zero (CC0) Public Domain license.
|
||||
|
||||
This means you can copy, modify, distribute, and use them for commercial purposes,
|
||||
all without asking permission or providing attribution.
|
||||
|
||||
However, we gratefully acknowledge the following sources:
|
||||
|
||||
Corner Decorations
|
||||
------------------
|
||||
- corner_decoration.svg - FreeSVG.org (OpenClipart)
|
||||
- corner_ornament.svg - FreeSVG.org (RebeccaRead/OpenClipart)
|
||||
- floral_corner.svg - FreeSVG.org (OpenClipart)
|
||||
- floral_flourish.svg - FreeSVG.org (OpenClipart)
|
||||
- ornate_corner.svg - FreeSVG.org (OpenClipart)
|
||||
- simple_corner.svg - FreeSVG.org (OpenClipart)
|
||||
|
||||
Source: https://freesvg.org
|
||||
License: CC0 1.0 Universal (Public Domain)
|
||||
License URL: https://creativecommons.org/publicdomain/zero/1.0/
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 1841.389 1732.463" enable-background="new 0 0 1841.389 1732.463" xml:space="preserve">
|
||||
<path d="M184.653,96.295c1.15-14.484,13.733-31.753,38.55-29.904c29.204,4.076,42.908,45.411,8.953,64.39
|
||||
c0.023,60.887,0.07,552.723-0.005,570.255c38.034,27.832,49.897,75.373,48.848,122.498
|
||||
c-16.504,188.412-190.187,324.389-180.679,564.55c1.477,12.405-1.462,120.691,59.234,189.029
|
||||
c41.404,45.424,106.154,65.484,144.66,61.721c0.01-0.217,0.025-0.653,0.035-0.87c-101.171-51.399-226.038-227.923-76.517-543.644
|
||||
c63.962-135.204,126.934-191.678,97.933-292.904c25.013,1.679,55.741,13.99,68.385,38.53
|
||||
c37.641,72.201-74.811,159.089-91.294,182.904c-0.638,1.483,1.241,1.122,1.804,0.386c66.486-54.828,80.583-14.788,224.163-55.398
|
||||
c-2.803,29.004-17.49,60.919-43.306,81.97c-40.102,32.437-92.657,27.109-152.846,63.669c-24.711,15.401-40.752,35.538-47.473,52.427
|
||||
c4.069-1.374,22.147-11.821,53.51-20.842c109.773-32.011,219.625,2.926,259.841,99.243
|
||||
c73.343,179.044-170.407,316.569-276.348,182.592c-34.819-44.759-25.714-103.207,4.652-123.823c1.622-1.177,3.614-1.933,4.761-3.653
|
||||
c-30.783-3.947-65.948,51.188-47.226,114.716c38.729,133.524,279.285,176.476,398.262,57.781
|
||||
c38.612-37.569,68.479-108.457,44.547-155.743c-18.193-37.729-57.937-36.345-62.804-82.464
|
||||
c-2.762-50.859,60.605-60.299,84.303-15.711c0.771,1.285,1.29,2.966,2.857,3.51c-7.765-45.051-47.815-113.135-83.839-140.67
|
||||
c-0.01-0.227-0.025-0.682-0.035-0.91c30.333-7.572,51.561-4.551,59.704-4.4c-37.721-112.279,18.498-176.688,80.517-183.161
|
||||
c27.057-4.285,78.192,10.172,77.007,48.813c0.526,20.185-15.404,39.847-20.195,22.592c-1.56-4.961,0.958-21.982-13.669-33.003
|
||||
c-15.829-12.263-42.279-8.734-55.245,11.192c-55.269,81.238,181.193,219.377,102.995,317.394
|
||||
c33.196-1.605,52.222,21.494,57.9,45.521c-18.135-0.985-21.631-11.204-71.475,71.109c-25.625,41.334-60.584,78.848-95.881,105.694
|
||||
c-1.518,1.216-3.505,2.121-4.158,4.118c11.689-2.368,46.189-28.835,57.296-37.957c94.629-77.732,128.727-135.385,239.424-110.534
|
||||
c21.531,5.01,30.999,9.577,34.833,10.718c-8.894,26.039-24.603,36.121-44.893,42.545c-0.114,0.267-0.341,0.801-0.455,1.068
|
||||
c28.557,2.119,53.529,23.403,59.704,50.736c192.237,0.044,384.469,0.025,576.706,0.01c15.283-26.042,52.749-21.042,61.592,5.947
|
||||
c13.052,39.741-43.46,63.559-63.071,24.535c-291.078,0.076-576.278-0.056-578.026,0.084c-1.33,1.127-1.953,2.828-2.951,4.232
|
||||
c-22.205,31.744-58.788,21.901-64.816,18.573c2.645-0.292,5.314,0.049,7.974-0.143c42.13-2.471,40.518-54.133,11.672-72.681
|
||||
c-10.145-7.151-30.452-11.674-43.336-12.779c-136.137-4.945-250.616,166.126-515.979,168.048
|
||||
c1.288,50.475-52.655,93.797-141.526,83.018c-34.4-4.311-23.027-7.397-34.64-3.915c-73.552,24.828-155.421-4.746-198.095-56.308
|
||||
c-55.492-62.957-83.424-182.369-66.126-297.437c3.442-23.872,15.723-70.315,5.596-122.873
|
||||
c-4.835-25.755-15.503-52.649-15.518-78.341c-1.172-50.249,19.305-90.939,15.933-118.681c-0.198-2.333-0.539-4.657-0.593-6.99
|
||||
c14.591,7.231,41.682,29.066,50.919,62.982c5.006,18.17,2.906,32.105,3.179,35.03c1.463-1.582,2.155-3.658,3.03-5.581
|
||||
c34.931-81.401-63.977-103.566-14.129-222.571c50.281,12.177,83.149,48.884,78.129,111.483c-0.45,4.805-1.364,9.551-1.963,14.341
|
||||
c4.373-3.68,46.006-80.086,40.829-149.831c-2.328-35.437-11.496-82.418-47.004-80.808c-15.512,2.457-19.603,12.066-29.662,15.36
|
||||
c-26.231,8.804-40.365-43.123,11.029-60.757c6.946-2.229,14.084-4.331,21.455-4.192c0.01-31.06,0.073-537.774-0.04-562.953
|
||||
C190.727,123.194,184.282,112.876,184.653,96.295 M396.031,1642.443c66.063,10.096,95.962-36.85,72.859-69.235
|
||||
c-1.117-1.913-3.658-1.577-5.517-2.096c-170.088-34.001-211.965-148.234-205.194-199.84c1.73-71.28,57.756-112.691,104.834-103.786
|
||||
c60.029,9.739,75.038,74.317,29.731,83.428c-6.238,1.475-20.58,2.308-28.099-4.123c-7.742-7.076-7.962-21.503-6.946-31.901
|
||||
c-37.145,11.637-45.122,83.882,9.803,110.628c113.072,56.562,191.342-87.317,141.392-172.651
|
||||
c-23.058-40.393-81.69-75.012-149.945-74.075C108.948,1186.177,135.34,1608.76,396.031,1642.443z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,522 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="120.2629 30.938 896.7742 728.0339" enable-background="new 120.2629 30.938 896.7742 728.0339" xml:space="preserve">
|
||||
<g>
|
||||
|
||||
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="-1080.8132" y1="8.0304" x2="-1080.8132" y2="776.3375" gradientTransform="matrix(-1 0 0 -1 -212.8987 792)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_1_)" d="M887.6567,523.3495c-16.137-11.164-32.957-20.432-47.223-26.9
|
||||
c-7.1289-15.531-16.168-41.627-11.465-72.766c6.3125-41.775,24.479-67.732,57.168-81.688
|
||||
c24.383-10.408,52.9821-8.3306,78.465,5.7022c10.8361,5.9668,19.912,14.484,26.936,24.746
|
||||
c-4.6406,5.5117-10.207,10.029-16.2791,13.287c-17.0291,9.1406-46.418,8.1289-57.965,6.6602
|
||||
c-0.2773-0.043-1.3047-0.2051-2.8066-0.4258c-0.2695-0.0508-0.5176-0.0996-0.7344-0.1484l-0.0098,0.041
|
||||
c-3.7168-0.5332-9.623-1.3164-14.525-1.6465c-1.0234-0.0703-2.0586-0.123-3.0762-0.1621
|
||||
c-10.799-0.3965-18.965,1.0449-23.828,2.2871c5.1758-3.1816,15.377-7.5859,31.406-6.7969c8.4746,0.4141,17.084,2.7754,17.17,2.7988
|
||||
l0.7441,0.207l0.3184-0.707c0.1758-0.3848,4.2246-9.5527-1.7324-15.789c-3.1777-3.3247-8.127-5.2017-14.312-5.4292
|
||||
c-12.57-0.4639-27.473,5.9546-37.082,15.972c-7.873,8.207-18.135,22.557-18.236,22.701l-1.5586,2.1836l2.5586-0.791
|
||||
c0.1387-0.043,14.328-4.3965,22.896-3.791c9.7988,0.6973,17.693,4.4238,23.457,7.1465c2.9004,1.3691,5.1914,2.4512,6.9023,2.7305
|
||||
c1.248,0.2031,2.4668,0.3281,3.6191,0.3691c4.8418,0.1797,8.7012-1.0391,11.158-3.5254c1.8262-1.8496,2.7246-4.2695,2.6035-7
|
||||
c-0.1738-3.8262-1.5469-6.5098-2.7227-8.1348c2.582,0.2695,5.6953,0.498,9.1641,0.625c14.15,0.5215,34.152-0.6426,47.4431-7.7773
|
||||
c6.3184-3.3906,11.8979-7.8945,16.52-13.246c14.283,21.924,19.432,51.359,12.631,80.906
|
||||
c-8.6328,37.512-44.062,64.533-84.244,64.252c-36.223-0.2656-67.562-16.895-72.91-38.713
|
||||
c-3.7637-15.354,2.3691-24.92,7.5488-29.959l0.0254,0.0117l0.0645-0.1035c0.5957-0.5742,1.1777-1.0859,1.7285-1.5449
|
||||
c2.8086-1.8633,9.2441-5.5059,15.807-6.8203c3.9023-0.7813,7.0644-1.2832,10.41-1.8144c4.3496-0.6895,8.8496-1.4004,15.631-2.8164
|
||||
c5.6602-1.1836,10.158-2.918,13.488-4.5469c-3.1035,2.7109-6.9863,5.5938-10.473,6.6992l-1.6777,0.5352
|
||||
c-5.2676,1.6836-8.748,2.7949-17.201,4.209c-11.451,1.9121-19.67,6.2305-22.549,11.844c-1.2578,2.4551-1.3672,5.0059-0.3047,7.1816
|
||||
c2.043,4.1836,7.7832,8.8184,15.555,9.1035c4.0254,0.1484,8.0352-0.8809,11.92-3.0625c36.307-20.379,43.799-52.762,43.869-53.086
|
||||
l0.8613-3.9121l-2.4551,3.1602c-0.0508,0.0664-5.2461,6.6504-13.275,7.8477c-2.4941,0.3711-5.7891,0.4746-10.076,0.3164
|
||||
c-5.9394-0.2168-12.641-0.9023-18.551-1.5059c-2.0371-0.209-3.9707-0.4063-5.7344-0.5703
|
||||
c-13.723-1.2734-23.355,1.1894-28.506,7.2832c-3.9004,4.6172-4.4121,10.596-3.5879,14.652c0.6895,3.3887,2.4492,5.2168,3.502,6.041
|
||||
c-5.4219,5.5-11.617,15.609-7.7734,31.289c5.3574,21.865,35.566,38.709,71.049,40.016c1.1953,0.0449,2.3984,0.0703,3.6035,0.0781
|
||||
c41.0179,0.2871,77.191-27.328,86.016-65.658c5.2832-22.953,3.7148-45.812-4.5371-66.105
|
||||
c-2.4004-5.9062-5.332-11.459-8.7188-16.578c4.0332-4.9663,7.2734-10.602,9.5352-16.73c5.5-14.891,4.834-31.301-1.9297-47.455
|
||||
c-7.7324-18.471-23.158-27.004-34.736-30.91c-13.209-4.456-28.936-5.2168-41.045-1.9829
|
||||
c-13.154,3.5132-26.213,11.133-35.379,17.344c8.5-7.2988,16.848-13.414,24.262-18.844c0.5332-0.3906,1.0606-0.7759,1.5879-1.1626
|
||||
c6.459-4.0015,27.248-16.797,42.1541-25.012c11.25-6.2007,25.412-16.56,29-30.694c0.8828-3.4795,3.2715-15.614-3.0625-25.656
|
||||
c-2.6211-4.1582-6.3281-7.2202-11.051-9.1616c1.957-2.3613,3.8008-4.686,5.5293-6.9717c14.191-18.764,18.873-37.575,20.082-51.382
|
||||
c1.2148,0.7315,3.1914,1.6401,5.6563,1.7305c2.8086,0.104,5.5313-0.876,8.0898-2.9106c4.8496-3.8599,6.0352-12.814,2.6445-19.961
|
||||
c-5.9023-12.432-15.283-21.835-22.137-28.7c-2.1426-2.1484-3.9922-4.0029-5.4609-5.6636l-3.0273-3.4228l1.498,4.3223
|
||||
c0.0352,0.1016,3.4941,10.234,1.5137,17.759c-1.5293,5.8066-2.584,7.8555-4.502,11.574c-0.7031,1.3599-1.498,2.9014-2.4766,4.9248
|
||||
c-2.8906,5.977-3.5,11.614-1.6699,15.467c1.2168,2.5644,3.4746,4.2998,6.5254,5.0176c0.5664,0.1333,1.1973,0.2129,1.875,0.2383
|
||||
c2.9551,0.1089,5.9922-0.8457,6.1211-0.8862l0.4746-0.1509l0.125-0.4824c0.5234-2.0376,1.2617-6.0317,1.2168-14.041
|
||||
c-0.0098-1.9228,0.3887-14.694-2.5098-27.918c4.6328,10.495,4.375,43.917,4.457,39.991c-0.7637,13.745-4.9551,33.593-19.934,53.4
|
||||
c-1.832,2.4209-3.7949,4.8862-5.8848,7.394c-1.2734-0.4258-2.6133-0.7773-4.0176-1.0537c-0.3613-0.0708-0.7285-0.1299-1.0977-0.186
|
||||
c0.0215-4.8535,0.0039-10.68-0.0156-17.41c-0.0176-6.0903-0.0371-12.909-0.0313-20.393c0.0098-16.619,0.4004-29.825,2.5117-55.891
|
||||
c2.373-29.333,11.838-51.479,11.934-51.699l1.8203-4.2051l-3.2793,3.1948c-0.9746,0.9526-23.986,23.555-30.176,44.466
|
||||
c-5.4961,18.577-3.8594,39.297-3.4141,43.785c-2.6699,0.4761-10.564,2.5776-16.4301,11.286
|
||||
c-6.0606,8.999-1.293,23.991-1.0898,24.625l0.9765,2.9976l0.7598-3.0586c0.0215-0.0791,2.0391-7.9541,7.8887-11.056
|
||||
c3.0078-1.5952,6.5527-1.6572,10.537-0.1812c3.5098,1.2988,13.688,7.3154,15.561,33.27c-13.799-0.9946-46.008,6.3477-70.326,36.879
|
||||
c3.9102-4.9927,7.3789-14.938,27.74-30.707c8.4316-6.5317,18.264-7.8892,18.264-7.8892c1.1367-3.2876-6.3691-17.875-21.438-12.273
|
||||
c-12.324,4.582-16.855,12.851-21.545,24.757c-3.2168,8.1719-5.4668,15.952-9.1914,28.84c-1.3516,4.6768-2.8828,9.978-4.7441,16.287
|
||||
l-1.4258,4.8286l3.0137-4.0273c0.1133-0.1514,5.252-7.3745,19.725-15.907c4.1856-2.4683,17.297-8.1352,22.029-9.2612
|
||||
c9.1719-2.1831,16.174-2.5225,19.846-5.5674c2.8105-2.3306,4.4844-5.5024,4.709-8.9316c0.2031-3.1079-0.7891-6.0884-2.7227-8.1782
|
||||
c-2.082-2.2471-4.293-3.3369-7.1113-3.5244c6.4824-1.9678,15.619-4.1001,23.295-3.4971c0.1387,2.4917,0.2012,5.1562,0.1777,8.0083
|
||||
c-13.08,14.355-30.537,30.826-50.672,48.077c-12.467,10.68-48.738,44.198-72.203,96.934
|
||||
c-11.709,26.317-15.332,39.254-19.951,57.201c-1.459,5.6641-2.8398,13.082-3.4727,20.01c-0.207,2.2832-1.8496,16.404-0.002,34.145
|
||||
c1.8652,17.945,6.5996,31.643,14.631,46.486c-21.939-9.2871-36.643-9.3301-48.248-10.055
|
||||
c-32.658-2.0391-52.726,8.5039-61.72,29.123c-5.4033,12.389-4.5,24.01,1.0869,35.9c3.3643,7.1602,10.064,11.18,17.334,13.498
|
||||
c8.7446,2.793,16.02-0.4844,19.534-2.6445c0.8672,3.2246,3.9492,10.986,13.627,11.344c0.3984,0.0156,0.8105,0.0156,1.2324,0.0059
|
||||
c13.764-0.377,17.842-10.854,21.119-19.273c0.8262-2.1211,1.6055-4.127,2.4824-5.8398l0.1152-0.2285
|
||||
c6.7012-13.107,9.7598-19.092,21.262-22.375c6.0449-1.7285,13.666-2.1699,13.74-2.1758l3.4609-0.1914l-3.1133-1.5273
|
||||
c-0.5137-0.25-12.666-6.1719-21.795-7.7891c-6.4883-1.1504-12.277-1.8066-17.695-2.0059
|
||||
c-5.0879-0.1895-9.9414,0.0371-14.836,0.6855c-15.584,2.0664-23.102,7.7285-28.398,12.994
|
||||
c-4.875,4.8457-8.6128,13.119-4.4844,21.801c2.4648,5.1856,9.5,9.4707,9.7988,9.6504l0.3145,0.1895l0.3594-0.084
|
||||
c0.3828-0.0918,3.916-1.1582,9.8047-10.352c2.416-3.7754,8.3125-10.816,13.096-14.217c4.8555-3.4512,10.521-5.4082,15.316-6.5215
|
||||
c-0.3496,0.1523-0.7051,0.3125-1.0664,0.4805c-0.4297,0.2012-0.8535,0.3926-1.2793,0.582
|
||||
c-5.1328,2.2988-10.441,4.6777-23.02,22.174c-4.4883,6.2402-9.9375,10.402-9.9922,10.443l-0.2754,0.2051
|
||||
c-2.8672,1.8887-10.127,5.6992-18.759,2.9453c-6.7812-2.166-12.225-7.0547-15.327-13.771
|
||||
c-4.1641-9.0117-5.8398-22.045-0.2314-33.775c8.2344-17.229,23.646-26.377,56.033-26.543
|
||||
c10.865-0.0547,30.229,1.8496,52.756,11.723c1.3711,2.3906,3.7227,6.9023,5.2539,9.2012c1.0742,1.6152,69.076,108.13,82.693,148.95
|
||||
c16.91,50.688,24.33,92.812,5.0742,103.44l1.8145-0.123c21.67-4.6777,19.443-52.178,1.2344-103.01
|
||||
c-24.691-68.932-68.994-120.9-83.703-146.78c-1.8887-2.9297-2.3828-3.9492-4.377-7.7285c13.576,6.3418,29.008,13.719,43.758,24.844
|
||||
c32.684,24.654,46.0861,42.58,59.084,75.105c13.699,34.275,14.963,51.723,14.066,86.943
|
||||
c-0.6035,23.672-11.3199,69.805-28.109,70.697l2.7832,0.1016c20.881-4.6816,32.105-39.494,33.541-70.293
|
||||
c1.4961-32.117-4.2012-64.631-18.553-95.975c-14.8-32.2-31.7-48.67-61.61-69.39L887.6567,523.3495z M982.6667,192.5796
|
||||
c4.7246,7.4936,4.5625,17.436,2.8359,24.241c-3.4258,13.504-17.182,23.524-28.1169,29.553
|
||||
c-8.4844,4.6753-18.865,10.828-27.438,16.007c1.1211-0.9297,2.2109-1.855,3.2383-2.7759c13.639-12.22,28.078-28.297,31.746-54.893
|
||||
c0.4004-2.9141,0.6738-5.9526,0.8086-9.0322c0.1523-1.1787,0.2617-2.8867,0.3379-5.0913c2.043-2.2583,3.9902-4.4854,5.8418-6.6807
|
||||
c4.7,1.79,8.4,4.69,10.9,8.67L982.6667,192.5796z M966.1667,182.3696c0.2539,0.042,0.5098,0.0786,0.7598,0.1279
|
||||
c1.0762,0.2119,2.1094,0.4761,3.1016,0.7803c-1.2637,1.4912-2.5762,2.998-3.9297,4.5176v-5.43L966.1667,182.3696z
|
||||
M847.6767,331.0195c25.152-51.59,53.029-77.839,65.832-89.074c19.3051-16.936,37.5291-35.109,50.479-49.119
|
||||
c-0.0215,0.895-0.0527,1.8018-0.0918,2.7319c-0.1328,3.0371-0.4004,6.0322-0.7949,8.9033c-3.5879,26.014-17.766,41.789-31.16,53.79
|
||||
c-4.7578,4.2617-10.631,8.626-16.889,13.212c-1.207,0.7476-1.9004,1.1807-1.9434,1.2065l0.0938,0.1494
|
||||
c-14.523,10.634-32.506,22.65-48.373,42.287c-12.816,15.861-21.521,26.165-33.588,57.312c6.1-19.26,8.76-25.57,16.47-41.4
|
||||
L847.6767,331.0195z M822.6867,412.3695c0.8906-5.25,1.6719-10.775,3.209-16.295l0.0078,0.002
|
||||
c0.0039-0.0137,0.0234-0.0938,0.0547-0.2207c7.5781-27.081,22.033-56.765,40.232-78.393c14.195-16.869,36.232-33.77,59.859-40.081
|
||||
c21.879-5.8442,60.418,0.2354,73.652,31.843c10.146,24.24,5.0332,46.57-7.0508,61.845c-7.3145-10.55-16.6021-19.137-27.184-24.963
|
||||
c-25.973-14.302-55.145-16.409-80.039-5.7812c-16.062,6.8564-28.406,16.28-37.74,28.811
|
||||
c-10.389,13.943-18.254,30.246-21.666,52.822c-4.1445,27.439-0.875,41.41,4.1465,57.277
|
||||
c-5.5644-16.051-6.6074-20.389-7.7832-34.848c-1.49-18.4,0.06-30.6,0.3-32.03L822.6867,412.3695z"/>
|
||||
|
||||
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="-1083.3602" y1="8.0304" x2="-1083.3602" y2="776.3354" gradientTransform="matrix(-1 0 0 -1 -212.8987 792)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_2_)" d="M895.0468,653.3495c-7.2559-5.9922-17.59-13.248-27.584-20.268
|
||||
c-10.689-7.5078-20.867-14.656-26.676-19.793c-2.6934-3.1387-5.6543-7.3594-8.4727-12.965
|
||||
c-2.0606-5.1543-3.8691-11.217-5.3359-16.834c3.8242,9.2109,9.1211,19.686,15.174,25.275
|
||||
c3.5508,3.2754,7.8555,5.0977,12.451,5.2695c7.8945,0.291,15.178-4.4805,16.945-11.096c2.7969-10.477-2.2344-18.893-16.312-27.285
|
||||
c-1.1523-0.6855-2.2676-1.3457-3.3477-1.9863c-10.969-6.4883-18.215-10.777-23.34-19.176
|
||||
c-4.2168-6.9102-7.0391-23.127-7.0684-23.291l-0.5938-3.4492l-1.1484,3.3066c-0.0723,0.2051-7.1348,20.604-10.883,37.168
|
||||
c-1.2383,5.4707-3.373,18.004-3.5293,30.279c-0.1523,11.867,2.2109,22.33,6.4844,28.703c1.5644,2.3359,7.4453,9.9844,17.381,10.35
|
||||
c2.1348,0.0801,4.3125-0.2012,6.4785-0.834c11.539-3.3691,14.426-10.637,15.109-13.295c4.7344,3.4727,10.123,7.2578,15.646,11.137
|
||||
c9.9668,7,20.271,14.236,27.473,20.182c17.137,14.145,26.83,28.451,34.516,57.582c11.121,42.156-2.5918,46.002-2.5859,46.26
|
||||
l1.0137,0.1133c11.9771-7.2266,9.6074-25.158,5.0254-46.818c-6.2-29.98-19.3-44.12-36.72-58.52L895.0468,653.3495z"/>
|
||||
|
||||
<linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="-1128.5793" y1="8.0304" x2="-1128.5793" y2="776.3384" gradientTransform="matrix(-1 0 0 -1 -212.8987 792)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_3_)" d="M948.0667,731.4296c0.0313-0.1367,2.7715-4.4941,5.7246-24.129
|
||||
c1.2852-8.5527,2.0664-20.654,1.9512-25.713c-0.6738-29.553-3.9863-45.898-18.449-77.033
|
||||
c-22.656-48.785-61.686-68.453-61.686-68.453s22.504,14.578,33.693,37.957c4.4102,9.2207,7.2617,21.076,8.7988,29.814
|
||||
c0.7578,4.2969,0.8125,9.9219,3.377,16.66c4.7031,12.359,16.303,32.861,20.084,48.09c7.1,29.08,6.4,62.8,6.4,62.8
|
||||
L948.0667,731.4296z"/>
|
||||
|
||||
<linearGradient id="SVGID_4_" gradientUnits="userSpaceOnUse" x1="-1200.0969" y1="-67.3895" x2="-1200.0969" y2="907.8705" gradientTransform="matrix(-1 0 0 -1 -212.8987 792)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_4_)" d="M1008.8668,508.6495l3.0723-4.6523l-4.3809,3.4375c-0.1953,0.1523-19.572,15.455-32.545,34.041
|
||||
c-6.9043,9.8926-15.6071,26.627-11.496,57.619c1.1426,8.6191,2.2285,14.58,3.5156,19.766
|
||||
c12.426,50.041,6.4258,70.041,6.4258,70.041s7.1123-38.802,6.666-58.995c-0.2539-11.492-1.6992-19.513-1.7891-29.157
|
||||
c-0.0605-6.627-0.125-13.482,0.6934-20.758c1.6602-14.709,5.1738-26.893,7.9473-34.775c-1.8203,9.0527-4.1602,22.227-4.584,31.951
|
||||
c-0.3496,8.0215,0.0898,14.385,0.5117,20.537c0.5547,8.0938,2.1602,19.493,2.0547,30.369
|
||||
c-0.334,34.495-10.667,70.333-10.667,70.333s15.152-24.767,22.5-49.5c4.5674-15.373,7.2471-38.265,3.8057-58.909
|
||||
c-2.7578-16.529-4.9355-36.346-0.1895-57.979c3.4-15.63,8.4-23.3,8.5-23.37L1008.8668,508.6495z"/>
|
||||
|
||||
<linearGradient id="SVGID_5_" gradientUnits="userSpaceOnUse" x1="-998.1218" y1="8.0204" x2="-998.1218" y2="776.3414" gradientTransform="matrix(-1 0 0 -1 -212.8987 792)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_5_)" d="M751.7667,448.5796c3.0098-1.9727,7.8574-3.0352,12.969-2.8477
|
||||
c4.377,0.1602,8.5898,1.207,12.182,3.0254c6.3438,3.209,11.037,8.002,16.006,13.076c2.457,2.5078,4.9961,5.1016,7.793,7.5449
|
||||
c10.066,8.7949,18.037,13.512,18.117,13.561l2.2598,1.3281l-0.9648-2.4453c-0.0371-0.0957-3.8144-9.7441-6.0547-22.213
|
||||
c-5.0664-28.221-4.6875-40.334-1.0781-60.305c1.6309-9.0254,5.6289-20.043,9.8633-31.709
|
||||
c3.9707-10.948,8.0781-22.269,10.584-32.915c6.7949-28.878-9.3496-54.282-9.5117-54.534l-2.3535-3.6377l0.7031,4.2788
|
||||
c0.0254,0.1479,2.4199,14.868,1.2109,24.768c-1.6758,13.729-3.0859,19.245-8.8672,34.718
|
||||
c-1.7871,4.7832-4.2383,9.2188-6.8301,13.915c-5.6894,10.301-12.137,21.978-14.945,42.494
|
||||
c-2.6504,19.359,1.4922,35.984,4.541,44.898c-4.8652-6.2344-13.207-15.242-21.916-17.031
|
||||
c-2.2695-0.4648-4.4844-0.7422-6.582-0.8203c-10.648-0.3906-19.08,4.2168-25.772,14.09c-6.0478,8.9238-7.3975,24.15-7.4512,24.795
|
||||
l-0.335,3.9629l2.0117-3.4277c0.05-0.09,5.01-8.41,14.42-14.57L751.7667,448.5796z"/>
|
||||
|
||||
<linearGradient id="SVGID_6_" gradientUnits="userSpaceOnUse" x1="-1059.9657" y1="8.0304" x2="-1059.9657" y2="776.3395" gradientTransform="matrix(-1 0 0 -1 -212.8987 792)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_6_)" d="M856.2968,289.8695c8.7832-21.179,10.922-45.943,12.801-76.116
|
||||
c2.041-32.708,21.543-49.919,21.74-50.089l3.7051-3.2017l-4.6016,1.6626c-0.2988,0.1084-30.117,11.15-40.463,37.731
|
||||
c-2.9648,7.6221-2.0273,18.878-1.5039,23.263c-2.916-1.5288-9.4668-4.4551-17.334-4.7451
|
||||
c-1.4824-0.0547-2.9609-0.0107-4.3906,0.1309c-20.393,2.0176-26.082,20.516-26.137,20.703l-0.5215,1.7671l1.7129-0.6655
|
||||
c0.0684-0.0259,6.7637-2.585,14.959-2.2827c8.4883,0.3125,19.975,3.8267,26.486,18.792"/>
|
||||
|
||||
<linearGradient id="SVGID_7_" gradientUnits="userSpaceOnUse" x1="-1181.3455" y1="8.0304" x2="-1181.3455" y2="776.3375" gradientTransform="matrix(-1 0 0 -1 -212.8987 792)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_7_)" d="M996.2668,217.6096c0,0,1.1719,19.598-12.859,32.867c-9.6035,9.0815-53.432,19.486-53.432,19.486
|
||||
s18.441-2.8774,30.1331-3.0195c12.355-0.1484,28.031,2.2422,39.104,15.141c4.8594,5.6631,7.6641,9.9522,7.6641,9.9522
|
||||
s0.5781-9.5391-2.6113-16.313c-5.3086-11.276-14.7321-14.344-14.7321-14.344s3.6563-1.1738,8.2012-10.072
|
||||
c6.1-12.12-1.5-33.71-1.5-33.71L996.2668,217.6096z"/>
|
||||
|
||||
<linearGradient id="SVGID_8_" gradientUnits="userSpaceOnUse" x1="-1130.5813" y1="8.0304" x2="-1130.5813" y2="776.3375" gradientTransform="matrix(-1 0 0 -1 -212.8987 792)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_8_)" d="M856.1967,355.4395c0,0,22.207-22.299,51.486-23.94c22.107-1.2383,32.951,0.9126,46.9059,6.4131
|
||||
c8.5195,3.3589,10.699,4.9731,10.699,4.9731s8.2129-3.4805,11.359-10.959c7.7578-18.45-4.8594-31.607-4.8594-31.607
|
||||
s-9.998,19.097-30.4919,18.216c-24.66-1.0596-33.445,1.1504-44.785,5.4507c-22.13,8.4-40.29,31.46-40.29,31.46L856.1967,355.4395z"
|
||||
/>
|
||||
|
||||
<linearGradient id="SVGID_9_" gradientUnits="userSpaceOnUse" x1="-1174.0514" y1="8.0304" x2="-1174.0514" y2="776.3375" gradientTransform="matrix(-1 0 0 -1 -212.8987 792)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_9_)" d="M1004.6667,420.8896c0,0,6.4766,32.723-16.9139,62.533c-18.324,23.355-38.486,29.982-49.66,31.562
|
||||
c-6.3594,0.8984-8.0059,0.6113-8.0059,0.6113c-2.9863-1.6172-7.2305-4.0449-11.564-13.117
|
||||
c-4.4219-9.2559,1.1445-21.414,1.1445-21.414s14.932,10.25,29.324,7.9102c14.395-2.3418,28.068-5.2812,39.965-21.826
|
||||
c13.4-18.53,15.8-46.25,15.8-46.25L1004.6667,420.8896z"/>
|
||||
<path fill="#1B2851" d="M766.9367,413.7395c0,0-0.3809,0.3477-0.9785,0.9746c0.0449-0.043,0.0977-0.0918,0.1406-0.1328
|
||||
c0.36-0.34,0.76-0.73,0.84-0.84L766.9367,413.7395z"/>
|
||||
<path fill="#1B2851" d="M766.0167,426.3695c0,0-0.3809,0.3477-0.9785,0.9746c0.0449-0.043,0.0977-0.0918,0.1387-0.1328
|
||||
c0.35-0.34,0.76-0.72,0.84-0.84L766.0167,426.3695z"/>
|
||||
|
||||
<linearGradient id="SVGID_10_" gradientUnits="userSpaceOnUse" x1="-876.9413" y1="1101.4695" x2="-876.9413" y2="1869.7794" gradientTransform="matrix(0.022 -0.9998 0.9998 0.022 -985.1675 -309.9955)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_10_)" d="M356.8567,616.4695c11.516-15.887,21.152-32.499,27.933-46.619
|
||||
c15.685-6.7852,41.974-15.247,73.001-9.8594c41.626,7.2305,67.177,25.963,80.409,58.953c9.8691,24.605,7.1621,53.152-7.4277,78.32
|
||||
c-6.2041,10.701-14.92,19.588-25.333,26.383c-5.4082-4.7598-9.8018-10.424-12.926-16.566
|
||||
c-8.7637-17.228-7.1045-46.586-5.3818-58.099c0.0488-0.2764,0.2334-1.2998,0.4873-2.7959
|
||||
c0.0566-0.2686,0.1104-0.5156,0.1641-0.7314l-0.041-0.0107c0.6152-3.7041,1.5283-9.5918,1.9668-14.485
|
||||
c0.0928-1.0215,0.168-2.0557,0.2295-3.0713c0.6338-10.788-0.627-18.984-1.7617-23.873c3.0664,5.2441,7.2451,15.54,6.1035,31.548
|
||||
c-0.6006,8.4639-3.1504,17.019-3.1758,17.104l-0.2236,0.7393l0.6992,0.334c0.3809,0.1846,9.458,4.4336,15.824-1.3838
|
||||
c3.3936-3.1045,5.3789-8.0117,5.7433-14.19c0.7402-12.558-5.3486-27.597-15.152-37.425c-8.0312-8.0518-22.151-18.627-22.294-18.731
|
||||
l-2.1494-1.6064l0.7354,2.5762c0.04,0.1387,4.0801,14.421,3.2861,22.975c-0.9131,9.7803-4.8125,17.591-7.6621,23.293
|
||||
c-1.4326,2.8701-2.5644,5.1367-2.8809,6.8408c-0.2314,1.2432-0.3828,2.459-0.4492,3.6104
|
||||
c-0.2861,4.8359,0.8477,8.7217,3.2783,11.232c1.8096,1.8672,4.209,2.8184,6.9414,2.7578c3.8291-0.0898,6.543-1.4033,8.1934-2.543
|
||||
c-0.3271,2.5752-0.624,5.6826-0.8271,9.1484c-0.833,14.135-0.1094,34.158,6.7305,47.603c3.251,6.3916,7.6309,12.069,12.88,16.808
|
||||
c-22.233,13.797-51.775,18.295-81.165,10.846c-37.312-9.457-63.547-45.473-62.382-85.639
|
||||
c1.0635-36.208,18.379-67.174,40.309-72.039c15.434-3.4258,24.862,2.917,29.786,8.206l-0.0117,0.0254l0.1016,0.0664
|
||||
c0.5605,0.6084,1.0596,1.2012,1.5068,1.7627c1.8008,2.8486,5.3008,9.3623,6.4697,15.952c0.6953,3.919,1.1279,7.0908,1.5859,10.448
|
||||
c0.5928,4.3633,1.2041,8.8779,2.4707,15.688c1.0596,5.6846,2.6943,10.22,4.249,13.585c-2.6426-3.1621-5.4385-7.1074-6.4668-10.617
|
||||
l-0.499-1.6894c-1.5664-5.3027-2.6006-8.8066-3.8281-17.289c-1.6602-11.49-5.7959-19.803-11.345-22.805
|
||||
c-2.4268-1.3115-4.9746-1.4766-7.1738-0.4629c-4.2275,1.9512-8.9873,7.5879-9.4434,15.352
|
||||
c-0.2363,4.0205,0.7041,8.0527,2.7998,11.984c19.574,36.746,51.784,44.949,52.107,45.027l3.8916,0.9473l-3.1055-2.5244
|
||||
c-0.0654-0.0518-6.5332-5.3916-7.5537-13.445c-0.3164-2.501-0.3467-5.7978-0.0947-10.081c0.3477-5.9336,1.1807-12.617,1.915-18.513
|
||||
c0.2529-2.0322,0.4932-3.96,0.6953-5.7197c1.5762-13.692-0.6738-23.377-6.6533-28.66c-4.5303-4.001-10.496-4.6445-14.569-3.9102
|
||||
c-3.4033,0.6152-5.2695,2.334-6.1172,3.3691c-5.3789-5.542-15.35-11.959-31.11-8.4609c-21.978,4.875-39.482,34.705-41.57,70.15
|
||||
c-0.0713,1.1943-0.123,2.3965-0.1572,3.6016c-1.1904,41.001,25.622,77.773,63.748,87.439c22.832,5.7871,45.72,4.7227,66.189-3.0801
|
||||
c5.958-2.2695,11.573-5.0781,16.767-8.3516c4.876,4.1416,10.4391,7.5059,16.516,9.9014c14.766,5.8272,31.188,5.5215,47.486-0.8838
|
||||
c18.636-7.3242,27.508-22.559,31.667-34.048c4.7461-13.108,5.8525-28.813,2.8862-40.991
|
||||
c-3.2227-13.229-10.553-26.452-16.562-35.752c7.1104,8.6582,13.04,17.139,18.305,24.67c0.3789,0.541,0.7524,1.0781,1.127,1.6133
|
||||
c3.8584,6.5459,16.194,27.611,24.078,42.695c5.9512,11.383,15.996,25.77,30.048,29.668c3.459,0.959,15.538,3.6152,25.717-2.4961
|
||||
c4.2148-2.5293,7.3574-6.168,9.4023-10.847c2.3184,2.0088,4.6016,3.9033,6.8486,5.6807c18.446,14.602,37.149,19.697,50.928,21.209
|
||||
c-0.7578,1.1982-1.7109,3.1543-1.8555,5.6172c-0.166,2.8057,0.7539,5.5488,2.7324,8.1514c3.752,4.9336,12.678,6.3154,19.898,3.084
|
||||
c12.559-5.6279,22.166-14.8,29.18-21.499c2.1953-2.0957,4.0898-3.9043,5.7832-5.3359l3.4883-2.9512l-4.3535,1.4023
|
||||
c-0.1035,0.0332-10.309,3.2686-17.789,1.123c-5.7715-1.6572-7.7969-2.7568-11.473-4.7559
|
||||
c-1.3438-0.7334-2.8672-1.5625-4.8691-2.584c-5.9121-3.0225-11.533-3.7559-15.426-2.0107
|
||||
c-2.5898,1.1602-4.375,3.3789-5.1602,6.4131c-0.1445,0.5635-0.2383,1.1924-0.2793,1.8691
|
||||
c-0.1738,2.9522,0.7129,6.0098,0.752,6.1396l0.1387,0.4775l0.4805,0.1357c2.0254,0.5684,6.002,1.3945,14.012,1.5264
|
||||
c1.9219,0.0322,14.682,0.7109,27.967-1.8945c-10.596,4.4004-44.004,3.4062-40.08,3.5752
|
||||
c-13.724-1.0664-33.475-5.6943-52.949-21.104c-2.3789-1.8848-4.8008-3.9023-7.2617-6.0469
|
||||
c0.4531-1.2637,0.834-2.5957,1.1416-3.9922c0.0791-0.3604,0.1455-0.7266,0.21-1.0938c4.8516,0.1279,10.678,0.2383,17.406,0.3672
|
||||
c6.0889,0.1172,12.906,0.2471,20.389,0.418c16.615,0.375,29.808,1.0566,55.822,3.7422c29.273,3.0176,51.205,12.968,51.424,13.068
|
||||
l4.1641,1.9121l-3.123-3.3477c-0.9297-0.9961-23.02-24.5-43.791-31.148c-18.451-5.9043-39.203-4.7227-43.697-4.377
|
||||
c-0.418-2.6797-2.3457-10.619-10.921-16.675c-8.8633-6.2568-23.957-1.8203-24.595-1.6318l-3.0186,0.9111l3.041,0.8271
|
||||
c0.0781,0.0225,7.9082,2.2129,10.88,8.1289c1.5283,3.043,1.5127,6.5879-0.0508,10.539c-1.376,3.4805-7.6143,13.523-33.604,14.824
|
||||
c1.2988-13.773-5.332-46.137-35.321-71.121c4.9053,4.0195,14.771,7.707,30.089,28.41c6.3438,8.5732,7.4854,18.433,7.4854,18.433
|
||||
c3.2617,1.209,18.011-5.9736,12.741-21.162c-4.3086-12.422-12.476-17.134-24.275-22.085
|
||||
c-8.0996-3.3965-15.828-5.8164-28.631-9.8242c-4.6465-1.4531-9.9121-3.1016-16.179-5.1016l-4.7959-1.5312l3.96,3.1016
|
||||
c0.1484,0.1172,7.2568,5.4121,15.468,20.07c2.3755,4.2383,7.7529,17.471,8.7739,22.228c1.981,9.2178,2.166,16.226,5.1289,19.964
|
||||
c2.2686,2.8613,5.4033,4.6035,8.8262,4.9043c3.1025,0.2715,6.1045-0.6553,8.2363-2.543c2.292-2.0312,3.4307-4.2178,3.6797-7.0312
|
||||
c1.8252,6.5244,3.7549,15.705,2.9834,23.366c-2.4941,0.084-5.1592,0.0869-8.0098,0.001c-14.064-13.393-30.146-31.208-46.95-51.718
|
||||
c-10.403-12.699-43.115-49.7-95.321-74.319c-26.053-12.286-38.907-16.193-56.748-21.206c-5.6309-1.583-13.017-3.1279-19.929-3.9121
|
||||
c-2.2773-0.2568-16.359-2.21-34.136-0.7539c-17.982,1.4697-31.78,5.9014-46.797,13.604c9.7676-21.73,10.134-36.429,11.114-48.016
|
||||
c2.7578-32.605-7.3408-52.9-27.758-62.347c-12.267-5.6748-23.904-5.0283-35.915,0.2959c-7.2324,3.206-11.398,9.8164-13.877,17.033
|
||||
c-2.9844,8.6816,0.1318,16.026,2.2148,19.587c-3.2441,0.7959-11.071,3.706-11.643,13.374
|
||||
c-0.0244,0.3984-0.0332,0.8105-0.0322,1.2324c0.0732,13.768,10.458,18.076,18.805,21.537c2.1016,0.873,4.0898,1.6973,5.7832,2.6113
|
||||
l0.2256,0.1201c12.957,6.9883,18.872,10.178,21.902,21.749c1.5938,6.082,1.8672,13.711,1.8721,13.785l0.1152,3.4639l1.5957-3.0791
|
||||
c0.2617-0.5078,6.4492-12.526,8.2666-21.618c1.293-6.4609,2.0762-12.234,2.3945-17.646c0.3018-5.083,0.1816-9.9404-0.3584-14.847
|
||||
c-1.7227-15.627-7.2178-23.267-12.365-28.678c-4.7373-4.9805-12.927-8.9004-21.697-4.9639
|
||||
c-5.2383,2.3506-9.6777,9.2891-9.8643,9.584l-0.1963,0.3096l0.0762,0.3613c0.083,0.3848,1.0723,3.9414,10.134,10.031
|
||||
c3.7217,2.498,10.631,8.5488,13.925,13.404c3.3438,4.9316,5.1758,10.639,6.1826,15.457c-0.1445-0.3535-0.2969-0.7119-0.457-1.0772
|
||||
c-0.1914-0.4346-0.373-0.8613-0.5537-1.292c-2.1846-5.1816-4.4463-10.541-21.661-23.502c-6.1406-4.624-10.182-10.164-10.221-10.22
|
||||
l-0.1992-0.2803c-1.8252-2.9072-5.4746-10.25-2.5312-18.818c2.3144-6.7324,7.3213-12.066,14.104-15.021
|
||||
c9.1016-3.9639,22.169-5.3525,33.772,0.5127c17.044,8.6123,25.851,24.222,25.304,56.604c-0.1846,10.863-2.5146,30.18-12.882,52.484
|
||||
c-2.4199,1.3184-6.9824,3.5703-9.3144,5.0508c-1.6387,1.0371-109.62,66.678-150.74,79.393
|
||||
c-51.048,15.791-93.326,22.281-103.52,2.7959l0.083,1.8174c4.1992,21.768,51.736,20.587,102.96,3.502
|
||||
c69.458-23.168,122.39-66.316,148.58-80.451c2.9717-1.8242,4.001-2.2959,7.8232-4.207c-6.6387,13.434-14.354,28.699-25.801,43.201
|
||||
c-25.368,32.133-43.585,45.137-76.389,57.416c-34.568,12.941-52.039,13.82-87.231,12.148
|
||||
c-23.653-1.124-69.539-12.854-70.062-29.659l-0.1631,2.7803c4.2207,20.979,38.778,32.968,69.538,35.08
|
||||
c32.076,2.2041,64.707-2.7764,96.359-16.434c32.44-14.01,49.28-30.48,70.65-59.95L356.8567,616.4695z M685.4567,718.6995
|
||||
c-7.5967,4.5586-17.532,4.1777-24.298,2.3018c-13.426-3.7227-23.141-17.696-28.927-28.761
|
||||
c-4.4873-8.5859-10.409-19.1-15.399-27.783c0.9053,1.1406,1.8057,2.25,2.7041,3.2978c11.916,13.905,27.672,28.694,54.181,32.947
|
||||
c2.9038,0.4648,5.936,0.8047,9.0112,1.0068c1.1758,0.1787,2.8809,0.3262,5.084,0.4502c2.2119,2.0928,4.3955,4.0879,6.5488,5.9873
|
||||
c-1.87,4.58-4.86,8.11-8.89,10.54L685.4567,718.6995z M696.0267,702.4796c-0.0469,0.2539-0.0889,0.5078-0.1436,0.7578
|
||||
c-0.2354,1.0703-0.5225,2.0977-0.8486,3.082c-1.4629-1.2949-2.9414-2.6406-4.4297-4.0273c1.61,0.07,3.42,0.13,5.42,0.19
|
||||
L696.0267,702.4796z M550.0167,580.7296c51.025,26.282,76.654,54.73,87.603,67.777c16.507,19.674,34.274,38.293,47.997,51.548
|
||||
c-0.8945-0.041-1.8008-0.0928-2.7305-0.1514c-3.0332-0.2002-6.0215-0.5332-8.8833-0.9912
|
||||
c-25.929-4.1602-41.388-18.682-53.09-32.337c-4.1558-4.8506-8.3896-10.818-12.836-17.176
|
||||
c-0.7212-1.2227-1.1392-1.9258-1.1641-1.9688l-0.1514,0.0898c-10.311-14.754-21.929-32.996-41.211-49.293
|
||||
c-15.575-13.162-25.685-22.092-56.558-34.841c19.12,6.49,25.37,9.28,41.0201,17.34L550.0167,580.7296z M469.2367,553.9595
|
||||
c5.2285,1.0059,10.735,1.9082,16.22,3.5674l-0.002,0.0078c0.0137,0.0049,0.0938,0.0254,0.2197,0.0596
|
||||
c26.907,8.1719,56.266,23.277,77.488,41.949c16.552,14.562,32.964,36.967,38.753,60.727c5.3608,22.002-1.5654,60.398-33.458,72.934
|
||||
c-24.458,9.6094-46.669,4.0059-61.675-8.4102c10.709-7.081,19.4991-16.177,25.557-26.629c14.87-25.65,17.618-54.77,7.542-79.892
|
||||
c-6.502-16.21-15.651-28.758-27.974-38.365c-13.711-10.693-29.837-18.915-52.332-22.823c-27.342-4.748-41.381-1.7871-57.354,2.8838
|
||||
c16.169-5.21,20.529-6.1572,35.011-7.0146c18.41-1.11,30.57,0.72,31.99,0.99L469.2367,553.9595z"/>
|
||||
|
||||
<linearGradient id="SVGID_11_" gradientUnits="userSpaceOnUse" x1="-879.4864" y1="1101.4695" x2="-879.4864" y2="1869.7815" gradientTransform="matrix(0.022 -0.9998 0.9998 0.022 -985.1675 -309.9955)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_11_)" d="M226.7267,620.9896c6.1514-7.1221,13.633-17.294,20.87-27.131
|
||||
c7.7412-10.521,15.111-20.54,20.375-26.233c3.1973-2.623,7.4824-5.4902,13.148-8.1856c5.1992-1.9453,11.3-3.6211,16.948-4.9639
|
||||
c-9.294,3.6201-19.882,8.6865-25.604,14.614c-3.3535,3.4775-5.2705,7.7412-5.543,12.332
|
||||
c-0.4648,7.8867,4.1455,15.272,10.721,17.186c10.412,3.0273,18.937-1.8184,27.638-15.708
|
||||
c0.7109-1.1367,1.3955-2.2373,2.0586-3.3037c6.7285-10.822,11.177-17.973,19.686-22.912c7.001-4.0625,23.276-6.5273,23.441-6.5527
|
||||
l3.4609-0.5176l-3.2803-1.2207c-0.2031-0.0781-20.441-7.5879-36.92-11.699c-5.4424-1.3584-17.926-3.7686-30.193-4.1953
|
||||
c-11.862-0.4141-22.374,1.7188-28.839,5.8516c-2.3711,1.5117-10.146,7.2227-10.73,17.148
|
||||
c-0.127,2.1328,0.1064,4.3164,0.6914,6.4951c3.1143,11.61,10.315,14.657,12.959,15.398c-3.5762,4.6572-7.4785,9.96-11.479,15.397
|
||||
c-7.2168,9.8106-14.679,19.953-20.781,27.021c-14.518,16.821-29.034,26.197-58.327,33.24
|
||||
c-42.392,10.189-45.935-3.6045-46.192-3.6045l-0.1357,1.0107c6.9609,12.133,24.941,10.159,46.697,6.0547
|
||||
c30.12-5.69,44.54-18.4,59.32-35.53L226.7267,620.9896z"/>
|
||||
|
||||
<linearGradient id="SVGID_12_" gradientUnits="userSpaceOnUse" x1="-924.6873" y1="1101.4695" x2="-924.6873" y2="1869.7784" gradientTransform="matrix(0.022 -0.9998 0.9998 0.022 -985.1675 -309.9955)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_12_)" d="M147.4967,672.2595c0.1348,0.0342,4.4316,2.8691,23.997,6.2539
|
||||
c8.5215,1.4736,20.603,2.5215,25.663,2.5176c29.561-0.0234,45.976-2.9746,77.421-16.749c49.272-21.577,69.795-60.163,69.795-60.163
|
||||
s-15.07,22.178-38.69,32.85c-9.3144,4.2051-21.23,6.7949-30,8.1396c-4.3125,0.6631-9.9385,0.5947-16.731,3.0088
|
||||
c-12.46,4.4307-33.212,15.576-48.52,19.021c-29.23,6.58-62.93,5.12-62.93,5.12L147.4967,672.2595z"/>
|
||||
|
||||
<linearGradient id="SVGID_13_" gradientUnits="userSpaceOnUse" x1="-996.1837" y1="1026.0594" x2="-996.1837" y2="2001.3094" gradientTransform="matrix(0.022 -0.9998 0.9998 0.022 -985.1675 -309.9955)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_13_)" d="M368.8867,737.9296l4.583,3.1738l-3.3398-4.4551c-0.1475-0.1992-15.021-19.908-33.316-33.287
|
||||
c-9.7373-7.1211-26.276-16.19-57.352-12.762c-8.6426,0.9521-14.626,1.9062-19.838,3.0801
|
||||
c-50.303,11.32-70.166,4.8809-70.166,4.8809s38.636,7.9648,58.834,7.9639c11.494-0.001,19.545-1.2695,29.189-1.1475
|
||||
c6.626,0.0859,13.482,0.1719,20.737,1.1504c14.669,1.9844,26.772,5.7656,34.592,8.7109c-9.0098-2.0186-22.129-4.6484-31.842-5.2852
|
||||
c-8.0127-0.5273-14.383-0.2275-20.544,0.0586c-8.1035,0.377-19.536,1.7305-30.406,1.3857
|
||||
c-34.48-1.0928-70.082-12.213-70.082-12.213s24.428,15.694,48.993,23.585c15.269,4.9043,38.097,8.0869,58.811,5.1016
|
||||
c16.586-2.3936,36.445-4.1348,57.969,1.0869c15.57,3.78,23.12,8.93,23.19,8.98L368.8867,737.9296z"/>
|
||||
|
||||
<linearGradient id="SVGID_14_" gradientUnits="userSpaceOnUse" x1="-794.2529" y1="1101.4695" x2="-794.2529" y2="1869.7834" gradientTransform="matrix(0.022 -0.9998 0.9998 0.022 -985.1675 -309.9955)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_14_)" d="M434.6067,482.2596c1.9053,3.0527,2.8613,7.9219,2.5606,13.027
|
||||
c-0.2559,4.373-1.3955,8.5625-3.292,12.113c-3.3486,6.2715-8.2432,10.857-13.426,15.713c-2.5615,2.4023-5.21,4.8828-7.7148,7.625
|
||||
c-9.0146,9.8711-13.905,17.736-13.956,17.814l-1.3779,2.2305l2.4668-0.9102c0.0957-0.0352,9.8252-3.5996,22.341-5.5644
|
||||
c28.325-4.4443,40.427-3.7988,60.313,0.25c8.9873,1.8281,19.914,6.0684,31.484,10.559c10.858,4.2109,22.085,8.5664,32.674,11.307
|
||||
c28.722,7.4287,54.475-8.1523,54.731-8.3086l3.6885-2.2734l-4.2935,0.6094c-0.1484,0.0215-14.917,2.0918-24.789,0.6641
|
||||
c-13.688-1.9766-19.172-3.5088-34.514-9.6289c-4.7432-1.8926-9.123-4.4404-13.761-7.1348
|
||||
c-10.174-5.9141-21.706-12.617-42.154-15.877c-19.297-3.0762-36.009,0.6992-44.988,3.5508
|
||||
c6.3398-4.7266,15.529-12.868,17.51-21.535c0.5146-2.2588,0.8408-4.4668,0.9648-6.5625c0.625-10.638-3.7959-19.169-13.519-26.076
|
||||
c-8.7881-6.2422-23.981-7.9277-24.625-7.9951l-3.9541-0.4229l3.3818,2.0879c0.08,0.04,8.3,5.18,14.25,14.73L434.6067,482.2596z"/>
|
||||
|
||||
<linearGradient id="SVGID_15_" gradientUnits="userSpaceOnUse" x1="-856.0887" y1="1101.4695" x2="-856.0887" y2="1869.7834" gradientTransform="matrix(0.022 -0.9998 0.9998 0.022 -985.1675 -309.9955)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_15_)" d="M590.9667,590.2496c20.98,9.2471,45.691,11.931,75.816,14.473
|
||||
c32.655,2.7617,49.433,22.638,49.599,22.838l3.1191,3.7754l-1.5615-4.6367c-0.1016-0.3018-10.484-30.355-36.831-41.284
|
||||
c-7.5547-3.1318-18.829-2.4424-23.224-2.0156c1.5923-2.8818,4.6621-9.3662,5.125-17.226c0.0874-1.4805,0.0762-2.96-0.0342-4.3926
|
||||
c-1.5679-20.432-19.936-26.527-20.122-26.586l-1.7554-0.5605l0.6274,1.7275c0.0244,0.0693,2.4356,6.8193,1.9531,15.006
|
||||
c-0.4995,8.4795-4.2656,19.886-19.371,26.065"/>
|
||||
|
||||
<linearGradient id="SVGID_16_" gradientUnits="userSpaceOnUse" x1="-977.4203" y1="1101.4695" x2="-977.4203" y2="1869.7804" gradientTransform="matrix(0.022 -0.9998 0.9998 0.022 -985.1675 -309.9955)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_16_)" d="M660.1367,731.7296c0,0-19.619,0.7402-32.576-13.58c-8.8677-9.8008-18.305-53.848-18.305-53.848
|
||||
s2.4712,18.501,2.355,30.192c-0.1235,12.356-2.8584,27.976-15.998,38.761c-5.7686,4.7334-10.118,7.4434-10.118,7.4434
|
||||
s9.5234,0.7881,16.367-2.252c11.391-5.0586,14.665-14.412,14.665-14.412s1.0928,3.6807,9.8892,8.4199
|
||||
c11.96,6.46,33.72-0.72,33.72-0.72L660.1367,731.7296z"/>
|
||||
|
||||
<linearGradient id="SVGID_17_" gradientUnits="userSpaceOnUse" x1="-926.7069" y1="1101.4695" x2="-926.7069" y2="1869.7804" gradientTransform="matrix(0.022 -0.9998 0.9998 0.022 -985.1675 -309.9955)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_17_)" d="M525.4268,588.7095c0,0,21.806,22.692,22.801,52c0.752,22.13-1.6377,32.924-7.4434,46.754
|
||||
c-3.5459,8.4443-5.208,10.588-5.208,10.588s3.2988,8.2871,10.707,11.598c18.275,8.1621,31.706-4.1621,31.706-4.1621
|
||||
s-18.873-10.416-17.541-30.887c1.6025-24.63-0.4136-33.462-4.4634-44.894c-7.93-22.33-30.57-41-30.57-41L525.4268,588.7095z"/>
|
||||
|
||||
<linearGradient id="SVGID_18_" gradientUnits="userSpaceOnUse" x1="-970.2051" y1="1101.4695" x2="-970.2051" y2="1869.7834" gradientTransform="matrix(0.022 -0.9998 0.9998 0.022 -985.1675 -309.9955)">
|
||||
<stop offset="0" style="stop-color:#6E3600"/>
|
||||
<stop offset="0.0338" style="stop-color:#7A4005"/>
|
||||
<stop offset="0.0952" style="stop-color:#9A5A11"/>
|
||||
<stop offset="0.1765" style="stop-color:#CE8424"/>
|
||||
<stop offset="0.2" style="stop-color:#DE912A"/>
|
||||
<stop offset="0.4299" style="stop-color:#834B00"/>
|
||||
<stop offset="0.4638" style="stop-color:#915A0D"/>
|
||||
<stop offset="0.5293" style="stop-color:#B78230"/>
|
||||
<stop offset="0.6175" style="stop-color:#F2C167"/>
|
||||
<stop offset="0.7387" style="stop-color:#D48C2E"/>
|
||||
<stop offset="0.9045" style="stop-color:#825121"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_18_)" d="M456.7167,735.7296c0,0-32.857,5.7549-62.146-18.286c-22.946-18.835-29.128-39.138-30.461-50.344
|
||||
c-0.7588-6.377-0.4355-8.0176-0.4355-8.0176c1.6836-2.9492,4.2031-7.1387,13.369-11.272c9.3506-4.2168,21.384,1.6162,21.384,1.6162
|
||||
s-10.576,14.702-8.5537,29.143c2.0244,14.442,4.6611,28.178,20.94,40.436c18.25,13.73,45.91,16.72,45.91,16.72L456.7167,735.7296z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 43 KiB |
@@ -0,0 +1,167 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 1770.836 1767.559" enable-background="new 0 0 1770.836 1767.559" xml:space="preserve">
|
||||
<g>
|
||||
<path d="M581.339,577.203c44.942-59.167,42.215-56.099,46.471-56.441c123.901-9.935,230.483-81.742,241.81-94.921
|
||||
c-3.087,1.369-5.2,2.187-7.207,3.213c-129.752,66.308-299.555,62.547-361.954-78.95c-4.085-9.263-3.783-11.716-7.578-10.81
|
||||
c-19.053,4.548-20.912-4.169-26.85,2.614c-40.082,45.784-126.308,24.947-137.509-44.772
|
||||
c-12.43-77.368,53.514-141.431,162.087-109.046c4.33,1.292,4.391,1.328,6.326-2.864c39.507-85.613,139.834-123.431,223.317-86.321
|
||||
c37.392,16.622,37.781,31.949,46.748,28.7c45.624-16.532,96.74-1.747,113.556,50.571c6.239,19.411,1.795,23.955,6.997,23.547
|
||||
c0.119-0.009,114.077-0.006,114.196-0.006c4.172,0,5.993,0.783,7.211-3.192c17.641-57.562-11.78-112.342-12.135-114.747
|
||||
c3.443,1.772,21.983,39.836,23.666,90.662c0.908,27.445-4.043,27.066,2.533,27.266c0.367,0.011,639.538,0.017,639.906,0.017
|
||||
c3.814,0,7.271,0.237,10.243-3.505c2.777-3.496,8.56-2.555,11.588,0.906c7.609,8.697-6.286,19.623-12.229,11.579
|
||||
c-3.485-4.717,39.974-3.161-647.676-3.161c-6.368,0-6.188-0.251-6.876,4.487c-3.417,23.52-11.87,58.076-35.57,107.404
|
||||
c-88.591,184.391-331.918,285.362-343.41,280.728c-17.691-7.134-35.45-14.1-53.185-21.126
|
||||
C584.581,578.548,583.357,578.031,581.339,577.203z M631.913,190.091c-23.601,14.765-39.329,32.907-41.861,64.971
|
||||
c-0.976,12.354,0.277,24.409,4.452,36.111c1.506,4.22,1.551,4.239,5.584,3.022c21.898-6.608,45.765,1.158,54.818,22.119
|
||||
c9.277,21.479-3.17,28.458,6.11,29.896c26.184,4.059,56.961-2.668,82.131-22.12c5.477-4.232,0.383-1.697-12.615-18.92
|
||||
c-36.537-48.415-35.512-119.543,4.404-158.47c2.614-2.549,3.421-2.466-5.359-8.527c-62.56-43.189-159.113-24.698-204.584,60.753
|
||||
c-1.978,3.716-1.906,3.777,1.74,6.212c23.045,15.395,40.17,35.768,51.452,61.076c0.584,1.311,0.679,3.006,2.747,3.767
|
||||
C582.835,233.585,596.349,204.616,631.913,190.091z M938.08,320.493c-32.176,18.305-65.482,34.755-114.22,31.341
|
||||
c-20.006-1.401-38.943-6.418-56.621-15.917c-2.406-1.293-3.978-1.373-6.214,0.465c-24.683,20.291-56.966,29.39-84.444,27.819
|
||||
c-24.309-1.39-25.306-6.399-28.922-1.288c-0.911,1.288-2.061,2.726-4.675,3.412c5.391-7.012,5.669-6.672,0.711-8.386
|
||||
c-22.368-7.731-39.034-22.304-50.754-43.385c-0.684-1.23-0.939-3.444-2.806-3.036c-3.402,0.744-0.314,11.258-3.993,19.797
|
||||
c0.197-4.902,1.109-9.785,0.375-14.712c-1.923,0.134-2.321,1.347-2.925,2.288c-22.272,34.733,4.918,110.538,121.601,116.928
|
||||
c98.146,5.375,177.658-42.337,183.697-49.801c-2.304,0.649-4.461,1.503-6.524,2.543c-84.976,42.834-171.348,51.75-196.296,22.111
|
||||
c-7.609-9.039-12.348-29.646,14.1-39.017c50.03-17.728,125.734,15.205,213.639-33.718C922.593,333.049,931,327.599,938.08,320.493z
|
||||
M843.413,312.188c-0.829-4.699,11.709-31.161,41.137-44.212c20.746-9.2,45.623-5.403,75.558-17.329
|
||||
c33.867-13.493,45.367-40.564,44.348-42.727c-1.189-0.597-118.66-0.359-117.658-0.353c-2.911-0.018-1.692,1.69-4.162,12.415
|
||||
c-7.855,34.096-41.484,53.235-74.653,42.439c-2.345-0.763-3.172-0.524-3.709,2.04c-5.444,25.949-10.818,25.159-7.115,30.5
|
||||
c29.812,43.004,124.738,35.865,178.051-23.44c2.118-2.356,4.103-4.854,5.31-7.94C942.399,301.4,895.43,313.668,843.413,312.188z
|
||||
M393.944,312.826c-10.028-4.157-21.494-20.052-18.889-42.201c0.24-2.039,0.362-3.629-1.724-5.173
|
||||
c-35.155-26.018,11.766-100.299,115.319-69.334c1.196,0.358,2.874,1.399,3.406-0.327c0.886-2.876-46.599-16.258-86.888-6.001
|
||||
c-50.612,12.885-74.173,57.368-63.702,104.618c12.035,54.311,76.906,73.782,111.753,41.086c5.781-5.424-6.045-3.312-13.391-22.677
|
||||
c-8.253-21.758-6.256-42.356,7.666-61.388c2.429-3.32,2.56-3.373-1.128-5.355c-11.179-6.009-23.048-7.782-35.499-5.36
|
||||
c-4.094,0.796-4.069,0.924-3.496,5.173c2.665,19.751-15.011,25.646-24.396,23.628C376.271,268.071,374.657,295.402,393.944,312.826
|
||||
z M943.8,351.357c-0.18-0.172-0.359-0.344-0.539-0.516c-9.226,8.306-3.896,4.775-23.744,21.217
|
||||
c-56.717,46.985-129.558,73.428-197.025,76.511c-136.459,6.235-182.276-91.208-145.958-138.041
|
||||
c4.951-6.384,8.846-7.158,7.68-10.957c-13.144-42.838-52.027-25.525-72.581-36.339c-0.909-0.478-1.872-1.605-3.197-0.543
|
||||
c-4.475,18.01,11.5,11.088,13.647,34.539c1.92,20.97-8.606,22.575-5.799,31.782C574.368,519.53,846.717,470.437,943.8,351.357z
|
||||
M735.745,234.632c4.688,2.176,13.478,12.658,38.321,17.02c8.608,1.511,7.767,1.063,7.922-6.562
|
||||
c0.628-30.928-10.299-60.484-27.067-82.425c-1.315-1.721-2.504-2.029-4.069-0.43c-37.536,38.343-32.088,107.977,5.764,143.531
|
||||
c2.196,2.063,2.181,3.696,6.898-3.415c1.012-1.525-0.38-2.443-1.254-3.397C741.505,276.295,735.713,244.397,735.745,234.632z
|
||||
M868.717,200.862c-7.499-40.566-38.855-67.805-87.516-54.803c-2.524,0.674-2.444,1.746-1.26,3.515
|
||||
c9.021,13.472,15.775,28.015,20.395,43.543c1.444,4.855,2.821,0.433,11.082-0.924c7.768-1.277,14.833,0.837,17.424,7.481
|
||||
c1.042,2.671-0.679,2.091,36.21,2.031C866.041,201.703,867.073,201.862,868.717,200.862z M808.571,255.428
|
||||
c32.022,15.303,59.976-8.376,61.023-43.346c0.181-6.04,2.764-4.469-37.165-4.545c-1.6-0.003-2.64,0.414-3.637,1.941
|
||||
c-5.715,8.762-13.263,8.731-18.545-0.067c-1.199-1.997-2.379-1.858-4.125-1.149c-2.254,0.916-1.616,2.624-1.299,4.229
|
||||
C810.464,241.085,802.771,252.656,808.571,255.428z M376.153,261.612c3.73-1.317,7.729-28.02,45.006-30.11
|
||||
c12.21-0.684,23.585,2.207,34.047,8.51c1.859,1.12,3.222,1.11,4.988-0.126c15.336-10.728,25.645-6.796,23.856-14.969
|
||||
c-0.91-4.159-16.66,0.867-45.536,3.319c-34.704,2.946-36.347-34.704,24.813-30.092c19.779,1.491,26,5.922,26.682,3.366
|
||||
c2.11-7.91-115.752-21.011-121.913,39.944C367.28,249.535,369.708,256.513,376.153,261.612z M604.583,302.371
|
||||
c-4.24,1.334-4.448,1.876-2.211,5.495c11.015,17.82,26.419,29.974,46.622,35.855c4.138,1.205,4.724,0.863,5.006-3.353
|
||||
C655.955,311.067,631.296,293.968,604.583,302.371z M449.987,289.848c-0.321-17.755,5.917-26.476,8.809-31.387
|
||||
c1.248-2.119-1.083-5.304-3.46-2.575c-14.702,16.873-14.464,50.417,3.781,66.368c2.752,2.406,3.18,2.366,4.829-0.667
|
||||
c6.331-11.637,8.988-24.067,7.593-37.266c-0.575-5.444-0.95-5.49-6.292-3.755C459.77,282.345,455.11,285.483,449.987,289.848z
|
||||
M927.206,314.612c-1.535-0.335-3.851,0.561-5.358,1.134c-33.207,12.632-70.952,20.019-105.685,12.826
|
||||
c-30.95-6.409-33.554-18.306-37.898-9.004c-2.93,6.273,66.388,35.459,143.154-1.168
|
||||
C923.494,317.409,925.617,316.484,927.206,314.612z M381.827,264.443c22.916,8.787,28.944-24.669,20.513-21.077
|
||||
C392.667,247.488,386.072,254.542,381.827,264.443z M483.512,300.816c-3.197,25.179-12.333,28.842-5.452,29.266
|
||||
c20.232,1.246,13.272,1.189,7.303-27.197C485.202,302.12,485.183,301.174,483.512,300.816z M558.424,258.015
|
||||
c-6.271-7.025-20.366-15.085-41.937-16.866c-3.646-0.301-6.115-0.348-6.198,2.895c-0.1,3.89,1.721,0.561,20.065,3.695
|
||||
C540.336,249.444,549.472,253.455,558.424,258.015z M512.208,307.608c4.142-9.577,2.272-21.835-3.877-25.76
|
||||
C509.282,290.673,509.663,299.268,512.208,307.608z M469.559,247.877c8.952-5.253,11.338-6.099,11.339-6.099
|
||||
c1.386-0.782,0.932-3.32-1.235-2.558C460.161,246.08,465.178,250.448,469.559,247.877z M479.564,268.224
|
||||
c0.343-0.034,0.685-0.067,1.028-0.101c-0.193-4.81,0.229-9.639-0.315-14.973C473.967,259.056,474.954,258.264,479.564,268.224z
|
||||
M513.944,227.064c1.969,4.04-1.752,0.476,19.047,7.103C527.316,230.808,521.272,228.571,513.944,227.064z M512.941,236.148
|
||||
c-0.076,0.468-0.151,0.936-0.227,1.404c5.039,0.723,10.078,1.446,15.117,2.169C523.072,237.27,518.048,236.449,512.941,236.148z
|
||||
M521.469,209.177c1.742,1.073,3.038,3.051,6.412,3.154C525.306,210.54,523.773,209.102,521.469,209.177z"/>
|
||||
<path d="M83.935,992.123c35.367,16.879,74.429,24.872,113.07,15.002c5.713-1.46,8.102-1.525,8.087-6.051
|
||||
c-0.454-131.154,1.858-118.95-4.03-119.625c-61.312-7.033-90.004-59.237-70.523-116.641c1.166-3.434,0.88-5.655-1.698-8.323
|
||||
C53.24,678.233,87.274,536.832,189.97,493.101c6.469-2.755-2.979-6.53-6.243-45.074c-4.867-57.468,17.059-106.087,72.758-120.623
|
||||
c96.57-25.202,147.083,82.706,87.924,135.999c-1.699,1.531-2.26,2.938-1.782,5.23c4.073,19.531-4.213,22.657,2.625,25.289
|
||||
c117.932,45.39,150.278,166.653,114.115,295.087c-13.066,46.402-25.929,66.824-28.941,74.296c-0.172,0.428-0.197,0.915-0.424,2.043
|
||||
c14.431-10.928,87.326-128.7,94.13-241.25c0.246-4.061-2.834-0.63,52.991-43.564c0.908-0.699,1.944-1.231,2.818-1.778
|
||||
c1.912,1.006-0.084-1.138,23.906,58.108c3.172,7.833-109.576,303.365-352.996,370.348c-41.352,11.38-40.036,4.473-40.171,12.642
|
||||
c-0.002,0.106-0.008,639.828-0.008,639.933c0,3.811-0.259,7.233,3.605,10.174c3.726,2.836,2.93,8.355-0.561,11.55
|
||||
c-8.815,8.069-19.545-6.199-11.829-12.004c4.64-3.491,3.112,40.21,3.111-646.768c0-11.9-1.347-2.125-40.614-6.099
|
||||
c-18.185-1.841-36.078-5.319-53.355-11.524C101.559,1001.714,92.35,997.781,83.935,992.123z M334.846,582.521l-0.097-0.071
|
||||
c-0.711,2.357-11.494,2.516-15.316,2.663c-1.785,0.069-4.226-0.449-4.688,1.816c-0.414,2.032,2.098,2.246,3.435,2.99
|
||||
c20.643,11.475,35.122,28.167,43.024,50.492c0.434,1.227,0.508,2.674,1.813,3.624c2.376-0.118,3.587-2.938,6.682-3.285
|
||||
c-12.229,14.205,0.178,8.533-2.267,44.639c-1.819,26.869-10.738,51.003-27.535,72.1c-1.848,2.321-2.147,3.973-0.728,6.642
|
||||
c26.97,50.732,19.015,112.934-10.608,162.181c-1.755,2.918-3.35,5.932-5.407,9.591c2.014-1.166,1.143-0.32,3.207-2.911
|
||||
c72.019-90.41,25.351-200.07,52.731-244.961c16.482-27.023,71.917-9.784,45.328,102.787
|
||||
c-12.275,51.969-32.994,87.239-35.848,96.223c15.471-17.316,65.77-113.3,46.604-216.522
|
||||
c-16.403-88.339-80.118-111.105-113.613-89.775c-0.903,0.575-2.262,0.989-1.486,3.079
|
||||
C324.917,583.396,329.881,582.959,334.846,582.521z M273.304,577.737c-0.732-1.981-2.083-1.976-3.095-2.424
|
||||
c-16.424-7.266-31.122-17.041-43.835-29.784c-19.828-19.873-17.82-27.752-23.565-24.087c-1.893,1.208-27.029,12.235-48.556,38.407
|
||||
c-47.594,57.864-43.084,128.274-9.667,170.799c5.941,7.56,2.563-2.715,28.308-16.308c48.522-25.62,113.377-13.628,149.551,26.005
|
||||
c2.186,2.395,2.658,2.389,4.645-0.103c17.15-21.508,26.846-51.935,22.353-83.031c-0.421-2.911-1.705-3.396-3.965-2.839
|
||||
c-31.059,7.654-58.459-22.336-48.218-57.139c1.305-4.433,1.315-4.495-2.857-5.946c-34.806-12.104-77.622-1.778-97.727,32.526
|
||||
c-1.109,1.893-1.202,2.616-2.714,1.776C209.974,592.323,238.036,579.366,273.304,577.737z M266.718,977.59
|
||||
c55.713-34.538,82.912-147.65,31.578-183.571c-4.146-2.901-0.93-0.969-33.154,7.94c0,4.103,6.879,11.9,1.681,34.035
|
||||
c-6.329,26.952-25.362,41.52-52.593,45.125c-4.69,0.621-3.55,1.934-3.55,19.229c-0.001,112.084-2.282,103.548,5.715,99.372
|
||||
c73.272-38.261,18.805-117.312,90.1-156.519c2.609-1.435,5.266-2.842,8.845-3.489C316.932,892.245,304.659,939.333,266.718,977.59z
|
||||
M316.673,391.814c-11.13-12.102-23.661-18.903-41.394-15.465c-4.474,0.867-1.732,2.59-2.406,9.197
|
||||
c-1.211,11.868-9.238,20.353-23.567,18.571c-4.524-0.563-4.661-0.566-5.476,3.938c-2.258,12.481-0.398,24.346,5.734,35.479
|
||||
c1.794,3.256,1.925,3.207,5.167,0.73c26.63-20.344,63.823-14.189,79.641,6.01c1.421,1.815,2.63,1.732,4.024,0.102
|
||||
c13.011-15.212,18.942-31.594,15.43-52.591c-13.618-81.418-137.389-85.06-160.393,2.813c-10.137,38.721,1.422,82.83,4.211,87.667
|
||||
c0.948,1.644,2.655,0.645,2.362-0.962c-0.409-2.237-9.803-28.568-6.858-61.003c2.894-31.876,17.957-63.773,50.066-66.946
|
||||
c22.222-2.196,22.124,13.434,31.254,12.434C297.567,369.26,312.985,381.829,316.673,391.814z M265.805,505.309
|
||||
c-0.436,1.16-0.003,1.889,0.393,2.626c3.484,6.498,5.015,13.434,4.777,20.796c-0.817,25.174,6.484,44.131,30.393,51.736
|
||||
c2.819,0.897,4.582,0.507,6.593-1.669c30.144-32.627,98.698-20.692,128.916,48.679c35.566,81.649,6.742,210.443-66.865,295.174
|
||||
c-5.453,6.277-11.092,12.382-16.59,18.642c62.834-43.994,110.917-165.368,105.993-255.314
|
||||
c-8.012-146.375-128.436-177.388-134.545-172.247c-5.405,4.549-33.32,11.841-46.376-7.055c-0.72-1.042-1.611-1.677-2.907-1.644
|
||||
C272.283,505.117,268.952,504.667,265.805,505.309z M238,732.49c11.127,0.254,41.214,6.196,63.139,25.657
|
||||
c3.538,3.141,3.548,3.13,7.415,0.16c2.88-2.212,2.923-2.252,0.464-4.888c-35.98-38.588-108.57-42.523-143.566-5.704
|
||||
c-2.268,2.386-0.741,3.191,2.929,5.834c26.073,18.783,57.429,26.683,82.982,25.177c4.261-0.251,4.695-0.7,4.076-4.887
|
||||
c-1.961-13.275-6.296-25.649-14.352-36.551C240.122,735.982,238.692,734.985,238,732.49z M202.113,865.075
|
||||
c2.663,0.567,2.856,0.359,2.871-2.936c0.163-36.712,0.749-35.138-2.261-36.553c-8.42-3.958-8.25-12.387-6.326-21.018
|
||||
c0.497-2.231,3.228-4.289,1.837-6.397c-1.204-1.826-4.206-1.663-6.4-2.397c-39.67-13.27-40.181-25.487-42.7-17.025
|
||||
C137.115,819.124,157.209,855.508,202.113,865.075z M210.68,846.021c0.003,0,0.006,0,0.009,0c0,18.528-1.005,20.492,3.211,20.523
|
||||
c7.654,0.057,19.859-2.456,30.94-8.964c27.275-16.019,19.247-54.385,9.271-54.377c-1.845,0.001-22.425,1.707-38.964-1.73
|
||||
c-1.756-0.365-3.147-0.108-3.899,2.042c-0.93,2.66,0.853,3.073,2.493,3.967c9.048,4.931,5.005,15.159-0.256,17.788
|
||||
C209.816,827.102,210.68,827.946,210.68,846.021z M226.913,480.611c11.973-0.3,3.487-5.221,15.362-22.245
|
||||
c1.845-2.646,2.112-4.581,0.383-7.596c-15.767-27.493-8.012-64.386,19.904-75.927c2.723-1.126,2.837-1.843,0.754-3.787
|
||||
c-26.312-24.56-83.943,20.82-60.559,113.411c0.289,1.143,0.827,2.702,1.975,2.457c1.02-0.218,1.098-0.803-0.012-5.995
|
||||
c-17.205-80.508,30.929-86.069,26.642-43.584c-0.89,8.82-2.189,17.587-3.428,26.366
|
||||
C227.153,469.261,225.923,474.91,226.913,480.611z M347.399,649.722c0.313-1.686-4.551-31.477-37.55-51.239
|
||||
c-2.235-1.339-2.856-0.988-3.864,1.943C294.441,633.992,324.807,657.569,347.399,649.722z M292.847,446.917
|
||||
c-3.777,4.954-6.918,7.958-9.615,16.781c-0.984,3.221-0.689,3.589,2.741,4.327c6.046,1.301,22.847,1.488,38.778-7.277
|
||||
c3.073-1.691,3.109-2.077,0.772-4.823c-23.936-28.121-71.012-6.827-67.26-1.358c2.058,2.999,3.635,0.627,7.236-1.354
|
||||
C273.715,448.692,282.473,446.484,292.847,446.917z M316.29,926.535c7.921-7.576,39.474-78.366,13.564-141.221
|
||||
c-4.632-11.238-5.494-10.824-6.595-10.512c-3.084,0.872-4.625,4.119-3.086,6.831C346.326,827.686,332.537,880.506,316.29,926.535z
|
||||
M264.942,379.829c-8.185,4.102-14.339,10.219-18.06,18.65c-1.205,2.73-0.722,3.526,2.258,3.902
|
||||
C270.563,405.083,273.107,375.737,264.942,379.829z M303.632,480.505c2.654,2.831,5.121,0.996,26.344,7.89
|
||||
c3.036,0.986,4.143-1.268,3.251-14.051C332.828,468.622,329.88,476.593,303.632,480.505z M260.395,553.949
|
||||
c-2.842-7.221-11.251-21.515-10.9-43.934c0.021-1.31,0.253-2.879-1.753-2.955c-1.863-0.07-3.346,0.473-3.34,2.743
|
||||
C244.438,525.314,250.137,544.629,260.395,553.949z M311.606,508.749c-8.753-2.134-17.597-3.51-27.254-3.605
|
||||
C290.037,511.947,303.005,513.61,311.606,508.749z M252.256,464.191c-0.661-2.02-2.291-2.52-3.412-0.827
|
||||
c-1.807,2.725-8.634,14.224-5.432,14.898c2.85,0.6,1.732-3.671,8.136-12.494C251.918,465.257,252.08,464.595,252.256,464.191z
|
||||
M272.541,477.34c0.073-0.199,0.146-0.399,0.219-0.599c-9.27-4.581-11.783-7.01-16.404,0.599
|
||||
C261.89,477.34,267.215,477.34,272.541,477.34z M236.903,528.473c-4.691-18.019-4.092-17.397-5.296-17.194
|
||||
C228.472,511.807,235.169,525.827,236.903,528.473z M240.888,509.475c-0.488,0.078-0.976,0.156-1.465,0.234
|
||||
c0.232,4.678,0.867,9.285,3.301,13.586C242.112,518.688,241.5,514.081,240.888,509.475z M217.399,527.494
|
||||
c-2.853-7.291-2.931-7.399-6.104-9.129C213.554,521.744,215.476,524.619,217.399,527.494z"/>
|
||||
<path d="M645.459,486.168c-0.211,28.555-43.356,27.735-42.94-0.111C602.958,456.661,645.664,458.351,645.459,486.168z"/>
|
||||
<path d="M489.465,599.331c28.961,0.387,27.377,43.85-0.745,42.89C460.296,641.251,461.365,598.956,489.465,599.331z"/>
|
||||
<path d="M685.994,490.81c0.105,23.478-35.311,23.196-35.225,0.044C650.857,467.215,685.892,467.82,685.994,490.81z"/>
|
||||
<path d="M476.517,665.094c0.432-23.729,35.149-23.029,35.081,0.216C511.528,688.69,476.096,688.166,476.517,665.094z"/>
|
||||
<path d="M723.616,487.827c0.016,19.216-28.856,19.832-29.392,0.177C693.708,469.119,723.6,468.064,723.616,487.827z"/>
|
||||
<path d="M476.542,705.965c0.045-19.954,29.043-19.695,29.176-0.345C505.852,725.154,476.499,725.371,476.542,705.965z"/>
|
||||
<path d="M743.963,489.152c-13.001,0.245-13.61-19.558-0.405-19.707C756.829,469.296,756.701,488.913,743.963,489.152z"/>
|
||||
<path d="M459.953,580.311c-0.195-12.765,19.554-13.366,19.691-0.231C479.781,593.223,460.147,592.983,459.953,580.311z"/>
|
||||
<path d="M492.26,740.837c-0.297,12.813-19.848,12.908-19.435-0.727C473.217,727.139,492.559,727.923,492.26,740.837z"/>
|
||||
<path d="M583.48,476.4c-12.928,0.128-13.033-19.149-0.442-19.617C595.879,456.306,596.721,476.269,583.48,476.4z"/>
|
||||
<path d="M454.597,566.748c-10.2-0.288-10.001-15.633,0.198-15.682C465.208,551.016,465.382,567.052,454.597,566.748z"/>
|
||||
<path d="M476.218,775.724c-9.794,0.142-11.066-15.52-0.491-15.828C486.939,759.568,486.185,775.579,476.218,775.724z"/>
|
||||
<path d="M763.071,472.894c-0.021-10.682,15.303-10.306,15.82-0.295C779.403,482.528,763.092,484.011,763.071,472.894z"/>
|
||||
<path d="M569.917,451.795c-0.243,10.364-15.475,9.987-15.603-0.12C554.182,441.23,570.166,441.181,569.917,451.795z"/>
|
||||
<path d="M432.333,541.463c0.035-7.331,11.043-5.962,10.455,0.371C442.206,548.117,432.301,548.364,432.333,541.463z"/>
|
||||
<path d="M544.65,439.569c-6.622-0.13-6.782-10.909,0.418-10.503C551.906,429.452,550.792,439.69,544.65,439.569z"/>
|
||||
<path d="M788.051,463.284c0.103-7.053,10.519-6.542,10.1,0.379C797.774,469.886,787.949,470.269,788.051,463.284z"/>
|
||||
<path d="M471.667,789.755c0.144,7.093-10.453,6.779-10.251-0.022C461.602,783.487,471.528,782.949,471.667,789.755z"/>
|
||||
<path d="M424.25,525.992c3.566,0.215,3.8,5.345,0.125,5.978C420.431,532.65,419.618,525.713,424.25,525.992z"/>
|
||||
<path d="M532.613,423.831c-3.656,0.543-4.905-5.085-0.81-5.953C535.224,417.154,536.78,423.213,532.613,423.831z"/>
|
||||
<path d="M462.717,808.436c-0.363,3.943-6.073,3.617-5.823-0.328C457.165,803.842,463.05,804.818,462.717,808.436z"/>
|
||||
<path d="M811.432,459.43c-3.84,0.046-4.153-5.743-0.042-5.754C814.736,453.667,815.983,459.375,811.432,459.43z"/>
|
||||
<path d="M830.087,448.685c-0.315,3.024-4.487,2.669-4.407,0.032C825.745,446.567,829.374,445.987,830.087,448.685z"/>
|
||||
<path d="M454.057,824.646c-0.678,3.451-4.188,2.629-3.997-0.172C450.221,822.126,453.485,821.431,454.057,824.646z"/>
|
||||
<path d="M407.777,518.303c-0.099-2.854,3.921-3.198,4.314-0.719C412.448,519.829,408.865,520.751,407.777,518.303z"/>
|
||||
<path d="M523.013,406.786c-0.515,3.097-3.925,2.523-3.757-0.121C519.427,403.976,522.706,403.9,523.013,406.786z"/>
|
||||
<path d="M334.846,582.521c1.135-0.135,2.27-0.27,3.406-0.406c-1.123,0.615-2.279,0.817-3.487,0.348
|
||||
C334.749,582.451,334.846,582.521,334.846,582.521z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 18 KiB |
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 138 KiB |
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Centralized OpenGL imports for pyPhotoAlbum.
|
||||
|
||||
Provides a single point of import for all OpenGL functions used throughout
|
||||
the application. This centralizes GL dependency management and provides
|
||||
graceful handling when OpenGL is not available (e.g., during testing).
|
||||
|
||||
Usage:
|
||||
from pyPhotoAlbum.gl_imports import glBegin, glEnd, GL_QUADS, GL_AVAILABLE
|
||||
|
||||
if GL_AVAILABLE:
|
||||
# Safe to use GL functions
|
||||
glBegin(GL_QUADS)
|
||||
...
|
||||
"""
|
||||
|
||||
try:
|
||||
from OpenGL.GL import (
|
||||
# Drawing primitives
|
||||
glBegin,
|
||||
glEnd,
|
||||
glVertex2f,
|
||||
GL_QUADS,
|
||||
GL_LINE_LOOP,
|
||||
GL_LINE_STRIP,
|
||||
GL_LINES,
|
||||
GL_TRIANGLE_FAN,
|
||||
# Colors
|
||||
glColor3f,
|
||||
glColor4f,
|
||||
# Line state
|
||||
glLineWidth,
|
||||
glLineStipple,
|
||||
GL_LINE_STIPPLE,
|
||||
# General state
|
||||
glEnable,
|
||||
glDisable,
|
||||
GL_DEPTH_TEST,
|
||||
GL_BLEND,
|
||||
GL_SRC_ALPHA,
|
||||
GL_ONE_MINUS_SRC_ALPHA,
|
||||
glBlendFunc,
|
||||
# Textures
|
||||
glGenTextures,
|
||||
glBindTexture,
|
||||
glTexImage2D,
|
||||
glTexParameteri,
|
||||
glDeleteTextures,
|
||||
GL_TEXTURE_2D,
|
||||
GL_RGBA,
|
||||
GL_UNSIGNED_BYTE,
|
||||
GL_TEXTURE_MIN_FILTER,
|
||||
GL_TEXTURE_MAG_FILTER,
|
||||
GL_LINEAR,
|
||||
glTexCoord2f,
|
||||
# Matrix operations
|
||||
glPushMatrix,
|
||||
glPopMatrix,
|
||||
glScalef,
|
||||
glTranslatef,
|
||||
glLoadIdentity,
|
||||
glRotatef,
|
||||
# Clear operations
|
||||
glClear,
|
||||
glClearColor,
|
||||
glFlush,
|
||||
GL_COLOR_BUFFER_BIT,
|
||||
GL_DEPTH_BUFFER_BIT,
|
||||
# Viewport
|
||||
glViewport,
|
||||
glMatrixMode,
|
||||
glOrtho,
|
||||
GL_PROJECTION,
|
||||
GL_MODELVIEW,
|
||||
# Info/debug
|
||||
glGetString,
|
||||
GL_VERSION,
|
||||
)
|
||||
|
||||
GL_AVAILABLE = True
|
||||
|
||||
except ImportError:
|
||||
GL_AVAILABLE = False
|
||||
|
||||
# Define dummy functions/constants for when OpenGL is not available
|
||||
# This allows the code to be imported without OpenGL for testing
|
||||
def _gl_stub(*args, **kwargs):
|
||||
pass
|
||||
|
||||
glBegin = glEnd = glVertex2f = _gl_stub
|
||||
glColor3f = glColor4f = _gl_stub
|
||||
glLineWidth = glLineStipple = _gl_stub
|
||||
glEnable = glDisable = glBlendFunc = _gl_stub
|
||||
glGenTextures = glBindTexture = glTexImage2D = _gl_stub
|
||||
glTexParameteri = glDeleteTextures = glTexCoord2f = _gl_stub
|
||||
glPushMatrix = glPopMatrix = glScalef = glTranslatef = _gl_stub
|
||||
glLoadIdentity = glRotatef = _gl_stub
|
||||
glClear = glClearColor = glFlush = _gl_stub
|
||||
glViewport = glMatrixMode = glOrtho = _gl_stub
|
||||
glGetString = _gl_stub
|
||||
|
||||
# Constants
|
||||
GL_QUADS = GL_LINE_LOOP = GL_LINE_STRIP = GL_LINES = GL_TRIANGLE_FAN = 0
|
||||
GL_LINE_STIPPLE = GL_DEPTH_TEST = GL_BLEND = 0
|
||||
GL_SRC_ALPHA = GL_ONE_MINUS_SRC_ALPHA = 0
|
||||
GL_TEXTURE_2D = GL_RGBA = GL_UNSIGNED_BYTE = 0
|
||||
GL_TEXTURE_MIN_FILTER = GL_TEXTURE_MAG_FILTER = GL_LINEAR = 0
|
||||
GL_COLOR_BUFFER_BIT = GL_DEPTH_BUFFER_BIT = 0
|
||||
GL_PROJECTION = GL_MODELVIEW = 0
|
||||
GL_VERSION = 0
|
||||
+238
-6
@@ -4,11 +4,12 @@ OpenGL widget for pyPhotoAlbum rendering - refactored with mixins
|
||||
|
||||
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
|
||||
from PyQt6.QtCore import Qt
|
||||
from OpenGL.GL import *
|
||||
from pyPhotoAlbum.gl_imports import *
|
||||
|
||||
# Import all mixins
|
||||
from pyPhotoAlbum.mixins.viewport import ViewportMixin
|
||||
from pyPhotoAlbum.mixins.rendering import RenderingMixin
|
||||
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
|
||||
from pyPhotoAlbum.mixins.asset_drop import AssetDropMixin
|
||||
from pyPhotoAlbum.mixins.page_navigation import PageNavigationMixin
|
||||
from pyPhotoAlbum.mixins.image_pan import ImagePanMixin
|
||||
@@ -24,6 +25,7 @@ class GLWidget(
|
||||
AsyncLoadingMixin,
|
||||
ViewportMixin,
|
||||
RenderingMixin,
|
||||
AssetPathMixin,
|
||||
AssetDropMixin,
|
||||
PageNavigationMixin,
|
||||
ImagePanMixin,
|
||||
@@ -32,7 +34,7 @@ class GLWidget(
|
||||
MouseInteractionMixin,
|
||||
UndoableInteractionMixin,
|
||||
KeyboardNavigationMixin,
|
||||
QOpenGLWidget
|
||||
QOpenGLWidget,
|
||||
):
|
||||
"""OpenGL widget for pyPhotoAlbum rendering and user interaction
|
||||
|
||||
@@ -51,11 +53,21 @@ class GLWidget(
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
# Store reference to main window for accessing project
|
||||
self._main_window = parent
|
||||
|
||||
# Initialize async loading system
|
||||
self._init_async_loading()
|
||||
|
||||
# Initialize OpenGL
|
||||
self.setFormat(self.format())
|
||||
# Set up OpenGL surface format with explicit double buffering
|
||||
from PyQt6.QtGui import QSurfaceFormat
|
||||
|
||||
fmt = QSurfaceFormat()
|
||||
fmt.setSwapBehavior(QSurfaceFormat.SwapBehavior.DoubleBuffer)
|
||||
fmt.setSwapInterval(1) # Enable vsync
|
||||
self.setFormat(fmt)
|
||||
|
||||
# Force full redraws to ensure viewport updates
|
||||
self.setUpdateBehavior(QOpenGLWidget.UpdateBehavior.NoPartialUpdate)
|
||||
|
||||
# Enable mouse tracking and drag-drop
|
||||
@@ -66,18 +78,45 @@ class GLWidget(
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.setFocus()
|
||||
|
||||
# Enable gesture support for pinch-to-zoom
|
||||
self.grabGesture(Qt.GestureType.PinchGesture)
|
||||
|
||||
# Track pinch gesture state
|
||||
self._pinch_scale_factor = 1.0
|
||||
|
||||
def window(self):
|
||||
"""Override window() to return stored main_window reference.
|
||||
|
||||
This fixes the Qt widget hierarchy issue where window() returns None
|
||||
because the GL widget is nested in container widgets.
|
||||
"""
|
||||
return self._main_window if hasattr(self, "_main_window") else super().window()
|
||||
|
||||
def update(self):
|
||||
"""Override update to force immediate repaint"""
|
||||
super().update()
|
||||
# Force immediate processing of paint events
|
||||
self.repaint()
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Handle widget close event."""
|
||||
# Cleanup async loading
|
||||
self._cleanup_async_loading()
|
||||
super().closeEvent(event)
|
||||
|
||||
def _get_project_folder(self):
|
||||
"""Override AssetPathMixin to access project via main window."""
|
||||
main_window = self.window()
|
||||
if hasattr(main_window, "project") and main_window.project:
|
||||
return getattr(main_window.project, "folder_path", None)
|
||||
return None
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
"""Handle key press events"""
|
||||
if event.key() == Qt.Key.Key_Delete or event.key() == Qt.Key.Key_Backspace:
|
||||
if self.selected_element:
|
||||
main_window = self.window()
|
||||
if hasattr(main_window, 'delete_selected_element'):
|
||||
if hasattr(main_window, "delete_selected_element"):
|
||||
main_window.delete_selected_element()
|
||||
|
||||
elif event.key() == Qt.Key.Key_Escape:
|
||||
@@ -90,7 +129,7 @@ class GLWidget(
|
||||
if self.selected_element:
|
||||
self.rotation_mode = not self.rotation_mode
|
||||
main_window = self.window()
|
||||
if hasattr(main_window, 'show_status'):
|
||||
if hasattr(main_window, "show_status"):
|
||||
mode_text = "Rotation Mode" if self.rotation_mode else "Move/Resize Mode"
|
||||
main_window.show_status(f"Switched to {mode_text}", 2000)
|
||||
print(f"Rotation mode: {self.rotation_mode}")
|
||||
@@ -122,3 +161,196 @@ class GLWidget(
|
||||
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def event(self, event):
|
||||
"""Handle gesture events for pinch-to-zoom"""
|
||||
from PyQt6.QtCore import QEvent, Qt as QtCore
|
||||
from PyQt6.QtWidgets import QPinchGesture
|
||||
from PyQt6.QtGui import QNativeGestureEvent
|
||||
|
||||
# Handle native touchpad gestures (Linux, macOS)
|
||||
if event.type() == QEvent.Type.NativeGesture:
|
||||
native_event = event
|
||||
gesture_type = native_event.gestureType()
|
||||
|
||||
print(f"DEBUG: Native gesture detected - type: {gesture_type}")
|
||||
|
||||
# Check for zoom/pinch gesture
|
||||
if gesture_type == QtCore.NativeGestureType.ZoomNativeGesture:
|
||||
# Get zoom value (typically a delta around 0)
|
||||
value = native_event.value()
|
||||
print(f"DEBUG: Zoom value: {value}")
|
||||
|
||||
# Convert to scale factor (value is typically small, like -0.1 to 0.1)
|
||||
# Positive value = zoom in, negative = zoom out
|
||||
scale_factor = 1.0 + value
|
||||
|
||||
# Get the position of the gesture
|
||||
pos = native_event.position()
|
||||
mouse_x = pos.x()
|
||||
mouse_y = pos.y()
|
||||
|
||||
self._apply_zoom_at_point(mouse_x, mouse_y, scale_factor)
|
||||
return True
|
||||
|
||||
# Check for pan gesture (two-finger drag)
|
||||
elif gesture_type == QtCore.NativeGestureType.PanNativeGesture:
|
||||
# Get the pan delta
|
||||
delta = native_event.delta()
|
||||
dx = delta.x()
|
||||
dy = delta.y()
|
||||
|
||||
print(f"DEBUG: Pan delta: dx={dx}, dy={dy}")
|
||||
|
||||
# Apply pan
|
||||
self.pan_offset[0] += dx
|
||||
self.pan_offset[1] += dy
|
||||
|
||||
# Clamp pan offset to content bounds
|
||||
if hasattr(self, "clamp_pan_offset"):
|
||||
self.clamp_pan_offset()
|
||||
|
||||
self.update()
|
||||
|
||||
# Update scrollbars if available
|
||||
main_window = self.window()
|
||||
if hasattr(main_window, "update_scrollbars"):
|
||||
main_window.update_scrollbars()
|
||||
|
||||
return True
|
||||
|
||||
# Handle Qt gesture events (fallback for other platforms)
|
||||
elif event.type() == QEvent.Type.Gesture:
|
||||
print("DEBUG: Qt Gesture event detected")
|
||||
gesture_event = event
|
||||
pinch = gesture_event.gesture(Qt.GestureType.PinchGesture)
|
||||
|
||||
if pinch:
|
||||
print(f"DEBUG: Pinch gesture detected - state: {pinch.state()}, scale: {pinch.totalScaleFactor()}")
|
||||
self._handle_pinch_gesture(pinch)
|
||||
return True
|
||||
|
||||
return super().event(event)
|
||||
|
||||
def _handle_pinch_gesture(self, pinch):
|
||||
"""Handle pinch gesture for zooming"""
|
||||
from PyQt6.QtCore import Qt as QtCore
|
||||
|
||||
# Check gesture state
|
||||
state = pinch.state()
|
||||
|
||||
if state == QtCore.GestureState.GestureStarted:
|
||||
# Reset scale factor at gesture start
|
||||
self._pinch_scale_factor = 1.0
|
||||
return
|
||||
|
||||
elif state == QtCore.GestureState.GestureUpdated:
|
||||
# Get current total scale factor
|
||||
current_scale = pinch.totalScaleFactor()
|
||||
|
||||
# Calculate incremental change from last update
|
||||
if current_scale > 0:
|
||||
scale_change = current_scale / self._pinch_scale_factor
|
||||
self._pinch_scale_factor = current_scale
|
||||
|
||||
# Get the center point of the pinch gesture
|
||||
center_point = pinch.centerPoint()
|
||||
mouse_x = center_point.x()
|
||||
mouse_y = center_point.y()
|
||||
|
||||
# Calculate world coordinates at the pinch center
|
||||
world_x = (mouse_x - self.pan_offset[0]) / self.zoom_level
|
||||
world_y = (mouse_y - self.pan_offset[1]) / self.zoom_level
|
||||
|
||||
# Apply incremental zoom change
|
||||
new_zoom = self.zoom_level * scale_change
|
||||
|
||||
# Clamp zoom level to reasonable bounds
|
||||
if 0.1 <= new_zoom <= 5.0:
|
||||
old_pan_x = self.pan_offset[0]
|
||||
old_pan_y = self.pan_offset[1]
|
||||
|
||||
self.zoom_level = new_zoom
|
||||
|
||||
# Adjust pan offset to keep the pinch center point fixed
|
||||
self.pan_offset[0] = mouse_x - world_x * self.zoom_level
|
||||
self.pan_offset[1] = mouse_y - world_y * self.zoom_level
|
||||
|
||||
# If dragging, adjust drag_start_pos to account for pan_offset change
|
||||
if (
|
||||
hasattr(self, "is_dragging")
|
||||
and self.is_dragging
|
||||
and hasattr(self, "drag_start_pos")
|
||||
and self.drag_start_pos
|
||||
):
|
||||
pan_delta_x = self.pan_offset[0] - old_pan_x
|
||||
pan_delta_y = self.pan_offset[1] - old_pan_y
|
||||
self.drag_start_pos = (
|
||||
self.drag_start_pos[0] + pan_delta_x,
|
||||
self.drag_start_pos[1] + pan_delta_y,
|
||||
)
|
||||
|
||||
# Clamp pan offset to content bounds
|
||||
if hasattr(self, "clamp_pan_offset"):
|
||||
self.clamp_pan_offset()
|
||||
|
||||
self.update()
|
||||
|
||||
# Update status bar
|
||||
main_window = self.window()
|
||||
if hasattr(main_window, "status_bar"):
|
||||
main_window.status_bar.showMessage(f"Zoom: {int(self.zoom_level * 100)}%", 2000)
|
||||
|
||||
# Update scrollbars if available
|
||||
if hasattr(main_window, "update_scrollbars"):
|
||||
main_window.update_scrollbars()
|
||||
|
||||
elif state == QtCore.GestureState.GestureFinished or state == QtCore.GestureState.GestureCanceled:
|
||||
# Reset on gesture end
|
||||
self._pinch_scale_factor = 1.0
|
||||
|
||||
def _apply_zoom_at_point(self, mouse_x, mouse_y, scale_factor):
|
||||
"""Apply zoom centered at a specific point"""
|
||||
# Calculate world coordinates at the zoom center
|
||||
world_x = (mouse_x - self.pan_offset[0]) / self.zoom_level
|
||||
world_y = (mouse_y - self.pan_offset[1]) / self.zoom_level
|
||||
|
||||
# Apply zoom
|
||||
new_zoom = self.zoom_level * scale_factor
|
||||
|
||||
# Clamp zoom level to reasonable bounds
|
||||
if 0.1 <= new_zoom <= 5.0:
|
||||
old_pan_x = self.pan_offset[0]
|
||||
old_pan_y = self.pan_offset[1]
|
||||
|
||||
self.zoom_level = new_zoom
|
||||
|
||||
# Adjust pan offset to keep the zoom center point fixed
|
||||
self.pan_offset[0] = mouse_x - world_x * self.zoom_level
|
||||
self.pan_offset[1] = mouse_y - world_y * self.zoom_level
|
||||
|
||||
# If dragging, adjust drag_start_pos to account for pan_offset change
|
||||
if (
|
||||
hasattr(self, "is_dragging")
|
||||
and self.is_dragging
|
||||
and hasattr(self, "drag_start_pos")
|
||||
and self.drag_start_pos
|
||||
):
|
||||
pan_delta_x = self.pan_offset[0] - old_pan_x
|
||||
pan_delta_y = self.pan_offset[1] - old_pan_y
|
||||
self.drag_start_pos = (self.drag_start_pos[0] + pan_delta_x, self.drag_start_pos[1] + pan_delta_y)
|
||||
|
||||
# Clamp pan offset to content bounds
|
||||
if hasattr(self, "clamp_pan_offset"):
|
||||
self.clamp_pan_offset()
|
||||
|
||||
self.update()
|
||||
|
||||
# Update status bar
|
||||
main_window = self.window()
|
||||
if hasattr(main_window, "status_bar"):
|
||||
main_window.status_bar.showMessage(f"Zoom: {int(self.zoom_level * 100)}%", 2000)
|
||||
|
||||
# Update scrollbars if available
|
||||
if hasattr(main_window, "update_scrollbars"):
|
||||
main_window.update_scrollbars()
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
"""
|
||||
Centralized image processing utilities for pyPhotoAlbum.
|
||||
|
||||
This module consolidates common image operations to avoid code duplication
|
||||
across models.py, pdf_exporter.py, and async_backend.py.
|
||||
"""
|
||||
|
||||
from typing import Tuple
|
||||
from PIL import Image
|
||||
|
||||
# =============================================================================
|
||||
# Image Processing Utilities
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def apply_pil_rotation(image: Image.Image, pil_rotation_90: int) -> Image.Image:
|
||||
"""
|
||||
Apply 90-degree rotation increments to a PIL image.
|
||||
|
||||
Args:
|
||||
image: PIL Image to rotate
|
||||
pil_rotation_90: Number of 90-degree rotations (0, 1, 2, or 3)
|
||||
|
||||
Returns:
|
||||
Rotated PIL Image (or original if no rotation needed)
|
||||
"""
|
||||
if pil_rotation_90 <= 0:
|
||||
return image
|
||||
|
||||
angle = pil_rotation_90 * 90
|
||||
if angle == 90:
|
||||
return image.transpose(Image.Transpose.ROTATE_270) # CCW 90 = rotate right
|
||||
elif angle == 180:
|
||||
return image.transpose(Image.Transpose.ROTATE_180)
|
||||
elif angle == 270:
|
||||
return image.transpose(Image.Transpose.ROTATE_90) # CCW 270 = rotate left
|
||||
|
||||
return image
|
||||
|
||||
|
||||
def convert_to_rgba(image: Image.Image) -> Image.Image:
|
||||
"""
|
||||
Convert image to RGBA mode if not already.
|
||||
|
||||
Args:
|
||||
image: PIL Image in any mode
|
||||
|
||||
Returns:
|
||||
PIL Image in RGBA mode
|
||||
"""
|
||||
if image.mode != "RGBA":
|
||||
return image.convert("RGBA")
|
||||
return image
|
||||
|
||||
|
||||
def calculate_center_crop_coords(
|
||||
img_width: int,
|
||||
img_height: int,
|
||||
target_width: float,
|
||||
target_height: float,
|
||||
crop_info: Tuple[float, float, float, float] = (0, 0, 1, 1),
|
||||
) -> Tuple[float, float, float, float]:
|
||||
"""
|
||||
Calculate texture/crop coordinates for center-crop fitting an image to a target aspect ratio.
|
||||
|
||||
This implements the center-crop algorithm used for fitting images into frames
|
||||
while preserving aspect ratio. The image is scaled to cover the target area,
|
||||
then the excess is cropped equally from both sides.
|
||||
|
||||
Args:
|
||||
img_width: Source image width in pixels
|
||||
img_height: Source image height in pixels
|
||||
target_width: Target frame width (any unit, only ratio matters)
|
||||
target_height: Target frame height (any unit, only ratio matters)
|
||||
crop_info: Additional crop range as (x_min, y_min, x_max, y_max) in 0-1 range
|
||||
Default (0, 0, 1, 1) means no additional cropping
|
||||
|
||||
Returns:
|
||||
Tuple of (tx_min, ty_min, tx_max, ty_max) texture coordinates in 0-1 range
|
||||
"""
|
||||
crop_x_min, crop_y_min, crop_x_max, crop_y_max = crop_info
|
||||
|
||||
img_aspect = img_width / img_height
|
||||
target_aspect = target_width / target_height
|
||||
|
||||
# Calculate base texture coordinates for center crop
|
||||
if img_aspect > target_aspect:
|
||||
# Image is wider than target - crop horizontally
|
||||
scale = target_aspect / img_aspect
|
||||
tx_offset = (1.0 - scale) / 2.0
|
||||
tx_min_base = tx_offset
|
||||
tx_max_base = 1.0 - tx_offset
|
||||
ty_min_base = 0.0
|
||||
ty_max_base = 1.0
|
||||
else:
|
||||
# Image is taller than target - crop vertically
|
||||
scale = img_aspect / target_aspect
|
||||
ty_offset = (1.0 - scale) / 2.0
|
||||
tx_min_base = 0.0
|
||||
tx_max_base = 1.0
|
||||
ty_min_base = ty_offset
|
||||
ty_max_base = 1.0 - ty_offset
|
||||
|
||||
# Apply additional crop from crop_info (for spanning elements, user crops, etc.)
|
||||
tx_range = tx_max_base - tx_min_base
|
||||
ty_range = ty_max_base - ty_min_base
|
||||
|
||||
tx_min = tx_min_base + crop_x_min * tx_range
|
||||
tx_max = tx_min_base + crop_x_max * tx_range
|
||||
ty_min = ty_min_base + crop_y_min * ty_range
|
||||
ty_max = ty_min_base + crop_y_max * ty_range
|
||||
|
||||
return (tx_min, ty_min, tx_max, ty_max)
|
||||
|
||||
|
||||
def crop_image_to_coords(image: Image.Image, coords: Tuple[float, float, float, float]) -> Image.Image:
|
||||
"""
|
||||
Crop an image using normalized texture coordinates.
|
||||
|
||||
Args:
|
||||
image: PIL Image to crop
|
||||
coords: Tuple of (tx_min, ty_min, tx_max, ty_max) in 0-1 range
|
||||
|
||||
Returns:
|
||||
Cropped PIL Image
|
||||
"""
|
||||
tx_min, ty_min, tx_max, ty_max = coords
|
||||
img_width, img_height = image.size
|
||||
|
||||
crop_left_px = int(tx_min * img_width)
|
||||
crop_right_px = int(tx_max * img_width)
|
||||
crop_top_px = int(ty_min * img_height)
|
||||
crop_bottom_px = int(ty_max * img_height)
|
||||
|
||||
return image.crop((crop_left_px, crop_top_px, crop_right_px, crop_bottom_px))
|
||||
|
||||
|
||||
def resize_to_fit(
|
||||
image: Image.Image, max_size: int, resample: Image.Resampling = Image.Resampling.LANCZOS
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Resize image to fit within max_size while preserving aspect ratio.
|
||||
|
||||
Args:
|
||||
image: PIL Image to resize
|
||||
max_size: Maximum dimension (width or height)
|
||||
resample: Resampling filter (default LANCZOS for quality)
|
||||
|
||||
Returns:
|
||||
Resized PIL Image, or original if already smaller
|
||||
"""
|
||||
if image.width <= max_size and image.height <= max_size:
|
||||
return image
|
||||
|
||||
scale = min(max_size / image.width, max_size / image.height)
|
||||
new_width = int(image.width * scale)
|
||||
new_height = int(image.height * scale)
|
||||
|
||||
return image.resize((new_width, new_height), resample)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Image Styling Utilities
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def apply_rounded_corners(
|
||||
image: Image.Image,
|
||||
radius_percent: float,
|
||||
antialias: bool = True,
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Apply rounded corners to an image.
|
||||
|
||||
Args:
|
||||
image: PIL Image (should be RGBA)
|
||||
radius_percent: Corner radius as percentage of shorter side (0-50)
|
||||
antialias: If True, use supersampling for smooth antialiased edges
|
||||
|
||||
Returns:
|
||||
PIL Image with rounded corners (transparent outside corners)
|
||||
"""
|
||||
from PIL import ImageDraw
|
||||
|
||||
if radius_percent <= 0:
|
||||
return image
|
||||
|
||||
# Ensure RGBA mode for transparency
|
||||
if image.mode != "RGBA":
|
||||
image = image.convert("RGBA")
|
||||
|
||||
width, height = image.size
|
||||
shorter_side = min(width, height)
|
||||
|
||||
# Clamp radius to 0-50%
|
||||
radius_percent = max(0, min(50, radius_percent))
|
||||
radius = int(shorter_side * radius_percent / 100)
|
||||
|
||||
if radius <= 0:
|
||||
return image
|
||||
|
||||
# Use supersampling for antialiasing
|
||||
if antialias:
|
||||
# Create mask at higher resolution (4x), then downscale for smooth edges
|
||||
supersample_factor = 4
|
||||
ss_width = width * supersample_factor
|
||||
ss_height = height * supersample_factor
|
||||
ss_radius = radius * supersample_factor
|
||||
|
||||
mask_large = Image.new("L", (ss_width, ss_height), 0)
|
||||
draw = ImageDraw.Draw(mask_large)
|
||||
draw.rounded_rectangle([0, 0, ss_width - 1, ss_height - 1], radius=ss_radius, fill=255)
|
||||
|
||||
# Downscale with LANCZOS for smooth antialiased edges
|
||||
mask = mask_large.resize((width, height), Image.Resampling.LANCZOS)
|
||||
else:
|
||||
# Original non-antialiased path
|
||||
mask = Image.new("L", (width, height), 0)
|
||||
draw = ImageDraw.Draw(mask)
|
||||
draw.rounded_rectangle([0, 0, width - 1, height - 1], radius=radius, fill=255)
|
||||
|
||||
# Apply mask to alpha channel
|
||||
result = image.copy()
|
||||
if result.mode == "RGBA":
|
||||
# Composite with existing alpha
|
||||
r, g, b, a = result.split()
|
||||
# Combine existing alpha with our mask
|
||||
from PIL import ImageChops
|
||||
|
||||
new_alpha = ImageChops.multiply(a, mask)
|
||||
result = Image.merge("RGBA", (r, g, b, new_alpha))
|
||||
else:
|
||||
result.putalpha(mask)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def apply_drop_shadow(
|
||||
image: Image.Image,
|
||||
offset: Tuple[float, float] = (2.0, 2.0),
|
||||
blur_radius: float = 3.0,
|
||||
shadow_color: Tuple[int, int, int, int] = (0, 0, 0, 128),
|
||||
expand: bool = True,
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Apply a drop shadow effect to an image.
|
||||
|
||||
Args:
|
||||
image: PIL Image (should be RGBA with transparency for best results)
|
||||
offset: Shadow offset in pixels (x, y)
|
||||
blur_radius: Shadow blur radius in pixels
|
||||
shadow_color: Shadow color as RGBA tuple (0-255)
|
||||
expand: If True, expand canvas to fit shadow; if False, shadow may be clipped
|
||||
|
||||
Returns:
|
||||
PIL Image with drop shadow
|
||||
"""
|
||||
from PIL import ImageFilter
|
||||
|
||||
# Ensure RGBA
|
||||
if image.mode != "RGBA":
|
||||
image = image.convert("RGBA")
|
||||
|
||||
offset_x, offset_y = int(offset[0]), int(offset[1])
|
||||
blur_radius = max(0, int(blur_radius))
|
||||
|
||||
# Calculate canvas expansion needed
|
||||
if expand:
|
||||
# Account for blur spread and offset
|
||||
padding = blur_radius * 2 + max(abs(offset_x), abs(offset_y))
|
||||
new_width = image.width + padding * 2
|
||||
new_height = image.height + padding * 2
|
||||
img_x = padding
|
||||
img_y = padding
|
||||
else:
|
||||
new_width = image.width
|
||||
new_height = image.height
|
||||
padding = 0
|
||||
img_x = 0
|
||||
img_y = 0
|
||||
|
||||
# Create shadow layer from alpha channel
|
||||
_, _, _, alpha = image.split()
|
||||
|
||||
# Create shadow image (same shape as alpha, filled with shadow color)
|
||||
shadow = Image.new("RGBA", (image.width, image.height), shadow_color[:3] + (0,))
|
||||
shadow.putalpha(alpha)
|
||||
|
||||
# Apply blur to shadow
|
||||
if blur_radius > 0:
|
||||
shadow = shadow.filter(ImageFilter.GaussianBlur(blur_radius))
|
||||
|
||||
# Adjust shadow alpha based on shadow_color alpha
|
||||
if shadow_color[3] < 255:
|
||||
r, g, b, a = shadow.split()
|
||||
# Scale alpha by shadow_color alpha
|
||||
a = a.point(lambda x: int(x * shadow_color[3] / 255))
|
||||
shadow = Image.merge("RGBA", (r, g, b, a))
|
||||
|
||||
# Create result canvas
|
||||
result = Image.new("RGBA", (new_width, new_height), (0, 0, 0, 0))
|
||||
|
||||
# Paste shadow (offset from image position)
|
||||
shadow_x = img_x + offset_x
|
||||
shadow_y = img_y + offset_y
|
||||
result.paste(shadow, (shadow_x, shadow_y), shadow)
|
||||
|
||||
# Paste original image on top
|
||||
result.paste(image, (img_x, img_y), image)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def create_border_image(
|
||||
width: int,
|
||||
height: int,
|
||||
border_width: int,
|
||||
border_color: Tuple[int, int, int] = (0, 0, 0),
|
||||
corner_radius: int = 0,
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Create an image with just a border (transparent center).
|
||||
|
||||
Args:
|
||||
width: Image width in pixels
|
||||
height: Image height in pixels
|
||||
border_width: Border width in pixels
|
||||
border_color: Border color as RGB tuple (0-255)
|
||||
corner_radius: Corner radius in pixels (0 for square corners)
|
||||
|
||||
Returns:
|
||||
PIL Image with border only (RGBA with transparent center)
|
||||
"""
|
||||
from PIL import ImageDraw
|
||||
|
||||
if border_width <= 0:
|
||||
return Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
||||
|
||||
result = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(result)
|
||||
|
||||
# Draw outer rounded rectangle
|
||||
outer_color = border_color + (255,) # Add full alpha
|
||||
if corner_radius > 0:
|
||||
draw.rounded_rectangle(
|
||||
[0, 0, width - 1, height - 1],
|
||||
radius=corner_radius,
|
||||
fill=outer_color,
|
||||
)
|
||||
# Draw inner transparent area
|
||||
inner_radius = max(0, corner_radius - border_width)
|
||||
draw.rounded_rectangle(
|
||||
[border_width, border_width, width - 1 - border_width, height - 1 - border_width],
|
||||
radius=inner_radius,
|
||||
fill=(0, 0, 0, 0),
|
||||
)
|
||||
else:
|
||||
draw.rectangle([0, 0, width - 1, height - 1], fill=outer_color)
|
||||
draw.rectangle(
|
||||
[border_width, border_width, width - 1 - border_width, height - 1 - border_width],
|
||||
fill=(0, 0, 0, 0),
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def apply_style_to_image(
|
||||
image: Image.Image,
|
||||
corner_radius: float = 0.0,
|
||||
border_width: float = 0.0,
|
||||
border_color: Tuple[int, int, int] = (0, 0, 0),
|
||||
shadow_enabled: bool = False,
|
||||
shadow_offset: Tuple[float, float] = (2.0, 2.0),
|
||||
shadow_blur: float = 3.0,
|
||||
shadow_color: Tuple[int, int, int, int] = (0, 0, 0, 128),
|
||||
dpi: float = 96.0,
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Apply all styling effects to an image in the correct order.
|
||||
|
||||
Args:
|
||||
image: Source PIL Image
|
||||
corner_radius: Corner radius as percentage (0-50)
|
||||
border_width: Border width in mm
|
||||
border_color: Border color as RGB (0-255)
|
||||
shadow_enabled: Whether to apply drop shadow
|
||||
shadow_offset: Shadow offset in mm (x, y)
|
||||
shadow_blur: Shadow blur in mm
|
||||
shadow_color: Shadow color as RGBA (0-255)
|
||||
dpi: DPI for converting mm to pixels
|
||||
|
||||
Returns:
|
||||
Styled PIL Image
|
||||
"""
|
||||
# Ensure RGBA
|
||||
result = convert_to_rgba(image)
|
||||
|
||||
# Convert mm to pixels
|
||||
mm_to_px = dpi / 25.4
|
||||
border_width_px = int(border_width * mm_to_px)
|
||||
shadow_offset_px = (shadow_offset[0] * mm_to_px, shadow_offset[1] * mm_to_px)
|
||||
shadow_blur_px = shadow_blur * mm_to_px
|
||||
|
||||
# 1. Apply rounded corners first
|
||||
if corner_radius > 0:
|
||||
result = apply_rounded_corners(result, corner_radius)
|
||||
|
||||
# 2. Apply border (composite border image on top)
|
||||
if border_width_px > 0:
|
||||
shorter_side = min(result.width, result.height)
|
||||
corner_radius_px = int(shorter_side * min(50, corner_radius) / 100) if corner_radius > 0 else 0
|
||||
|
||||
border_img = create_border_image(
|
||||
result.width,
|
||||
result.height,
|
||||
border_width_px,
|
||||
border_color,
|
||||
corner_radius_px,
|
||||
)
|
||||
result = Image.alpha_composite(result, border_img)
|
||||
|
||||
# 3. Apply shadow last (expands canvas)
|
||||
if shadow_enabled:
|
||||
result = apply_drop_shadow(
|
||||
result,
|
||||
offset=shadow_offset_px,
|
||||
blur_radius=shadow_blur_px,
|
||||
shadow_color=shadow_color,
|
||||
expand=True,
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -5,7 +5,7 @@ Displays loading progress in the lower-right corner of the window.
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QProgressBar
|
||||
from PyQt6.QtCore import Qt, QPropertyAnimation, QEasingCurve, pyqtProperty
|
||||
from PyQt6.QtCore import Qt, QPropertyAnimation, QEasingCurve, pyqtProperty # type: ignore[attr-defined]
|
||||
from PyQt6.QtGui import QPalette, QColor
|
||||
|
||||
|
||||
@@ -96,8 +96,8 @@ class LoadingWidget(QWidget):
|
||||
"""Get opacity for animation"""
|
||||
return self._opacity
|
||||
|
||||
@opacity.setter
|
||||
def opacity(self, value):
|
||||
@opacity.setter # type: ignore[no-redef]
|
||||
def opacity(self, value: float) -> None:
|
||||
"""Set opacity for animation"""
|
||||
self._opacity = value
|
||||
self.setWindowOpacity(value)
|
||||
|
||||
+184
-35
@@ -6,12 +6,19 @@ This version uses the mixin architecture with auto-generated ribbon configuratio
|
||||
"""
|
||||
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication, QMainWindow, QVBoxLayout, QWidget,
|
||||
QStatusBar, QScrollBar, QHBoxLayout
|
||||
QApplication,
|
||||
QMainWindow,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
QStatusBar,
|
||||
QScrollBar,
|
||||
QHBoxLayout,
|
||||
QMessageBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QSize
|
||||
from PyQt6.QtCore import Qt, QSize, QTimer
|
||||
from PyQt6.QtGui import QIcon
|
||||
|
||||
from pyPhotoAlbum.project import Project
|
||||
@@ -19,9 +26,12 @@ from pyPhotoAlbum.template_manager import TemplateManager
|
||||
from pyPhotoAlbum.ribbon_widget import RibbonWidget
|
||||
from pyPhotoAlbum.ribbon_builder import build_ribbon_config, print_ribbon_summary
|
||||
from pyPhotoAlbum.gl_widget import GLWidget
|
||||
from pyPhotoAlbum.autosave_manager import AutosaveManager
|
||||
from pyPhotoAlbum.thumbnail_browser import ThumbnailBrowserDock
|
||||
|
||||
# Import mixins
|
||||
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
|
||||
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
|
||||
from pyPhotoAlbum.mixins.operations import (
|
||||
FileOperationsMixin,
|
||||
EditOperationsMixin,
|
||||
@@ -33,12 +43,15 @@ from pyPhotoAlbum.mixins.operations import (
|
||||
DistributionOperationsMixin,
|
||||
SizeOperationsMixin,
|
||||
ZOrderOperationsMixin,
|
||||
MergeOperationsMixin,
|
||||
StyleOperationsMixin,
|
||||
)
|
||||
|
||||
|
||||
class MainWindow(
|
||||
QMainWindow,
|
||||
ApplicationStateMixin,
|
||||
AssetPathMixin,
|
||||
FileOperationsMixin,
|
||||
EditOperationsMixin,
|
||||
ElementOperationsMixin,
|
||||
@@ -49,27 +62,40 @@ class MainWindow(
|
||||
DistributionOperationsMixin,
|
||||
SizeOperationsMixin,
|
||||
ZOrderOperationsMixin,
|
||||
MergeOperationsMixin,
|
||||
StyleOperationsMixin,
|
||||
):
|
||||
"""
|
||||
Main application window using mixin architecture.
|
||||
|
||||
|
||||
This class composes functionality from multiple mixins rather than
|
||||
implementing everything directly. The ribbon configuration is
|
||||
automatically generated from decorated methods in the mixins.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
|
||||
# Initialize autosave manager
|
||||
self._autosave_manager = AutosaveManager()
|
||||
|
||||
# Initialize shared state first
|
||||
self._init_state()
|
||||
|
||||
|
||||
# Initialize UI
|
||||
self._init_ui()
|
||||
|
||||
|
||||
# Check for checkpoint recovery
|
||||
self._check_checkpoint_recovery()
|
||||
|
||||
# Setup autosave timer (every 5 minutes)
|
||||
self._autosave_timer = QTimer(self)
|
||||
self._autosave_timer.timeout.connect(self._perform_autosave)
|
||||
self._autosave_timer.start(5 * 60 * 1000) # 5 minutes in milliseconds
|
||||
|
||||
# Add a sample page for demonstration
|
||||
#self._add_sample_page()
|
||||
|
||||
# self._add_sample_page()
|
||||
|
||||
def _init_state(self):
|
||||
"""Initialize shared application state"""
|
||||
# Initialize project
|
||||
@@ -77,17 +103,18 @@ class MainWindow(
|
||||
|
||||
# Set asset resolution context
|
||||
from pyPhotoAlbum.models import set_asset_resolution_context
|
||||
|
||||
set_asset_resolution_context(self._project.folder_path)
|
||||
|
||||
# Initialize template manager
|
||||
self._template_manager = TemplateManager()
|
||||
|
||||
|
||||
def _init_ui(self):
|
||||
"""Initialize user interface"""
|
||||
# Basic window setup
|
||||
self.setWindowTitle("pyPhotoAlbum")
|
||||
self.resize(1200, 800)
|
||||
|
||||
|
||||
# Set window icon
|
||||
icon_path = Path(__file__).parent / "icons" / "icon.png"
|
||||
print(f"Window icon path: {icon_path}")
|
||||
@@ -96,20 +123,20 @@ class MainWindow(
|
||||
icon = QIcon(str(icon_path))
|
||||
print(f"Icon is null: {icon.isNull()}")
|
||||
self.setWindowIcon(icon)
|
||||
|
||||
|
||||
# Create main widget with layout
|
||||
main_widget = QWidget()
|
||||
main_layout = QVBoxLayout()
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.setSpacing(0)
|
||||
main_widget.setLayout(main_layout)
|
||||
|
||||
|
||||
# Build ribbon config from decorated methods
|
||||
ribbon_config = build_ribbon_config(self.__class__)
|
||||
|
||||
|
||||
# Print summary (for debugging)
|
||||
print_ribbon_summary(ribbon_config)
|
||||
|
||||
|
||||
# Create ribbon with auto-generated config
|
||||
self.ribbon = RibbonWidget(self, ribbon_config)
|
||||
main_layout.addWidget(self.ribbon, 0)
|
||||
@@ -147,18 +174,25 @@ class MainWindow(
|
||||
|
||||
canvas_widget.setLayout(canvas_layout)
|
||||
main_layout.addWidget(canvas_widget, 1)
|
||||
|
||||
|
||||
self.setCentralWidget(main_widget)
|
||||
|
||||
|
||||
# Create thumbnail browser dock
|
||||
self._thumbnail_browser = ThumbnailBrowserDock(self)
|
||||
self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self._thumbnail_browser)
|
||||
self._thumbnail_browser.hide() # Initially hidden
|
||||
|
||||
# Create status bar
|
||||
self._status_bar = QStatusBar()
|
||||
self.setStatusBar(self._status_bar)
|
||||
|
||||
|
||||
# Register keyboard shortcuts
|
||||
self._register_shortcuts()
|
||||
|
||||
# Track scrollbar updates to prevent feedback loops
|
||||
self._updating_scrollbars = False
|
||||
# Track scrollbar visibility changes to prevent resize-triggered recentering
|
||||
self._updating_scrollbar_visibility = False
|
||||
|
||||
def _on_vertical_scroll(self, value):
|
||||
"""Handle vertical scrollbar changes"""
|
||||
@@ -178,13 +212,17 @@ class MainWindow(
|
||||
"""Update scrollbar positions and ranges based on current content and pan offset"""
|
||||
self._updating_scrollbars = True
|
||||
|
||||
# Block signals to prevent feedback loop
|
||||
self._v_scrollbar.blockSignals(True)
|
||||
self._h_scrollbar.blockSignals(True)
|
||||
|
||||
# Get content bounds
|
||||
bounds = self._gl_widget.get_content_bounds()
|
||||
viewport_width = self._gl_widget.width()
|
||||
viewport_height = self._gl_widget.height()
|
||||
|
||||
content_height = bounds['height']
|
||||
content_width = bounds['width']
|
||||
content_height = bounds["height"]
|
||||
content_width = bounds["width"]
|
||||
|
||||
# Vertical scrollbar
|
||||
# Scrollbar value 0 = top of content
|
||||
@@ -198,6 +236,8 @@ class MainWindow(
|
||||
self._v_scrollbar.setValue(int(max(0, min(v_range, -self._gl_widget.pan_offset[1]))))
|
||||
|
||||
# Show/hide vertical scrollbar based on whether scrolling is needed
|
||||
# Set flag to prevent resizeGL from recentering when scrollbar visibility changes
|
||||
self._updating_scrollbar_visibility = True
|
||||
self._v_scrollbar.setVisible(v_range > 0)
|
||||
|
||||
# Horizontal scrollbar
|
||||
@@ -209,6 +249,11 @@ class MainWindow(
|
||||
|
||||
# Show/hide horizontal scrollbar based on whether scrolling is needed
|
||||
self._h_scrollbar.setVisible(h_range > 0)
|
||||
self._updating_scrollbar_visibility = False
|
||||
|
||||
# Unblock signals
|
||||
self._v_scrollbar.blockSignals(False)
|
||||
self._h_scrollbar.blockSignals(False)
|
||||
|
||||
self._updating_scrollbars = False
|
||||
|
||||
@@ -227,7 +272,7 @@ class MainWindow(
|
||||
print(f"Registered shortcut: {shortcut_str} -> {method_name}")
|
||||
|
||||
# Register additional Ctrl+Shift+Z shortcut for redo
|
||||
if hasattr(self, 'redo'):
|
||||
if hasattr(self, "redo"):
|
||||
redo_shortcut = QShortcut(QKeySequence("Ctrl+Shift+Z"), self)
|
||||
redo_shortcut.activated.connect(self.redo)
|
||||
print("Registered shortcut: Ctrl+Shift+Z -> redo")
|
||||
@@ -235,47 +280,151 @@ class MainWindow(
|
||||
def resizeEvent(self, event):
|
||||
"""Handle window resize to reposition loading widget"""
|
||||
super().resizeEvent(event)
|
||||
if hasattr(self, '_loading_widget'):
|
||||
if hasattr(self, "_loading_widget"):
|
||||
self._loading_widget.resizeParent()
|
||||
|
||||
|
||||
def _add_sample_page(self):
|
||||
"""Add a sample page with some elements for demonstration"""
|
||||
from pyPhotoAlbum.project import Page
|
||||
from pyPhotoAlbum.page_layout import PageLayout, GridLayout
|
||||
from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
|
||||
|
||||
|
||||
# Create a page with project default size
|
||||
width_mm, height_mm = self.project.page_size_mm
|
||||
page_layout = PageLayout(width=width_mm, height=height_mm)
|
||||
grid = GridLayout(rows=2, columns=2, spacing=20.0)
|
||||
page_layout.set_grid_layout(grid)
|
||||
|
||||
|
||||
# Add some sample elements (scaled to new default size)
|
||||
image = ImageData(image_path="sample.jpg", x=20, y=20, width=50, height=50)
|
||||
page_layout.add_element(image)
|
||||
|
||||
|
||||
text_box = TextBoxData(text_content="Sample Text", x=80, y=20, width=50, height=20)
|
||||
page_layout.add_element(text_box)
|
||||
|
||||
|
||||
placeholder = PlaceholderData(placeholder_type="image", x=20, y=80, width=50, height=50)
|
||||
page_layout.add_element(placeholder)
|
||||
|
||||
|
||||
# Create and add the page
|
||||
page = Page(layout=page_layout, page_number=1)
|
||||
page.manually_sized = False # Not manually sized, uses defaults
|
||||
self.project.add_page(page)
|
||||
|
||||
|
||||
def _perform_autosave(self):
|
||||
"""Perform automatic checkpoint save"""
|
||||
if self.project and self.project.is_dirty():
|
||||
success, message = self._autosave_manager.create_checkpoint(self.project)
|
||||
if success:
|
||||
print(f"Autosave: {message}")
|
||||
else:
|
||||
print(f"Autosave failed: {message}")
|
||||
|
||||
def _check_checkpoint_recovery(self):
|
||||
"""Check for available checkpoints on startup and offer recovery"""
|
||||
if not self._autosave_manager.has_checkpoints():
|
||||
return
|
||||
|
||||
# Get the latest checkpoint
|
||||
checkpoint_info = self._autosave_manager.get_latest_checkpoint()
|
||||
if not checkpoint_info:
|
||||
return
|
||||
|
||||
checkpoint_path, metadata = checkpoint_info
|
||||
project_name = metadata.get("project_name", "Unknown")
|
||||
timestamp_str = metadata.get("timestamp", "Unknown time")
|
||||
|
||||
# Parse timestamp for better display
|
||||
try:
|
||||
timestamp = datetime.fromisoformat(timestamp_str)
|
||||
time_display = timestamp.strftime("%Y-%m-%d %H:%M:%S")
|
||||
except:
|
||||
time_display = timestamp_str
|
||||
|
||||
# Ask user if they want to recover
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Checkpoint Recovery",
|
||||
f"A checkpoint was found:\n\n"
|
||||
f"Project: {project_name}\n"
|
||||
f"Time: {time_display}\n\n"
|
||||
f"Would you like to recover this checkpoint?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.Yes,
|
||||
)
|
||||
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
# Load the checkpoint
|
||||
success, result = self._autosave_manager.load_checkpoint(checkpoint_path)
|
||||
|
||||
if success:
|
||||
# Replace current project with recovered one
|
||||
if hasattr(self, "_project") and self._project:
|
||||
self._project.cleanup()
|
||||
|
||||
self._project = result
|
||||
self.gl_widget.current_page_index = 0
|
||||
self.update_view()
|
||||
|
||||
self.show_status(f"Recovered checkpoint: {project_name}")
|
||||
print(f"Successfully recovered checkpoint: {project_name}")
|
||||
else:
|
||||
error_msg = f"Failed to recover checkpoint: {result}"
|
||||
self.show_error("Recovery Failed", error_msg)
|
||||
print(error_msg)
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Handle window close event"""
|
||||
# Check if project has unsaved changes
|
||||
if self.project and self.project.is_dirty():
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Unsaved Changes",
|
||||
"You have unsaved changes. Would you like to save before exiting?",
|
||||
QMessageBox.StandardButton.Save
|
||||
| QMessageBox.StandardButton.Discard
|
||||
| QMessageBox.StandardButton.Cancel,
|
||||
QMessageBox.StandardButton.Save,
|
||||
)
|
||||
|
||||
if reply == QMessageBox.StandardButton.Save:
|
||||
# Save is async — ignore event and let on_complete trigger close
|
||||
self._pending_close = True
|
||||
save_started = self.save_project()
|
||||
if not save_started:
|
||||
# User cancelled the file dialog
|
||||
self._pending_close = False
|
||||
event.ignore()
|
||||
return
|
||||
elif reply == QMessageBox.StandardButton.Cancel:
|
||||
# User cancelled exit
|
||||
event.ignore()
|
||||
return
|
||||
# If Discard, continue with exit
|
||||
|
||||
# Clean up checkpoints on successful exit
|
||||
if self.project:
|
||||
self._autosave_manager.delete_all_checkpoints(self.project.name)
|
||||
self.project.cleanup()
|
||||
|
||||
# Stop autosave timer
|
||||
if hasattr(self, "_autosave_timer"):
|
||||
self._autosave_timer.stop()
|
||||
|
||||
# Cleanup old checkpoints
|
||||
self._autosave_manager.cleanup_old_checkpoints()
|
||||
|
||||
event.accept()
|
||||
|
||||
|
||||
def main():
|
||||
"""Application entry point"""
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
|
||||
# Set application identity for proper taskbar/window manager integration
|
||||
app.setApplicationName("pyPhotoAlbum")
|
||||
app.setApplicationDisplayName("pyPhotoAlbum")
|
||||
app.setDesktopFileName("pyphotoalbum.desktop")
|
||||
|
||||
|
||||
# Set application icon
|
||||
icon_path = Path(__file__).parent / "icons" / "icon.png"
|
||||
print(f"Application icon path: {icon_path}")
|
||||
@@ -284,17 +433,17 @@ def main():
|
||||
icon = QIcon(str(icon_path))
|
||||
print(f"Icon is null: {icon.isNull()}")
|
||||
app.setWindowIcon(icon)
|
||||
|
||||
|
||||
# Enable high DPI scaling
|
||||
try:
|
||||
app.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True)
|
||||
app.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps, True)
|
||||
except AttributeError:
|
||||
pass # Qt version doesn't support these attributes
|
||||
|
||||
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
|
||||
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
"""
|
||||
Merge dialog for resolving project conflicts visually
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QPushButton,
|
||||
QLabel,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QSplitter,
|
||||
QWidget,
|
||||
QScrollArea,
|
||||
QRadioButton,
|
||||
QButtonGroup,
|
||||
QTextEdit,
|
||||
QComboBox,
|
||||
QGroupBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QSize, pyqtSignal
|
||||
from PyQt6.QtGui import QPixmap, QPainter, QColor, QFont, QPen
|
||||
|
||||
from typing import Dict, Any, List, Optional
|
||||
from pyPhotoAlbum.merge_manager import MergeManager, ConflictInfo, MergeStrategy
|
||||
from pyPhotoAlbum.page_renderer import PageRenderer
|
||||
|
||||
|
||||
class PagePreviewWidget(QWidget):
|
||||
"""Widget to render a page preview"""
|
||||
|
||||
def __init__(self, page_data: Dict[str, Any], parent=None):
|
||||
super().__init__(parent)
|
||||
self.page_data = page_data
|
||||
self.setMinimumSize(200, 280)
|
||||
self.setSizePolicy(self.sizePolicy().Policy.Expanding, self.sizePolicy().Policy.Expanding)
|
||||
|
||||
def paintEvent(self, event):
|
||||
"""Render the page preview"""
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
|
||||
# Draw white background
|
||||
painter.fillRect(self.rect(), QColor(255, 255, 255))
|
||||
|
||||
# Draw border
|
||||
painter.setPen(QPen(QColor(200, 200, 200), 2))
|
||||
painter.drawRect(self.rect().adjusted(1, 1, -1, -1))
|
||||
|
||||
# Draw placeholder text
|
||||
painter.setPen(QColor(100, 100, 100))
|
||||
font = QFont("Arial", 10)
|
||||
painter.setFont(font)
|
||||
|
||||
# Page info
|
||||
page_num = self.page_data.get("page_number", "?")
|
||||
element_count = len(self.page_data.get("layout", {}).get("elements", []))
|
||||
last_modified = self.page_data.get("last_modified", "Unknown")
|
||||
|
||||
# Draw simplified representation
|
||||
y_offset = 20
|
||||
painter.drawText(10, y_offset, f"Page {page_num}")
|
||||
y_offset += 20
|
||||
painter.drawText(10, y_offset, f"Elements: {element_count}")
|
||||
y_offset += 20
|
||||
|
||||
# Draw element representations
|
||||
elements = self.page_data.get("layout", {}).get("elements", [])
|
||||
for i, elem in enumerate(elements[:5]): # Show first 5 elements
|
||||
elem_type = elem.get("type", "unknown")
|
||||
deleted = elem.get("deleted", False)
|
||||
|
||||
color = QColor(200, 200, 200) if deleted else QColor(100, 150, 200)
|
||||
painter.setBrush(color)
|
||||
painter.setPen(QPen(color.darker(120), 1))
|
||||
|
||||
# Draw small rectangle representing element
|
||||
x = 10 + (i % 3) * 60
|
||||
y = y_offset + (i // 3) * 60
|
||||
painter.drawRect(x, y, 50, 50)
|
||||
|
||||
# Draw type label
|
||||
painter.setPen(QColor(0, 0, 0))
|
||||
painter.drawText(x + 5, y + 25, elem_type[:3].upper())
|
||||
|
||||
# Draw timestamp at bottom
|
||||
painter.setPen(QColor(100, 100, 100))
|
||||
painter.setFont(QFont("Arial", 8))
|
||||
modified_text = last_modified[:19] if last_modified else "No timestamp"
|
||||
painter.drawText(10, self.height() - 10, modified_text)
|
||||
|
||||
|
||||
class ConflictItemWidget(QWidget):
|
||||
"""Widget for displaying and resolving a single conflict"""
|
||||
|
||||
resolution_changed = pyqtSignal(int, str) # conflict_index, choice ("ours" or "theirs")
|
||||
|
||||
def __init__(self, conflict_index: int, conflict: ConflictInfo, parent=None):
|
||||
super().__init__(parent)
|
||||
self.conflict_index = conflict_index
|
||||
self.conflict = conflict
|
||||
|
||||
self._init_ui()
|
||||
|
||||
def _init_ui(self):
|
||||
"""Initialize the UI"""
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Conflict description
|
||||
desc_label = QLabel(f"<b>Conflict {self.conflict_index + 1}:</b> {self.conflict.description}")
|
||||
desc_label.setWordWrap(True)
|
||||
layout.addWidget(desc_label)
|
||||
|
||||
# Splitter for side-by-side comparison
|
||||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
|
||||
# Our version
|
||||
our_widget = QGroupBox("Your Version")
|
||||
our_layout = QVBoxLayout()
|
||||
|
||||
if self.conflict.conflict_type.name.startswith("PAGE"):
|
||||
# Show page preview
|
||||
our_preview = PagePreviewWidget(self.conflict.our_version)
|
||||
our_layout.addWidget(our_preview)
|
||||
elif self.conflict.conflict_type.name.startswith("ELEMENT"):
|
||||
# Show element details
|
||||
our_details = self._create_element_details(self.conflict.our_version)
|
||||
our_layout.addWidget(our_details)
|
||||
else:
|
||||
# Show settings
|
||||
our_details = self._create_settings_details(self.conflict.our_version)
|
||||
our_layout.addWidget(our_details)
|
||||
|
||||
our_widget.setLayout(our_layout)
|
||||
splitter.addWidget(our_widget)
|
||||
|
||||
# Their version
|
||||
their_widget = QGroupBox("Other Version")
|
||||
their_layout = QVBoxLayout()
|
||||
|
||||
if self.conflict.conflict_type.name.startswith("PAGE"):
|
||||
# Show page preview
|
||||
their_preview = PagePreviewWidget(self.conflict.their_version)
|
||||
their_layout.addWidget(their_preview)
|
||||
elif self.conflict.conflict_type.name.startswith("ELEMENT"):
|
||||
# Show element details
|
||||
their_details = self._create_element_details(self.conflict.their_version)
|
||||
their_layout.addWidget(their_details)
|
||||
else:
|
||||
# Show settings
|
||||
their_details = self._create_settings_details(self.conflict.their_version)
|
||||
their_layout.addWidget(their_details)
|
||||
|
||||
their_widget.setLayout(their_layout)
|
||||
splitter.addWidget(their_widget)
|
||||
|
||||
layout.addWidget(splitter)
|
||||
|
||||
# Resolution buttons
|
||||
resolution_layout = QHBoxLayout()
|
||||
|
||||
self.button_group = QButtonGroup(self)
|
||||
|
||||
use_ours_btn = QRadioButton("Use Your Version")
|
||||
use_ours_btn.setChecked(True)
|
||||
use_ours_btn.toggled.connect(lambda checked: self._on_resolution_changed("ours") if checked else None)
|
||||
self.button_group.addButton(use_ours_btn)
|
||||
resolution_layout.addWidget(use_ours_btn)
|
||||
|
||||
use_theirs_btn = QRadioButton("Use Other Version")
|
||||
use_theirs_btn.toggled.connect(lambda checked: self._on_resolution_changed("theirs") if checked else None)
|
||||
self.button_group.addButton(use_theirs_btn)
|
||||
resolution_layout.addWidget(use_theirs_btn)
|
||||
|
||||
resolution_layout.addStretch()
|
||||
layout.addLayout(resolution_layout)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def _create_element_details(self, element_data: Dict[str, Any]) -> QTextEdit:
|
||||
"""Create a text widget showing element details"""
|
||||
details = QTextEdit()
|
||||
details.setReadOnly(True)
|
||||
details.setMaximumHeight(150)
|
||||
|
||||
elem_type = element_data.get("type", "unknown")
|
||||
position = element_data.get("position", (0, 0))
|
||||
size = element_data.get("size", (0, 0))
|
||||
deleted = element_data.get("deleted", False)
|
||||
last_modified = element_data.get("last_modified", "Unknown")
|
||||
|
||||
text = f"Type: {elem_type}\n"
|
||||
text += f"Position: ({position[0]:.1f}, {position[1]:.1f})\n"
|
||||
text += f"Size: ({size[0]:.1f} × {size[1]:.1f})\n"
|
||||
text += f"Deleted: {deleted}\n"
|
||||
text += f"Modified: {last_modified[:19] if last_modified else 'Unknown'}\n"
|
||||
|
||||
if elem_type == "image":
|
||||
text += f"Image: {element_data.get('image_path', 'N/A')}\n"
|
||||
elif elem_type == "textbox":
|
||||
text += f"Text: {element_data.get('text_content', '')[:50]}...\n"
|
||||
|
||||
details.setPlainText(text)
|
||||
return details
|
||||
|
||||
def _create_settings_details(self, settings_data: Dict[str, Any]) -> QTextEdit:
|
||||
"""Create a text widget showing settings details"""
|
||||
details = QTextEdit()
|
||||
details.setReadOnly(True)
|
||||
details.setMaximumHeight(150)
|
||||
|
||||
text = ""
|
||||
for key, value in settings_data.items():
|
||||
if key != "last_modified":
|
||||
text += f"{key}: {value}\n"
|
||||
|
||||
last_modified = settings_data.get("last_modified", "Unknown")
|
||||
text += f"\nModified: {last_modified[:19] if last_modified else 'Unknown'}"
|
||||
|
||||
details.setPlainText(text)
|
||||
return details
|
||||
|
||||
def _on_resolution_changed(self, choice: str):
|
||||
"""Emit signal when resolution choice changes"""
|
||||
self.resolution_changed.emit(self.conflict_index, choice)
|
||||
|
||||
def get_resolution(self) -> str:
|
||||
"""Get the current resolution choice"""
|
||||
for button in self.button_group.buttons():
|
||||
if button.isChecked():
|
||||
if "Your" in button.text():
|
||||
return "ours"
|
||||
else:
|
||||
return "theirs"
|
||||
return "ours" # Default
|
||||
|
||||
|
||||
class MergeDialog(QDialog):
|
||||
"""
|
||||
Dialog for visually resolving merge conflicts between two project versions
|
||||
"""
|
||||
|
||||
def __init__(self, our_project_data: Dict[str, Any], their_project_data: Dict[str, Any], parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.our_project_data = our_project_data
|
||||
self.their_project_data = their_project_data
|
||||
self.merge_manager = MergeManager()
|
||||
|
||||
# Detect conflicts
|
||||
self.conflicts = self.merge_manager.detect_conflicts(our_project_data, their_project_data)
|
||||
|
||||
# Resolution choices (conflict_index -> "ours" or "theirs")
|
||||
self.resolutions: Dict[int, str] = {}
|
||||
|
||||
# Initialize default resolutions (all "ours")
|
||||
for i in range(len(self.conflicts)):
|
||||
self.resolutions[i] = "ours"
|
||||
|
||||
self.setWindowTitle("Merge Projects")
|
||||
self.resize(900, 700)
|
||||
|
||||
self._init_ui()
|
||||
|
||||
def _init_ui(self):
|
||||
"""Initialize the user interface"""
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Header
|
||||
header_label = QLabel(
|
||||
f"<h2>Merge Conflicts Detected</h2>"
|
||||
f"<p>Your project: <b>{self.our_project_data.get('name', 'Untitled')}</b> "
|
||||
f"(modified {self.our_project_data.get('last_modified', 'unknown')[:19]})</p>"
|
||||
f"<p>Other project: <b>{self.their_project_data.get('name', 'Untitled')}</b> "
|
||||
f"(modified {self.their_project_data.get('last_modified', 'unknown')[:19]})</p>"
|
||||
f"<p>Found <b>{len(self.conflicts)}</b> conflict(s) requiring resolution.</p>"
|
||||
)
|
||||
header_label.setWordWrap(True)
|
||||
layout.addWidget(header_label)
|
||||
|
||||
# Auto-resolve strategy
|
||||
strategy_layout = QHBoxLayout()
|
||||
strategy_layout.addWidget(QLabel("Auto-resolve all:"))
|
||||
|
||||
self.strategy_combo = QComboBox()
|
||||
self.strategy_combo.addItem("Latest Wins", MergeStrategy.LATEST_WINS)
|
||||
self.strategy_combo.addItem("Always Use Yours", MergeStrategy.OURS)
|
||||
self.strategy_combo.addItem("Always Use Theirs", MergeStrategy.THEIRS)
|
||||
strategy_layout.addWidget(self.strategy_combo)
|
||||
|
||||
auto_resolve_btn = QPushButton("Auto-Resolve All")
|
||||
auto_resolve_btn.clicked.connect(self._auto_resolve)
|
||||
strategy_layout.addWidget(auto_resolve_btn)
|
||||
|
||||
strategy_layout.addStretch()
|
||||
layout.addLayout(strategy_layout)
|
||||
|
||||
# Scroll area for conflicts
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
|
||||
conflicts_widget = QWidget()
|
||||
conflicts_layout = QVBoxLayout()
|
||||
|
||||
# Create conflict widgets
|
||||
self.conflict_widgets: List[ConflictItemWidget] = []
|
||||
for i, conflict in enumerate(self.conflicts):
|
||||
conflict_widget = ConflictItemWidget(i, conflict)
|
||||
conflict_widget.resolution_changed.connect(self._on_resolution_changed)
|
||||
self.conflict_widgets.append(conflict_widget)
|
||||
conflicts_layout.addWidget(conflict_widget)
|
||||
|
||||
conflicts_layout.addStretch()
|
||||
conflicts_widget.setLayout(conflicts_layout)
|
||||
scroll.setWidget(conflicts_widget)
|
||||
|
||||
layout.addWidget(scroll)
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.addStretch()
|
||||
|
||||
cancel_button = QPushButton("Cancel")
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
button_layout.addWidget(cancel_button)
|
||||
|
||||
merge_button = QPushButton("Apply Merge")
|
||||
merge_button.clicked.connect(self.accept)
|
||||
merge_button.setDefault(True)
|
||||
button_layout.addWidget(merge_button)
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def _on_resolution_changed(self, conflict_index: int, choice: str):
|
||||
"""Handle resolution choice change"""
|
||||
self.resolutions[conflict_index] = choice
|
||||
|
||||
def _auto_resolve(self):
|
||||
"""Auto-resolve all conflicts based on selected strategy"""
|
||||
strategy = self.strategy_combo.currentData()
|
||||
auto_resolutions = self.merge_manager.auto_resolve_conflicts(strategy)
|
||||
|
||||
# Update resolution choices
|
||||
self.resolutions.update(auto_resolutions)
|
||||
|
||||
# Update UI to reflect auto-resolutions
|
||||
for i, resolution in auto_resolutions.items():
|
||||
if i < len(self.conflict_widgets):
|
||||
# Find the correct radio button and check it
|
||||
for button in self.conflict_widgets[i].button_group.buttons():
|
||||
if resolution == "ours" and "Your" in button.text():
|
||||
button.setChecked(True)
|
||||
elif resolution == "theirs" and "Other" in button.text():
|
||||
button.setChecked(True)
|
||||
|
||||
def get_merged_project_data(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get the merged project data based on user's conflict resolutions.
|
||||
|
||||
Returns:
|
||||
Merged project data dictionary
|
||||
"""
|
||||
return self.merge_manager.apply_resolutions(self.our_project_data, self.their_project_data, self.resolutions)
|
||||
@@ -0,0 +1,504 @@
|
||||
"""
|
||||
Merge manager for handling project merge conflicts
|
||||
|
||||
This module provides functionality for:
|
||||
- Detecting when two projects should be merged vs. concatenated
|
||||
- Finding conflicts between two project versions
|
||||
- Resolving conflicts based on user input or automatic strategies
|
||||
"""
|
||||
|
||||
import copy
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
class ConflictType(Enum):
|
||||
"""Types of merge conflicts"""
|
||||
|
||||
# Page-level conflicts
|
||||
PAGE_MODIFIED_BOTH = "page_modified_both" # Page modified in both versions
|
||||
PAGE_DELETED_ONE = "page_deleted_one" # Page deleted in one version, modified in other
|
||||
PAGE_ADDED_BOTH = "page_added_both" # Same page number added in both (rare)
|
||||
|
||||
# Element-level conflicts
|
||||
ELEMENT_MODIFIED_BOTH = "element_modified_both" # Element modified in both versions
|
||||
ELEMENT_DELETED_ONE = "element_deleted_one" # Element deleted in one, modified in other
|
||||
|
||||
# Project-level conflicts
|
||||
SETTINGS_MODIFIED_BOTH = "settings_modified_both" # Project settings changed in both
|
||||
|
||||
|
||||
class MergeStrategy(Enum):
|
||||
"""Automatic merge resolution strategies"""
|
||||
|
||||
LATEST_WINS = "latest_wins" # Most recent last_modified wins
|
||||
OURS = "ours" # Always use our version
|
||||
THEIRS = "theirs" # Always use their version
|
||||
MANUAL = "manual" # Require manual resolution
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConflictInfo:
|
||||
"""Information about a single merge conflict"""
|
||||
|
||||
conflict_type: ConflictType
|
||||
page_uuid: Optional[str] # UUID of the page (if page-level conflict)
|
||||
element_uuid: Optional[str] # UUID of the element (if element-level conflict)
|
||||
our_version: Any # Our version of the conflicted item
|
||||
their_version: Any # Their version of the conflicted item
|
||||
description: str # Human-readable description
|
||||
|
||||
|
||||
class MergeManager:
|
||||
"""Manages merge operations between two project versions"""
|
||||
|
||||
def __init__(self):
|
||||
self.conflicts: List[ConflictInfo] = []
|
||||
|
||||
def should_merge_projects(self, project_a_data: Dict[str, Any], project_b_data: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Determine if two projects should be merged or concatenated.
|
||||
|
||||
Projects with the same project_id should be merged (conflict resolution).
|
||||
Projects with different project_ids should be concatenated (combine content).
|
||||
|
||||
Args:
|
||||
project_a_data: First project's serialized data
|
||||
project_b_data: Second project's serialized data
|
||||
|
||||
Returns:
|
||||
True if projects should be merged, False if concatenated
|
||||
"""
|
||||
project_a_id = project_a_data.get("project_id")
|
||||
project_b_id = project_b_data.get("project_id")
|
||||
|
||||
# If either project lacks a project_id (v2.0 or earlier), assume different projects
|
||||
if not project_a_id or not project_b_id:
|
||||
print("MergeManager: One or both projects lack project_id, assuming concatenation")
|
||||
return False
|
||||
|
||||
return bool(project_a_id == project_b_id)
|
||||
|
||||
def detect_conflicts(
|
||||
self, our_project_data: Dict[str, Any], their_project_data: Dict[str, Any]
|
||||
) -> List[ConflictInfo]:
|
||||
"""
|
||||
Detect conflicts between two versions of the same project.
|
||||
|
||||
Args:
|
||||
our_project_data: Our version of the project (serialized)
|
||||
their_project_data: Their version of the project (serialized)
|
||||
|
||||
Returns:
|
||||
List of conflicts found
|
||||
"""
|
||||
self.conflicts = []
|
||||
|
||||
# Detect project-level conflicts
|
||||
self._detect_project_settings_conflicts(our_project_data, their_project_data)
|
||||
|
||||
# Detect page-level conflicts
|
||||
self._detect_page_conflicts(our_project_data, their_project_data)
|
||||
|
||||
return self.conflicts
|
||||
|
||||
def _detect_project_settings_conflicts(self, our_data: Dict[str, Any], their_data: Dict[str, Any]):
|
||||
"""Detect conflicts in project-level settings."""
|
||||
# Settings that can conflict
|
||||
settings_keys = [
|
||||
"name",
|
||||
"page_size_mm",
|
||||
"working_dpi",
|
||||
"export_dpi",
|
||||
"has_cover",
|
||||
"paper_thickness_mm",
|
||||
"cover_bleed_mm",
|
||||
"binding_type",
|
||||
]
|
||||
|
||||
our_modified = our_data.get("last_modified")
|
||||
their_modified = their_data.get("last_modified")
|
||||
|
||||
for key in settings_keys:
|
||||
our_value = our_data.get(key)
|
||||
their_value = their_data.get(key)
|
||||
|
||||
# If values differ, it's a conflict
|
||||
if our_value != their_value:
|
||||
self.conflicts.append(
|
||||
ConflictInfo(
|
||||
conflict_type=ConflictType.SETTINGS_MODIFIED_BOTH,
|
||||
page_uuid=None,
|
||||
element_uuid=None,
|
||||
our_version={key: our_value, "last_modified": our_modified},
|
||||
their_version={key: their_value, "last_modified": their_modified},
|
||||
description=f"Project setting '{key}' modified in both versions",
|
||||
)
|
||||
)
|
||||
|
||||
def _detect_page_conflicts(self, our_data: Dict[str, Any], their_data: Dict[str, Any]):
|
||||
"""Detect conflicts at page level."""
|
||||
our_pages = {page["uuid"]: page for page in our_data.get("pages", [])}
|
||||
their_pages = {page["uuid"]: page for page in their_data.get("pages", [])}
|
||||
|
||||
# Check all pages that exist in our version
|
||||
for page_uuid, our_page in our_pages.items():
|
||||
their_page = their_pages.get(page_uuid)
|
||||
|
||||
if their_page is None:
|
||||
# Page exists in ours but not theirs - check if deleted
|
||||
if our_page.get("deleted"):
|
||||
continue # Both deleted, no conflict
|
||||
# We have it, they don't (might have deleted it)
|
||||
# This could be a conflict if we modified it after they deleted it
|
||||
continue
|
||||
|
||||
# Page exists in both - check for modifications
|
||||
self._detect_page_modification_conflicts(page_uuid, our_page, their_page)
|
||||
|
||||
# Check for pages that exist only in their version
|
||||
for page_uuid, their_page in their_pages.items():
|
||||
if page_uuid not in our_pages:
|
||||
# They have a page we don't - this is fine, add it
|
||||
# Unless we deleted it
|
||||
pass
|
||||
|
||||
def _detect_page_modification_conflicts(self, page_uuid: str, our_page: Dict[str, Any], their_page: Dict[str, Any]):
|
||||
"""Detect conflicts in a specific page."""
|
||||
our_modified = our_page.get("last_modified")
|
||||
their_modified = their_page.get("last_modified")
|
||||
|
||||
# Check if both deleted
|
||||
if our_page.get("deleted") and their_page.get("deleted"):
|
||||
return # No conflict
|
||||
|
||||
# Check if one deleted, one modified
|
||||
if our_page.get("deleted") != their_page.get("deleted"):
|
||||
self.conflicts.append(
|
||||
ConflictInfo(
|
||||
conflict_type=ConflictType.PAGE_DELETED_ONE,
|
||||
page_uuid=page_uuid,
|
||||
element_uuid=None,
|
||||
our_version=our_page,
|
||||
their_version=their_page,
|
||||
description=f"Page deleted in one version but modified in the other",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
# Check page-level properties
|
||||
page_props = ["page_number", "is_cover", "is_double_spread"]
|
||||
page_modified = False
|
||||
for prop in page_props:
|
||||
if our_page.get(prop) != their_page.get(prop):
|
||||
page_modified = True
|
||||
break
|
||||
|
||||
# Only flag as conflict if properties differ AND timestamps are identical
|
||||
# (See element conflict detection for detailed explanation of this strategy)
|
||||
if page_modified and our_modified == their_modified:
|
||||
self.conflicts.append(
|
||||
ConflictInfo(
|
||||
conflict_type=ConflictType.PAGE_MODIFIED_BOTH,
|
||||
page_uuid=page_uuid,
|
||||
element_uuid=None,
|
||||
our_version=our_page,
|
||||
their_version=their_page,
|
||||
description=f"Page properties modified with same timestamp (possible conflict)",
|
||||
)
|
||||
)
|
||||
|
||||
# Check element-level conflicts
|
||||
self._detect_element_conflicts(page_uuid, our_page, their_page)
|
||||
|
||||
def _detect_element_conflicts(self, page_uuid: str, our_page: Dict[str, Any], their_page: Dict[str, Any]):
|
||||
"""Detect conflicts in elements within a page."""
|
||||
our_layout = our_page.get("layout", {})
|
||||
their_layout = their_page.get("layout", {})
|
||||
|
||||
our_elements = {elem["uuid"]: elem for elem in our_layout.get("elements", [])}
|
||||
their_elements = {elem["uuid"]: elem for elem in their_layout.get("elements", [])}
|
||||
|
||||
# Check all elements in our version
|
||||
for elem_uuid, our_elem in our_elements.items():
|
||||
their_elem = their_elements.get(elem_uuid)
|
||||
|
||||
if their_elem is None:
|
||||
# Element exists in ours but not theirs
|
||||
if our_elem.get("deleted"):
|
||||
continue # Both deleted, no conflict
|
||||
# We have it, they don't
|
||||
continue
|
||||
|
||||
# Element exists in both - check for modifications
|
||||
self._detect_element_modification_conflicts(page_uuid, elem_uuid, our_elem, their_elem)
|
||||
|
||||
def _detect_element_modification_conflicts(
|
||||
self, page_uuid: str, elem_uuid: str, our_elem: Dict[str, Any], their_elem: Dict[str, Any]
|
||||
):
|
||||
"""Detect conflicts in a specific element."""
|
||||
our_modified = our_elem.get("last_modified")
|
||||
their_modified = their_elem.get("last_modified")
|
||||
|
||||
# Check if both deleted
|
||||
if our_elem.get("deleted") and their_elem.get("deleted"):
|
||||
return # No conflict
|
||||
|
||||
# Check if one deleted, one modified
|
||||
if our_elem.get("deleted") != their_elem.get("deleted"):
|
||||
self.conflicts.append(
|
||||
ConflictInfo(
|
||||
conflict_type=ConflictType.ELEMENT_DELETED_ONE,
|
||||
page_uuid=page_uuid,
|
||||
element_uuid=elem_uuid,
|
||||
our_version=our_elem,
|
||||
their_version=their_elem,
|
||||
description=f"Element deleted in one version but modified in the other",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
# Check element properties
|
||||
elem_props = ["position", "size", "rotation", "z_index"]
|
||||
|
||||
# Add type-specific properties
|
||||
elem_type = our_elem.get("type")
|
||||
if elem_type == "image":
|
||||
elem_props.extend(["image_path", "crop_info", "pil_rotation_90"])
|
||||
elif elem_type == "textbox":
|
||||
elem_props.extend(["text_content", "font_settings", "alignment"])
|
||||
|
||||
# Check if any properties differ
|
||||
props_modified = False
|
||||
for prop in elem_props:
|
||||
if our_elem.get(prop) != their_elem.get(prop):
|
||||
props_modified = True
|
||||
break
|
||||
|
||||
# Without a 3-way merge (base version), we cannot reliably detect if BOTH versions
|
||||
# modified an element vs only ONE version modifying it.
|
||||
#
|
||||
# Strategy: Only flag as conflict when we have strong evidence of concurrent modification:
|
||||
# - Properties differ AND timestamps are identical → suspicious, possible conflict
|
||||
# - Properties differ AND timestamps differ → one version modified it, auto-merge by timestamp
|
||||
#
|
||||
# If timestamps differ, _merge_non_conflicting_changes will handle it by using the newer version.
|
||||
if props_modified and our_modified == their_modified:
|
||||
# Properties differ but timestamps match - this is unusual and might indicate
|
||||
# that both versions modified it at exactly the same time, or there's data corruption.
|
||||
# Flag as conflict to be safe.
|
||||
self.conflicts.append(
|
||||
ConflictInfo(
|
||||
conflict_type=ConflictType.ELEMENT_MODIFIED_BOTH,
|
||||
page_uuid=page_uuid,
|
||||
element_uuid=elem_uuid,
|
||||
our_version=our_elem,
|
||||
their_version=their_elem,
|
||||
description=f"Element modified with same timestamp (possible conflict)",
|
||||
)
|
||||
)
|
||||
|
||||
# Note: If timestamps differ, we assume one version modified it and the other didn't.
|
||||
# The _merge_non_conflicting_changes method will automatically use the newer version.
|
||||
|
||||
def auto_resolve_conflicts(self, strategy: MergeStrategy = MergeStrategy.LATEST_WINS) -> Dict[int, str]:
|
||||
"""
|
||||
Automatically resolve conflicts based on a strategy.
|
||||
|
||||
Args:
|
||||
strategy: The resolution strategy to use
|
||||
|
||||
Returns:
|
||||
Dictionary mapping conflict index to resolution choice ("ours" or "theirs")
|
||||
"""
|
||||
resolutions = {}
|
||||
|
||||
for i, conflict in enumerate(self.conflicts):
|
||||
if strategy == MergeStrategy.LATEST_WINS:
|
||||
# Compare timestamps
|
||||
our_modified = self._get_timestamp(conflict.our_version)
|
||||
their_modified = self._get_timestamp(conflict.their_version)
|
||||
|
||||
if our_modified and their_modified:
|
||||
resolutions[i] = "ours" if our_modified >= their_modified else "theirs"
|
||||
else:
|
||||
resolutions[i] = "ours" # Default to ours if timestamps missing
|
||||
|
||||
elif strategy == MergeStrategy.OURS:
|
||||
resolutions[i] = "ours"
|
||||
|
||||
elif strategy == MergeStrategy.THEIRS:
|
||||
resolutions[i] = "theirs"
|
||||
|
||||
# MANUAL strategy leaves resolutions empty
|
||||
|
||||
return resolutions
|
||||
|
||||
def _get_timestamp(self, version_data: Any) -> Optional[str]:
|
||||
"""Extract timestamp from version data."""
|
||||
if isinstance(version_data, dict):
|
||||
return version_data.get("last_modified")
|
||||
return None
|
||||
|
||||
def apply_resolutions(
|
||||
self, our_project_data: Dict[str, Any], their_project_data: Dict[str, Any], resolutions: Dict[int, str]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Apply conflict resolutions to create merged project.
|
||||
|
||||
Args:
|
||||
our_project_data: Our version of the project
|
||||
their_project_data: Their version of the project
|
||||
resolutions: Dictionary mapping conflict index to choice ("ours" or "theirs")
|
||||
|
||||
Returns:
|
||||
Merged project data
|
||||
"""
|
||||
# Start with a copy of our project
|
||||
merged_data = copy.deepcopy(our_project_data)
|
||||
|
||||
# Apply resolutions
|
||||
for conflict_idx, choice in resolutions.items():
|
||||
if conflict_idx >= len(self.conflicts):
|
||||
continue
|
||||
|
||||
conflict = self.conflicts[conflict_idx]
|
||||
|
||||
if choice == "theirs":
|
||||
# Apply their version
|
||||
self._apply_their_version(merged_data, conflict)
|
||||
# If choice is "ours", no need to do anything
|
||||
|
||||
# Add pages/elements from their version that we don't have
|
||||
self._merge_non_conflicting_changes(merged_data, their_project_data)
|
||||
|
||||
return merged_data
|
||||
|
||||
def _apply_their_version(self, merged_data: Dict[str, Any], conflict: ConflictInfo):
|
||||
"""Apply their version for a specific conflict."""
|
||||
if conflict.conflict_type == ConflictType.SETTINGS_MODIFIED_BOTH:
|
||||
# Update project setting
|
||||
for key, value in conflict.their_version.items():
|
||||
if key != "last_modified":
|
||||
merged_data[key] = value
|
||||
|
||||
elif conflict.conflict_type in [ConflictType.PAGE_MODIFIED_BOTH, ConflictType.PAGE_DELETED_ONE]:
|
||||
# Replace entire page
|
||||
for i, page in enumerate(merged_data.get("pages", [])):
|
||||
if page.get("uuid") == conflict.page_uuid:
|
||||
merged_data["pages"][i] = conflict.their_version
|
||||
break
|
||||
|
||||
elif conflict.conflict_type in [ConflictType.ELEMENT_MODIFIED_BOTH, ConflictType.ELEMENT_DELETED_ONE]:
|
||||
# Replace element within page
|
||||
for page in merged_data.get("pages", []):
|
||||
if page.get("uuid") == conflict.page_uuid:
|
||||
layout = page.get("layout", {})
|
||||
for i, elem in enumerate(layout.get("elements", [])):
|
||||
if elem.get("uuid") == conflict.element_uuid:
|
||||
layout["elements"][i] = conflict.their_version
|
||||
break
|
||||
break
|
||||
|
||||
def _merge_non_conflicting_changes(self, merged_data: Dict[str, Any], their_data: Dict[str, Any]):
|
||||
"""Add non-conflicting pages and elements from their version."""
|
||||
self._add_missing_pages(merged_data, their_data)
|
||||
self._merge_page_elements(merged_data, their_data)
|
||||
|
||||
def _add_missing_pages(self, merged_data: Dict[str, Any], their_data: Dict[str, Any]):
|
||||
"""Add pages that exist only in their version."""
|
||||
our_page_uuids = {page["uuid"] for page in merged_data.get("pages", [])}
|
||||
|
||||
for their_page in their_data.get("pages", []):
|
||||
if their_page["uuid"] not in our_page_uuids:
|
||||
merged_data["pages"].append(their_page)
|
||||
|
||||
def _merge_page_elements(self, merged_data: Dict[str, Any], their_data: Dict[str, Any]):
|
||||
"""For pages that exist in both versions, merge their elements."""
|
||||
their_pages = {page["uuid"]: page for page in their_data.get("pages", [])}
|
||||
|
||||
for our_page in merged_data.get("pages", []):
|
||||
their_page = their_pages.get(our_page["uuid"])
|
||||
if not their_page:
|
||||
continue
|
||||
|
||||
our_elements = {elem["uuid"]: elem for elem in our_page.get("layout", {}).get("elements", [])}
|
||||
|
||||
for their_elem in their_page.get("layout", {}).get("elements", []):
|
||||
self._merge_element(
|
||||
our_page=our_page, page_uuid=our_page["uuid"], their_elem=their_elem, our_elements=our_elements
|
||||
)
|
||||
|
||||
def _merge_element(
|
||||
self, our_page: Dict[str, Any], page_uuid: str, their_elem: Dict[str, Any], our_elements: Dict[str, Any]
|
||||
):
|
||||
"""Merge a single element from their version into our page."""
|
||||
elem_uuid = their_elem["uuid"]
|
||||
|
||||
# Add new elements that we don't have
|
||||
if elem_uuid not in our_elements:
|
||||
our_page["layout"]["elements"].append(their_elem)
|
||||
return
|
||||
|
||||
# Element exists in both - check if already resolved as conflict
|
||||
if self._is_element_in_conflict(elem_uuid, page_uuid):
|
||||
return
|
||||
|
||||
# No conflict - use the more recently modified version
|
||||
self._merge_by_timestamp(our_page, elem_uuid, their_elem, our_elements[elem_uuid])
|
||||
|
||||
def _is_element_in_conflict(self, elem_uuid: str, page_uuid: str) -> bool:
|
||||
"""Check if element was part of a conflict that was already resolved."""
|
||||
return any(c.element_uuid == elem_uuid and c.page_uuid == page_uuid for c in self.conflicts)
|
||||
|
||||
def _merge_by_timestamp(
|
||||
self, our_page: Dict[str, Any], elem_uuid: str, their_elem: Dict[str, Any], our_elem: Dict[str, Any]
|
||||
):
|
||||
"""Use the more recently modified version of an element."""
|
||||
our_modified = our_elem.get("last_modified")
|
||||
their_modified = their_elem.get("last_modified")
|
||||
|
||||
# Their version is newer
|
||||
if not their_modified or (our_modified and their_modified <= our_modified):
|
||||
return
|
||||
|
||||
# Replace with their newer version
|
||||
for i, elem in enumerate(our_page["layout"]["elements"]):
|
||||
if elem["uuid"] == elem_uuid:
|
||||
our_page["layout"]["elements"][i] = their_elem
|
||||
break
|
||||
|
||||
|
||||
def concatenate_projects(project_a_data: Dict[str, Any], project_b_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Concatenate two projects with different project_ids.
|
||||
|
||||
This combines the pages from both projects into a single project.
|
||||
|
||||
Args:
|
||||
project_a_data: First project data
|
||||
project_b_data: Second project data
|
||||
|
||||
Returns:
|
||||
Combined project data
|
||||
"""
|
||||
# Start with project A as base
|
||||
merged_data = copy.deepcopy(project_a_data)
|
||||
|
||||
# Append all pages from project B
|
||||
merged_data["pages"].extend(copy.deepcopy(project_b_data.get("pages", [])))
|
||||
|
||||
# Update project name to indicate merge
|
||||
merged_data["name"] = f"{project_a_data.get('name', 'Untitled')} + {project_b_data.get('name', 'Untitled')}"
|
||||
|
||||
# Keep project A's ID and settings
|
||||
# Update last_modified to now
|
||||
merged_data["last_modified"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
print(
|
||||
f"Concatenated projects: {len(project_a_data.get('pages', []))} + {len(project_b_data.get('pages', []))} = {len(merged_data['pages'])} pages"
|
||||
)
|
||||
|
||||
return merged_data
|
||||
@@ -3,5 +3,6 @@ Mixin modules for pyPhotoAlbum
|
||||
"""
|
||||
|
||||
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
|
||||
from pyPhotoAlbum.mixins.dialog_mixin import DialogMixin
|
||||
|
||||
__all__ = ['ApplicationStateMixin']
|
||||
__all__ = ["ApplicationStateMixin", "DialogMixin"]
|
||||
|
||||
@@ -14,7 +14,7 @@ class AssetDropMixin:
|
||||
or updating ImageData elements.
|
||||
"""
|
||||
|
||||
IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp']
|
||||
IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp"]
|
||||
|
||||
def dragEnterEvent(self, event):
|
||||
"""Handle drag enter events"""
|
||||
@@ -35,108 +35,122 @@ class AssetDropMixin:
|
||||
event.ignore()
|
||||
|
||||
def dropEvent(self, event):
|
||||
"""Handle drop events"""
|
||||
if not event.mimeData().hasUrls():
|
||||
event.ignore()
|
||||
return
|
||||
|
||||
image_path = None
|
||||
|
||||
for url in event.mimeData().urls():
|
||||
file_path = url.toLocalFile()
|
||||
if any(file_path.lower().endswith(ext) for ext in self.IMAGE_EXTENSIONS):
|
||||
image_path = file_path
|
||||
break
|
||||
|
||||
"""Handle drop events - delegates to specialized handlers"""
|
||||
image_path = self._extract_image_path(event)
|
||||
if not image_path:
|
||||
event.ignore()
|
||||
return
|
||||
|
||||
x, y = event.position().x(), event.position().y()
|
||||
|
||||
target_element = self._get_element_at(x, y)
|
||||
|
||||
if target_element and isinstance(target_element, (ImageData, PlaceholderData)):
|
||||
main_window = self.window()
|
||||
if hasattr(main_window, 'project') and main_window.project:
|
||||
try:
|
||||
# Import the asset to the project's assets folder
|
||||
asset_path = main_window.project.asset_manager.import_asset(image_path)
|
||||
|
||||
if isinstance(target_element, PlaceholderData):
|
||||
new_image = ImageData(
|
||||
image_path=asset_path, # Use imported asset path
|
||||
x=target_element.position[0],
|
||||
y=target_element.position[1],
|
||||
width=target_element.size[0],
|
||||
height=target_element.size[1],
|
||||
z_index=target_element.z_index
|
||||
)
|
||||
if main_window.project.pages:
|
||||
for page in main_window.project.pages:
|
||||
if target_element in page.layout.elements:
|
||||
page.layout.elements.remove(target_element)
|
||||
page.layout.add_element(new_image)
|
||||
break
|
||||
else:
|
||||
# Update existing ImageData with imported asset
|
||||
target_element.image_path = asset_path
|
||||
|
||||
print(f"Updated element with image: {asset_path}")
|
||||
except Exception as e:
|
||||
print(f"Error importing dropped image: {e}")
|
||||
self._handle_drop_on_element(image_path, target_element)
|
||||
else:
|
||||
try:
|
||||
from PIL import Image
|
||||
img = Image.open(image_path)
|
||||
img_width, img_height = img.size
|
||||
|
||||
max_size = 300
|
||||
if img_width > max_size or img_height > max_size:
|
||||
scale = min(max_size / img_width, max_size / img_height)
|
||||
img_width = int(img_width * scale)
|
||||
img_height = int(img_height * scale)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error loading image dimensions: {e}")
|
||||
img_width, img_height = 200, 150
|
||||
|
||||
main_window = self.window()
|
||||
if hasattr(main_window, 'project') and main_window.project and main_window.project.pages:
|
||||
# Detect which page the drop occurred on
|
||||
target_page, page_index, page_renderer = self._get_page_at(x, y)
|
||||
|
||||
if target_page and page_renderer:
|
||||
# Update current_page_index
|
||||
if page_index >= 0:
|
||||
self.current_page_index = page_index
|
||||
|
||||
# Convert screen coordinates to page-local coordinates
|
||||
page_local_x, page_local_y = page_renderer.screen_to_page(x, y)
|
||||
|
||||
try:
|
||||
asset_path = main_window.project.asset_manager.import_asset(image_path)
|
||||
|
||||
new_image = ImageData(
|
||||
image_path=asset_path,
|
||||
x=page_local_x,
|
||||
y=page_local_y,
|
||||
width=img_width,
|
||||
height=img_height
|
||||
)
|
||||
|
||||
cmd = AddElementCommand(
|
||||
target_page.layout,
|
||||
new_image,
|
||||
asset_manager=main_window.project.asset_manager
|
||||
)
|
||||
main_window.project.history.execute(cmd)
|
||||
|
||||
print(f"Added new image to page {page_index + 1} at ({page_local_x:.1f}, {page_local_y:.1f}): {asset_path}")
|
||||
except Exception as e:
|
||||
print(f"Error adding dropped image: {e}")
|
||||
else:
|
||||
print("Drop location not on any page")
|
||||
self._handle_drop_on_empty_space(image_path, x, y)
|
||||
|
||||
event.acceptProposedAction()
|
||||
self.update()
|
||||
|
||||
def _extract_image_path(self, event):
|
||||
"""Extract the first valid image path from drop event"""
|
||||
if not event.mimeData().hasUrls():
|
||||
return None
|
||||
|
||||
for url in event.mimeData().urls():
|
||||
file_path = url.toLocalFile()
|
||||
if any(file_path.lower().endswith(ext) for ext in self.IMAGE_EXTENSIONS):
|
||||
return file_path
|
||||
return None
|
||||
|
||||
def _handle_drop_on_element(self, image_path, target_element):
|
||||
"""Handle dropping an image onto an existing element"""
|
||||
main_window = self.window()
|
||||
if not (hasattr(main_window, "project") and main_window.project):
|
||||
return
|
||||
|
||||
try:
|
||||
asset_path = main_window.project.asset_manager.import_asset(image_path)
|
||||
|
||||
if isinstance(target_element, PlaceholderData):
|
||||
self._replace_placeholder_with_image(target_element, asset_path, main_window)
|
||||
else:
|
||||
target_element.image_path = asset_path
|
||||
|
||||
print(f"Updated element with image: {asset_path}")
|
||||
except Exception as e:
|
||||
print(f"Error importing dropped image: {e}")
|
||||
|
||||
def _replace_placeholder_with_image(self, placeholder, asset_path, main_window):
|
||||
"""Replace a placeholder element with an ImageData element"""
|
||||
new_image = ImageData(
|
||||
image_path=asset_path,
|
||||
x=placeholder.position[0],
|
||||
y=placeholder.position[1],
|
||||
width=placeholder.size[0],
|
||||
height=placeholder.size[1],
|
||||
z_index=placeholder.z_index,
|
||||
# Inherit styling from placeholder (for templatable styles)
|
||||
style=placeholder.style.copy(),
|
||||
)
|
||||
|
||||
if not main_window.project.pages:
|
||||
return
|
||||
|
||||
for page in main_window.project.pages:
|
||||
if placeholder in page.layout.elements:
|
||||
page.layout.elements.remove(placeholder)
|
||||
page.layout.add_element(new_image)
|
||||
break
|
||||
|
||||
def _handle_drop_on_empty_space(self, image_path, x, y):
|
||||
"""Handle dropping an image onto empty space"""
|
||||
main_window = self.window()
|
||||
if not (hasattr(main_window, "project") and main_window.project and main_window.project.pages):
|
||||
return
|
||||
|
||||
target_page, page_index, page_renderer = self._get_page_at(x, y)
|
||||
|
||||
if not (target_page and page_renderer):
|
||||
print("Drop location not on any page")
|
||||
return
|
||||
|
||||
try:
|
||||
# Import asset first, then calculate dimensions from imported asset
|
||||
asset_path = main_window.project.asset_manager.import_asset(image_path)
|
||||
full_asset_path = self.get_asset_full_path(asset_path)
|
||||
img_width, img_height = self._calculate_image_dimensions(full_asset_path)
|
||||
|
||||
self._add_new_image_to_page(
|
||||
asset_path, target_page, page_index, page_renderer, x, y, img_width, img_height, main_window
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error importing dropped image: {e}")
|
||||
|
||||
def _calculate_image_dimensions(self, image_path):
|
||||
"""Calculate scaled image dimensions for new image using centralized utility."""
|
||||
from pyPhotoAlbum.async_backend import get_image_dimensions
|
||||
|
||||
# Use centralized utility (max 300px for UI display)
|
||||
dimensions = get_image_dimensions(image_path, max_size=300)
|
||||
if dimensions:
|
||||
return dimensions
|
||||
|
||||
# Fallback dimensions if image cannot be read
|
||||
return 200, 150
|
||||
|
||||
def _add_new_image_to_page(
|
||||
self, asset_path, target_page, page_index, page_renderer, x, y, img_width, img_height, main_window
|
||||
):
|
||||
"""Add a new image element to the target page (asset already imported)"""
|
||||
if page_index >= 0:
|
||||
self.current_page_index = page_index
|
||||
|
||||
page_local_x, page_local_y = page_renderer.screen_to_page(x, y)
|
||||
|
||||
new_image = ImageData(image_path=asset_path, x=page_local_x, y=page_local_y, width=img_width, height=img_height)
|
||||
|
||||
cmd = AddElementCommand(target_page.layout, new_image, asset_manager=main_window.project.asset_manager)
|
||||
main_window.project.history.execute(cmd)
|
||||
|
||||
print(f"Added new image to page {page_index + 1} at ({page_local_x:.1f}, {page_local_y:.1f}): {asset_path}")
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
Asset path resolution mixin for components that need to resolve asset paths.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class AssetPathMixin:
|
||||
"""
|
||||
Mixin providing asset path resolution functionality.
|
||||
|
||||
Requires access to self.project (typically via ApplicationStateMixin).
|
||||
"""
|
||||
|
||||
def resolve_asset_path(self, asset_path: str) -> Optional[str]:
|
||||
"""
|
||||
Resolve a relative asset path to an absolute path.
|
||||
|
||||
Args:
|
||||
asset_path: Relative path (e.g., "assets/photo.jpg") or absolute path
|
||||
|
||||
Returns:
|
||||
Absolute path if the asset exists, None otherwise
|
||||
"""
|
||||
if not asset_path:
|
||||
return None
|
||||
|
||||
# Handle absolute paths
|
||||
if os.path.isabs(asset_path):
|
||||
if os.path.exists(asset_path):
|
||||
return asset_path
|
||||
return None
|
||||
|
||||
# Resolve relative path using project folder
|
||||
project_folder = self._get_project_folder()
|
||||
if project_folder:
|
||||
full_path = os.path.join(project_folder, asset_path)
|
||||
if os.path.exists(full_path):
|
||||
return full_path
|
||||
|
||||
return None
|
||||
|
||||
def get_asset_full_path(self, relative_path: str) -> Optional[str]:
|
||||
"""
|
||||
Get the full path for a relative asset path (without existence check).
|
||||
|
||||
Args:
|
||||
relative_path: Relative path from project folder
|
||||
|
||||
Returns:
|
||||
Full absolute path, or None if no project folder
|
||||
"""
|
||||
project_folder = self._get_project_folder()
|
||||
if project_folder and relative_path:
|
||||
return os.path.join(project_folder, relative_path)
|
||||
return None
|
||||
|
||||
def _get_project_folder(self) -> Optional[str]:
|
||||
"""
|
||||
Get the current project folder.
|
||||
|
||||
Override this method if the project is accessed differently.
|
||||
Default implementation uses self.project.folder_path.
|
||||
"""
|
||||
if hasattr(self, "project") and self.project:
|
||||
return getattr(self.project, "folder_path", None)
|
||||
return None
|
||||
@@ -2,18 +2,34 @@
|
||||
Async loading mixin for non-blocking image loading and PDF generation.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import TYPE_CHECKING, Optional, cast
|
||||
import logging
|
||||
|
||||
from PyQt6.QtCore import QObject
|
||||
from PyQt6.QtWidgets import QProgressDialog
|
||||
|
||||
from pyPhotoAlbum.async_backend import AsyncImageLoader, AsyncPDFGenerator, ImageCache, LoadPriority
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from PyQt6.QtWidgets import QMainWindow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AsyncLoadingMixin:
|
||||
# Type hints for expected attributes from mixing class
|
||||
_pdf_progress_dialog: Optional[QProgressDialog]
|
||||
|
||||
def update(self) -> None: # type: ignore[empty-body]
|
||||
"""Expected from QWidget"""
|
||||
...
|
||||
|
||||
def window(self) -> "QMainWindow": # type: ignore[empty-body]
|
||||
"""Expected from QWidget"""
|
||||
...
|
||||
|
||||
"""
|
||||
Mixin to add async loading capabilities to GLWidget.
|
||||
|
||||
@@ -47,13 +63,13 @@ class AsyncLoadingMixin:
|
||||
"""Cleanup async loading components."""
|
||||
logger.info("Cleaning up async loading system...")
|
||||
|
||||
if hasattr(self, 'async_image_loader'):
|
||||
if hasattr(self, "async_image_loader"):
|
||||
self.async_image_loader.stop()
|
||||
|
||||
if hasattr(self, 'async_pdf_generator'):
|
||||
if hasattr(self, "async_pdf_generator"):
|
||||
self.async_pdf_generator.stop()
|
||||
|
||||
if hasattr(self, 'image_cache'):
|
||||
if hasattr(self, "image_cache"):
|
||||
self.image_cache.clear()
|
||||
|
||||
logger.info("Async loading system cleaned up")
|
||||
@@ -69,7 +85,7 @@ class AsyncLoadingMixin:
|
||||
"""
|
||||
logger.debug(f"Image loaded callback: {path}")
|
||||
|
||||
if user_data and hasattr(user_data, '_on_async_image_loaded'):
|
||||
if user_data and hasattr(user_data, "_on_async_image_loaded"):
|
||||
user_data._on_async_image_loaded(image)
|
||||
|
||||
# Trigger re-render to show newly loaded image
|
||||
@@ -86,7 +102,7 @@ class AsyncLoadingMixin:
|
||||
"""
|
||||
logger.warning(f"Image load failed: {path} - {error_msg}")
|
||||
|
||||
if user_data and hasattr(user_data, '_on_async_image_load_failed'):
|
||||
if user_data and hasattr(user_data, "_on_async_image_load_failed"):
|
||||
user_data._on_async_image_load_failed(error_msg)
|
||||
|
||||
def _on_pdf_progress(self, current: int, total: int, message: str):
|
||||
@@ -101,9 +117,11 @@ class AsyncLoadingMixin:
|
||||
logger.debug(f"PDF progress: {current}/{total} - {message}")
|
||||
|
||||
# Update progress dialog if it exists
|
||||
if hasattr(self, '_pdf_progress_dialog') and self._pdf_progress_dialog:
|
||||
self._pdf_progress_dialog.setValue(current)
|
||||
self._pdf_progress_dialog.setLabelText(message)
|
||||
# Use local reference to avoid race condition
|
||||
dialog = getattr(self, "_pdf_progress_dialog", None)
|
||||
if dialog is not None:
|
||||
dialog.setValue(current)
|
||||
dialog.setLabelText(message)
|
||||
|
||||
def _on_pdf_complete(self, success: bool, warnings: list):
|
||||
"""
|
||||
@@ -116,19 +134,16 @@ class AsyncLoadingMixin:
|
||||
logger.info(f"PDF export complete: success={success}, warnings={len(warnings)}")
|
||||
|
||||
# Close progress dialog
|
||||
if hasattr(self, '_pdf_progress_dialog') and self._pdf_progress_dialog:
|
||||
if hasattr(self, "_pdf_progress_dialog") and self._pdf_progress_dialog:
|
||||
self._pdf_progress_dialog.close()
|
||||
self._pdf_progress_dialog = None
|
||||
|
||||
# Show completion message
|
||||
main_window = self.window()
|
||||
if hasattr(main_window, 'show_status'):
|
||||
if hasattr(main_window, "show_status"):
|
||||
if success:
|
||||
if warnings:
|
||||
main_window.show_status(
|
||||
f"PDF exported successfully with {len(warnings)} warnings",
|
||||
5000
|
||||
)
|
||||
main_window.show_status(f"PDF exported successfully with {len(warnings)} warnings", 5000)
|
||||
else:
|
||||
main_window.show_status("PDF exported successfully", 3000)
|
||||
else:
|
||||
@@ -144,13 +159,13 @@ class AsyncLoadingMixin:
|
||||
logger.error(f"PDF export failed: {error_msg}")
|
||||
|
||||
# Close progress dialog
|
||||
if hasattr(self, '_pdf_progress_dialog') and self._pdf_progress_dialog:
|
||||
if hasattr(self, "_pdf_progress_dialog") and self._pdf_progress_dialog:
|
||||
self._pdf_progress_dialog.close()
|
||||
self._pdf_progress_dialog = None
|
||||
|
||||
# Show error message
|
||||
main_window = self.window()
|
||||
if hasattr(main_window, 'show_status'):
|
||||
if hasattr(main_window, "show_status"):
|
||||
main_window.show_status(f"PDF export failed: {error_msg}", 5000)
|
||||
|
||||
def request_image_load(self, image_data, priority: LoadPriority = LoadPriority.NORMAL):
|
||||
@@ -161,32 +176,23 @@ class AsyncLoadingMixin:
|
||||
image_data: ImageData element to load
|
||||
priority: Load priority level
|
||||
"""
|
||||
if not hasattr(self, 'async_image_loader'):
|
||||
if not hasattr(self, "async_image_loader"):
|
||||
logger.warning("Async image loader not initialized")
|
||||
return
|
||||
|
||||
if not image_data.image_path:
|
||||
return
|
||||
|
||||
# Resolve path
|
||||
from pyPhotoAlbum.models import get_asset_search_paths
|
||||
import os
|
||||
# Security: only load images from the assets folder
|
||||
if not image_data.image_path.startswith("assets/"):
|
||||
logger.warning(f"Skipping path not in assets folder (needs healing): {image_data.image_path}")
|
||||
return
|
||||
|
||||
image_full_path = image_data.image_path
|
||||
if not os.path.isabs(image_data.image_path):
|
||||
project_folder, search_paths = get_asset_search_paths()
|
||||
possible_paths = []
|
||||
|
||||
if project_folder:
|
||||
possible_paths.append(os.path.join(project_folder, image_data.image_path))
|
||||
|
||||
for search_path in search_paths:
|
||||
possible_paths.append(os.path.join(search_path, image_data.image_path))
|
||||
|
||||
for path in possible_paths:
|
||||
if os.path.exists(path):
|
||||
image_full_path = path
|
||||
break
|
||||
# Use ImageData's path resolution (delegates to project layer)
|
||||
image_full_path = image_data.resolve_image_path()
|
||||
if not image_full_path:
|
||||
logger.warning(f"Image not found (needs healing): {image_data.image_path}")
|
||||
return
|
||||
|
||||
# Calculate target size (max 2048px like original)
|
||||
target_size = (2048, 2048) # Will be downsampled if larger
|
||||
@@ -196,7 +202,7 @@ class AsyncLoadingMixin:
|
||||
Path(image_full_path),
|
||||
priority=priority,
|
||||
target_size=target_size,
|
||||
user_data=image_data # Pass element for callback
|
||||
user_data=image_data, # Pass element for callback
|
||||
)
|
||||
|
||||
def export_pdf_async(self, project, output_path: str, export_dpi: int = 300):
|
||||
@@ -208,7 +214,7 @@ class AsyncLoadingMixin:
|
||||
output_path: Output PDF file path
|
||||
export_dpi: Export DPI (default 300)
|
||||
"""
|
||||
if not hasattr(self, 'async_pdf_generator'):
|
||||
if not hasattr(self, "async_pdf_generator"):
|
||||
logger.warning("Async PDF generator not initialized")
|
||||
return False
|
||||
|
||||
@@ -216,18 +222,11 @@ class AsyncLoadingMixin:
|
||||
from PyQt6.QtWidgets import QProgressDialog
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
total_pages = sum(
|
||||
1 if page.is_cover else (2 if page.is_double_spread else 1)
|
||||
for page in project.pages
|
||||
)
|
||||
total_pages = sum(1 if page.is_cover else (2 if page.is_double_spread else 1) for page in project.pages)
|
||||
|
||||
self._pdf_progress_dialog = QProgressDialog(
|
||||
"Exporting to PDF...",
|
||||
"Cancel",
|
||||
0,
|
||||
total_pages,
|
||||
self
|
||||
)
|
||||
from PyQt6.QtWidgets import QWidget
|
||||
|
||||
self._pdf_progress_dialog = QProgressDialog("Exporting to PDF...", "Cancel", 0, total_pages, cast(QWidget, self))
|
||||
self._pdf_progress_dialog.setWindowModality(Qt.WindowModality.WindowModal)
|
||||
self._pdf_progress_dialog.setWindowTitle("PDF Export")
|
||||
self._pdf_progress_dialog.canceled.connect(self._on_pdf_cancel)
|
||||
@@ -240,17 +239,17 @@ class AsyncLoadingMixin:
|
||||
"""Handle PDF export cancellation."""
|
||||
logger.info("User requested PDF export cancellation")
|
||||
|
||||
if hasattr(self, 'async_pdf_generator'):
|
||||
if hasattr(self, "async_pdf_generator"):
|
||||
self.async_pdf_generator.cancel_export()
|
||||
|
||||
def get_async_stats(self) -> dict:
|
||||
"""Get async loading system statistics."""
|
||||
stats = {}
|
||||
|
||||
if hasattr(self, 'async_image_loader'):
|
||||
stats['image_loader'] = self.async_image_loader.get_stats()
|
||||
if hasattr(self, "async_image_loader"):
|
||||
stats["image_loader"] = self.async_image_loader.get_stats()
|
||||
|
||||
if hasattr(self, 'async_pdf_generator'):
|
||||
stats['pdf_generator'] = self.async_pdf_generator.get_stats()
|
||||
if hasattr(self, "async_pdf_generator"):
|
||||
stats["pdf_generator"] = self.async_pdf_generator.get_stats()
|
||||
|
||||
return stats
|
||||
|
||||
+44
-47
@@ -2,57 +2,57 @@
|
||||
Base mixin providing shared application state access
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from PyQt6.QtWidgets import QStatusBar, QMessageBox
|
||||
from typing import Any, Optional, cast
|
||||
from PyQt6.QtWidgets import QStatusBar, QMessageBox, QWidget
|
||||
|
||||
|
||||
class ApplicationStateMixin:
|
||||
"""
|
||||
Base mixin providing access to shared application state.
|
||||
|
||||
|
||||
This mixin provides properties and helper methods for accessing
|
||||
core application objects that are shared across all operation mixins.
|
||||
|
||||
|
||||
Required attributes (must be set by MainWindow):
|
||||
_project: Project instance
|
||||
_gl_widget: GLWidget instance
|
||||
_status_bar: QStatusBar instance
|
||||
_template_manager: TemplateManager instance
|
||||
"""
|
||||
|
||||
|
||||
@property
|
||||
def project(self):
|
||||
"""Access to current project"""
|
||||
if not hasattr(self, '_project'):
|
||||
if not hasattr(self, "_project"):
|
||||
raise AttributeError("MainWindow must set _project attribute")
|
||||
return self._project
|
||||
|
||||
|
||||
@project.setter
|
||||
def project(self, value):
|
||||
"""Set the current project"""
|
||||
self._project = value
|
||||
|
||||
|
||||
@property
|
||||
def gl_widget(self):
|
||||
"""Access to GL rendering widget"""
|
||||
if not hasattr(self, '_gl_widget'):
|
||||
if not hasattr(self, "_gl_widget"):
|
||||
raise AttributeError("MainWindow must set _gl_widget attribute")
|
||||
return self._gl_widget
|
||||
|
||||
|
||||
@property
|
||||
def status_bar(self) -> QStatusBar:
|
||||
"""Access to status bar"""
|
||||
if not hasattr(self, '_status_bar'):
|
||||
if not hasattr(self, "_status_bar"):
|
||||
raise AttributeError("MainWindow must set _status_bar attribute")
|
||||
return self._status_bar
|
||||
|
||||
return cast(QStatusBar, self._status_bar)
|
||||
|
||||
@property
|
||||
def template_manager(self):
|
||||
"""Access to template manager"""
|
||||
if not hasattr(self, '_template_manager'):
|
||||
if not hasattr(self, "_template_manager"):
|
||||
raise AttributeError("MainWindow must set _template_manager attribute")
|
||||
return self._template_manager
|
||||
|
||||
|
||||
# Common helper methods
|
||||
|
||||
def _get_most_visible_page_index(self):
|
||||
@@ -62,7 +62,7 @@ class ApplicationStateMixin:
|
||||
Returns:
|
||||
int: Index of the most visible page
|
||||
"""
|
||||
if not hasattr(self.gl_widget, '_page_renderers') or not self.gl_widget._page_renderers:
|
||||
if not hasattr(self.gl_widget, "_page_renderers") or not self.gl_widget._page_renderers:
|
||||
return self.gl_widget.current_page_index
|
||||
|
||||
# Get viewport dimensions
|
||||
@@ -70,7 +70,7 @@ class ApplicationStateMixin:
|
||||
viewport_center_y = viewport_height / 2
|
||||
|
||||
# Find which page's center is closest to viewport center
|
||||
min_distance = float('inf')
|
||||
min_distance = float("inf")
|
||||
best_page_index = self.gl_widget.current_page_index
|
||||
|
||||
for renderer, page in self.gl_widget._page_renderers:
|
||||
@@ -107,109 +107,106 @@ class ApplicationStateMixin:
|
||||
return self.project.pages[index]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_current_page_index(self) -> int:
|
||||
"""
|
||||
Get current page index.
|
||||
|
||||
|
||||
Returns:
|
||||
Current page index, or -1 if no page
|
||||
"""
|
||||
if not self.project or not self.project.pages:
|
||||
return -1
|
||||
return self.gl_widget.current_page_index
|
||||
|
||||
return int(self.gl_widget.current_page_index)
|
||||
|
||||
def show_status(self, message: str, timeout: int = 2000):
|
||||
"""
|
||||
Show message in status bar.
|
||||
|
||||
|
||||
Args:
|
||||
message: Message to display
|
||||
timeout: Display duration in milliseconds
|
||||
"""
|
||||
if self.status_bar:
|
||||
self.status_bar.showMessage(message, timeout)
|
||||
|
||||
|
||||
def show_error(self, title: str, message: str):
|
||||
"""
|
||||
Show error dialog.
|
||||
|
||||
|
||||
Args:
|
||||
title: Dialog title
|
||||
message: Error message
|
||||
"""
|
||||
QMessageBox.critical(self, title, message)
|
||||
|
||||
QMessageBox.critical(cast(QWidget, self), title, message)
|
||||
|
||||
def show_warning(self, title: str, message: str):
|
||||
"""
|
||||
Show warning dialog.
|
||||
|
||||
|
||||
Args:
|
||||
title: Dialog title
|
||||
message: Warning message
|
||||
"""
|
||||
QMessageBox.warning(self, title, message)
|
||||
|
||||
QMessageBox.warning(cast(QWidget, self), title, message)
|
||||
|
||||
def show_info(self, title: str, message: str):
|
||||
"""
|
||||
Show information dialog.
|
||||
|
||||
|
||||
Args:
|
||||
title: Dialog title
|
||||
message: Information message
|
||||
"""
|
||||
QMessageBox.information(self, title, message)
|
||||
|
||||
QMessageBox.information(cast(QWidget, self), title, message)
|
||||
|
||||
def require_page(self, show_warning: bool = True) -> bool:
|
||||
"""
|
||||
Check if a page is available and optionally show warning.
|
||||
|
||||
|
||||
Args:
|
||||
show_warning: Whether to show warning dialog if no page exists
|
||||
|
||||
|
||||
Returns:
|
||||
True if page exists, False otherwise
|
||||
"""
|
||||
current_page = self.get_current_page()
|
||||
|
||||
|
||||
if current_page is None:
|
||||
if show_warning:
|
||||
self.show_warning("No Page", "Please create a page first.")
|
||||
return False
|
||||
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def require_selection(self, min_count: int = 1, show_warning: bool = True) -> bool:
|
||||
"""
|
||||
Check if required number of elements are selected.
|
||||
|
||||
|
||||
Args:
|
||||
min_count: Minimum number of selected elements required
|
||||
show_warning: Whether to show warning dialog if requirement not met
|
||||
|
||||
|
||||
Returns:
|
||||
True if requirements met, False otherwise
|
||||
"""
|
||||
selected_count = len(self.gl_widget.selected_elements)
|
||||
|
||||
|
||||
if selected_count < min_count:
|
||||
if show_warning:
|
||||
if min_count == 1:
|
||||
self.show_info("No Selection", "Please select an element.")
|
||||
else:
|
||||
self.show_info(
|
||||
"Selection Required",
|
||||
f"Please select at least {min_count} elements."
|
||||
)
|
||||
self.show_info("Selection Required", f"Please select at least {min_count} elements.")
|
||||
return False
|
||||
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def update_view(self):
|
||||
"""Trigger GL widget update to refresh the view"""
|
||||
if self.gl_widget:
|
||||
self.gl_widget.update()
|
||||
|
||||
# Update scrollbars to reflect new content
|
||||
if hasattr(self, 'update_scrollbars'):
|
||||
if hasattr(self, "update_scrollbars"):
|
||||
self.update_scrollbars()
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
Dialog operations mixin for pyPhotoAlbum
|
||||
|
||||
Provides common functionality for creating and managing dialogs.
|
||||
"""
|
||||
|
||||
from typing import Optional, Any, Callable
|
||||
from PyQt6.QtWidgets import QDialog
|
||||
|
||||
|
||||
class DialogMixin:
|
||||
"""
|
||||
Mixin providing dialog creation and management capabilities.
|
||||
|
||||
This mixin separates dialog UI concerns from business logic,
|
||||
making it easier to create, test, and maintain complex dialogs.
|
||||
"""
|
||||
|
||||
def create_dialog(self, dialog_class: type, title: Optional[str] = None, **kwargs) -> Optional[Any]:
|
||||
"""
|
||||
Create and show a dialog, handling the result.
|
||||
|
||||
Args:
|
||||
dialog_class: Dialog class to instantiate
|
||||
title: Optional title override
|
||||
**kwargs: Additional arguments passed to dialog constructor
|
||||
|
||||
Returns:
|
||||
Dialog result if accepted, None if rejected
|
||||
"""
|
||||
# Create dialog instance
|
||||
dialog = dialog_class(parent=self, **kwargs)
|
||||
|
||||
# Set title if provided
|
||||
if title:
|
||||
dialog.setWindowTitle(title)
|
||||
|
||||
# Show dialog and handle result
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
# Check if dialog has a get_values method
|
||||
if hasattr(dialog, "get_values"):
|
||||
return dialog.get_values()
|
||||
return True
|
||||
|
||||
return None
|
||||
|
||||
def show_dialog(self, dialog_class: type, on_accept: Optional[Callable] = None, **kwargs) -> bool:
|
||||
"""
|
||||
Show a dialog and execute callback on acceptance.
|
||||
|
||||
Args:
|
||||
dialog_class: Dialog class to instantiate
|
||||
on_accept: Callback to execute if dialog is accepted.
|
||||
Will receive dialog result as parameter.
|
||||
**kwargs: Additional arguments passed to dialog constructor
|
||||
|
||||
Returns:
|
||||
True if dialog was accepted, False otherwise
|
||||
"""
|
||||
result = self.create_dialog(dialog_class, **kwargs)
|
||||
|
||||
if result is not None and on_accept:
|
||||
on_accept(result)
|
||||
return True
|
||||
|
||||
return result is not None
|
||||
@@ -2,18 +2,31 @@
|
||||
Element manipulation mixin for GLWidget - handles element transformations
|
||||
"""
|
||||
|
||||
from typing import Optional, Tuple
|
||||
from typing import TYPE_CHECKING, Any, Optional, Tuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyPhotoAlbum.models import BaseLayoutElement
|
||||
from PyQt6.QtWidgets import QMainWindow
|
||||
|
||||
|
||||
class ElementManipulationMixin:
|
||||
"""
|
||||
Mixin providing element transformation functionality.
|
||||
# Type hints for expected attributes from mixing class
|
||||
selected_element: Optional["BaseLayoutElement"]
|
||||
drag_start_pos: Optional[Tuple[float, float]]
|
||||
drag_start_element_pos: Optional[Tuple[float, float]]
|
||||
|
||||
This mixin handles resizing, rotating, and moving elements, including
|
||||
snapping support and cross-page element transfers.
|
||||
"""
|
||||
def window(self) -> "QMainWindow": # type: ignore[empty-body]
|
||||
"""Expected from QWidget"""
|
||||
...
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""
|
||||
Initialize element manipulation mixin.
|
||||
|
||||
This mixin provides element transformation functionality including
|
||||
resizing, rotating, moving elements, snapping support and cross-page
|
||||
element transfers.
|
||||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Resize state
|
||||
@@ -27,11 +40,7 @@ class ElementManipulationMixin:
|
||||
self.rotation_snap_angle: int = 15 # Default snap angle in degrees
|
||||
|
||||
# Snap state tracking
|
||||
self.snap_state = {
|
||||
'is_snapped': False,
|
||||
'last_position': None,
|
||||
'last_size': None
|
||||
}
|
||||
self.snap_state = {"is_snapped": False, "last_position": None, "last_size": None}
|
||||
|
||||
def _resize_element(self, dx: float, dy: float):
|
||||
"""
|
||||
@@ -49,19 +58,21 @@ class ElementManipulationMixin:
|
||||
|
||||
# Get the snapping system from the element's parent page
|
||||
main_window = self.window()
|
||||
if not hasattr(self.selected_element, '_parent_page'):
|
||||
if not hasattr(self.selected_element, "_parent_page"):
|
||||
self._resize_element_no_snap(dx, dy)
|
||||
return
|
||||
|
||||
parent_page = self.selected_element._parent_page
|
||||
parent_page = self.selected_element._parent_page # type: ignore[attr-defined]
|
||||
snap_sys = parent_page.layout.snapping_system
|
||||
|
||||
# Get page size
|
||||
page_size = parent_page.layout.size
|
||||
dpi = main_window.project.working_dpi
|
||||
dpi = main_window.project.working_dpi # type: ignore[attr-defined]
|
||||
|
||||
# Apply snapping to resize
|
||||
new_pos, new_size = snap_sys.snap_resize(
|
||||
from pyPhotoAlbum.snapping import SnapResizeParams
|
||||
|
||||
params = SnapResizeParams(
|
||||
position=self.resize_start_pos,
|
||||
size=self.resize_start_size,
|
||||
dx=dx,
|
||||
@@ -69,8 +80,9 @@ class ElementManipulationMixin:
|
||||
resize_handle=self.resize_handle,
|
||||
page_size=page_size,
|
||||
dpi=dpi,
|
||||
project=main_window.project
|
||||
project=main_window.project, # type: ignore[attr-defined]
|
||||
)
|
||||
new_pos, new_size = snap_sys.snap_resize(params)
|
||||
|
||||
# Apply the snapped values
|
||||
self.selected_element.position = new_pos
|
||||
@@ -94,20 +106,22 @@ class ElementManipulationMixin:
|
||||
"""
|
||||
if not self.resize_start_pos or not self.resize_start_size:
|
||||
return
|
||||
if self.selected_element is None:
|
||||
return
|
||||
|
||||
start_x, start_y = self.resize_start_pos
|
||||
start_w, start_h = self.resize_start_size
|
||||
|
||||
if self.resize_handle == 'nw':
|
||||
if self.resize_handle == "nw":
|
||||
self.selected_element.position = (start_x + dx, start_y + dy)
|
||||
self.selected_element.size = (start_w - dx, start_h - dy)
|
||||
elif self.resize_handle == 'ne':
|
||||
elif self.resize_handle == "ne":
|
||||
self.selected_element.position = (start_x, start_y + dy)
|
||||
self.selected_element.size = (start_w + dx, start_h - dy)
|
||||
elif self.resize_handle == 'sw':
|
||||
elif self.resize_handle == "sw":
|
||||
self.selected_element.position = (start_x + dx, start_y)
|
||||
self.selected_element.size = (start_w - dx, start_h + dy)
|
||||
elif self.resize_handle == 'se':
|
||||
elif self.resize_handle == "se":
|
||||
self.selected_element.size = (start_w + dx, start_h + dy)
|
||||
|
||||
# Ensure minimum size
|
||||
@@ -119,7 +133,9 @@ class ElementManipulationMixin:
|
||||
w, _ = self.selected_element.size
|
||||
self.selected_element.size = (w, min_size)
|
||||
|
||||
def _transfer_element_to_page(self, element, source_page, target_page, mouse_x: float, mouse_y: float, target_renderer):
|
||||
def _transfer_element_to_page(
|
||||
self, element, source_page, target_page, mouse_x: float, mouse_y: float, target_renderer
|
||||
):
|
||||
"""
|
||||
Transfer an element from one page to another during drag operation.
|
||||
|
||||
|
||||
@@ -2,11 +2,22 @@
|
||||
Element selection mixin for GLWidget - handles element selection and hit detection
|
||||
"""
|
||||
|
||||
from typing import Optional, Set
|
||||
from typing import Any, TYPE_CHECKING, Optional, Set
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from PyQt6.QtWidgets import QMainWindow
|
||||
|
||||
from pyPhotoAlbum.models import BaseLayoutElement
|
||||
|
||||
|
||||
class ElementSelectionMixin:
|
||||
# Type hints for expected attributes from mixing class
|
||||
_page_renderers: list
|
||||
|
||||
def window(self) -> "QMainWindow": # type: ignore[empty-body]
|
||||
"""Expected from QWidget"""
|
||||
...
|
||||
|
||||
"""
|
||||
Mixin providing element selection and hit detection functionality.
|
||||
|
||||
@@ -54,7 +65,7 @@ class ElementSelectionMixin:
|
||||
Returns:
|
||||
BaseLayoutElement or None: The topmost element at the position, or None
|
||||
"""
|
||||
if not hasattr(self, '_page_renderers') or not self._page_renderers:
|
||||
if not hasattr(self, "_page_renderers") or not self._page_renderers:
|
||||
return None
|
||||
|
||||
# Check each page from top to bottom (reverse z-order)
|
||||
@@ -73,9 +84,9 @@ class ElementSelectionMixin:
|
||||
# Simple bounds check (no rotation transformation needed - images are already rotated)
|
||||
if ex <= page_x <= ex + ew and ey <= page_y <= ey + eh:
|
||||
# Store the renderer with the element for later use
|
||||
element._page_renderer = renderer
|
||||
element._parent_page = page
|
||||
return element
|
||||
element._page_renderer = renderer # type: ignore[attr-defined]
|
||||
element._parent_page = page # type: ignore[attr-defined]
|
||||
return element # type: ignore[no-any-return]
|
||||
|
||||
return None
|
||||
|
||||
@@ -96,14 +107,14 @@ class ElementSelectionMixin:
|
||||
return None
|
||||
|
||||
main_window = self.window()
|
||||
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
|
||||
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
|
||||
return None
|
||||
|
||||
# Get the PageRenderer for this element (stored when element was selected)
|
||||
if not hasattr(self.selected_element, '_page_renderer'):
|
||||
if not hasattr(self.selected_element, "_page_renderer"):
|
||||
return None
|
||||
|
||||
renderer = self.selected_element._page_renderer
|
||||
renderer: Any = self.selected_element._page_renderer # type: ignore[attr-defined]
|
||||
|
||||
# Get element position and size in page-local coordinates
|
||||
elem_x, elem_y = self.selected_element.position
|
||||
@@ -117,10 +128,10 @@ class ElementSelectionMixin:
|
||||
|
||||
# Check handles (no rotation transformation needed - images are already rotated)
|
||||
handles = {
|
||||
'nw': (ex - handle_size/2, ey - handle_size/2),
|
||||
'ne': (ex + ew - handle_size/2, ey - handle_size/2),
|
||||
'sw': (ex - handle_size/2, ey + eh - handle_size/2),
|
||||
'se': (ex + ew - handle_size/2, ey + eh - handle_size/2),
|
||||
"nw": (ex - handle_size / 2, ey - handle_size / 2),
|
||||
"ne": (ex + ew - handle_size / 2, ey - handle_size / 2),
|
||||
"sw": (ex - handle_size / 2, ey + eh - handle_size / 2),
|
||||
"se": (ex + ew - handle_size / 2, ey + eh - handle_size / 2),
|
||||
}
|
||||
|
||||
for name, (hx, hy) in handles.items():
|
||||
|
||||
@@ -2,11 +2,16 @@
|
||||
Image pan mixin for GLWidget - handles panning images within frames
|
||||
"""
|
||||
|
||||
from typing import Optional, Tuple
|
||||
from typing import TYPE_CHECKING, Optional, Tuple
|
||||
|
||||
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
|
||||
class ImagePanMixin:
|
||||
# Type hints for expected attributes from mixing class
|
||||
drag_start_pos: Optional[Tuple[float, float]]
|
||||
zoom_level: float
|
||||
"""
|
||||
Mixin providing image panning functionality.
|
||||
|
||||
@@ -21,7 +26,7 @@ class ImagePanMixin:
|
||||
self.image_pan_mode: bool = False # True when Control+dragging an ImageData element
|
||||
self.image_pan_start_crop: Optional[Tuple[float, float, float, float]] = None # Starting crop_info
|
||||
|
||||
def _handle_image_pan_move(self, x: float, y: float, element: ImageData):
|
||||
def _handle_image_pan_move(self, x: float, y: float, element: "ImageData"):
|
||||
"""
|
||||
Handle image panning within a frame during mouse move.
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
Command builders for different interaction types.
|
||||
|
||||
Each builder is responsible for:
|
||||
1. Validating if a command should be created
|
||||
2. Creating the appropriate command object
|
||||
3. Logging the operation
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional, Any
|
||||
from pyPhotoAlbum.models import BaseLayoutElement
|
||||
from .interaction_validators import InteractionChangeDetector
|
||||
|
||||
|
||||
class CommandBuilder(ABC):
|
||||
"""Base class for command builders."""
|
||||
|
||||
def __init__(self, change_detector: Optional[InteractionChangeDetector] = None):
|
||||
self.change_detector = change_detector or InteractionChangeDetector()
|
||||
|
||||
@abstractmethod
|
||||
def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
|
||||
"""
|
||||
Check if a command should be built based on state changes.
|
||||
|
||||
Args:
|
||||
element: The element being modified
|
||||
start_state: Dict containing the initial state
|
||||
**kwargs: Additional context
|
||||
|
||||
Returns:
|
||||
True if a command should be created
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
|
||||
"""
|
||||
Build and return the command object.
|
||||
|
||||
Args:
|
||||
element: The element being modified
|
||||
start_state: Dict containing the initial state
|
||||
**kwargs: Additional context
|
||||
|
||||
Returns:
|
||||
Command object or None
|
||||
"""
|
||||
pass
|
||||
|
||||
def log_command(self, command_type: str, details: str):
|
||||
"""Log command creation for debugging."""
|
||||
print(f"{command_type} command created: {details}")
|
||||
|
||||
|
||||
class MoveCommandBuilder(CommandBuilder):
|
||||
"""Builds MoveElementCommand objects."""
|
||||
|
||||
def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
|
||||
"""Check if position changed significantly."""
|
||||
old_pos = start_state.get("position")
|
||||
if old_pos is None:
|
||||
return False
|
||||
|
||||
new_pos = element.position
|
||||
return self.change_detector.detect_position_change(old_pos, new_pos) is not None
|
||||
|
||||
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
|
||||
"""Build a MoveElementCommand."""
|
||||
old_pos = start_state.get("position")
|
||||
if old_pos is None:
|
||||
return None
|
||||
|
||||
new_pos = element.position
|
||||
change_info = self.change_detector.detect_position_change(old_pos, new_pos)
|
||||
|
||||
if change_info is None:
|
||||
return None
|
||||
|
||||
from pyPhotoAlbum.commands import MoveElementCommand
|
||||
|
||||
command = MoveElementCommand(element, old_pos, new_pos)
|
||||
|
||||
self.log_command("Move", f"{old_pos} → {new_pos}")
|
||||
return command
|
||||
|
||||
|
||||
class ResizeCommandBuilder(CommandBuilder):
|
||||
"""Builds ResizeElementCommand objects."""
|
||||
|
||||
def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
|
||||
"""Check if position or size changed significantly."""
|
||||
old_pos = start_state.get("position")
|
||||
old_size = start_state.get("size")
|
||||
|
||||
if old_pos is None or old_size is None:
|
||||
return False
|
||||
|
||||
new_pos = element.position
|
||||
new_size = element.size
|
||||
|
||||
pos_change = self.change_detector.detect_position_change(old_pos, new_pos)
|
||||
size_change = self.change_detector.detect_size_change(old_size, new_size)
|
||||
|
||||
return pos_change is not None or size_change is not None
|
||||
|
||||
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
|
||||
"""Build a ResizeElementCommand."""
|
||||
old_pos = start_state.get("position")
|
||||
old_size = start_state.get("size")
|
||||
|
||||
if old_pos is None or old_size is None:
|
||||
return None
|
||||
|
||||
new_pos = element.position
|
||||
new_size = element.size
|
||||
|
||||
if not self.can_build(element, start_state):
|
||||
return None
|
||||
|
||||
from pyPhotoAlbum.commands import ResizeElementCommand
|
||||
|
||||
command = ResizeElementCommand(element, old_pos, old_size, new_pos, new_size)
|
||||
|
||||
self.log_command("Resize", f"{old_size} → {new_size}")
|
||||
return command
|
||||
|
||||
|
||||
class RotateCommandBuilder(CommandBuilder):
|
||||
"""Builds RotateElementCommand objects."""
|
||||
|
||||
def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
|
||||
"""Check if rotation changed significantly."""
|
||||
old_rotation = start_state.get("rotation")
|
||||
if old_rotation is None:
|
||||
return False
|
||||
|
||||
new_rotation = element.rotation
|
||||
return self.change_detector.detect_rotation_change(old_rotation, new_rotation) is not None
|
||||
|
||||
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
|
||||
"""Build a RotateElementCommand."""
|
||||
old_rotation = start_state.get("rotation")
|
||||
if old_rotation is None:
|
||||
return None
|
||||
|
||||
new_rotation = element.rotation
|
||||
change_info = self.change_detector.detect_rotation_change(old_rotation, new_rotation)
|
||||
|
||||
if change_info is None:
|
||||
return None
|
||||
|
||||
from pyPhotoAlbum.commands import RotateElementCommand
|
||||
|
||||
command = RotateElementCommand(element, old_rotation, new_rotation)
|
||||
|
||||
self.log_command("Rotation", f"{old_rotation:.1f}° → {new_rotation:.1f}°")
|
||||
return command
|
||||
|
||||
|
||||
class ImagePanCommandBuilder(CommandBuilder):
|
||||
"""Builds AdjustImageCropCommand objects for image panning."""
|
||||
|
||||
def can_build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> bool:
|
||||
"""Check if crop info changed significantly."""
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
if not isinstance(element, ImageData):
|
||||
return False
|
||||
|
||||
old_crop = start_state.get("crop_info")
|
||||
if old_crop is None:
|
||||
return False
|
||||
|
||||
new_crop = element.crop_info
|
||||
change_detector = InteractionChangeDetector(threshold=0.001)
|
||||
return change_detector.detect_crop_change(old_crop, new_crop) is not None
|
||||
|
||||
def build(self, element: BaseLayoutElement, start_state: dict, **kwargs) -> Optional[Any]:
|
||||
"""Build an AdjustImageCropCommand."""
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
if not isinstance(element, ImageData):
|
||||
return None
|
||||
|
||||
old_crop = start_state.get("crop_info")
|
||||
if old_crop is None:
|
||||
return None
|
||||
|
||||
new_crop = element.crop_info
|
||||
change_detector = InteractionChangeDetector(threshold=0.001)
|
||||
change_info = change_detector.detect_crop_change(old_crop, new_crop)
|
||||
|
||||
if change_info is None:
|
||||
return None
|
||||
|
||||
from pyPhotoAlbum.commands import AdjustImageCropCommand
|
||||
|
||||
command = AdjustImageCropCommand(element, old_crop, new_crop)
|
||||
|
||||
self.log_command("Image pan", f"{old_crop} → {new_crop}")
|
||||
return command
|
||||
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
Factory for creating interaction commands based on interaction type.
|
||||
|
||||
This implements the Strategy pattern, allowing different command builders
|
||||
to be registered and used based on the interaction type.
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any
|
||||
from pyPhotoAlbum.models import BaseLayoutElement
|
||||
from .interaction_command_builders import (
|
||||
CommandBuilder,
|
||||
MoveCommandBuilder,
|
||||
ResizeCommandBuilder,
|
||||
RotateCommandBuilder,
|
||||
ImagePanCommandBuilder,
|
||||
)
|
||||
|
||||
|
||||
class InteractionCommandFactory:
|
||||
"""
|
||||
Factory for creating commands from interaction data.
|
||||
|
||||
Uses the Strategy pattern to delegate command creation to
|
||||
specialized builder classes based on interaction type.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize factory with default builders."""
|
||||
self._builders: Dict[str, CommandBuilder] = {}
|
||||
self._register_default_builders()
|
||||
|
||||
def _register_default_builders(self):
|
||||
"""Register the default command builders."""
|
||||
self.register_builder("move", MoveCommandBuilder())
|
||||
self.register_builder("resize", ResizeCommandBuilder())
|
||||
self.register_builder("rotate", RotateCommandBuilder())
|
||||
self.register_builder("image_pan", ImagePanCommandBuilder())
|
||||
|
||||
def register_builder(self, interaction_type: str, builder: CommandBuilder):
|
||||
"""
|
||||
Register a command builder for an interaction type.
|
||||
|
||||
Args:
|
||||
interaction_type: The type of interaction (e.g., 'move', 'resize')
|
||||
builder: The CommandBuilder instance to handle this type
|
||||
"""
|
||||
self._builders[interaction_type] = builder
|
||||
|
||||
def create_command(
|
||||
self, interaction_type: str, element: BaseLayoutElement, start_state: dict, **kwargs
|
||||
) -> Optional[Any]:
|
||||
"""
|
||||
Create a command based on interaction type and state changes.
|
||||
|
||||
Args:
|
||||
interaction_type: Type of interaction ('move', 'resize', etc.)
|
||||
element: The element that was interacted with
|
||||
start_state: Dictionary containing initial state values
|
||||
**kwargs: Additional context for command creation
|
||||
|
||||
Returns:
|
||||
Command object if changes warrant it, None otherwise
|
||||
"""
|
||||
builder = self._builders.get(interaction_type)
|
||||
|
||||
if builder is None:
|
||||
print(f"Warning: No builder registered for interaction type '{interaction_type}'")
|
||||
return None
|
||||
|
||||
if not builder.can_build(element, start_state, **kwargs):
|
||||
return None
|
||||
|
||||
return builder.build(element, start_state, **kwargs)
|
||||
|
||||
def has_builder(self, interaction_type: str) -> bool:
|
||||
"""Check if a builder is registered for the given interaction type."""
|
||||
return interaction_type in self._builders
|
||||
|
||||
def get_supported_types(self) -> list:
|
||||
"""Get list of supported interaction types."""
|
||||
return list(self._builders.keys())
|
||||
|
||||
|
||||
class InteractionState:
|
||||
"""
|
||||
Value object representing the state of an interaction.
|
||||
|
||||
This simplifies passing interaction data around and makes
|
||||
the code more maintainable.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
element: Optional[BaseLayoutElement] = None,
|
||||
interaction_type: Optional[str] = None,
|
||||
position: Optional[tuple] = None,
|
||||
size: Optional[tuple] = None,
|
||||
rotation: Optional[float] = None,
|
||||
crop_info: Optional[tuple] = None,
|
||||
):
|
||||
"""
|
||||
Initialize interaction state.
|
||||
|
||||
Args:
|
||||
element: The element being interacted with
|
||||
interaction_type: Type of interaction
|
||||
position: Initial position
|
||||
size: Initial size
|
||||
rotation: Initial rotation
|
||||
crop_info: Initial crop info (for images)
|
||||
"""
|
||||
self.element = element
|
||||
self.interaction_type = interaction_type
|
||||
self.position = position
|
||||
self.size = size
|
||||
self.rotation = rotation
|
||||
self.crop_info = crop_info
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""
|
||||
Convert state to dictionary for command builders.
|
||||
|
||||
Returns:
|
||||
Dict with non-None state values
|
||||
"""
|
||||
state: Dict[str, Any] = {}
|
||||
if self.position is not None:
|
||||
state["position"] = self.position
|
||||
if self.size is not None:
|
||||
state["size"] = self.size
|
||||
if self.rotation is not None:
|
||||
state["rotation"] = self.rotation
|
||||
if self.crop_info is not None:
|
||||
state["crop_info"] = self.crop_info
|
||||
return state
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
"""Check if state has required fields for command creation."""
|
||||
return self.element is not None and self.interaction_type is not None
|
||||
|
||||
def clear(self):
|
||||
"""Clear all state values."""
|
||||
self.element = None
|
||||
self.interaction_type = None
|
||||
self.position = None
|
||||
self.size = None
|
||||
self.rotation = None
|
||||
self.crop_info = None
|
||||
@@ -4,202 +4,113 @@ Mixin for automatic undo/redo handling in interactive mouse operations
|
||||
|
||||
from typing import Optional
|
||||
from pyPhotoAlbum.models import BaseLayoutElement
|
||||
from .interaction_command_factory import InteractionCommandFactory, InteractionState
|
||||
|
||||
|
||||
class UndoableInteractionMixin:
|
||||
"""
|
||||
Mixin providing automatic undo/redo for interactive mouse operations.
|
||||
|
||||
|
||||
This mixin tracks the state of elements before interactive operations
|
||||
(move, resize, rotate) and automatically creates appropriate Command
|
||||
objects when the interaction completes.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Interaction tracking state
|
||||
self._interaction_element: Optional[BaseLayoutElement] = None
|
||||
self._interaction_type: Optional[str] = None
|
||||
self._interaction_start_pos: Optional[tuple] = None
|
||||
self._interaction_start_size: Optional[tuple] = None
|
||||
self._interaction_start_rotation: Optional[float] = None
|
||||
|
||||
|
||||
# Command factory for creating undo/redo commands
|
||||
self._command_factory = InteractionCommandFactory()
|
||||
|
||||
# Interaction state tracking
|
||||
self._interaction_state = InteractionState()
|
||||
|
||||
def _begin_move(self, element: BaseLayoutElement):
|
||||
"""
|
||||
Begin tracking a move operation.
|
||||
|
||||
|
||||
Args:
|
||||
element: The element being moved
|
||||
"""
|
||||
self._interaction_element = element
|
||||
self._interaction_type = 'move'
|
||||
self._interaction_start_pos = element.position
|
||||
self._interaction_start_size = None
|
||||
self._interaction_start_rotation = None
|
||||
|
||||
self._interaction_state.element = element
|
||||
self._interaction_state.interaction_type = "move"
|
||||
self._interaction_state.position = element.position
|
||||
|
||||
def _begin_resize(self, element: BaseLayoutElement):
|
||||
"""
|
||||
Begin tracking a resize operation.
|
||||
|
||||
|
||||
Args:
|
||||
element: The element being resized
|
||||
"""
|
||||
self._interaction_element = element
|
||||
self._interaction_type = 'resize'
|
||||
self._interaction_start_pos = element.position
|
||||
self._interaction_start_size = element.size
|
||||
self._interaction_start_rotation = None
|
||||
|
||||
self._interaction_state.element = element
|
||||
self._interaction_state.interaction_type = "resize"
|
||||
self._interaction_state.position = element.position
|
||||
self._interaction_state.size = element.size
|
||||
|
||||
def _begin_rotate(self, element: BaseLayoutElement):
|
||||
"""
|
||||
Begin tracking a rotate operation.
|
||||
|
||||
|
||||
Args:
|
||||
element: The element being rotated
|
||||
"""
|
||||
self._interaction_element = element
|
||||
self._interaction_type = 'rotate'
|
||||
self._interaction_start_pos = None
|
||||
self._interaction_start_size = None
|
||||
self._interaction_start_rotation = element.rotation
|
||||
|
||||
self._interaction_state.element = element
|
||||
self._interaction_state.interaction_type = "rotate"
|
||||
self._interaction_state.rotation = element.rotation
|
||||
|
||||
def _begin_image_pan(self, element):
|
||||
"""
|
||||
Begin tracking an image pan operation.
|
||||
|
||||
|
||||
Args:
|
||||
element: The ImageData element being panned
|
||||
"""
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
if not isinstance(element, ImageData):
|
||||
return
|
||||
|
||||
self._interaction_element = element
|
||||
self._interaction_type = 'image_pan'
|
||||
self._interaction_start_pos = None
|
||||
self._interaction_start_size = None
|
||||
self._interaction_start_rotation = None
|
||||
self._interaction_start_crop_info = element.crop_info
|
||||
|
||||
|
||||
self._interaction_state.element = element
|
||||
self._interaction_state.interaction_type = "image_pan"
|
||||
self._interaction_state.crop_info = element.crop_info
|
||||
|
||||
def _end_interaction(self):
|
||||
"""
|
||||
End the current interaction and create appropriate undo/redo command.
|
||||
|
||||
This method checks what changed during the interaction and creates
|
||||
the appropriate Command object (MoveElementCommand, ResizeElementCommand,
|
||||
or RotateElementCommand).
|
||||
|
||||
This method uses the command factory to create the appropriate
|
||||
Command object based on what changed during the interaction.
|
||||
"""
|
||||
if not self._interaction_element or not self._interaction_type:
|
||||
# Validate interaction state
|
||||
if not self._interaction_state.is_valid():
|
||||
self._clear_interaction_state()
|
||||
return
|
||||
|
||||
element = self._interaction_element
|
||||
|
||||
|
||||
# Get main window to access project history
|
||||
main_window = self.window()
|
||||
if not hasattr(main_window, 'project'):
|
||||
if not hasattr(main_window, "project"):
|
||||
self._clear_interaction_state()
|
||||
return
|
||||
|
||||
# Create appropriate command based on interaction type
|
||||
command = None
|
||||
|
||||
if self._interaction_type == 'move':
|
||||
# Check if position actually changed
|
||||
new_pos = element.position
|
||||
if self._interaction_start_pos and new_pos != self._interaction_start_pos:
|
||||
# Check for significant change (> 0.1 units)
|
||||
dx = abs(new_pos[0] - self._interaction_start_pos[0])
|
||||
dy = abs(new_pos[1] - self._interaction_start_pos[1])
|
||||
if dx > 0.1 or dy > 0.1:
|
||||
from pyPhotoAlbum.commands import MoveElementCommand
|
||||
command = MoveElementCommand(
|
||||
element,
|
||||
self._interaction_start_pos,
|
||||
new_pos
|
||||
)
|
||||
print(f"Move command created: {self._interaction_start_pos} → {new_pos}")
|
||||
|
||||
elif self._interaction_type == 'resize':
|
||||
# Check if position or size actually changed
|
||||
new_pos = element.position
|
||||
new_size = element.size
|
||||
if self._interaction_start_pos and self._interaction_start_size:
|
||||
pos_changed = new_pos != self._interaction_start_pos
|
||||
size_changed = new_size != self._interaction_start_size
|
||||
|
||||
if pos_changed or size_changed:
|
||||
# Check for significant change
|
||||
dx = abs(new_pos[0] - self._interaction_start_pos[0])
|
||||
dy = abs(new_pos[1] - self._interaction_start_pos[1])
|
||||
dw = abs(new_size[0] - self._interaction_start_size[0])
|
||||
dh = abs(new_size[1] - self._interaction_start_size[1])
|
||||
|
||||
if dx > 0.1 or dy > 0.1 or dw > 0.1 or dh > 0.1:
|
||||
from pyPhotoAlbum.commands import ResizeElementCommand
|
||||
command = ResizeElementCommand(
|
||||
element,
|
||||
self._interaction_start_pos,
|
||||
self._interaction_start_size,
|
||||
new_pos,
|
||||
new_size
|
||||
)
|
||||
print(f"Resize command created: {self._interaction_start_size} → {new_size}")
|
||||
|
||||
elif self._interaction_type == 'rotate':
|
||||
# Check if rotation actually changed
|
||||
new_rotation = element.rotation
|
||||
if self._interaction_start_rotation is not None:
|
||||
if abs(new_rotation - self._interaction_start_rotation) > 0.1:
|
||||
from pyPhotoAlbum.commands import RotateElementCommand
|
||||
command = RotateElementCommand(
|
||||
element,
|
||||
self._interaction_start_rotation,
|
||||
new_rotation
|
||||
)
|
||||
print(f"Rotation command created: {self._interaction_start_rotation:.1f}° → {new_rotation:.1f}°")
|
||||
|
||||
elif self._interaction_type == 'image_pan':
|
||||
# Check if crop_info actually changed
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
if isinstance(element, ImageData):
|
||||
new_crop_info = element.crop_info
|
||||
if hasattr(self, '_interaction_start_crop_info') and self._interaction_start_crop_info is not None:
|
||||
# Check if crop changed significantly (more than 0.001 in any coordinate)
|
||||
if new_crop_info != self._interaction_start_crop_info:
|
||||
old_crop = self._interaction_start_crop_info
|
||||
significant_change = any(
|
||||
abs(new_crop_info[i] - old_crop[i]) > 0.001
|
||||
for i in range(4)
|
||||
)
|
||||
|
||||
if significant_change:
|
||||
from pyPhotoAlbum.commands import AdjustImageCropCommand
|
||||
command = AdjustImageCropCommand(
|
||||
element,
|
||||
self._interaction_start_crop_info,
|
||||
new_crop_info
|
||||
)
|
||||
print(f"Image pan command created: {self._interaction_start_crop_info} → {new_crop_info}")
|
||||
|
||||
|
||||
# Use factory to create command based on interaction type and changes
|
||||
command = self._command_factory.create_command(
|
||||
interaction_type=self._interaction_state.interaction_type,
|
||||
element=self._interaction_state.element,
|
||||
start_state=self._interaction_state.to_dict(),
|
||||
)
|
||||
|
||||
# Execute the command through history if one was created
|
||||
if command:
|
||||
main_window.project.history.execute(command)
|
||||
|
||||
|
||||
# Clear interaction state
|
||||
self._clear_interaction_state()
|
||||
|
||||
|
||||
def _clear_interaction_state(self):
|
||||
"""Clear all interaction tracking state"""
|
||||
self._interaction_element = None
|
||||
self._interaction_type = None
|
||||
self._interaction_start_pos = None
|
||||
self._interaction_start_size = None
|
||||
self._interaction_start_rotation = None
|
||||
if hasattr(self, '_interaction_start_crop_info'):
|
||||
self._interaction_start_crop_info = None
|
||||
|
||||
self._interaction_state.clear()
|
||||
|
||||
def _cancel_interaction(self):
|
||||
"""Cancel the current interaction without creating a command"""
|
||||
self._clear_interaction_state()
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
Decorators and validators for interaction change detection.
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
from typing import Optional, Tuple, Any
|
||||
|
||||
|
||||
def significant_change(threshold: float = 0.1):
|
||||
"""
|
||||
Decorator that validates if a change is significant enough to warrant a command.
|
||||
|
||||
Args:
|
||||
threshold: Minimum change magnitude to be considered significant
|
||||
|
||||
Returns:
|
||||
None if change is insignificant, otherwise returns the command builder result
|
||||
"""
|
||||
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
result = func(*args, **kwargs)
|
||||
if result is None:
|
||||
return None
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class ChangeValidator:
|
||||
"""Validates whether changes are significant enough to create commands."""
|
||||
|
||||
@staticmethod
|
||||
def position_changed(
|
||||
old_pos: Optional[Tuple[float, float]], new_pos: Optional[Tuple[float, float]], threshold: float = 0.1
|
||||
) -> bool:
|
||||
"""Check if position changed significantly."""
|
||||
if old_pos is None or new_pos is None:
|
||||
return False
|
||||
|
||||
dx = abs(new_pos[0] - old_pos[0])
|
||||
dy = abs(new_pos[1] - old_pos[1])
|
||||
return dx > threshold or dy > threshold
|
||||
|
||||
@staticmethod
|
||||
def size_changed(
|
||||
old_size: Optional[Tuple[float, float]], new_size: Optional[Tuple[float, float]], threshold: float = 0.1
|
||||
) -> bool:
|
||||
"""Check if size changed significantly."""
|
||||
if old_size is None or new_size is None:
|
||||
return False
|
||||
|
||||
dw = abs(new_size[0] - old_size[0])
|
||||
dh = abs(new_size[1] - old_size[1])
|
||||
return dw > threshold or dh > threshold
|
||||
|
||||
@staticmethod
|
||||
def rotation_changed(old_rotation: Optional[float], new_rotation: Optional[float], threshold: float = 0.1) -> bool:
|
||||
"""Check if rotation changed significantly."""
|
||||
if old_rotation is None or new_rotation is None:
|
||||
return False
|
||||
|
||||
return abs(new_rotation - old_rotation) > threshold
|
||||
|
||||
@staticmethod
|
||||
def crop_changed(
|
||||
old_crop: Optional[Tuple[float, float, float, float]],
|
||||
new_crop: Optional[Tuple[float, float, float, float]],
|
||||
threshold: float = 0.001,
|
||||
) -> bool:
|
||||
"""Check if crop info changed significantly."""
|
||||
if old_crop is None or new_crop is None:
|
||||
return False
|
||||
|
||||
if old_crop == new_crop:
|
||||
return False
|
||||
|
||||
return any(abs(new_crop[i] - old_crop[i]) > threshold for i in range(4))
|
||||
|
||||
|
||||
class InteractionChangeDetector:
|
||||
"""Detects and quantifies changes in element properties."""
|
||||
|
||||
def __init__(self, threshold: float = 0.1):
|
||||
self.threshold = threshold
|
||||
self.validator = ChangeValidator()
|
||||
|
||||
def detect_position_change(self, old_pos: Tuple[float, float], new_pos: Tuple[float, float]) -> Optional[dict]:
|
||||
"""
|
||||
Detect position change and return change info.
|
||||
|
||||
Returns:
|
||||
Dict with change info if significant, None otherwise
|
||||
"""
|
||||
if not self.validator.position_changed(old_pos, new_pos, self.threshold):
|
||||
return None
|
||||
|
||||
return {
|
||||
"old_position": old_pos,
|
||||
"new_position": new_pos,
|
||||
"delta_x": new_pos[0] - old_pos[0],
|
||||
"delta_y": new_pos[1] - old_pos[1],
|
||||
}
|
||||
|
||||
def detect_size_change(self, old_size: Tuple[float, float], new_size: Tuple[float, float]) -> Optional[dict]:
|
||||
"""
|
||||
Detect size change and return change info.
|
||||
|
||||
Returns:
|
||||
Dict with change info if significant, None otherwise
|
||||
"""
|
||||
if not self.validator.size_changed(old_size, new_size, self.threshold):
|
||||
return None
|
||||
|
||||
return {
|
||||
"old_size": old_size,
|
||||
"new_size": new_size,
|
||||
"delta_width": new_size[0] - old_size[0],
|
||||
"delta_height": new_size[1] - old_size[1],
|
||||
}
|
||||
|
||||
def detect_rotation_change(self, old_rotation: float, new_rotation: float) -> Optional[dict]:
|
||||
"""
|
||||
Detect rotation change and return change info.
|
||||
|
||||
Returns:
|
||||
Dict with change info if significant, None otherwise
|
||||
"""
|
||||
if not self.validator.rotation_changed(old_rotation, new_rotation, self.threshold):
|
||||
return None
|
||||
|
||||
return {"old_rotation": old_rotation, "new_rotation": new_rotation, "delta_angle": new_rotation - old_rotation}
|
||||
|
||||
def detect_crop_change(
|
||||
self, old_crop: Tuple[float, float, float, float], new_crop: Tuple[float, float, float, float]
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Detect crop change and return change info.
|
||||
|
||||
Returns:
|
||||
Dict with change info if significant, None otherwise
|
||||
"""
|
||||
if not self.validator.crop_changed(old_crop, new_crop, threshold=0.001):
|
||||
return None
|
||||
|
||||
return {"old_crop": old_crop, "new_crop": new_crop, "delta": tuple(new_crop[i] - old_crop[i] for i in range(4))}
|
||||
@@ -16,7 +16,7 @@ class KeyboardNavigationMixin:
|
||||
def _navigate_to_next_page(self):
|
||||
"""Navigate to the next page using Page Down key"""
|
||||
main_window = self.window()
|
||||
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
|
||||
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
|
||||
return
|
||||
|
||||
current_index = main_window._get_most_visible_page_index()
|
||||
@@ -24,14 +24,14 @@ class KeyboardNavigationMixin:
|
||||
next_page = main_window.project.pages[current_index + 1]
|
||||
self._scroll_to_page(next_page, current_index + 1)
|
||||
|
||||
if hasattr(main_window, 'show_status'):
|
||||
if hasattr(main_window, "show_status"):
|
||||
page_name = main_window.project.get_page_display_name(next_page)
|
||||
main_window.show_status(f"Navigated to {page_name}", 2000)
|
||||
|
||||
def _navigate_to_previous_page(self):
|
||||
"""Navigate to the previous page using Page Up key"""
|
||||
main_window = self.window()
|
||||
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
|
||||
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
|
||||
return
|
||||
|
||||
current_index = main_window._get_most_visible_page_index()
|
||||
@@ -39,7 +39,7 @@ class KeyboardNavigationMixin:
|
||||
prev_page = main_window.project.pages[current_index - 1]
|
||||
self._scroll_to_page(prev_page, current_index - 1)
|
||||
|
||||
if hasattr(main_window, 'show_status'):
|
||||
if hasattr(main_window, "show_status"):
|
||||
page_name = main_window.project.get_page_display_name(prev_page)
|
||||
main_window.show_status(f"Navigated to {page_name}", 2000)
|
||||
|
||||
@@ -52,7 +52,7 @@ class KeyboardNavigationMixin:
|
||||
page_index: The index of the page in the project
|
||||
"""
|
||||
main_window = self.window()
|
||||
if not hasattr(main_window, 'project'):
|
||||
if not hasattr(main_window, "project"):
|
||||
return
|
||||
|
||||
dpi = main_window.project.working_dpi
|
||||
@@ -79,14 +79,14 @@ class KeyboardNavigationMixin:
|
||||
self.pan_offset[1] = target_pan_y
|
||||
|
||||
# Clamp pan offset to content bounds
|
||||
if hasattr(self, 'clamp_pan_offset'):
|
||||
if hasattr(self, "clamp_pan_offset"):
|
||||
self.clamp_pan_offset()
|
||||
|
||||
self.update()
|
||||
|
||||
# Update scrollbars if available
|
||||
main_window = self.window()
|
||||
if hasattr(main_window, 'update_scrollbars'):
|
||||
if hasattr(main_window, "update_scrollbars"):
|
||||
main_window.update_scrollbars()
|
||||
|
||||
def _move_viewport_with_arrow_keys(self, key):
|
||||
@@ -109,14 +109,14 @@ class KeyboardNavigationMixin:
|
||||
self.pan_offset[0] -= move_amount
|
||||
|
||||
# Clamp pan offset to content bounds
|
||||
if hasattr(self, 'clamp_pan_offset'):
|
||||
if hasattr(self, "clamp_pan_offset"):
|
||||
self.clamp_pan_offset()
|
||||
|
||||
self.update()
|
||||
|
||||
# Update scrollbars if available
|
||||
main_window = self.window()
|
||||
if hasattr(main_window, 'update_scrollbars'):
|
||||
if hasattr(main_window, "update_scrollbars"):
|
||||
main_window.update_scrollbars()
|
||||
|
||||
def _move_selected_elements_with_arrow_keys(self, key):
|
||||
@@ -127,7 +127,7 @@ class KeyboardNavigationMixin:
|
||||
key: The Qt key code (Up, Down, Left, Right)
|
||||
"""
|
||||
main_window = self.window()
|
||||
if not hasattr(main_window, 'project'):
|
||||
if not hasattr(main_window, "project"):
|
||||
return
|
||||
|
||||
# Movement amount in mm
|
||||
@@ -151,7 +151,7 @@ class KeyboardNavigationMixin:
|
||||
new_y = current_y + dy
|
||||
|
||||
# Apply snapping if element has a parent page
|
||||
if hasattr(element, '_parent_page') and element._parent_page:
|
||||
if hasattr(element, "_parent_page") and element._parent_page:
|
||||
page = element._parent_page
|
||||
snap_sys = page.layout.snapping_system
|
||||
page_size = page.layout.size
|
||||
@@ -162,7 +162,7 @@ class KeyboardNavigationMixin:
|
||||
size=element.size,
|
||||
page_size=page_size,
|
||||
dpi=dpi,
|
||||
project=main_window.project
|
||||
project=main_window.project,
|
||||
)
|
||||
element.position = snapped_pos
|
||||
else:
|
||||
@@ -170,7 +170,7 @@ class KeyboardNavigationMixin:
|
||||
|
||||
self.update()
|
||||
|
||||
if hasattr(main_window, 'show_status'):
|
||||
if hasattr(main_window, "show_status"):
|
||||
count = len(self.selected_elements)
|
||||
elem_text = "element" if count == 1 else "elements"
|
||||
main_window.show_status(f"Moved {count} {elem_text}", 1000)
|
||||
|
||||
@@ -2,8 +2,15 @@
|
||||
Mouse interaction mixin for GLWidget - coordinates all mouse events
|
||||
"""
|
||||
|
||||
import math
|
||||
from typing import TYPE_CHECKING, Any, Optional, Set
|
||||
|
||||
from PyQt6.QtCore import Qt
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
from PyQt6.QtGui import QCursor
|
||||
from pyPhotoAlbum.models import ImageData, BaseLayoutElement
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from PyQt6.QtWidgets import QMainWindow
|
||||
|
||||
|
||||
class MouseInteractionMixin:
|
||||
@@ -14,6 +21,39 @@ class MouseInteractionMixin:
|
||||
the current interaction state.
|
||||
"""
|
||||
|
||||
# Type declarations for attributes provided by other mixins/base classes
|
||||
selected_elements: Set[BaseLayoutElement]
|
||||
selected_element: Optional[BaseLayoutElement]
|
||||
rotation_mode: bool
|
||||
rotation_snap_angle: int
|
||||
rotation_start_angle: Optional[float]
|
||||
pan_offset: list
|
||||
zoom_level: float
|
||||
image_pan_mode: bool
|
||||
current_page_index: int
|
||||
_page_renderers: list
|
||||
resize_start_pos: Optional[Any]
|
||||
resize_start_size: Optional[Any]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
def window(self) -> "QMainWindow": ...
|
||||
def update(self) -> None: ...
|
||||
def setCursor(self, cursor: Any) -> None: ...
|
||||
def setFocus(self, *args: Any) -> None: ...
|
||||
def _begin_rotate(self, element: Any) -> None: ...
|
||||
def _begin_resize(self, element: Any) -> None: ...
|
||||
def _begin_image_pan(self, element: Any) -> None: ...
|
||||
def _begin_move(self, element: Any) -> None: ...
|
||||
def _end_interaction(self) -> None: ...
|
||||
def _resize_element(self, dx: float, dy: float) -> None: ...
|
||||
def _get_page_at(self, x: float, y: float) -> Any: ...
|
||||
def _get_element_at(self, x: float, y: float) -> Optional[BaseLayoutElement]: ...
|
||||
def _get_resize_handle_at(self, x: float, y: float) -> Optional[str]: ...
|
||||
def _check_ghost_page_click(self, x: float, y: float) -> bool: ...
|
||||
def _transfer_element_to_page(self, element: Any, source: Any, target: Any, x: float, y: float, renderer: Any) -> None: ...
|
||||
def _handle_image_pan_move(self, x: float, y: float, element: Any) -> None: ...
|
||||
def _update_page_status(self, x: float, y: float) -> None: ...
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@@ -23,208 +63,229 @@ class MouseInteractionMixin:
|
||||
self.is_dragging = False
|
||||
self.is_panning = False
|
||||
|
||||
def _handle_rotation_start(self, x: float, y: float):
|
||||
"""Start rotation interaction for selected element."""
|
||||
assert self.selected_element is not None
|
||||
self._begin_rotate(self.selected_element)
|
||||
self.drag_start_pos = (x, y)
|
||||
self.rotation_start_angle = self.selected_element.rotation
|
||||
self.is_dragging = True
|
||||
|
||||
def _handle_resize_start(self, x: float, y: float, handle):
|
||||
"""Start resize interaction for selected element."""
|
||||
assert self.selected_element is not None
|
||||
self._begin_resize(self.selected_element)
|
||||
self.resize_handle = handle
|
||||
self.drag_start_pos = (x, y)
|
||||
self.resize_start_pos = self.selected_element.position
|
||||
self.resize_start_size = self.selected_element.size
|
||||
self.is_dragging = True
|
||||
|
||||
def _handle_image_pan_start(self, x: float, y: float, element):
|
||||
"""Start image pan mode for an ImageData element."""
|
||||
self.selected_elements = {element}
|
||||
self.drag_start_pos = (x, y)
|
||||
self.image_pan_mode = True
|
||||
self.image_pan_start_crop = element.crop_info
|
||||
self._begin_image_pan(element)
|
||||
self.is_dragging = True
|
||||
self.setCursor(Qt.CursorShape.SizeAllCursor)
|
||||
|
||||
def _handle_multi_select(self, element):
|
||||
"""Toggle element in multi-selection."""
|
||||
if element in self.selected_elements:
|
||||
self.selected_elements.remove(element)
|
||||
else:
|
||||
self.selected_elements.add(element)
|
||||
|
||||
def _handle_element_drag_start(self, x: float, y: float, element):
|
||||
"""Start dragging an element."""
|
||||
self.selected_elements = {element}
|
||||
self.drag_start_pos = (x, y)
|
||||
self.drag_start_element_pos = element.position
|
||||
if not self.rotation_mode:
|
||||
self._begin_move(element)
|
||||
self.is_dragging = True
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
"""Handle mouse press events"""
|
||||
# Ensure widget has focus for keyboard events
|
||||
self.setFocus()
|
||||
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
x, y = event.position().x(), event.position().y()
|
||||
ctrl_pressed = event.modifiers() & Qt.KeyboardModifier.ControlModifier
|
||||
shift_pressed = event.modifiers() & Qt.KeyboardModifier.ShiftModifier
|
||||
|
||||
# Check if clicking on ghost page button
|
||||
if self._check_ghost_page_click(x, y):
|
||||
return
|
||||
|
||||
# Update current_page_index based on where user clicked
|
||||
page, page_index, renderer = self._get_page_at(x, y)
|
||||
if page_index >= 0:
|
||||
self.current_page_index = page_index
|
||||
|
||||
if len(self.selected_elements) == 1 and self.selected_element:
|
||||
if self.rotation_mode:
|
||||
# In rotation mode, start rotation tracking
|
||||
self._begin_rotate(self.selected_element)
|
||||
self.drag_start_pos = (x, y)
|
||||
self.rotation_start_angle = self.selected_element.rotation
|
||||
self.is_dragging = True
|
||||
return
|
||||
else:
|
||||
# In normal mode, check for resize handles
|
||||
handle = self._get_resize_handle_at(x, y)
|
||||
if handle:
|
||||
self._begin_resize(self.selected_element)
|
||||
self.resize_handle = handle
|
||||
self.drag_start_pos = (x, y)
|
||||
self.resize_start_pos = self.selected_element.position
|
||||
self.resize_start_size = self.selected_element.size
|
||||
self.is_dragging = True
|
||||
return
|
||||
|
||||
element = self._get_element_at(x, y)
|
||||
if element:
|
||||
print(f"DEBUG: Clicked on element: {element}, ctrl_pressed: {ctrl_pressed}, shift_pressed: {shift_pressed}")
|
||||
# Check if Ctrl is pressed and element is ImageData - enter image pan mode
|
||||
if ctrl_pressed and isinstance(element, ImageData) and not self.rotation_mode:
|
||||
# Enter image pan mode - pan image within frame
|
||||
self.selected_elements = {element}
|
||||
self.drag_start_pos = (x, y)
|
||||
self.image_pan_mode = True
|
||||
self.image_pan_start_crop = element.crop_info
|
||||
self._begin_image_pan(element)
|
||||
self.is_dragging = True
|
||||
self.setCursor(Qt.CursorShape.SizeAllCursor)
|
||||
print(f"Entered image pan mode for {element}")
|
||||
elif ctrl_pressed:
|
||||
# Multi-select mode (for non-ImageData elements or when Ctrl is pressed)
|
||||
print(f"DEBUG: Multi-select mode triggered")
|
||||
if element in self.selected_elements:
|
||||
print(f"DEBUG: Removing element from selection")
|
||||
self.selected_elements.remove(element)
|
||||
else:
|
||||
print(f"DEBUG: Adding element to selection. Current count: {len(self.selected_elements)}")
|
||||
self.selected_elements.add(element)
|
||||
print(f"DEBUG: Total selected elements: {len(self.selected_elements)}")
|
||||
elif shift_pressed:
|
||||
# Shift can be used for multi-select as well
|
||||
if element in self.selected_elements:
|
||||
self.selected_elements.remove(element)
|
||||
else:
|
||||
self.selected_elements.add(element)
|
||||
else:
|
||||
# Normal drag mode
|
||||
print(f"DEBUG: Normal drag mode - single selection")
|
||||
self.selected_elements = {element}
|
||||
self.drag_start_pos = (x, y)
|
||||
self.drag_start_element_pos = element.position
|
||||
if not self.rotation_mode:
|
||||
self._begin_move(element)
|
||||
self.is_dragging = True
|
||||
else:
|
||||
if not ctrl_pressed:
|
||||
self.selected_elements.clear()
|
||||
|
||||
self.update()
|
||||
|
||||
self._handle_left_click(event)
|
||||
elif event.button() == Qt.MouseButton.MiddleButton:
|
||||
self.is_panning = True
|
||||
self.drag_start_pos = (event.position().x(), event.position().y())
|
||||
self.setCursor(Qt.CursorShape.ClosedHandCursor)
|
||||
|
||||
def _handle_left_click(self, event):
|
||||
"""Handle left mouse button click."""
|
||||
x, y = event.position().x(), event.position().y()
|
||||
ctrl_pressed = event.modifiers() & Qt.KeyboardModifier.ControlModifier
|
||||
shift_pressed = event.modifiers() & Qt.KeyboardModifier.ShiftModifier
|
||||
|
||||
# Check if clicking on ghost page button
|
||||
if self._check_ghost_page_click(x, y):
|
||||
return
|
||||
|
||||
# Update current_page_index based on where user clicked
|
||||
page, page_index, renderer = self._get_page_at(x, y)
|
||||
if page_index >= 0:
|
||||
self.current_page_index = page_index
|
||||
|
||||
# Handle interaction with already-selected element
|
||||
if len(self.selected_elements) == 1 and self.selected_element:
|
||||
if self.rotation_mode:
|
||||
self._handle_rotation_start(x, y)
|
||||
return
|
||||
else:
|
||||
handle = self._get_resize_handle_at(x, y)
|
||||
if handle:
|
||||
self._handle_resize_start(x, y, handle)
|
||||
return
|
||||
|
||||
# Handle click on element
|
||||
element = self._get_element_at(x, y)
|
||||
if element:
|
||||
if ctrl_pressed and isinstance(element, ImageData) and not self.rotation_mode:
|
||||
self._handle_image_pan_start(x, y, element)
|
||||
elif ctrl_pressed or shift_pressed:
|
||||
self._handle_multi_select(element)
|
||||
else:
|
||||
self._handle_element_drag_start(x, y, element)
|
||||
else:
|
||||
if not ctrl_pressed:
|
||||
self.selected_elements.clear()
|
||||
|
||||
self.update()
|
||||
|
||||
def _handle_canvas_pan(self, x: float, y: float):
|
||||
"""Handle canvas panning with middle mouse button."""
|
||||
dx = x - self.drag_start_pos[0]
|
||||
dy = y - self.drag_start_pos[1]
|
||||
|
||||
self.pan_offset[0] += dx
|
||||
self.pan_offset[1] += dy
|
||||
|
||||
if hasattr(self, "clamp_pan_offset"):
|
||||
self.clamp_pan_offset()
|
||||
|
||||
self.drag_start_pos = (x, y)
|
||||
self.update()
|
||||
|
||||
main_window = self.window()
|
||||
if hasattr(main_window, "update_scrollbars"):
|
||||
main_window.update_scrollbars()
|
||||
|
||||
def _handle_rotation_move(self, x: float, y: float):
|
||||
"""Handle element rotation during drag."""
|
||||
if self.selected_element is None or not hasattr(self.selected_element, "_page_renderer"):
|
||||
return
|
||||
|
||||
renderer = self.selected_element._page_renderer # type: ignore[attr-defined]
|
||||
elem_x, elem_y = self.selected_element.position
|
||||
elem_w, elem_h = self.selected_element.size
|
||||
|
||||
center_page_x = elem_x + elem_w / 2
|
||||
center_page_y = elem_y + elem_h / 2
|
||||
screen_center_x, screen_center_y = renderer.page_to_screen(center_page_x, center_page_y)
|
||||
|
||||
dx = x - screen_center_x
|
||||
dy = y - screen_center_y
|
||||
angle = math.degrees(math.atan2(dy, dx))
|
||||
angle = round(angle / self.rotation_snap_angle) * self.rotation_snap_angle
|
||||
angle = angle % 360
|
||||
|
||||
self.selected_element.rotation = angle
|
||||
|
||||
main_window = self.window()
|
||||
if hasattr(main_window, "show_status"):
|
||||
main_window.show_status(f"Rotation: {angle:.1f}°", 100)
|
||||
|
||||
def _handle_resize_move(self, x: float, y: float):
|
||||
"""Handle element resize during drag."""
|
||||
screen_dx = x - self.drag_start_pos[0]
|
||||
screen_dy = y - self.drag_start_pos[1]
|
||||
|
||||
total_dx = screen_dx / self.zoom_level
|
||||
total_dy = screen_dy / self.zoom_level
|
||||
|
||||
self._resize_element(total_dx, total_dy)
|
||||
|
||||
def _handle_element_move(self, x: float, y: float):
|
||||
"""Handle element movement during drag, including page transfer."""
|
||||
assert self.selected_element is not None
|
||||
current_page, current_page_index, current_renderer = self._get_page_at(x, y)
|
||||
|
||||
if current_page and hasattr(self.selected_element, "_parent_page"):
|
||||
source_page = self.selected_element._parent_page # type: ignore[attr-defined]
|
||||
|
||||
if current_page is not source_page:
|
||||
self._transfer_element_to_page(self.selected_element, source_page, current_page, x, y, current_renderer)
|
||||
else:
|
||||
self._move_element_within_page(x, y, source_page)
|
||||
else:
|
||||
# No page context - simple move without snapping
|
||||
total_dx = (x - self.drag_start_pos[0]) / self.zoom_level
|
||||
total_dy = (y - self.drag_start_pos[1]) / self.zoom_level
|
||||
|
||||
new_x = self.drag_start_element_pos[0] + total_dx
|
||||
new_y = self.drag_start_element_pos[1] + total_dy
|
||||
|
||||
self.selected_element.position = (new_x, new_y)
|
||||
|
||||
def _move_element_within_page(self, x: float, y: float, page):
|
||||
"""Move element within its current page with snapping."""
|
||||
total_dx = (x - self.drag_start_pos[0]) / self.zoom_level
|
||||
total_dy = (y - self.drag_start_pos[1]) / self.zoom_level
|
||||
|
||||
new_x = self.drag_start_element_pos[0] + total_dx
|
||||
new_y = self.drag_start_element_pos[1] + total_dy
|
||||
|
||||
assert self.selected_element is not None
|
||||
main_window = self.window()
|
||||
snap_sys = page.layout.snapping_system
|
||||
page_size = page.layout.size
|
||||
dpi = main_window.project.working_dpi # type: ignore[attr-defined]
|
||||
|
||||
snapped_pos = snap_sys.snap_position(
|
||||
position=(new_x, new_y),
|
||||
size=self.selected_element.size,
|
||||
page_size=page_size,
|
||||
dpi=dpi,
|
||||
project=main_window.project, # type: ignore[attr-defined]
|
||||
)
|
||||
|
||||
self.selected_element.position = snapped_pos
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
"""Handle mouse move events"""
|
||||
x, y = event.position().x(), event.position().y()
|
||||
|
||||
# Update status bar with page information
|
||||
self._update_page_status(x, y)
|
||||
|
||||
# Canvas panning (middle mouse button)
|
||||
if self.is_panning and self.drag_start_pos:
|
||||
dx = x - self.drag_start_pos[0]
|
||||
dy = y - self.drag_start_pos[1]
|
||||
|
||||
self.pan_offset[0] += dx
|
||||
self.pan_offset[1] += dy
|
||||
|
||||
# Clamp pan offset to content bounds
|
||||
if hasattr(self, 'clamp_pan_offset'):
|
||||
self.clamp_pan_offset()
|
||||
|
||||
self.drag_start_pos = (x, y)
|
||||
self.update()
|
||||
|
||||
# Update scrollbars if available
|
||||
main_window = self.window()
|
||||
if hasattr(main_window, 'update_scrollbars'):
|
||||
main_window.update_scrollbars()
|
||||
|
||||
self._handle_canvas_pan(x, y)
|
||||
return
|
||||
|
||||
if not self.is_dragging or not self.drag_start_pos:
|
||||
return
|
||||
|
||||
if self.selected_element:
|
||||
if self.image_pan_mode:
|
||||
# Image pan mode - delegate to ImagePanMixin
|
||||
self._handle_image_pan_move(x, y, self.selected_element)
|
||||
if not self.selected_element:
|
||||
return
|
||||
|
||||
elif self.rotation_mode:
|
||||
# Rotation mode
|
||||
import math
|
||||
# Dispatch to appropriate handler based on interaction mode
|
||||
if self.image_pan_mode:
|
||||
self._handle_image_pan_move(x, y, self.selected_element)
|
||||
elif self.rotation_mode:
|
||||
self._handle_rotation_move(x, y)
|
||||
elif self.resize_handle:
|
||||
self._handle_resize_move(x, y)
|
||||
else:
|
||||
self._handle_element_move(x, y)
|
||||
|
||||
if not hasattr(self.selected_element, '_page_renderer'):
|
||||
return
|
||||
|
||||
renderer = self.selected_element._page_renderer
|
||||
elem_x, elem_y = self.selected_element.position
|
||||
elem_w, elem_h = self.selected_element.size
|
||||
|
||||
center_page_x = elem_x + elem_w / 2
|
||||
center_page_y = elem_y + elem_h / 2
|
||||
screen_center_x, screen_center_y = renderer.page_to_screen(center_page_x, center_page_y)
|
||||
|
||||
dx = x - screen_center_x
|
||||
dy = y - screen_center_y
|
||||
angle = math.degrees(math.atan2(dy, dx))
|
||||
|
||||
angle = round(angle / self.rotation_snap_angle) * self.rotation_snap_angle
|
||||
angle = angle % 360
|
||||
|
||||
self.selected_element.rotation = angle
|
||||
|
||||
main_window = self.window()
|
||||
if hasattr(main_window, 'show_status'):
|
||||
main_window.show_status(f"Rotation: {angle:.1f}°", 100)
|
||||
|
||||
elif self.resize_handle:
|
||||
# Resize mode
|
||||
screen_dx = x - self.drag_start_pos[0]
|
||||
screen_dy = y - self.drag_start_pos[1]
|
||||
|
||||
total_dx = screen_dx / self.zoom_level
|
||||
total_dy = screen_dy / self.zoom_level
|
||||
|
||||
self._resize_element(total_dx, total_dy)
|
||||
else:
|
||||
# Move mode
|
||||
current_page, current_page_index, current_renderer = self._get_page_at(x, y)
|
||||
|
||||
if current_page and hasattr(self.selected_element, '_parent_page'):
|
||||
source_page = self.selected_element._parent_page
|
||||
|
||||
if current_page is not source_page:
|
||||
self._transfer_element_to_page(self.selected_element, source_page, current_page, x, y, current_renderer)
|
||||
else:
|
||||
total_dx = (x - self.drag_start_pos[0]) / self.zoom_level
|
||||
total_dy = (y - self.drag_start_pos[1]) / self.zoom_level
|
||||
|
||||
new_x = self.drag_start_element_pos[0] + total_dx
|
||||
new_y = self.drag_start_element_pos[1] + total_dy
|
||||
|
||||
main_window = self.window()
|
||||
snap_sys = source_page.layout.snapping_system
|
||||
page_size = source_page.layout.size
|
||||
dpi = main_window.project.working_dpi
|
||||
|
||||
snapped_pos = snap_sys.snap_position(
|
||||
position=(new_x, new_y),
|
||||
size=self.selected_element.size,
|
||||
page_size=page_size,
|
||||
dpi=dpi,
|
||||
project=main_window.project
|
||||
)
|
||||
|
||||
self.selected_element.position = snapped_pos
|
||||
else:
|
||||
total_dx = (x - self.drag_start_pos[0]) / self.zoom_level
|
||||
total_dy = (y - self.drag_start_pos[1]) / self.zoom_level
|
||||
|
||||
new_x = self.drag_start_element_pos[0] + total_dx
|
||||
new_y = self.drag_start_element_pos[1] + total_dy
|
||||
|
||||
self.selected_element.position = (new_x, new_y)
|
||||
|
||||
self.update()
|
||||
self.update()
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
"""Handle mouse release events"""
|
||||
@@ -238,11 +299,7 @@ class MouseInteractionMixin:
|
||||
self.rotation_start_angle = None
|
||||
self.image_pan_mode = False
|
||||
self.image_pan_start_crop = None
|
||||
self.snap_state = {
|
||||
'is_snapped': False,
|
||||
'last_position': None,
|
||||
'last_size': None
|
||||
}
|
||||
self.snap_state = {"is_snapped": False, "last_position": None, "last_size": None}
|
||||
self.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
|
||||
elif event.button() == Qt.MouseButton.MiddleButton:
|
||||
@@ -257,6 +314,7 @@ class MouseInteractionMixin:
|
||||
element = self._get_element_at(x, y)
|
||||
|
||||
from pyPhotoAlbum.models import TextBoxData
|
||||
|
||||
if isinstance(element, TextBoxData):
|
||||
self._edit_text_element(element)
|
||||
return
|
||||
@@ -280,38 +338,63 @@ class MouseInteractionMixin:
|
||||
new_zoom = self.zoom_level * zoom_factor
|
||||
|
||||
if 0.1 <= new_zoom <= 5.0:
|
||||
old_zoom = self.zoom_level
|
||||
old_pan_x = self.pan_offset[0]
|
||||
old_pan_y = self.pan_offset[1]
|
||||
|
||||
self.zoom_level = new_zoom
|
||||
|
||||
self.pan_offset[0] = mouse_x - world_x * self.zoom_level
|
||||
self.pan_offset[1] = mouse_y - world_y * self.zoom_level
|
||||
|
||||
# If dragging, adjust drag_start_pos to account for pan_offset change
|
||||
if self.is_dragging and self.drag_start_pos:
|
||||
pan_delta_x = self.pan_offset[0] - old_pan_x
|
||||
pan_delta_y = self.pan_offset[1] - old_pan_y
|
||||
self.drag_start_pos = (self.drag_start_pos[0] + pan_delta_x, self.drag_start_pos[1] + pan_delta_y)
|
||||
|
||||
# Clamp pan offset to content bounds
|
||||
if hasattr(self, 'clamp_pan_offset'):
|
||||
if hasattr(self, "clamp_pan_offset"):
|
||||
self.clamp_pan_offset()
|
||||
|
||||
self.update()
|
||||
|
||||
main_window = self.window()
|
||||
if hasattr(main_window, 'status_bar'):
|
||||
if hasattr(main_window, "status_bar"):
|
||||
main_window.status_bar.showMessage(f"Zoom: {int(self.zoom_level * 100)}%", 2000)
|
||||
|
||||
# Update scrollbars if available
|
||||
if hasattr(main_window, 'update_scrollbars'):
|
||||
if hasattr(main_window, "update_scrollbars"):
|
||||
main_window.update_scrollbars()
|
||||
else:
|
||||
# Regular wheel: Vertical scroll
|
||||
scroll_amount = delta * 0.5
|
||||
self.pan_offset[1] += scroll_amount
|
||||
# Regular wheel: Two-finger scroll (vertical and horizontal)
|
||||
delta_x = event.angleDelta().x()
|
||||
delta_y = event.angleDelta().y()
|
||||
|
||||
scroll_amount_x = delta_x * 0.5
|
||||
scroll_amount_y = delta_y * 0.5
|
||||
|
||||
old_pan_x = self.pan_offset[0]
|
||||
old_pan_y = self.pan_offset[1]
|
||||
|
||||
self.pan_offset[0] += scroll_amount_x
|
||||
self.pan_offset[1] += scroll_amount_y
|
||||
|
||||
# Clamp pan offset to content bounds
|
||||
if hasattr(self, 'clamp_pan_offset'):
|
||||
if hasattr(self, "clamp_pan_offset"):
|
||||
self.clamp_pan_offset()
|
||||
|
||||
# If dragging, adjust drag_start_pos to account for pan_offset change
|
||||
if self.is_dragging and self.drag_start_pos:
|
||||
pan_delta_x = self.pan_offset[0] - old_pan_x
|
||||
pan_delta_y = self.pan_offset[1] - old_pan_y
|
||||
self.drag_start_pos = (self.drag_start_pos[0] + pan_delta_x, self.drag_start_pos[1] + pan_delta_y)
|
||||
|
||||
self.update()
|
||||
|
||||
# Update scrollbars if available
|
||||
main_window = self.window()
|
||||
if hasattr(main_window, 'update_scrollbars'):
|
||||
if hasattr(main_window, "update_scrollbars"):
|
||||
main_window.update_scrollbars()
|
||||
|
||||
def _edit_text_element(self, text_element):
|
||||
@@ -322,9 +405,9 @@ class MouseInteractionMixin:
|
||||
if dialog.exec() == TextEditDialog.DialogCode.Accepted:
|
||||
values = dialog.get_values()
|
||||
|
||||
text_element.text_content = values['text_content']
|
||||
text_element.font_settings = values['font_settings']
|
||||
text_element.alignment = values['alignment']
|
||||
text_element.text_content = values["text_content"]
|
||||
text_element.font_settings = values["font_settings"]
|
||||
text_element.alignment = values["alignment"]
|
||||
|
||||
self.update()
|
||||
|
||||
|
||||
@@ -12,16 +12,20 @@ from pyPhotoAlbum.mixins.operations.alignment_ops import AlignmentOperationsMixi
|
||||
from pyPhotoAlbum.mixins.operations.distribution_ops import DistributionOperationsMixin
|
||||
from pyPhotoAlbum.mixins.operations.size_ops import SizeOperationsMixin
|
||||
from pyPhotoAlbum.mixins.operations.zorder_ops import ZOrderOperationsMixin
|
||||
from pyPhotoAlbum.mixins.operations.merge_ops import MergeOperationsMixin
|
||||
from pyPhotoAlbum.mixins.operations.style_ops import StyleOperationsMixin
|
||||
|
||||
__all__ = [
|
||||
'FileOperationsMixin',
|
||||
'EditOperationsMixin',
|
||||
'ElementOperationsMixin',
|
||||
'PageOperationsMixin',
|
||||
'TemplateOperationsMixin',
|
||||
'ViewOperationsMixin',
|
||||
'AlignmentOperationsMixin',
|
||||
'DistributionOperationsMixin',
|
||||
'SizeOperationsMixin',
|
||||
'ZOrderOperationsMixin',
|
||||
"FileOperationsMixin",
|
||||
"EditOperationsMixin",
|
||||
"ElementOperationsMixin",
|
||||
"PageOperationsMixin",
|
||||
"TemplateOperationsMixin",
|
||||
"ViewOperationsMixin",
|
||||
"AlignmentOperationsMixin",
|
||||
"DistributionOperationsMixin",
|
||||
"SizeOperationsMixin",
|
||||
"ZOrderOperationsMixin",
|
||||
"MergeOperationsMixin",
|
||||
"StyleOperationsMixin",
|
||||
]
|
||||
|
||||
@@ -9,136 +9,101 @@ from pyPhotoAlbum.commands import AlignElementsCommand, ResizeElementsCommand
|
||||
|
||||
class AlignmentOperationsMixin:
|
||||
"""Mixin providing element alignment operations"""
|
||||
|
||||
|
||||
def _get_selected_elements_list(self):
|
||||
"""Get list of selected elements for alignment operations"""
|
||||
return list(self.gl_widget.selected_elements) if self.gl_widget.selected_elements else []
|
||||
|
||||
|
||||
def _execute_alignment(self, alignment_func, status_msg: str):
|
||||
"""
|
||||
Execute an alignment operation with common boilerplate.
|
||||
|
||||
Args:
|
||||
alignment_func: AlignmentManager method to call with elements
|
||||
status_msg: Status message format string (will receive element count)
|
||||
"""
|
||||
elements = self._get_selected_elements_list()
|
||||
if not self.require_selection(min_count=2): # type: ignore[attr-defined]
|
||||
return
|
||||
|
||||
changes = alignment_func(elements)
|
||||
if changes:
|
||||
cmd = AlignElementsCommand(changes)
|
||||
self.project.history.execute(cmd) # type: ignore[attr-defined]
|
||||
self.update_view() # type: ignore[attr-defined]
|
||||
self.show_status(status_msg.format(len(elements)), 2000) # type: ignore[attr-defined]
|
||||
|
||||
@ribbon_action(
|
||||
label="Align Left",
|
||||
tooltip="Align selected elements to the left",
|
||||
tab="Arrange",
|
||||
group="Align",
|
||||
requires_selection=True,
|
||||
min_selection=2
|
||||
min_selection=2,
|
||||
)
|
||||
def align_left(self):
|
||||
"""Align selected elements to the left"""
|
||||
elements = self._get_selected_elements_list()
|
||||
if not self.require_selection(min_count=2):
|
||||
return
|
||||
|
||||
changes = AlignmentManager.align_left(elements)
|
||||
if changes:
|
||||
cmd = AlignElementsCommand(changes)
|
||||
self.project.history.execute(cmd)
|
||||
self.update_view()
|
||||
self.show_status(f"Aligned {len(elements)} elements to left", 2000)
|
||||
|
||||
self._execute_alignment(AlignmentManager.align_left, "Aligned {} elements to left")
|
||||
|
||||
@ribbon_action(
|
||||
label="Align Right",
|
||||
tooltip="Align selected elements to the right",
|
||||
tab="Arrange",
|
||||
group="Align",
|
||||
requires_selection=True,
|
||||
min_selection=2
|
||||
min_selection=2,
|
||||
)
|
||||
def align_right(self):
|
||||
"""Align selected elements to the right"""
|
||||
elements = self._get_selected_elements_list()
|
||||
if not self.require_selection(min_count=2):
|
||||
return
|
||||
|
||||
changes = AlignmentManager.align_right(elements)
|
||||
if changes:
|
||||
cmd = AlignElementsCommand(changes)
|
||||
self.project.history.execute(cmd)
|
||||
self.update_view()
|
||||
self.show_status(f"Aligned {len(elements)} elements to right", 2000)
|
||||
|
||||
self._execute_alignment(AlignmentManager.align_right, "Aligned {} elements to right")
|
||||
|
||||
@ribbon_action(
|
||||
label="Align Top",
|
||||
tooltip="Align selected elements to the top",
|
||||
tab="Arrange",
|
||||
group="Align",
|
||||
requires_selection=True,
|
||||
min_selection=2
|
||||
min_selection=2,
|
||||
)
|
||||
def align_top(self):
|
||||
"""Align selected elements to the top"""
|
||||
elements = self._get_selected_elements_list()
|
||||
if not self.require_selection(min_count=2):
|
||||
return
|
||||
|
||||
changes = AlignmentManager.align_top(elements)
|
||||
if changes:
|
||||
cmd = AlignElementsCommand(changes)
|
||||
self.project.history.execute(cmd)
|
||||
self.update_view()
|
||||
self.show_status(f"Aligned {len(elements)} elements to top", 2000)
|
||||
|
||||
self._execute_alignment(AlignmentManager.align_top, "Aligned {} elements to top")
|
||||
|
||||
@ribbon_action(
|
||||
label="Align Bottom",
|
||||
tooltip="Align selected elements to the bottom",
|
||||
tab="Arrange",
|
||||
group="Align",
|
||||
requires_selection=True,
|
||||
min_selection=2
|
||||
min_selection=2,
|
||||
)
|
||||
def align_bottom(self):
|
||||
"""Align selected elements to the bottom"""
|
||||
elements = self._get_selected_elements_list()
|
||||
if not self.require_selection(min_count=2):
|
||||
return
|
||||
|
||||
changes = AlignmentManager.align_bottom(elements)
|
||||
if changes:
|
||||
cmd = AlignElementsCommand(changes)
|
||||
self.project.history.execute(cmd)
|
||||
self.update_view()
|
||||
self.show_status(f"Aligned {len(elements)} elements to bottom", 2000)
|
||||
|
||||
self._execute_alignment(AlignmentManager.align_bottom, "Aligned {} elements to bottom")
|
||||
|
||||
@ribbon_action(
|
||||
label="Align H-Center",
|
||||
tooltip="Align selected elements to horizontal center",
|
||||
tab="Arrange",
|
||||
group="Align",
|
||||
requires_selection=True,
|
||||
min_selection=2
|
||||
min_selection=2,
|
||||
)
|
||||
def align_horizontal_center(self):
|
||||
"""Align selected elements to horizontal center"""
|
||||
elements = self._get_selected_elements_list()
|
||||
if not self.require_selection(min_count=2):
|
||||
return
|
||||
|
||||
changes = AlignmentManager.align_horizontal_center(elements)
|
||||
if changes:
|
||||
cmd = AlignElementsCommand(changes)
|
||||
self.project.history.execute(cmd)
|
||||
self.update_view()
|
||||
self.show_status(f"Aligned {len(elements)} elements to horizontal center", 2000)
|
||||
|
||||
self._execute_alignment(AlignmentManager.align_horizontal_center, "Aligned {} elements to horizontal center")
|
||||
|
||||
@ribbon_action(
|
||||
label="Align V-Center",
|
||||
tooltip="Align selected elements to vertical center",
|
||||
tab="Arrange",
|
||||
group="Align",
|
||||
requires_selection=True,
|
||||
min_selection=2
|
||||
min_selection=2,
|
||||
)
|
||||
def align_vertical_center(self):
|
||||
"""Align selected elements to vertical center"""
|
||||
elements = self._get_selected_elements_list()
|
||||
if not self.require_selection(min_count=2):
|
||||
return
|
||||
|
||||
changes = AlignmentManager.align_vertical_center(elements)
|
||||
if changes:
|
||||
cmd = AlignElementsCommand(changes)
|
||||
self.project.history.execute(cmd)
|
||||
self.update_view()
|
||||
self.show_status(f"Aligned {len(elements)} elements to vertical center", 2000)
|
||||
self._execute_alignment(AlignmentManager.align_vertical_center, "Aligned {} elements to vertical center")
|
||||
|
||||
@ribbon_action(
|
||||
label="Maximize Pattern",
|
||||
@@ -146,7 +111,7 @@ class AlignmentOperationsMixin:
|
||||
tab="Arrange",
|
||||
group="Size",
|
||||
requires_selection=True,
|
||||
min_selection=1
|
||||
min_selection=1,
|
||||
)
|
||||
def maximize_pattern(self):
|
||||
"""Maximize selected elements until they are close to borders or each other"""
|
||||
|
||||
@@ -9,91 +9,74 @@ from pyPhotoAlbum.commands import AlignElementsCommand
|
||||
|
||||
class DistributionOperationsMixin:
|
||||
"""Mixin providing element distribution and spacing operations"""
|
||||
|
||||
|
||||
def _get_selected_elements_list(self):
|
||||
"""Get list of selected elements for distribution operations"""
|
||||
return list(self.gl_widget.selected_elements) if self.gl_widget.selected_elements else []
|
||||
|
||||
|
||||
def _execute_distribution(self, distribution_func, status_msg: str):
|
||||
"""
|
||||
Execute a distribution operation with common boilerplate.
|
||||
|
||||
Args:
|
||||
distribution_func: AlignmentManager method to call with elements
|
||||
status_msg: Status message format string (will receive element count)
|
||||
"""
|
||||
elements = self._get_selected_elements_list()
|
||||
if not self.require_selection(min_count=3): # type: ignore[attr-defined]
|
||||
return
|
||||
|
||||
changes = distribution_func(elements)
|
||||
if changes:
|
||||
cmd = AlignElementsCommand(changes)
|
||||
self.project.history.execute(cmd) # type: ignore[attr-defined]
|
||||
self.update_view() # type: ignore[attr-defined]
|
||||
self.show_status(status_msg.format(len(elements)), 2000) # type: ignore[attr-defined]
|
||||
|
||||
@ribbon_action(
|
||||
label="Distribute H",
|
||||
tooltip="Distribute selected elements evenly horizontally",
|
||||
tab="Arrange",
|
||||
group="Distribute",
|
||||
requires_selection=True,
|
||||
min_selection=3
|
||||
min_selection=3,
|
||||
)
|
||||
def distribute_horizontally(self):
|
||||
"""Distribute selected elements evenly horizontally"""
|
||||
elements = self._get_selected_elements_list()
|
||||
if not self.require_selection(min_count=3):
|
||||
return
|
||||
|
||||
changes = AlignmentManager.distribute_horizontally(elements)
|
||||
if changes:
|
||||
cmd = AlignElementsCommand(changes)
|
||||
self.project.history.execute(cmd)
|
||||
self.update_view()
|
||||
self.show_status(f"Distributed {len(elements)} elements horizontally", 2000)
|
||||
|
||||
self._execute_distribution(AlignmentManager.distribute_horizontally, "Distributed {} elements horizontally")
|
||||
|
||||
@ribbon_action(
|
||||
label="Distribute V",
|
||||
tooltip="Distribute selected elements evenly vertically",
|
||||
tab="Arrange",
|
||||
group="Distribute",
|
||||
requires_selection=True,
|
||||
min_selection=3
|
||||
min_selection=3,
|
||||
)
|
||||
def distribute_vertically(self):
|
||||
"""Distribute selected elements evenly vertically"""
|
||||
elements = self._get_selected_elements_list()
|
||||
if not self.require_selection(min_count=3):
|
||||
return
|
||||
|
||||
changes = AlignmentManager.distribute_vertically(elements)
|
||||
if changes:
|
||||
cmd = AlignElementsCommand(changes)
|
||||
self.project.history.execute(cmd)
|
||||
self.update_view()
|
||||
self.show_status(f"Distributed {len(elements)} elements vertically", 2000)
|
||||
|
||||
self._execute_distribution(AlignmentManager.distribute_vertically, "Distributed {} elements vertically")
|
||||
|
||||
@ribbon_action(
|
||||
label="Space H",
|
||||
tooltip="Space selected elements equally horizontally",
|
||||
tab="Arrange",
|
||||
group="Distribute",
|
||||
requires_selection=True,
|
||||
min_selection=3
|
||||
min_selection=3,
|
||||
)
|
||||
def space_horizontally(self):
|
||||
"""Space selected elements equally horizontally"""
|
||||
elements = self._get_selected_elements_list()
|
||||
if not self.require_selection(min_count=3):
|
||||
return
|
||||
|
||||
changes = AlignmentManager.space_horizontally(elements)
|
||||
if changes:
|
||||
cmd = AlignElementsCommand(changes)
|
||||
self.project.history.execute(cmd)
|
||||
self.update_view()
|
||||
self.show_status(f"Spaced {len(elements)} elements horizontally", 2000)
|
||||
|
||||
self._execute_distribution(AlignmentManager.space_horizontally, "Spaced {} elements horizontally")
|
||||
|
||||
@ribbon_action(
|
||||
label="Space V",
|
||||
tooltip="Space selected elements equally vertically",
|
||||
tab="Arrange",
|
||||
group="Distribute",
|
||||
requires_selection=True,
|
||||
min_selection=3
|
||||
min_selection=3,
|
||||
)
|
||||
def space_vertically(self):
|
||||
"""Space selected elements equally vertically"""
|
||||
elements = self._get_selected_elements_list()
|
||||
if not self.require_selection(min_count=3):
|
||||
return
|
||||
|
||||
changes = AlignmentManager.space_vertically(elements)
|
||||
if changes:
|
||||
cmd = AlignElementsCommand(changes)
|
||||
self.project.history.execute(cmd)
|
||||
self.update_view()
|
||||
self.show_status(f"Spaced {len(elements)} elements vertically", 2000)
|
||||
self._execute_distribution(AlignmentManager.space_vertically, "Spaced {} elements vertically")
|
||||
|
||||
@@ -8,14 +8,8 @@ from pyPhotoAlbum.commands import DeleteElementCommand, RotateElementCommand
|
||||
|
||||
class EditOperationsMixin:
|
||||
"""Mixin providing edit-related operations"""
|
||||
|
||||
@ribbon_action(
|
||||
label="Undo",
|
||||
tooltip="Undo last action (Ctrl+Z)",
|
||||
tab="Home",
|
||||
group="Edit",
|
||||
shortcut="Ctrl+Z"
|
||||
)
|
||||
|
||||
@ribbon_action(label="Undo", tooltip="Undo last action (Ctrl+Z)", tab="Home", group="Edit", shortcut="Ctrl+Z")
|
||||
def undo(self):
|
||||
"""Undo last action"""
|
||||
if self.project.history.undo():
|
||||
@@ -25,13 +19,9 @@ class EditOperationsMixin:
|
||||
else:
|
||||
self.show_status("Nothing to undo", 2000)
|
||||
print("Nothing to undo")
|
||||
|
||||
|
||||
@ribbon_action(
|
||||
label="Redo",
|
||||
tooltip="Redo last action (Ctrl+Y or Ctrl+Shift+Z)",
|
||||
tab="Home",
|
||||
group="Edit",
|
||||
shortcut="Ctrl+Y"
|
||||
label="Redo", tooltip="Redo last action (Ctrl+Y or Ctrl+Shift+Z)", tab="Home", group="Edit", shortcut="Ctrl+Y"
|
||||
)
|
||||
def redo(self):
|
||||
"""Redo last action"""
|
||||
@@ -42,117 +32,113 @@ class EditOperationsMixin:
|
||||
else:
|
||||
self.show_status("Nothing to redo", 2000)
|
||||
print("Nothing to redo")
|
||||
|
||||
|
||||
@ribbon_action(
|
||||
label="Delete",
|
||||
tooltip="Delete selected element (Delete key)",
|
||||
tab="Home",
|
||||
group="Edit",
|
||||
shortcut="Delete",
|
||||
requires_selection=True
|
||||
requires_selection=True,
|
||||
)
|
||||
def delete_selected_element(self):
|
||||
"""Delete the currently selected element"""
|
||||
if not self.require_selection(min_count=1):
|
||||
return
|
||||
|
||||
|
||||
current_page = self.get_current_page()
|
||||
if not current_page:
|
||||
return
|
||||
|
||||
|
||||
# Delete the first selected element (for backward compatibility)
|
||||
# In the future, we could delete all selected elements
|
||||
selected_element = next(iter(self.gl_widget.selected_elements))
|
||||
|
||||
|
||||
try:
|
||||
cmd = DeleteElementCommand(
|
||||
current_page.layout,
|
||||
selected_element,
|
||||
asset_manager=self.project.asset_manager
|
||||
)
|
||||
cmd = DeleteElementCommand(current_page.layout, selected_element, asset_manager=self.project.asset_manager)
|
||||
self.project.history.execute(cmd)
|
||||
|
||||
|
||||
# Clear selection
|
||||
self.gl_widget.selected_elements.clear()
|
||||
|
||||
|
||||
# Update display
|
||||
self.update_view()
|
||||
|
||||
|
||||
self.show_status("Element deleted (Ctrl+Z to undo)", 2000)
|
||||
print("Deleted selected element")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
self.show_error("Error", f"Failed to delete element: {str(e)}")
|
||||
print(f"Error deleting element: {e}")
|
||||
|
||||
|
||||
@ribbon_action(
|
||||
label="Rotate Left",
|
||||
tooltip="Rotate selected element 90° counter-clockwise",
|
||||
tab="Home",
|
||||
tab="Arrange",
|
||||
group="Transform",
|
||||
requires_selection=True
|
||||
requires_selection=True,
|
||||
)
|
||||
def rotate_left(self):
|
||||
"""Rotate selected element 90 degrees counter-clockwise"""
|
||||
if not self.require_selection(min_count=1):
|
||||
return
|
||||
|
||||
|
||||
selected_element = next(iter(self.gl_widget.selected_elements))
|
||||
old_rotation = selected_element.rotation
|
||||
new_rotation = (old_rotation - 90) % 360
|
||||
|
||||
|
||||
cmd = RotateElementCommand(selected_element, old_rotation, new_rotation)
|
||||
self.project.history.execute(cmd)
|
||||
|
||||
self.update_view()
|
||||
self.show_status(f"Rotated left (Ctrl+Z to undo)", 2000)
|
||||
print(f"Rotated element left: {old_rotation}° → {new_rotation}°")
|
||||
|
||||
|
||||
@ribbon_action(
|
||||
label="Rotate Right",
|
||||
tooltip="Rotate selected element 90° clockwise",
|
||||
tab="Home",
|
||||
tab="Arrange",
|
||||
group="Transform",
|
||||
requires_selection=True
|
||||
requires_selection=True,
|
||||
)
|
||||
def rotate_right(self):
|
||||
"""Rotate selected element 90 degrees clockwise"""
|
||||
if not self.require_selection(min_count=1):
|
||||
return
|
||||
|
||||
|
||||
selected_element = next(iter(self.gl_widget.selected_elements))
|
||||
old_rotation = selected_element.rotation
|
||||
new_rotation = (old_rotation + 90) % 360
|
||||
|
||||
|
||||
cmd = RotateElementCommand(selected_element, old_rotation, new_rotation)
|
||||
self.project.history.execute(cmd)
|
||||
|
||||
self.update_view()
|
||||
self.show_status(f"Rotated right (Ctrl+Z to undo)", 2000)
|
||||
print(f"Rotated element right: {old_rotation}° → {new_rotation}°")
|
||||
|
||||
|
||||
@ribbon_action(
|
||||
label="Reset Rotation",
|
||||
tooltip="Reset selected element rotation to 0°",
|
||||
tab="Home",
|
||||
tab="Arrange",
|
||||
group="Transform",
|
||||
requires_selection=True
|
||||
requires_selection=True,
|
||||
)
|
||||
def reset_rotation(self):
|
||||
"""Reset selected element rotation to 0 degrees"""
|
||||
if not self.require_selection(min_count=1):
|
||||
return
|
||||
|
||||
|
||||
selected_element = next(iter(self.gl_widget.selected_elements))
|
||||
old_rotation = selected_element.rotation
|
||||
|
||||
|
||||
if old_rotation == 0:
|
||||
self.show_status("Element already at 0°", 2000)
|
||||
return
|
||||
|
||||
|
||||
cmd = RotateElementCommand(selected_element, old_rotation, 0)
|
||||
self.project.history.execute(cmd)
|
||||
|
||||
|
||||
self.update_view()
|
||||
self.show_status("Reset rotation to 0° (Ctrl+Z to undo)", 2000)
|
||||
print(f"Reset element rotation: {old_rotation}° → 0°")
|
||||
|
||||
@@ -3,164 +3,131 @@ Element operations mixin for pyPhotoAlbum
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import QFileDialog
|
||||
from PIL import Image
|
||||
from pyPhotoAlbum.decorators import ribbon_action
|
||||
from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
|
||||
from pyPhotoAlbum.commands import AddElementCommand
|
||||
from pyPhotoAlbum.async_backend import get_image_dimensions
|
||||
|
||||
|
||||
class ElementOperationsMixin:
|
||||
"""Mixin providing element creation and manipulation operations"""
|
||||
|
||||
|
||||
@ribbon_action(
|
||||
label="Image",
|
||||
tooltip="Add an image to the current page",
|
||||
tab="Insert",
|
||||
group="Media",
|
||||
requires_page=True
|
||||
label="Image", tooltip="Add an image to the current page", tab="Insert", group="Media", requires_page=True
|
||||
)
|
||||
def add_image(self):
|
||||
"""Add an image to the current page"""
|
||||
if not self.require_page():
|
||||
return
|
||||
|
||||
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
"Select Image",
|
||||
"",
|
||||
"Image Files (*.jpg *.jpeg *.png *.gif *.bmp *.tiff *.webp);;All Files (*)"
|
||||
self, "Select Image", "", "Image Files (*.jpg *.jpeg *.png *.gif *.bmp *.tiff *.webp);;All Files (*)"
|
||||
)
|
||||
|
||||
|
||||
if not file_path:
|
||||
return
|
||||
|
||||
|
||||
current_page = self.get_current_page()
|
||||
if not current_page:
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
# Import asset to project
|
||||
asset_path = self.project.asset_manager.import_asset(file_path)
|
||||
|
||||
# Load image to get dimensions
|
||||
img = Image.open(file_path)
|
||||
img_width, img_height = img.size
|
||||
|
||||
# Scale to reasonable size (max 300px)
|
||||
max_size = 300
|
||||
if img_width > max_size or img_height > max_size:
|
||||
scale = min(max_size / img_width, max_size / img_height)
|
||||
img_width = int(img_width * scale)
|
||||
img_height = int(img_height * scale)
|
||||
|
||||
|
||||
# Get dimensions using centralized utility (max 300px for UI display)
|
||||
full_asset_path = self.get_asset_full_path(asset_path)
|
||||
dimensions = get_image_dimensions(full_asset_path, max_size=300)
|
||||
if dimensions:
|
||||
img_width, img_height = dimensions
|
||||
else:
|
||||
# Fallback dimensions if image cannot be read
|
||||
img_width, img_height = 200, 150
|
||||
|
||||
# Create image element at center of page
|
||||
page_width_mm = current_page.layout.size[0]
|
||||
page_height_mm = current_page.layout.size[1]
|
||||
|
||||
|
||||
# Center position
|
||||
x = (page_width_mm - img_width) / 2
|
||||
y = (page_height_mm - img_height) / 2
|
||||
|
||||
new_image = ImageData(
|
||||
image_path=asset_path,
|
||||
x=x,
|
||||
y=y,
|
||||
width=img_width,
|
||||
height=img_height
|
||||
)
|
||||
|
||||
|
||||
new_image = ImageData(image_path=asset_path, x=x, y=y, width=img_width, height=img_height)
|
||||
|
||||
# Add element using command pattern for undo/redo
|
||||
cmd = AddElementCommand(
|
||||
current_page.layout,
|
||||
new_image,
|
||||
asset_manager=self.project.asset_manager
|
||||
)
|
||||
cmd = AddElementCommand(current_page.layout, new_image, asset_manager=self.project.asset_manager)
|
||||
self.project.history.execute(cmd)
|
||||
|
||||
|
||||
self.update_view()
|
||||
self.show_status("Added image (Ctrl+Z to undo)", 2000)
|
||||
print(f"Added image to page {self.get_current_page_index() + 1}: {asset_path}")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
self.show_error("Error", f"Failed to add image: {str(e)}")
|
||||
print(f"Error adding image: {e}")
|
||||
|
||||
|
||||
@ribbon_action(
|
||||
label="Text",
|
||||
tooltip="Add a text box to the current page",
|
||||
tab="Insert",
|
||||
group="Media",
|
||||
requires_page=True
|
||||
label="Text", tooltip="Add a text box to the current page", tab="Insert", group="Media", requires_page=True
|
||||
)
|
||||
def add_text(self):
|
||||
"""Add text to the current page"""
|
||||
if not self.require_page():
|
||||
return
|
||||
|
||||
|
||||
current_page = self.get_current_page()
|
||||
if not current_page:
|
||||
return
|
||||
|
||||
|
||||
# Create text box element at center of page
|
||||
page_width_mm = current_page.layout.size[0]
|
||||
page_height_mm = current_page.layout.size[1]
|
||||
|
||||
|
||||
text_width = 200
|
||||
text_height = 50
|
||||
|
||||
|
||||
# Center position
|
||||
x = (page_width_mm - text_width) / 2
|
||||
y = (page_height_mm - text_height) / 2
|
||||
|
||||
new_text = TextBoxData(
|
||||
text_content="New Text",
|
||||
x=x,
|
||||
y=y,
|
||||
width=text_width,
|
||||
height=text_height
|
||||
)
|
||||
|
||||
|
||||
new_text = TextBoxData(text_content="New Text", x=x, y=y, width=text_width, height=text_height)
|
||||
|
||||
current_page.layout.add_element(new_text)
|
||||
self.update_view()
|
||||
|
||||
|
||||
print(f"Added text box to page {self.get_current_page_index() + 1}")
|
||||
|
||||
|
||||
@ribbon_action(
|
||||
label="Placeholder",
|
||||
tooltip="Add a placeholder to the current page",
|
||||
tab="Insert",
|
||||
group="Media",
|
||||
requires_page=True
|
||||
requires_page=True,
|
||||
)
|
||||
def add_placeholder(self):
|
||||
"""Add a placeholder to the current page"""
|
||||
if not self.require_page():
|
||||
return
|
||||
|
||||
|
||||
current_page = self.get_current_page()
|
||||
if not current_page:
|
||||
return
|
||||
|
||||
|
||||
# Create placeholder element at center of page
|
||||
page_width_mm = current_page.layout.size[0]
|
||||
page_height_mm = current_page.layout.size[1]
|
||||
|
||||
|
||||
placeholder_width = 200
|
||||
placeholder_height = 150
|
||||
|
||||
|
||||
# Center position
|
||||
x = (page_width_mm - placeholder_width) / 2
|
||||
y = (page_height_mm - placeholder_height) / 2
|
||||
|
||||
|
||||
new_placeholder = PlaceholderData(
|
||||
placeholder_type="image",
|
||||
x=x,
|
||||
y=y,
|
||||
width=placeholder_width,
|
||||
height=placeholder_height
|
||||
placeholder_type="image", x=x, y=y, width=placeholder_width, height=placeholder_height
|
||||
)
|
||||
|
||||
|
||||
current_page.layout.add_element(new_placeholder)
|
||||
self.update_view()
|
||||
|
||||
|
||||
print(f"Added placeholder to page {self.get_current_page_index() + 1}")
|
||||
|
||||
@@ -2,40 +2,74 @@
|
||||
File operations mixin for pyPhotoAlbum
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Optional, cast
|
||||
|
||||
from PyQt6.QtCore import QObject, pyqtSignal
|
||||
from PyQt6.QtWidgets import (
|
||||
QFileDialog, QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox, QRadioButton,
|
||||
QButtonGroup, QLineEdit, QTextEdit
|
||||
QFileDialog,
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QDoubleSpinBox,
|
||||
QSpinBox,
|
||||
QPushButton,
|
||||
QGroupBox,
|
||||
QRadioButton,
|
||||
QButtonGroup,
|
||||
QLineEdit,
|
||||
QTextEdit,
|
||||
QWidget,
|
||||
QMessageBox,
|
||||
)
|
||||
from pyPhotoAlbum.decorators import ribbon_action, numerical_input
|
||||
from pyPhotoAlbum.project import Project, Page
|
||||
from pyPhotoAlbum.async_project_loader import AsyncProjectLoader
|
||||
from pyPhotoAlbum.loading_widget import LoadingWidget
|
||||
from pyPhotoAlbum.project_serializer import save_to_zip
|
||||
from pyPhotoAlbum.project_serializer import save_to_zip, save_to_zip_async
|
||||
from pyPhotoAlbum.models import set_asset_resolution_context
|
||||
from pyPhotoAlbum.version_manager import format_version_info, CURRENT_DATA_VERSION
|
||||
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||
|
||||
|
||||
class _SaveBridge(QObject):
|
||||
"""Thread-safe signal bridge for async save callbacks.
|
||||
|
||||
Signals can be safely emitted from any thread; connected slots run on
|
||||
the main (GUI) thread via Qt's queued connection.
|
||||
"""
|
||||
|
||||
progress = pyqtSignal(int, str)
|
||||
finished = pyqtSignal(bool, str)
|
||||
|
||||
|
||||
class FileOperationsMixin:
|
||||
"""Mixin providing file-related operations"""
|
||||
|
||||
@ribbon_action(
|
||||
label="New",
|
||||
tooltip="Create a new project",
|
||||
tab="Home",
|
||||
group="File",
|
||||
shortcut="Ctrl+N"
|
||||
)
|
||||
|
||||
# Type hints for expected attributes from mixing class
|
||||
def show_status(self, message: str, timeout: int = 0) -> None:
|
||||
"""Expected from ApplicationStateMixin"""
|
||||
...
|
||||
|
||||
def show_error(self, title: str, message: str) -> None:
|
||||
"""Expected from ApplicationStateMixin"""
|
||||
...
|
||||
|
||||
def resolve_asset_path(self, path: str) -> Optional[str]:
|
||||
"""Expected from asset path mixin"""
|
||||
...
|
||||
|
||||
@ribbon_action(label="New", tooltip="Create a new project", tab="Home", group="File", shortcut="Ctrl+N")
|
||||
def new_project(self):
|
||||
"""Create a new project with initial setup dialog"""
|
||||
# Create new project setup dialog
|
||||
dialog = QDialog(self)
|
||||
dialog.setWindowTitle("New Project Setup")
|
||||
dialog.setMinimumWidth(450)
|
||||
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
|
||||
# Project name group
|
||||
name_group = QGroupBox("Project Name")
|
||||
name_layout = QVBoxLayout()
|
||||
@@ -45,16 +79,16 @@ class FileOperationsMixin:
|
||||
name_layout.addWidget(name_input)
|
||||
name_group.setLayout(name_layout)
|
||||
layout.addWidget(name_group)
|
||||
|
||||
|
||||
# Default page size group
|
||||
size_group = QGroupBox("Default Page Size")
|
||||
size_layout = QVBoxLayout()
|
||||
|
||||
|
||||
info_label = QLabel("This will be the default size for all new pages in this project.")
|
||||
info_label.setWordWrap(True)
|
||||
info_label.setStyleSheet("font-size: 9pt; color: gray;")
|
||||
size_layout.addWidget(info_label)
|
||||
|
||||
|
||||
# Width
|
||||
width_layout = QHBoxLayout()
|
||||
width_layout.addWidget(QLabel("Width:"))
|
||||
@@ -64,7 +98,7 @@ class FileOperationsMixin:
|
||||
width_spinbox.setSuffix(" mm")
|
||||
width_layout.addWidget(width_spinbox)
|
||||
size_layout.addLayout(width_layout)
|
||||
|
||||
|
||||
# Height
|
||||
height_layout = QHBoxLayout()
|
||||
height_layout.addWidget(QLabel("Height:"))
|
||||
@@ -74,37 +108,37 @@ class FileOperationsMixin:
|
||||
height_spinbox.setSuffix(" mm")
|
||||
height_layout.addWidget(height_spinbox)
|
||||
size_layout.addLayout(height_layout)
|
||||
|
||||
|
||||
# Add common size presets
|
||||
presets_layout = QHBoxLayout()
|
||||
presets_layout.addWidget(QLabel("Presets:"))
|
||||
|
||||
|
||||
def set_preset(w, h):
|
||||
width_spinbox.setValue(w)
|
||||
height_spinbox.setValue(h)
|
||||
|
||||
|
||||
preset_a4 = QPushButton("A4 (210×297)")
|
||||
preset_a4.clicked.connect(lambda: set_preset(210, 297))
|
||||
presets_layout.addWidget(preset_a4)
|
||||
|
||||
|
||||
preset_a5 = QPushButton("A5 (148×210)")
|
||||
preset_a5.clicked.connect(lambda: set_preset(148, 210))
|
||||
presets_layout.addWidget(preset_a5)
|
||||
|
||||
|
||||
preset_square = QPushButton("Square (200×200)")
|
||||
preset_square.clicked.connect(lambda: set_preset(200, 200))
|
||||
presets_layout.addWidget(preset_square)
|
||||
|
||||
|
||||
presets_layout.addStretch()
|
||||
size_layout.addLayout(presets_layout)
|
||||
|
||||
|
||||
size_group.setLayout(size_layout)
|
||||
layout.addWidget(size_group)
|
||||
|
||||
|
||||
# DPI settings group
|
||||
dpi_group = QGroupBox("DPI Settings")
|
||||
dpi_layout = QVBoxLayout()
|
||||
|
||||
|
||||
# Working DPI
|
||||
working_dpi_layout = QHBoxLayout()
|
||||
working_dpi_layout.addWidget(QLabel("Working DPI:"))
|
||||
@@ -113,7 +147,7 @@ class FileOperationsMixin:
|
||||
working_dpi_spinbox.setValue(300)
|
||||
working_dpi_layout.addWidget(working_dpi_spinbox)
|
||||
dpi_layout.addLayout(working_dpi_layout)
|
||||
|
||||
|
||||
# Export DPI
|
||||
export_dpi_layout = QHBoxLayout()
|
||||
export_dpi_layout.addWidget(QLabel("Export DPI:"))
|
||||
@@ -122,10 +156,10 @@ class FileOperationsMixin:
|
||||
export_dpi_spinbox.setValue(300)
|
||||
export_dpi_layout.addWidget(export_dpi_spinbox)
|
||||
dpi_layout.addLayout(export_dpi_layout)
|
||||
|
||||
|
||||
dpi_group.setLayout(dpi_layout)
|
||||
layout.addWidget(dpi_group)
|
||||
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
@@ -133,14 +167,14 @@ class FileOperationsMixin:
|
||||
create_btn = QPushButton("Create Project")
|
||||
create_btn.clicked.connect(dialog.accept)
|
||||
create_btn.setDefault(True)
|
||||
|
||||
|
||||
button_layout.addStretch()
|
||||
button_layout.addWidget(cancel_btn)
|
||||
button_layout.addWidget(create_btn)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
|
||||
dialog.setLayout(layout)
|
||||
|
||||
|
||||
# Show dialog
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
# Get values
|
||||
@@ -151,7 +185,7 @@ class FileOperationsMixin:
|
||||
export_dpi = export_dpi_spinbox.value()
|
||||
|
||||
# Cleanup old project if it exists
|
||||
if hasattr(self, 'project') and self.project:
|
||||
if hasattr(self, "project") and self.project:
|
||||
self.project.cleanup()
|
||||
|
||||
# Create project with custom settings
|
||||
@@ -165,34 +199,25 @@ class FileOperationsMixin:
|
||||
|
||||
# Update view
|
||||
self.update_view()
|
||||
|
||||
|
||||
self.show_status(f"New project created: {project_name} ({width_mm}×{height_mm} mm)")
|
||||
print(f"New project created: {project_name}, default page size: {width_mm}×{height_mm} mm")
|
||||
else:
|
||||
# User cancelled - keep current project
|
||||
print("New project creation cancelled")
|
||||
|
||||
@ribbon_action(
|
||||
label="Open",
|
||||
tooltip="Open an existing project",
|
||||
tab="Home",
|
||||
group="File",
|
||||
shortcut="Ctrl+O"
|
||||
)
|
||||
|
||||
@ribbon_action(label="Open", tooltip="Open an existing project", tab="Home", group="File", shortcut="Ctrl+O")
|
||||
def open_project(self):
|
||||
"""Open an existing project with async loading and progress bar"""
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
"Open Project",
|
||||
"",
|
||||
"pyPhotoAlbum Projects (*.ppz);;All Files (*)"
|
||||
self, "Open Project", "", "pyPhotoAlbum Projects (*.ppz);;All Files (*)"
|
||||
)
|
||||
|
||||
if file_path:
|
||||
print(f"Opening project: {file_path}")
|
||||
|
||||
# Create loading widget if not exists
|
||||
if not hasattr(self, '_loading_widget'):
|
||||
if not hasattr(self, "_loading_widget"):
|
||||
self._loading_widget = LoadingWidget(self)
|
||||
|
||||
# Show loading widget
|
||||
@@ -200,6 +225,7 @@ class FileOperationsMixin:
|
||||
|
||||
# Create and configure async loader
|
||||
self._project_loader = AsyncProjectLoader(file_path)
|
||||
self._opening_file_path = file_path # Store for later
|
||||
|
||||
# Connect signals
|
||||
self._project_loader.progress_updated.connect(self._on_load_progress)
|
||||
@@ -211,77 +237,137 @@ class FileOperationsMixin:
|
||||
|
||||
def _on_load_progress(self, current: int, total: int, message: str):
|
||||
"""Handle loading progress updates"""
|
||||
if hasattr(self, '_loading_widget'):
|
||||
if hasattr(self, "_loading_widget"):
|
||||
self._loading_widget.set_progress(current, total)
|
||||
self._loading_widget.set_status(message)
|
||||
|
||||
def _on_load_complete(self, project):
|
||||
"""Handle successful project load"""
|
||||
# Cleanup old project if it exists
|
||||
if hasattr(self, 'project') and self.project:
|
||||
if hasattr(self, "project") and self.project:
|
||||
self.project.cleanup()
|
||||
|
||||
# Set new project
|
||||
self.project = project
|
||||
|
||||
# Set file path and mark as clean
|
||||
if hasattr(self, "_opening_file_path"):
|
||||
self.project.file_path = self._opening_file_path
|
||||
delattr(self, "_opening_file_path")
|
||||
self.project.mark_clean()
|
||||
|
||||
self.gl_widget.current_page_index = 0 # Reset to first page
|
||||
|
||||
# Hide loading widget
|
||||
if hasattr(self, '_loading_widget'):
|
||||
if hasattr(self, "_loading_widget"):
|
||||
self._loading_widget.hide_loading()
|
||||
|
||||
# Update view (this will trigger progressive image loading)
|
||||
self.update_view()
|
||||
|
||||
self.show_status(f"Project opened: {project.name}")
|
||||
# Check for missing assets and inform user
|
||||
missing_assets = self._check_missing_assets()
|
||||
if missing_assets:
|
||||
self._show_missing_assets_warning(missing_assets)
|
||||
self.show_status(f"Project opened: {project.name} ({len(missing_assets)} missing images)")
|
||||
else:
|
||||
self.show_status(f"Project opened: {project.name}")
|
||||
print(f"Successfully loaded project: {project.name}")
|
||||
|
||||
def _on_load_failed(self, error_msg: str):
|
||||
"""Handle project load failure"""
|
||||
# Hide loading widget
|
||||
if hasattr(self, '_loading_widget'):
|
||||
if hasattr(self, "_loading_widget"):
|
||||
self._loading_widget.hide_loading()
|
||||
|
||||
error_msg = f"Failed to open project: {error_msg}"
|
||||
self.show_status(error_msg)
|
||||
self.show_error("Load Failed", error_msg)
|
||||
print(error_msg)
|
||||
|
||||
@ribbon_action(
|
||||
label="Save",
|
||||
tooltip="Save the current project",
|
||||
tab="Home",
|
||||
group="File",
|
||||
shortcut="Ctrl+S"
|
||||
)
|
||||
def save_project(self):
|
||||
"""Save the current project"""
|
||||
file_path, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
"Save Project",
|
||||
"",
|
||||
"pyPhotoAlbum Projects (*.ppz);;All Files (*)"
|
||||
)
|
||||
|
||||
if file_path:
|
||||
print(f"Saving project to: {file_path}")
|
||||
|
||||
# Save project to ZIP
|
||||
success, error = save_to_zip(self.project, file_path)
|
||||
|
||||
|
||||
@ribbon_action(label="Save", tooltip="Save the current project", tab="Home", group="File", shortcut="Ctrl+S")
|
||||
def save_project(self) -> bool:
|
||||
"""Save the current project asynchronously with progress feedback.
|
||||
|
||||
Returns True if an async save was started, False if the user cancelled.
|
||||
"""
|
||||
# Prevent concurrent saves — only one background save at a time
|
||||
if getattr(self, "_save_in_progress", False):
|
||||
self.show_status("Save already in progress...")
|
||||
return False
|
||||
|
||||
# If project has a file path, use it; otherwise prompt for location
|
||||
file_path = self.project.file_path if hasattr(self.project, "file_path") and self.project.file_path else None
|
||||
|
||||
if not file_path:
|
||||
file_path, _ = QFileDialog.getSaveFileName(
|
||||
self, "Save Project", "", "pyPhotoAlbum Projects (*.ppz);;All Files (*)" # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
if not file_path:
|
||||
return False
|
||||
|
||||
self._save_in_progress = True
|
||||
print(f"Saving project to: {file_path}")
|
||||
|
||||
# Create loading widget if not exists
|
||||
if not hasattr(self, "_loading_widget"):
|
||||
self._loading_widget = LoadingWidget(self)
|
||||
|
||||
# Show loading widget
|
||||
self._loading_widget.show_loading("Saving project...")
|
||||
|
||||
# Bridge object: signals are thread-safe so background thread can
|
||||
# emit them and slots always run on the main (GUI) thread.
|
||||
bridge = _SaveBridge(parent=self) # type: ignore[arg-type]
|
||||
|
||||
def _on_progress(progress: int, message: str):
|
||||
if hasattr(self, "_loading_widget"):
|
||||
try:
|
||||
self._loading_widget.set_progress(progress, 100)
|
||||
self._loading_widget.set_status(message)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
def _on_finished(success: bool, error: str):
|
||||
self._save_in_progress = False
|
||||
try:
|
||||
if hasattr(self, "_loading_widget"):
|
||||
self._loading_widget.hide_loading()
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
if success:
|
||||
self.project.file_path = file_path
|
||||
self.project.mark_clean()
|
||||
self.show_status(f"Project saved: {file_path}")
|
||||
print(f"Successfully saved project to: {file_path}")
|
||||
if getattr(self, "_pending_close", False):
|
||||
self._pending_close = False
|
||||
self.close() # type: ignore[attr-defined]
|
||||
else:
|
||||
self._pending_close = False
|
||||
error_msg = f"Failed to save project: {error}"
|
||||
self.show_status(error_msg)
|
||||
self.show_error("Save Failed", error_msg)
|
||||
print(error_msg)
|
||||
|
||||
@ribbon_action(
|
||||
label="Heal Assets",
|
||||
tooltip="Reconnect missing image assets",
|
||||
tab="Home",
|
||||
group="File"
|
||||
)
|
||||
bridge.progress.connect(_on_progress)
|
||||
bridge.finished.connect(_on_finished)
|
||||
|
||||
# Start async save — callbacks emit signals (thread-safe)
|
||||
save_to_zip_async(
|
||||
self.project,
|
||||
file_path,
|
||||
on_complete=lambda ok, err: bridge.finished.emit(ok, err or ""),
|
||||
on_progress=lambda p, m: bridge.progress.emit(p, m),
|
||||
)
|
||||
|
||||
# Show immediate feedback
|
||||
self.show_status("Saving project in background...", 2000)
|
||||
return True
|
||||
|
||||
@ribbon_action(label="Heal Assets", tooltip="Reconnect missing image assets", tab="Home", group="File")
|
||||
def heal_assets(self):
|
||||
"""Open the asset healing dialog to reconnect missing images"""
|
||||
dialog = AssetHealDialog(self.project, self)
|
||||
@@ -290,31 +376,74 @@ class FileOperationsMixin:
|
||||
# Update the view to reflect any changes
|
||||
self.update_view()
|
||||
|
||||
def _check_missing_assets(self) -> list:
|
||||
"""Check for missing assets in the project - returns list of missing paths"""
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
missing = []
|
||||
for page in self.project.pages:
|
||||
for element in page.layout.elements:
|
||||
if isinstance(element, ImageData) and element.image_path:
|
||||
# Absolute paths need healing
|
||||
if os.path.isabs(element.image_path):
|
||||
missing.append(element.image_path)
|
||||
# Paths not in assets/ need healing
|
||||
elif not element.image_path.startswith("assets/"):
|
||||
missing.append(element.image_path)
|
||||
else:
|
||||
# Check if file exists in assets using mixin
|
||||
if not self.resolve_asset_path(element.image_path):
|
||||
missing.append(element.image_path)
|
||||
return list(set(missing)) # Remove duplicates
|
||||
|
||||
def _show_missing_assets_warning(self, missing_assets: list):
|
||||
"""Show a warning about missing assets"""
|
||||
from PyQt6.QtWidgets import QMessageBox
|
||||
|
||||
# Build message with list of missing images
|
||||
if len(missing_assets) <= 5:
|
||||
asset_list = "\n".join(f" • {path}" for path in missing_assets)
|
||||
else:
|
||||
asset_list = "\n".join(f" • {path}" for path in missing_assets[:5])
|
||||
asset_list += f"\n ... and {len(missing_assets) - 5} more"
|
||||
|
||||
msg = QMessageBox(cast(QWidget, self))
|
||||
msg.setIcon(QMessageBox.Icon.Warning)
|
||||
msg.setWindowTitle("Missing Assets")
|
||||
msg.setText(f"{len(missing_assets)} image(s) could not be found in the assets folder:")
|
||||
msg.setInformativeText(asset_list)
|
||||
msg.setDetailedText(
|
||||
"These images need to be reconnected using the 'Heal Assets' feature.\n\n"
|
||||
"Go to: Home → Heal Assets\n\n"
|
||||
"Add search paths where the original images might be located, "
|
||||
"then click 'Attempt Healing' to find and import them."
|
||||
)
|
||||
msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Open)
|
||||
btn = msg.button(QMessageBox.StandardButton.Open)
|
||||
if btn is not None:
|
||||
btn.setText("Open Heal Assets")
|
||||
|
||||
result = msg.exec()
|
||||
if result == QMessageBox.StandardButton.Open:
|
||||
self.heal_assets()
|
||||
|
||||
@ribbon_action(
|
||||
label="Project Settings",
|
||||
tooltip="Configure project-wide page size and defaults",
|
||||
tab="Home",
|
||||
group="File"
|
||||
)
|
||||
@numerical_input(
|
||||
fields=[
|
||||
('width', 'Width', 'mm', 10, 1000),
|
||||
('height', 'Height', 'mm', 10, 1000)
|
||||
]
|
||||
label="Project Settings", tooltip="Configure project-wide page size and defaults", tab="Home", group="File"
|
||||
)
|
||||
@numerical_input(fields=[("width", "Width", "mm", 10, 1000), ("height", "Height", "mm", 10, 1000)])
|
||||
def project_settings(self):
|
||||
"""Configure project-wide settings including default page size"""
|
||||
# Create dialog
|
||||
dialog = QDialog(self)
|
||||
dialog.setWindowTitle("Project Settings")
|
||||
dialog.setMinimumWidth(500)
|
||||
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
|
||||
# Page size group
|
||||
size_group = QGroupBox("Default Page Size")
|
||||
size_layout = QVBoxLayout()
|
||||
|
||||
|
||||
# Width
|
||||
width_layout = QHBoxLayout()
|
||||
width_layout.addWidget(QLabel("Width:"))
|
||||
@@ -324,7 +453,7 @@ class FileOperationsMixin:
|
||||
width_spinbox.setSuffix(" mm")
|
||||
width_layout.addWidget(width_spinbox)
|
||||
size_layout.addLayout(width_layout)
|
||||
|
||||
|
||||
# Height
|
||||
height_layout = QHBoxLayout()
|
||||
height_layout.addWidget(QLabel("Height:"))
|
||||
@@ -334,14 +463,14 @@ class FileOperationsMixin:
|
||||
height_spinbox.setSuffix(" mm")
|
||||
height_layout.addWidget(height_spinbox)
|
||||
size_layout.addLayout(height_layout)
|
||||
|
||||
|
||||
size_group.setLayout(size_layout)
|
||||
layout.addWidget(size_group)
|
||||
|
||||
|
||||
# DPI settings group
|
||||
dpi_group = QGroupBox("DPI Settings")
|
||||
dpi_layout = QVBoxLayout()
|
||||
|
||||
|
||||
# Working DPI
|
||||
working_dpi_layout = QHBoxLayout()
|
||||
working_dpi_layout.addWidget(QLabel("Working DPI:"))
|
||||
@@ -350,7 +479,7 @@ class FileOperationsMixin:
|
||||
working_dpi_spinbox.setValue(self.project.working_dpi)
|
||||
working_dpi_layout.addWidget(working_dpi_spinbox)
|
||||
dpi_layout.addLayout(working_dpi_layout)
|
||||
|
||||
|
||||
# Export DPI
|
||||
export_dpi_layout = QHBoxLayout()
|
||||
export_dpi_layout.addWidget(QLabel("Export DPI:"))
|
||||
@@ -359,48 +488,58 @@ class FileOperationsMixin:
|
||||
export_dpi_spinbox.setValue(self.project.export_dpi)
|
||||
export_dpi_layout.addWidget(export_dpi_spinbox)
|
||||
dpi_layout.addLayout(export_dpi_layout)
|
||||
|
||||
|
||||
dpi_group.setLayout(dpi_layout)
|
||||
layout.addWidget(dpi_group)
|
||||
|
||||
|
||||
# Content scaling options (only if pages exist and size is changing)
|
||||
scaling_group = None
|
||||
scaling_buttons = None
|
||||
|
||||
|
||||
scope_buttons = None
|
||||
if self.project.pages:
|
||||
scaling_group = QGroupBox("Apply to Existing Pages")
|
||||
scaling_layout = QVBoxLayout()
|
||||
|
||||
info_label = QLabel("How should existing content be adjusted?\n(Pages with manual sizing will not be affected)")
|
||||
info_label.setWordWrap(True)
|
||||
scaling_layout.addWidget(info_label)
|
||||
|
||||
|
||||
# Scope: which pages to update
|
||||
scaling_layout.addWidget(QLabel("Pages to update:"))
|
||||
scope_buttons = QButtonGroup()
|
||||
scope_non_manual = QRadioButton("Non-manual pages only")
|
||||
scope_non_manual.setChecked(True)
|
||||
scope_all = QRadioButton("All pages (override manual sizing)")
|
||||
scope_buttons.addButton(scope_non_manual, 0)
|
||||
scope_buttons.addButton(scope_all, 1)
|
||||
scaling_layout.addWidget(scope_non_manual)
|
||||
scaling_layout.addWidget(scope_all)
|
||||
|
||||
# Content scaling
|
||||
scaling_layout.addWidget(QLabel("Content adjustment:"))
|
||||
scaling_buttons = QButtonGroup()
|
||||
|
||||
|
||||
proportional_radio = QRadioButton("Resize proportionally (fit to smallest axis)")
|
||||
proportional_radio.setToolTip("Scale content uniformly to fit the new page size")
|
||||
scaling_buttons.addButton(proportional_radio, 0)
|
||||
scaling_layout.addWidget(proportional_radio)
|
||||
|
||||
|
||||
stretch_radio = QRadioButton("Resize on both axes (stretch)")
|
||||
stretch_radio.setToolTip("Scale width and height independently")
|
||||
scaling_buttons.addButton(stretch_radio, 1)
|
||||
scaling_layout.addWidget(stretch_radio)
|
||||
|
||||
|
||||
reposition_radio = QRadioButton("Keep content size, reposition to center")
|
||||
reposition_radio.setToolTip("Maintain element sizes but center them on new page")
|
||||
scaling_buttons.addButton(reposition_radio, 2)
|
||||
scaling_layout.addWidget(reposition_radio)
|
||||
|
||||
|
||||
none_radio = QRadioButton("Don't adjust content (page size only)")
|
||||
none_radio.setToolTip("Only change page size, leave content as-is")
|
||||
none_radio.setChecked(True) # Default
|
||||
scaling_buttons.addButton(none_radio, 3)
|
||||
scaling_layout.addWidget(none_radio)
|
||||
|
||||
|
||||
scaling_group.setLayout(scaling_layout)
|
||||
layout.addWidget(scaling_group)
|
||||
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
@@ -408,14 +547,14 @@ class FileOperationsMixin:
|
||||
ok_btn = QPushButton("OK")
|
||||
ok_btn.clicked.connect(dialog.accept)
|
||||
ok_btn.setDefault(True)
|
||||
|
||||
|
||||
button_layout.addStretch()
|
||||
button_layout.addWidget(cancel_btn)
|
||||
button_layout.addWidget(ok_btn)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
|
||||
dialog.setLayout(layout)
|
||||
|
||||
|
||||
# Show dialog
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
# Get new values
|
||||
@@ -423,74 +562,79 @@ class FileOperationsMixin:
|
||||
new_height = height_spinbox.value()
|
||||
new_working_dpi = working_dpi_spinbox.value()
|
||||
new_export_dpi = export_dpi_spinbox.value()
|
||||
|
||||
# Determine scaling mode
|
||||
scaling_mode = 'none'
|
||||
|
||||
# Determine scaling mode and scope
|
||||
scaling_mode = "none"
|
||||
include_manual = False
|
||||
if scaling_buttons:
|
||||
selected_id = scaling_buttons.checkedId()
|
||||
modes = {0: 'proportional', 1: 'stretch', 2: 'reposition', 3: 'none'}
|
||||
scaling_mode = modes.get(selected_id, 'none')
|
||||
|
||||
modes = {0: "proportional", 1: "stretch", 2: "reposition", 3: "none"}
|
||||
scaling_mode = modes.get(selected_id, "none")
|
||||
if scope_buttons:
|
||||
include_manual = scope_buttons.checkedId() == 1
|
||||
|
||||
# Apply settings
|
||||
old_size = self.project.page_size_mm
|
||||
self.project.page_size_mm = (new_width, new_height)
|
||||
self.project.working_dpi = new_working_dpi
|
||||
self.project.export_dpi = new_export_dpi
|
||||
|
||||
# Update existing pages (exclude manually sized ones)
|
||||
|
||||
# Update existing pages
|
||||
if self.project.pages and old_size != (new_width, new_height):
|
||||
self._apply_page_size_to_project(old_size, (new_width, new_height), scaling_mode)
|
||||
|
||||
self._apply_page_size_to_project(old_size, (new_width, new_height), scaling_mode, include_manual)
|
||||
|
||||
self.update_view()
|
||||
self.show_status(f"Project settings updated: {new_width}×{new_height} mm", 2000)
|
||||
print(f"Project settings updated: {new_width}×{new_height} mm, scaling mode: {scaling_mode}")
|
||||
|
||||
def _apply_page_size_to_project(self, old_size, new_size, scaling_mode):
|
||||
|
||||
def _apply_page_size_to_project(self, old_size, new_size, scaling_mode, include_manual=False):
|
||||
"""
|
||||
Apply new page size to all non-manually-sized pages
|
||||
|
||||
Apply new page size to existing pages.
|
||||
|
||||
Args:
|
||||
old_size: Old page size (width, height) in mm
|
||||
new_size: New page size (width, height) in mm
|
||||
scaling_mode: 'proportional', 'stretch', 'reposition', or 'none'
|
||||
include_manual: If True, also resize manually-sized pages
|
||||
"""
|
||||
old_width, old_height = old_size
|
||||
new_width, new_height = new_size
|
||||
|
||||
|
||||
width_ratio = new_width / old_width if old_width > 0 else 1.0
|
||||
height_ratio = new_height / old_height if old_height > 0 else 1.0
|
||||
|
||||
|
||||
for page in self.project.pages:
|
||||
# Skip manually sized pages
|
||||
if page.manually_sized:
|
||||
if page.is_cover:
|
||||
continue
|
||||
|
||||
if page.manually_sized and not include_manual:
|
||||
continue
|
||||
|
||||
# Update page size
|
||||
old_page_width, old_page_height = page.layout.size
|
||||
|
||||
|
||||
# For double spreads, maintain the 2x multiplier
|
||||
if page.is_double_spread:
|
||||
page.layout.size = (new_width * 2, new_height)
|
||||
else:
|
||||
page.layout.size = (new_width, new_height)
|
||||
|
||||
|
||||
# Apply content scaling based on mode
|
||||
if scaling_mode == 'proportional':
|
||||
if scaling_mode == "proportional":
|
||||
# Use smallest ratio to fit content
|
||||
scale = min(width_ratio, height_ratio)
|
||||
self._scale_page_elements(page, scale, scale)
|
||||
elif scaling_mode == 'stretch':
|
||||
elif scaling_mode == "stretch":
|
||||
# Scale independently on each axis
|
||||
self._scale_page_elements(page, width_ratio, height_ratio)
|
||||
elif scaling_mode == 'reposition':
|
||||
elif scaling_mode == "reposition":
|
||||
# Keep size, center content
|
||||
self._reposition_page_elements(page, old_size, new_size)
|
||||
# 'none' - do nothing to elements
|
||||
|
||||
|
||||
def _scale_page_elements(self, page, x_scale, y_scale):
|
||||
"""
|
||||
Scale all elements on a page
|
||||
|
||||
|
||||
Args:
|
||||
page: Page object
|
||||
x_scale: Horizontal scale factor
|
||||
@@ -500,15 +644,15 @@ class FileOperationsMixin:
|
||||
# Scale position
|
||||
x, y = element.position
|
||||
element.position = (x * x_scale, y * y_scale)
|
||||
|
||||
|
||||
# Scale size
|
||||
width, height = element.size
|
||||
element.size = (width * x_scale, height * y_scale)
|
||||
|
||||
|
||||
def _reposition_page_elements(self, page, old_size, new_size):
|
||||
"""
|
||||
Reposition elements to center them on the new page size
|
||||
|
||||
|
||||
Args:
|
||||
page: Page object
|
||||
old_size: Old page size (width, height) in mm
|
||||
@@ -516,20 +660,15 @@ class FileOperationsMixin:
|
||||
"""
|
||||
old_width, old_height = old_size
|
||||
new_width, new_height = new_size
|
||||
|
||||
|
||||
x_offset = (new_width - old_width) / 2.0
|
||||
y_offset = (new_height - old_height) / 2.0
|
||||
|
||||
|
||||
for element in page.layout.elements:
|
||||
x, y = element.position
|
||||
element.position = (x + x_offset, y + y_offset)
|
||||
|
||||
@ribbon_action(
|
||||
label="Export PDF",
|
||||
tooltip="Export project to PDF",
|
||||
tab="Export",
|
||||
group="Export"
|
||||
)
|
||||
|
||||
@ribbon_action(label="Export PDF", tooltip="Export project to PDF", tab="Home", group="File")
|
||||
def export_pdf(self):
|
||||
"""Export project to PDF using async backend (non-blocking)"""
|
||||
# Check if we have pages to export
|
||||
@@ -538,33 +677,182 @@ class FileOperationsMixin:
|
||||
return
|
||||
|
||||
# Show file save dialog
|
||||
file_path, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
"Export to PDF",
|
||||
"",
|
||||
"PDF Files (*.pdf);;All Files (*)"
|
||||
)
|
||||
file_path, _ = QFileDialog.getSaveFileName(self, "Export to PDF", "", "PDF Files (*.pdf);;All Files (*)")
|
||||
|
||||
if not file_path:
|
||||
return
|
||||
|
||||
# Ensure .pdf extension
|
||||
if not file_path.lower().endswith('.pdf'):
|
||||
file_path += '.pdf'
|
||||
if not file_path.lower().endswith(".pdf"):
|
||||
file_path += ".pdf"
|
||||
|
||||
# Use async PDF export (non-blocking, UI stays responsive)
|
||||
success = self.gl_widget.export_pdf_async(self.project, file_path, export_dpi=300)
|
||||
success = self.gl_widget.export_pdf_async(self.project, file_path, export_dpi=self.project.export_dpi)
|
||||
if success:
|
||||
self.show_status("PDF export started...", 2000)
|
||||
else:
|
||||
self.show_status("PDF export failed to start", 3000)
|
||||
|
||||
@ribbon_action(
|
||||
label="About",
|
||||
tooltip="About pyPhotoAlbum and data format version",
|
||||
tab="Home",
|
||||
group="File"
|
||||
label="Clean Assets", tooltip="Find and remove duplicate or unused image files", tab="Home", group="File"
|
||||
)
|
||||
def clean_assets(self):
|
||||
"""Find and remove duplicate and unused asset files to save space"""
|
||||
from PyQt6.QtWidgets import QProgressDialog, QCheckBox
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
# Helper to format bytes
|
||||
def format_bytes(num_bytes):
|
||||
if num_bytes >= 1024 * 1024:
|
||||
return f"{num_bytes / (1024 * 1024):.1f} MB"
|
||||
elif num_bytes >= 1024:
|
||||
return f"{num_bytes / 1024:.1f} KB"
|
||||
else:
|
||||
return f"{num_bytes} bytes"
|
||||
|
||||
# Scan for issues with progress dialog
|
||||
progress = QProgressDialog("Scanning assets...", "Cancel", 0, 100, self)
|
||||
progress.setWindowTitle("Clean Assets")
|
||||
progress.setWindowModality(Qt.WindowModality.WindowModal)
|
||||
progress.setValue(10)
|
||||
|
||||
# Compute hashes for duplicate detection
|
||||
self.project.asset_manager.compute_all_hashes()
|
||||
progress.setValue(40)
|
||||
|
||||
if progress.wasCanceled():
|
||||
return
|
||||
|
||||
# Get duplicate stats
|
||||
dup_groups, dup_files, dup_bytes = self.project.asset_manager.get_duplicate_stats()
|
||||
progress.setValue(60)
|
||||
|
||||
# Get unused stats
|
||||
unused_files, unused_bytes = self.project.asset_manager.get_unused_stats()
|
||||
progress.setValue(80)
|
||||
|
||||
progress.close()
|
||||
|
||||
# Check if there's anything to clean
|
||||
if dup_files == 0 and unused_files == 0:
|
||||
QMessageBox.information(
|
||||
self, "Assets Clean", "No duplicate or unused files were found in your project assets."
|
||||
)
|
||||
return
|
||||
|
||||
# Build dialog with checkboxes for each cleanup type
|
||||
dialog = QDialog(self)
|
||||
dialog.setWindowTitle("Clean Assets")
|
||||
dialog.setMinimumWidth(450)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Info label
|
||||
info_label = QLabel("Select which cleanup operations to perform:")
|
||||
layout.addWidget(info_label)
|
||||
|
||||
# Duplicates checkbox
|
||||
dup_checkbox = None
|
||||
if dup_files > 0:
|
||||
dup_checkbox = QCheckBox(
|
||||
f"Remove {dup_files} duplicate file(s) in {dup_groups} group(s) " f"(saves {format_bytes(dup_bytes)})"
|
||||
)
|
||||
dup_checkbox.setChecked(True)
|
||||
dup_checkbox.setToolTip(
|
||||
"Duplicate files have identical content but different names.\n"
|
||||
"Image references will be automatically updated to use the kept file."
|
||||
)
|
||||
layout.addWidget(dup_checkbox)
|
||||
|
||||
# Unused checkbox
|
||||
unused_checkbox = None
|
||||
if unused_files > 0:
|
||||
unused_checkbox = QCheckBox(f"Remove {unused_files} unused file(s) (saves {format_bytes(unused_bytes)})")
|
||||
unused_checkbox.setChecked(True)
|
||||
unused_checkbox.setToolTip(
|
||||
"Unused files exist in the assets folder but are not referenced\n"
|
||||
"by any image element in your project."
|
||||
)
|
||||
layout.addWidget(unused_checkbox)
|
||||
|
||||
# Summary
|
||||
total_files = dup_files + unused_files
|
||||
total_bytes = dup_bytes + unused_bytes
|
||||
summary_label = QLabel(f"\nTotal potential savings: {format_bytes(total_bytes)} from {total_files} file(s)")
|
||||
summary_label.setStyleSheet("font-weight: bold;")
|
||||
layout.addWidget(summary_label)
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
cancel_btn.clicked.connect(dialog.reject)
|
||||
clean_btn = QPushButton("Clean Selected")
|
||||
clean_btn.clicked.connect(dialog.accept)
|
||||
clean_btn.setDefault(True)
|
||||
|
||||
button_layout.addStretch()
|
||||
button_layout.addWidget(cancel_btn)
|
||||
button_layout.addWidget(clean_btn)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
dialog.setLayout(layout)
|
||||
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
|
||||
# Perform selected cleanups
|
||||
total_removed = 0
|
||||
total_saved = 0
|
||||
|
||||
# Remove duplicates if selected
|
||||
if dup_checkbox and dup_checkbox.isChecked():
|
||||
|
||||
def update_image_references(old_path: str, new_path: str):
|
||||
"""Update all ImageData elements that reference the old path"""
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
for page in self.project.pages:
|
||||
for element in page.layout.elements:
|
||||
if isinstance(element, ImageData) and element.image_path == old_path:
|
||||
element.image_path = new_path
|
||||
element.mark_modified()
|
||||
print(f"Updated image reference: {old_path} -> {new_path}")
|
||||
|
||||
removed, saved = self.project.asset_manager.deduplicate_assets(
|
||||
update_references_callback=update_image_references
|
||||
)
|
||||
total_removed += removed
|
||||
total_saved += saved
|
||||
|
||||
# Remove unused if selected
|
||||
if unused_checkbox and unused_checkbox.isChecked():
|
||||
removed, saved = self.project.asset_manager.remove_unused_assets()
|
||||
total_removed += removed
|
||||
total_saved += saved
|
||||
|
||||
if total_removed > 0:
|
||||
# Mark project as dirty since we modified it
|
||||
self.project.mark_dirty()
|
||||
|
||||
# Update view
|
||||
self.update_view()
|
||||
|
||||
# Show result
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Cleanup Complete",
|
||||
f"Removed {total_removed} file(s).\n\n"
|
||||
f"Saved {format_bytes(total_saved)} of disk space.\n\n"
|
||||
f"Remember to save your project to preserve these changes.",
|
||||
)
|
||||
|
||||
self.show_status(
|
||||
f"Asset cleanup complete: removed {total_removed} files, saved {format_bytes(total_saved)}"
|
||||
)
|
||||
else:
|
||||
self.show_status("No files were removed")
|
||||
|
||||
@ribbon_action(label="About", tooltip="About pyPhotoAlbum and data format version", tab="Home", group="File")
|
||||
def show_about(self):
|
||||
"""Show about dialog with version information"""
|
||||
dialog = QDialog(self)
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
Merge operations mixin for pyPhotoAlbum
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import QFileDialog, QMessageBox
|
||||
from pyPhotoAlbum.decorators import ribbon_action
|
||||
from pyPhotoAlbum.merge_manager import MergeManager, concatenate_projects
|
||||
from pyPhotoAlbum.merge_dialog import MergeDialog
|
||||
from pyPhotoAlbum.project_serializer import load_from_zip, save_to_zip
|
||||
from pyPhotoAlbum.models import set_asset_resolution_context
|
||||
from pyPhotoAlbum.project import Project
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
|
||||
class MergeOperationsMixin:
|
||||
"""Mixin providing project merge operations"""
|
||||
|
||||
@ribbon_action(
|
||||
label="Merge Projects",
|
||||
tooltip="Merge another project file with the current project",
|
||||
tab="Home",
|
||||
group="File",
|
||||
)
|
||||
def merge_projects(self):
|
||||
"""
|
||||
Merge another project with the current project.
|
||||
|
||||
If the projects have the same project_id, conflicts will be resolved.
|
||||
If they have different project_ids, they will be concatenated.
|
||||
"""
|
||||
# Check if current project has changes
|
||||
if self.project.is_dirty():
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Unsaved Changes",
|
||||
"You have unsaved changes in the current project. Save before merging?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel,
|
||||
)
|
||||
|
||||
if reply == QMessageBox.StandardButton.Cancel:
|
||||
return
|
||||
elif reply == QMessageBox.StandardButton.Yes:
|
||||
# Save current project first
|
||||
if hasattr(self, "save_project"):
|
||||
self.save_project()
|
||||
|
||||
# Select file to merge
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Select Project to Merge", "", "Photo Album Projects (*.ppz);;All Files (*)"
|
||||
)
|
||||
|
||||
if not file_path:
|
||||
return
|
||||
|
||||
try:
|
||||
# Disable autosave during merge
|
||||
if hasattr(self, "_autosave_timer"):
|
||||
self._autosave_timer.stop()
|
||||
|
||||
# Load the other project
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Load project data
|
||||
other_project = load_from_zip(file_path, temp_dir)
|
||||
|
||||
# Serialize both projects for comparison
|
||||
our_data = self.project.serialize()
|
||||
their_data = other_project.serialize()
|
||||
|
||||
# Check if projects should be merged or concatenated
|
||||
merge_manager = MergeManager()
|
||||
should_merge = merge_manager.should_merge_projects(our_data, their_data)
|
||||
|
||||
if should_merge:
|
||||
# Same project - merge with conflict resolution
|
||||
self._perform_merge_with_conflicts(our_data, their_data)
|
||||
else:
|
||||
# Different projects - concatenate
|
||||
self._perform_concatenation(our_data, their_data)
|
||||
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Merge Error", f"Failed to merge projects:\n{str(e)}")
|
||||
finally:
|
||||
# Re-enable autosave
|
||||
if hasattr(self, "_autosave_timer"):
|
||||
self._autosave_timer.start()
|
||||
|
||||
def _perform_merge_with_conflicts(self, our_data, their_data):
|
||||
"""Perform merge with conflict resolution UI"""
|
||||
# Detect conflicts
|
||||
merge_manager = MergeManager()
|
||||
conflicts = merge_manager.detect_conflicts(our_data, their_data)
|
||||
|
||||
if not conflicts:
|
||||
# No conflicts - auto-merge
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"No Conflicts",
|
||||
"No conflicts detected. Merge projects automatically?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
)
|
||||
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
# Auto-merge non-conflicting changes
|
||||
merged_data = merge_manager.apply_resolutions(our_data, their_data, {})
|
||||
else:
|
||||
# Show merge dialog for conflict resolution
|
||||
dialog = MergeDialog(our_data, their_data, self)
|
||||
|
||||
if dialog.exec() != QMessageBox.DialogCode.Accepted:
|
||||
QMessageBox.information(self, "Merge Cancelled", "Merge operation cancelled.")
|
||||
return
|
||||
|
||||
# Get merged data from dialog
|
||||
merged_data = dialog.get_merged_project_data()
|
||||
|
||||
# Apply merged data to current project
|
||||
self._apply_merged_data(merged_data)
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Merge Complete",
|
||||
f"Projects merged successfully.\n"
|
||||
f"Total pages: {len(merged_data.get('pages', []))}\n"
|
||||
f"Resolved conflicts: {len(conflicts)}",
|
||||
)
|
||||
|
||||
def _perform_concatenation(self, our_data, their_data):
|
||||
"""Concatenate two different projects"""
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Different Projects",
|
||||
f"These are different projects:\n"
|
||||
f" • {our_data.get('name', 'Untitled')}\n"
|
||||
f" • {their_data.get('name', 'Untitled')}\n\n"
|
||||
f"Concatenate them (combine all pages)?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
)
|
||||
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
# Concatenate projects
|
||||
merged_data = concatenate_projects(our_data, their_data)
|
||||
|
||||
# Apply merged data
|
||||
self._apply_merged_data(merged_data)
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Concatenation Complete",
|
||||
f"Projects concatenated successfully.\n" f"Total pages: {len(merged_data.get('pages', []))}",
|
||||
)
|
||||
|
||||
def _apply_merged_data(self, merged_data):
|
||||
"""Apply merged project data to current project"""
|
||||
# Create new project from merged data
|
||||
new_project = Project()
|
||||
new_project.deserialize(merged_data)
|
||||
|
||||
# Replace current project
|
||||
self._project = new_project
|
||||
|
||||
# Update asset resolution context
|
||||
set_asset_resolution_context(new_project.folder_path)
|
||||
|
||||
# Mark as dirty (has unsaved changes from merge)
|
||||
new_project.mark_dirty()
|
||||
|
||||
# Update UI
|
||||
if hasattr(self, "gl_widget"):
|
||||
self.gl_widget.set_project(new_project)
|
||||
self.gl_widget.update()
|
||||
|
||||
if hasattr(self, "status_bar"):
|
||||
self.status_bar.showMessage("Merge completed successfully", 3000)
|
||||
@@ -2,348 +2,185 @@
|
||||
Page operations mixin for pyPhotoAlbum
|
||||
"""
|
||||
|
||||
from pyPhotoAlbum.decorators import ribbon_action
|
||||
from pyPhotoAlbum.decorators import ribbon_action, dialog_action
|
||||
from pyPhotoAlbum.dialogs import PageSetupDialog
|
||||
from pyPhotoAlbum.project import Page
|
||||
from pyPhotoAlbum.page_layout import PageLayout
|
||||
|
||||
|
||||
class PageOperationsMixin:
|
||||
"""Mixin providing page management operations"""
|
||||
|
||||
|
||||
# Note: Previous/Next page navigation removed - now using scrollable multi-page view
|
||||
# User can scroll through all pages vertically
|
||||
|
||||
@ribbon_action(
|
||||
label="Add Page",
|
||||
tooltip="Add a new page to the project",
|
||||
tab="Layout",
|
||||
group="Page"
|
||||
)
|
||||
|
||||
@ribbon_action(label="Add Page", tooltip="Add a new page to the project", tab="Layout", group="Page")
|
||||
def add_page(self):
|
||||
"""Add a new page to the project"""
|
||||
new_page_number = len(self.project.pages) + 1
|
||||
|
||||
"""Add a new page to the project after the current page"""
|
||||
# Get the most visible page in viewport to determine insertion point
|
||||
current_page_index = self._get_most_visible_page_index()
|
||||
|
||||
# Ensure index is valid, default to end if not
|
||||
if current_page_index < 0 or current_page_index >= len(self.project.pages):
|
||||
insert_index = len(self.project.pages)
|
||||
else:
|
||||
# Insert after the current page
|
||||
insert_index = current_page_index + 1
|
||||
|
||||
# Create layout with project default size
|
||||
width_mm, height_mm = self.project.page_size_mm
|
||||
new_layout = PageLayout(width=width_mm, height=height_mm)
|
||||
|
||||
|
||||
# Calculate proper page number for the new page
|
||||
# The page_number represents the logical page number in the book
|
||||
if insert_index == 0:
|
||||
# Inserting at the beginning
|
||||
new_page_number = 1
|
||||
elif insert_index >= len(self.project.pages):
|
||||
# Inserting at the end - calculate based on last page
|
||||
if self.project.pages:
|
||||
last_page = self.project.pages[-1]
|
||||
# Add the count of pages the last page represents
|
||||
new_page_number = last_page.page_number + last_page.get_page_count()
|
||||
else:
|
||||
new_page_number = 1
|
||||
else:
|
||||
# Inserting in the middle - take the page number of the page that will come after
|
||||
new_page_number = self.project.pages[insert_index].page_number
|
||||
|
||||
new_page = Page(layout=new_layout, page_number=new_page_number)
|
||||
# New pages are not manually sized - they use project defaults
|
||||
new_page.manually_sized = False
|
||||
|
||||
self.project.add_page(new_page)
|
||||
|
||||
# Insert the page at the calculated position
|
||||
self.project.add_page(new_page, index=insert_index)
|
||||
|
||||
# Renumber all pages to ensure consistent numbering
|
||||
# Page numbers represent logical page numbers in the book
|
||||
current_page_num = 1
|
||||
for page in self.project.pages:
|
||||
page.page_number = current_page_num
|
||||
current_page_num += page.get_page_count()
|
||||
|
||||
self.update_view()
|
||||
print(f"Added page {new_page_number} with size {width_mm}×{height_mm} mm")
|
||||
|
||||
@ribbon_action(
|
||||
label="Page Setup",
|
||||
tooltip="Configure page size and settings",
|
||||
tab="Layout",
|
||||
group="Page"
|
||||
)
|
||||
def page_setup(self):
|
||||
"""Open page setup dialog"""
|
||||
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox, QComboBox, QCheckBox
|
||||
|
||||
# Check if we have pages
|
||||
if not self.project.pages:
|
||||
return
|
||||
|
||||
# Create dialog
|
||||
dialog = QDialog(self)
|
||||
dialog.setWindowTitle("Page Setup")
|
||||
dialog.setMinimumWidth(450)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Page selection group
|
||||
page_select_group = QGroupBox("Select Page")
|
||||
page_select_layout = QVBoxLayout()
|
||||
|
||||
page_combo = QComboBox()
|
||||
for i, page in enumerate(self.project.pages):
|
||||
# Use display name helper
|
||||
page_label = self.project.get_page_display_name(page)
|
||||
if page.is_double_spread and not page.is_cover:
|
||||
page_label += f" (Double Spread)"
|
||||
if page.manually_sized:
|
||||
page_label += " *"
|
||||
page_combo.addItem(page_label, i)
|
||||
page_select_layout.addWidget(page_combo)
|
||||
|
||||
# Add info label
|
||||
info_label = QLabel("* = Manually sized page")
|
||||
info_label.setStyleSheet("font-size: 9pt; color: gray;")
|
||||
page_select_layout.addWidget(info_label)
|
||||
|
||||
page_select_group.setLayout(page_select_layout)
|
||||
layout.addWidget(page_select_group)
|
||||
|
||||
# Cover settings group (only show if first page is selected)
|
||||
cover_group = QGroupBox("Cover Settings")
|
||||
cover_layout = QVBoxLayout()
|
||||
|
||||
# Cover checkbox
|
||||
cover_checkbox = QCheckBox("Designate as Cover")
|
||||
cover_checkbox.setToolTip("Mark this page as the book cover with wrap-around front/spine/back")
|
||||
cover_layout.addWidget(cover_checkbox)
|
||||
|
||||
# Paper thickness
|
||||
thickness_layout = QHBoxLayout()
|
||||
thickness_layout.addWidget(QLabel("Paper Thickness:"))
|
||||
thickness_spinbox = QDoubleSpinBox()
|
||||
thickness_spinbox.setRange(0.05, 1.0)
|
||||
thickness_spinbox.setSingleStep(0.05)
|
||||
thickness_spinbox.setValue(self.project.paper_thickness_mm)
|
||||
thickness_spinbox.setSuffix(" mm")
|
||||
thickness_spinbox.setToolTip("Thickness of paper for spine calculation")
|
||||
thickness_layout.addWidget(thickness_spinbox)
|
||||
cover_layout.addLayout(thickness_layout)
|
||||
|
||||
# Bleed margin
|
||||
bleed_layout = QHBoxLayout()
|
||||
bleed_layout.addWidget(QLabel("Bleed Margin:"))
|
||||
bleed_spinbox = QDoubleSpinBox()
|
||||
bleed_spinbox.setRange(0, 10)
|
||||
bleed_spinbox.setSingleStep(0.5)
|
||||
bleed_spinbox.setValue(self.project.cover_bleed_mm)
|
||||
bleed_spinbox.setSuffix(" mm")
|
||||
bleed_spinbox.setToolTip("Extra margin around cover for printing bleed")
|
||||
bleed_layout.addWidget(bleed_spinbox)
|
||||
cover_layout.addLayout(bleed_layout)
|
||||
|
||||
# Calculated spine width display
|
||||
spine_info_label = QLabel()
|
||||
spine_info_label.setStyleSheet("font-size: 9pt; color: #0066cc; padding: 5px;")
|
||||
spine_info_label.setWordWrap(True)
|
||||
cover_layout.addWidget(spine_info_label)
|
||||
|
||||
cover_group.setLayout(cover_layout)
|
||||
layout.addWidget(cover_group)
|
||||
|
||||
# Page size group
|
||||
size_group = QGroupBox("Page Size")
|
||||
size_layout = QVBoxLayout()
|
||||
|
||||
# Width
|
||||
width_layout = QHBoxLayout()
|
||||
width_layout.addWidget(QLabel("Width:"))
|
||||
width_spinbox = QDoubleSpinBox()
|
||||
width_spinbox.setRange(10, 1000)
|
||||
width_spinbox.setSuffix(" mm")
|
||||
width_layout.addWidget(width_spinbox)
|
||||
size_layout.addLayout(width_layout)
|
||||
|
||||
# Height
|
||||
height_layout = QHBoxLayout()
|
||||
height_layout.addWidget(QLabel("Height:"))
|
||||
height_spinbox = QDoubleSpinBox()
|
||||
height_spinbox.setRange(10, 1000)
|
||||
height_spinbox.setSuffix(" mm")
|
||||
height_layout.addWidget(height_spinbox)
|
||||
size_layout.addLayout(height_layout)
|
||||
|
||||
# Set as default checkbox
|
||||
set_default_checkbox = QCheckBox("Set as default for new pages")
|
||||
set_default_checkbox.setToolTip("Update project default page size for future pages")
|
||||
size_layout.addWidget(set_default_checkbox)
|
||||
|
||||
size_group.setLayout(size_layout)
|
||||
layout.addWidget(size_group)
|
||||
|
||||
# DPI settings group
|
||||
dpi_group = QGroupBox("DPI Settings")
|
||||
dpi_layout = QVBoxLayout()
|
||||
|
||||
# Working DPI
|
||||
working_dpi_layout = QHBoxLayout()
|
||||
working_dpi_layout.addWidget(QLabel("Working DPI:"))
|
||||
working_dpi_spinbox = QSpinBox()
|
||||
working_dpi_spinbox.setRange(72, 1200)
|
||||
working_dpi_spinbox.setValue(self.project.working_dpi)
|
||||
working_dpi_layout.addWidget(working_dpi_spinbox)
|
||||
dpi_layout.addLayout(working_dpi_layout)
|
||||
|
||||
# Export DPI
|
||||
export_dpi_layout = QHBoxLayout()
|
||||
export_dpi_layout.addWidget(QLabel("Export DPI:"))
|
||||
export_dpi_spinbox = QSpinBox()
|
||||
export_dpi_spinbox.setRange(72, 1200)
|
||||
export_dpi_spinbox.setValue(self.project.export_dpi)
|
||||
export_dpi_layout.addWidget(export_dpi_spinbox)
|
||||
dpi_layout.addLayout(export_dpi_layout)
|
||||
|
||||
dpi_group.setLayout(dpi_layout)
|
||||
layout.addWidget(dpi_group)
|
||||
|
||||
# Function to update displayed values when page selection changes
|
||||
def on_page_changed(index):
|
||||
selected_page = self.project.pages[index]
|
||||
|
||||
# Show/hide cover settings based on page selection
|
||||
is_first_page = (index == 0)
|
||||
cover_group.setVisible(is_first_page)
|
||||
|
||||
# Update cover checkbox
|
||||
if is_first_page:
|
||||
cover_checkbox.setChecked(selected_page.is_cover)
|
||||
update_spine_info()
|
||||
|
||||
# Get base width (accounting for double spreads and covers)
|
||||
if selected_page.is_cover:
|
||||
# For covers, show the full calculated width
|
||||
display_width = selected_page.layout.size[0]
|
||||
elif selected_page.is_double_spread:
|
||||
display_width = selected_page.layout.base_width if hasattr(selected_page.layout, 'base_width') else selected_page.layout.size[0] / 2
|
||||
else:
|
||||
display_width = selected_page.layout.size[0]
|
||||
|
||||
width_spinbox.setValue(display_width)
|
||||
height_spinbox.setValue(selected_page.layout.size[1])
|
||||
|
||||
# Disable size editing for covers (auto-calculated)
|
||||
if selected_page.is_cover:
|
||||
width_spinbox.setEnabled(False)
|
||||
height_spinbox.setEnabled(False)
|
||||
set_default_checkbox.setEnabled(False)
|
||||
else:
|
||||
width_spinbox.setEnabled(True)
|
||||
height_spinbox.setEnabled(True)
|
||||
set_default_checkbox.setEnabled(True)
|
||||
|
||||
def update_spine_info():
|
||||
"""Update the spine information display"""
|
||||
if cover_checkbox.isChecked():
|
||||
# Calculate spine width with current settings
|
||||
content_pages = sum(p.get_page_count() for p in self.project.pages if not p.is_cover)
|
||||
import math
|
||||
sheets = math.ceil(content_pages / 4)
|
||||
spine_width = sheets * thickness_spinbox.value() * 2
|
||||
|
||||
page_width = self.project.page_size_mm[0]
|
||||
total_width = (page_width * 2) + spine_width + (bleed_spinbox.value() * 2)
|
||||
|
||||
spine_info_label.setText(
|
||||
f"Cover Layout: Front ({page_width:.0f}mm) + Spine ({spine_width:.2f}mm) + "
|
||||
f"Back ({page_width:.0f}mm) + Bleed ({bleed_spinbox.value():.1f}mm × 2)\n"
|
||||
f"Total Width: {total_width:.1f}mm | Content Pages: {content_pages} | Sheets: {sheets}"
|
||||
)
|
||||
else:
|
||||
spine_info_label.setText("")
|
||||
|
||||
# Connect signals
|
||||
cover_checkbox.stateChanged.connect(lambda: update_spine_info())
|
||||
thickness_spinbox.valueChanged.connect(lambda: update_spine_info())
|
||||
bleed_spinbox.valueChanged.connect(lambda: update_spine_info())
|
||||
|
||||
# Connect page selection change
|
||||
page_combo.currentIndexChanged.connect(on_page_changed)
|
||||
|
||||
# Initialize with most visible page
|
||||
initial_page_index = self._get_most_visible_page_index()
|
||||
if 0 <= initial_page_index < len(self.project.pages):
|
||||
page_combo.setCurrentIndex(initial_page_index)
|
||||
on_page_changed(initial_page_index if 0 <= initial_page_index < len(self.project.pages) else 0)
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
cancel_btn.clicked.connect(dialog.reject)
|
||||
ok_btn = QPushButton("OK")
|
||||
ok_btn.clicked.connect(dialog.accept)
|
||||
ok_btn.setDefault(True)
|
||||
|
||||
button_layout.addStretch()
|
||||
button_layout.addWidget(cancel_btn)
|
||||
button_layout.addWidget(ok_btn)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
dialog.setLayout(layout)
|
||||
|
||||
# Show dialog
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
# Get selected page
|
||||
selected_index = page_combo.currentData()
|
||||
selected_page = self.project.pages[selected_index]
|
||||
|
||||
# Update project cover settings
|
||||
self.project.paper_thickness_mm = thickness_spinbox.value()
|
||||
self.project.cover_bleed_mm = bleed_spinbox.value()
|
||||
|
||||
# Handle cover designation (only for first page)
|
||||
if selected_index == 0:
|
||||
was_cover = selected_page.is_cover
|
||||
is_cover = cover_checkbox.isChecked()
|
||||
|
||||
if was_cover != is_cover:
|
||||
selected_page.is_cover = is_cover
|
||||
self.project.has_cover = is_cover
|
||||
|
||||
if is_cover:
|
||||
# Calculate and set cover dimensions
|
||||
self.project.update_cover_dimensions()
|
||||
print(f"Page 1 designated as cover")
|
||||
else:
|
||||
# Restore normal page size
|
||||
selected_page.layout.size = self.project.page_size_mm
|
||||
print(f"Cover removed from page 1")
|
||||
|
||||
# Get new values
|
||||
width_mm = width_spinbox.value()
|
||||
height_mm = height_spinbox.value()
|
||||
|
||||
# Don't allow manual size changes for covers
|
||||
if not selected_page.is_cover:
|
||||
# Check if size actually changed
|
||||
# For double spreads, compare with base width
|
||||
if selected_page.is_double_spread:
|
||||
old_base_width = selected_page.layout.base_width if hasattr(selected_page.layout, 'base_width') else selected_page.layout.size[0] / 2
|
||||
old_height = selected_page.layout.size[1]
|
||||
size_changed = (old_base_width != width_mm or old_height != height_mm)
|
||||
|
||||
if size_changed:
|
||||
# Update double spread
|
||||
selected_page.layout.base_width = width_mm
|
||||
selected_page.layout.size = (width_mm * 2, height_mm)
|
||||
selected_page.manually_sized = True
|
||||
print(f"{self.project.get_page_display_name(selected_page)} (double spread) updated to {width_mm}×{height_mm} mm per page")
|
||||
# Get display name for status message
|
||||
new_page_name = self.project.get_page_display_name(new_page)
|
||||
print(f"Added {new_page_name} at position {insert_index + 1} with size {width_mm}×{height_mm} mm")
|
||||
|
||||
@ribbon_action(label="Page Setup", tooltip="Configure page size and settings", tab="Layout", group="Page")
|
||||
@dialog_action(dialog_class=PageSetupDialog, requires_pages=True)
|
||||
def page_setup(self, values):
|
||||
"""
|
||||
Apply page setup configuration.
|
||||
|
||||
This method contains only business logic. UI presentation
|
||||
is handled by PageSetupDialog and the dialog_action decorator.
|
||||
|
||||
Args:
|
||||
values: Dictionary of values from the dialog
|
||||
"""
|
||||
selected_page = values["selected_page"]
|
||||
selected_index = values["selected_index"]
|
||||
|
||||
# Update project cover settings
|
||||
self.project.paper_thickness_mm = values["paper_thickness_mm"]
|
||||
self.project.cover_bleed_mm = values["cover_bleed_mm"]
|
||||
|
||||
# Handle cover designation (only for first page)
|
||||
if selected_index == 0:
|
||||
was_cover = selected_page.is_cover
|
||||
is_cover = values["is_cover"]
|
||||
|
||||
if was_cover != is_cover:
|
||||
selected_page.is_cover = is_cover
|
||||
self.project.has_cover = is_cover
|
||||
|
||||
if is_cover:
|
||||
# Calculate and set cover dimensions
|
||||
self.project.update_cover_dimensions()
|
||||
print(f"Page 1 designated as cover")
|
||||
else:
|
||||
old_size = selected_page.layout.size
|
||||
size_changed = (old_size != (width_mm, height_mm))
|
||||
|
||||
if size_changed:
|
||||
# Update single page
|
||||
selected_page.layout.size = (width_mm, height_mm)
|
||||
selected_page.layout.base_width = width_mm
|
||||
selected_page.manually_sized = True
|
||||
print(f"{self.project.get_page_display_name(selected_page)} updated to {width_mm}×{height_mm} mm")
|
||||
|
||||
# Update DPI settings
|
||||
self.project.working_dpi = working_dpi_spinbox.value()
|
||||
self.project.export_dpi = export_dpi_spinbox.value()
|
||||
|
||||
# Set as default if checkbox is checked
|
||||
if set_default_checkbox.isChecked():
|
||||
self.project.page_size_mm = (width_mm, height_mm)
|
||||
print(f"Project default page size set to {width_mm}×{height_mm} mm")
|
||||
|
||||
self.update_view()
|
||||
|
||||
# Build status message
|
||||
page_name = self.project.get_page_display_name(selected_page)
|
||||
if selected_page.is_cover:
|
||||
status_msg = f"{page_name} updated"
|
||||
# Restore normal page size
|
||||
selected_page.layout.size = self.project.page_size_mm
|
||||
print(f"Cover removed from page 1")
|
||||
|
||||
# Get new values
|
||||
width_mm = values["width_mm"]
|
||||
height_mm = values["height_mm"]
|
||||
|
||||
# Don't allow manual size changes for covers
|
||||
if not selected_page.is_cover:
|
||||
# Check if size actually changed
|
||||
# For double spreads, compare with base width
|
||||
if selected_page.is_double_spread:
|
||||
old_base_width = (
|
||||
selected_page.layout.base_width
|
||||
if hasattr(selected_page.layout, "base_width")
|
||||
else selected_page.layout.size[0] / 2
|
||||
)
|
||||
old_height = selected_page.layout.size[1]
|
||||
size_changed = old_base_width != width_mm or old_height != height_mm
|
||||
|
||||
if size_changed:
|
||||
# Update double spread
|
||||
selected_page.layout.base_width = width_mm
|
||||
selected_page.layout.size = (width_mm * 2, height_mm)
|
||||
selected_page.manually_sized = True
|
||||
print(
|
||||
f"{self.project.get_page_display_name(selected_page)} "
|
||||
f"(double spread) updated to {width_mm}×{height_mm} mm per page"
|
||||
)
|
||||
else:
|
||||
status_msg = f"{page_name} size: {width_mm}×{height_mm} mm"
|
||||
if set_default_checkbox.isChecked():
|
||||
status_msg += " (set as default)"
|
||||
self.show_status(status_msg, 2000)
|
||||
old_size = selected_page.layout.size
|
||||
size_changed = old_size != (width_mm, height_mm)
|
||||
|
||||
if size_changed:
|
||||
# Update single page
|
||||
selected_page.layout.size = (width_mm, height_mm)
|
||||
selected_page.layout.base_width = width_mm
|
||||
selected_page.manually_sized = True
|
||||
print(
|
||||
f"{self.project.get_page_display_name(selected_page)} " f"updated to {width_mm}×{height_mm} mm"
|
||||
)
|
||||
|
||||
# Update DPI settings
|
||||
self.project.working_dpi = values["working_dpi"]
|
||||
self.project.export_dpi = values["export_dpi"]
|
||||
|
||||
# Apply to other pages based on scope
|
||||
# 0 = page only, 1 = non-manual pages, 2 = all pages
|
||||
apply_scope = values.get("apply_scope", 0)
|
||||
if apply_scope in (1, 2):
|
||||
self.project.page_size_mm = (width_mm, height_mm)
|
||||
print(f"Project default page size set to {width_mm}×{height_mm} mm")
|
||||
|
||||
for page in self.project.pages:
|
||||
if page is selected_page or page.is_cover:
|
||||
continue
|
||||
if apply_scope == 1 and page.manually_sized:
|
||||
continue
|
||||
if page.is_double_spread:
|
||||
page.layout.base_width = width_mm
|
||||
page.layout.size = (width_mm * 2, height_mm)
|
||||
else:
|
||||
page.layout.size = (width_mm, height_mm)
|
||||
page.layout.base_width = width_mm
|
||||
|
||||
self.update_view()
|
||||
|
||||
# Build status message
|
||||
page_name = self.project.get_page_display_name(selected_page)
|
||||
if selected_page.is_cover:
|
||||
status_msg = f"{page_name} updated"
|
||||
else:
|
||||
status_msg = f"{page_name} size: {width_mm}×{height_mm} mm"
|
||||
if values.get("apply_scope", 0) in (1, 2):
|
||||
status_msg += " (set as default)"
|
||||
self.show_status(status_msg, 2000)
|
||||
|
||||
@ribbon_action(
|
||||
label="Toggle Spread",
|
||||
tooltip="Toggle double page spread for current page",
|
||||
tab="Layout",
|
||||
group="Page"
|
||||
label="Toggle Spread", tooltip="Toggle double page spread for current page", tab="Layout", group="Page"
|
||||
)
|
||||
def toggle_double_spread(self):
|
||||
"""Toggle double spread for the current page"""
|
||||
@@ -358,31 +195,31 @@ class PageOperationsMixin:
|
||||
page_index = 0
|
||||
|
||||
current_page = self.project.pages[page_index]
|
||||
|
||||
|
||||
# Toggle the state
|
||||
is_double = not current_page.is_double_spread
|
||||
current_page.is_double_spread = is_double
|
||||
|
||||
|
||||
# Mark as manually sized when toggling spread
|
||||
current_page.manually_sized = True
|
||||
|
||||
|
||||
# Update the page layout width
|
||||
current_width = current_page.layout.size[0]
|
||||
current_height = current_page.layout.size[1]
|
||||
|
||||
|
||||
# Get base width (might already be doubled)
|
||||
if hasattr(current_page.layout, 'base_width'):
|
||||
if hasattr(current_page.layout, "base_width"):
|
||||
base_width = current_page.layout.base_width
|
||||
else:
|
||||
# Assume current width is single if not marked as facing
|
||||
base_width = current_width / 2 if current_page.layout.is_facing_page else current_width
|
||||
|
||||
|
||||
# Set new width based on double spread state
|
||||
new_width = base_width * 2 if is_double else base_width
|
||||
current_page.layout.base_width = base_width
|
||||
current_page.layout.is_facing_page = is_double
|
||||
current_page.layout.size = (new_width, current_height)
|
||||
|
||||
|
||||
# Update display
|
||||
self.update_view()
|
||||
|
||||
@@ -390,13 +227,8 @@ class PageOperationsMixin:
|
||||
page_name = self.project.get_page_display_name(current_page)
|
||||
self.show_status(f"{page_name}: Double spread {status}, width = {new_width:.0f}mm", 2000)
|
||||
print(f"{page_name}: Double spread {status}, width = {new_width}mm")
|
||||
|
||||
@ribbon_action(
|
||||
label="Remove Page",
|
||||
tooltip="Remove the currently selected page",
|
||||
tab="Layout",
|
||||
group="Page"
|
||||
)
|
||||
|
||||
@ribbon_action(label="Remove Page", tooltip="Remove the currently selected page", tab="Layout", group="Page")
|
||||
def remove_page(self):
|
||||
"""Remove the currently selected page"""
|
||||
if len(self.project.pages) <= 1:
|
||||
@@ -417,9 +249,12 @@ class PageOperationsMixin:
|
||||
# Remove the selected page
|
||||
self.project.remove_page(page_to_remove)
|
||||
|
||||
# Renumber remaining pages
|
||||
for i, page in enumerate(self.project.pages):
|
||||
page.page_number = i + 1
|
||||
# Renumber remaining pages to ensure consistent numbering
|
||||
# Page numbers represent logical page numbers in the book
|
||||
current_page_num = 1
|
||||
for page in self.project.pages:
|
||||
page.page_number = current_page_num
|
||||
current_page_num += page.get_page_count()
|
||||
|
||||
# Update display
|
||||
self.update_view()
|
||||
|
||||
@@ -9,73 +9,90 @@ from pyPhotoAlbum.commands import ResizeElementsCommand
|
||||
|
||||
class SizeOperationsMixin:
|
||||
"""Mixin providing element sizing operations"""
|
||||
|
||||
|
||||
def _get_selected_elements_list(self):
|
||||
"""Get list of selected elements for size operations"""
|
||||
return list(self.gl_widget.selected_elements) if self.gl_widget.selected_elements else []
|
||||
|
||||
|
||||
def _execute_resize(self, resize_func, status_msg: str):
|
||||
"""
|
||||
Execute a resize operation on multiple elements.
|
||||
|
||||
Args:
|
||||
resize_func: AlignmentManager method to call with elements
|
||||
status_msg: Status message format string (will receive element count)
|
||||
"""
|
||||
elements = self._get_selected_elements_list()
|
||||
if not self.require_selection(min_count=2): # type: ignore[attr-defined]
|
||||
return
|
||||
|
||||
changes = resize_func(elements)
|
||||
if changes:
|
||||
cmd = ResizeElementsCommand(changes)
|
||||
self.project.history.execute(cmd) # type: ignore[attr-defined]
|
||||
self.update_view() # type: ignore[attr-defined]
|
||||
self.show_status(status_msg.format(len(elements)), 2000) # type: ignore[attr-defined]
|
||||
|
||||
def _execute_fit_to_page(self, fit_func, status_msg: str):
|
||||
"""
|
||||
Execute a fit-to-page operation on a single element.
|
||||
|
||||
Args:
|
||||
fit_func: Function that takes (element, page) and returns a change tuple
|
||||
status_msg: Status message to display on success
|
||||
"""
|
||||
if not self.require_selection(min_count=1): # type: ignore[attr-defined]
|
||||
return
|
||||
|
||||
page = self.get_current_page() # type: ignore[attr-defined]
|
||||
if not page:
|
||||
self.show_warning("No Page", "Please create a page first.") # type: ignore[attr-defined]
|
||||
return
|
||||
|
||||
element = next(iter(self.gl_widget.selected_elements)) # type: ignore[attr-defined]
|
||||
change = fit_func(element, page)
|
||||
|
||||
if change:
|
||||
cmd = ResizeElementsCommand([change])
|
||||
self.project.history.execute(cmd) # type: ignore[attr-defined]
|
||||
self.update_view() # type: ignore[attr-defined]
|
||||
self.show_status(status_msg, 2000) # type: ignore[attr-defined]
|
||||
|
||||
@ribbon_action(
|
||||
label="Same Size",
|
||||
tooltip="Make all selected elements the same size",
|
||||
tab="Arrange",
|
||||
group="Size",
|
||||
requires_selection=True,
|
||||
min_selection=2
|
||||
min_selection=2,
|
||||
)
|
||||
def make_same_size(self):
|
||||
"""Make all selected elements the same size"""
|
||||
elements = self._get_selected_elements_list()
|
||||
if not self.require_selection(min_count=2):
|
||||
return
|
||||
|
||||
changes = AlignmentManager.make_same_size(elements)
|
||||
if changes:
|
||||
cmd = ResizeElementsCommand(changes)
|
||||
self.project.history.execute(cmd)
|
||||
self.update_view()
|
||||
self.show_status(f"Resized {len(elements)} elements to same size", 2000)
|
||||
|
||||
self._execute_resize(AlignmentManager.make_same_size, "Resized {} elements to same size")
|
||||
|
||||
@ribbon_action(
|
||||
label="Same Width",
|
||||
tooltip="Make all selected elements the same width",
|
||||
tab="Arrange",
|
||||
group="Size",
|
||||
requires_selection=True,
|
||||
min_selection=2
|
||||
min_selection=2,
|
||||
)
|
||||
def make_same_width(self):
|
||||
"""Make all selected elements the same width"""
|
||||
elements = self._get_selected_elements_list()
|
||||
if not self.require_selection(min_count=2):
|
||||
return
|
||||
|
||||
changes = AlignmentManager.make_same_width(elements)
|
||||
if changes:
|
||||
cmd = ResizeElementsCommand(changes)
|
||||
self.project.history.execute(cmd)
|
||||
self.update_view()
|
||||
self.show_status(f"Resized {len(elements)} elements to same width", 2000)
|
||||
|
||||
self._execute_resize(AlignmentManager.make_same_width, "Resized {} elements to same width")
|
||||
|
||||
@ribbon_action(
|
||||
label="Same Height",
|
||||
tooltip="Make all selected elements the same height",
|
||||
tab="Arrange",
|
||||
group="Size",
|
||||
requires_selection=True,
|
||||
min_selection=2
|
||||
min_selection=2,
|
||||
)
|
||||
def make_same_height(self):
|
||||
"""Make all selected elements the same height"""
|
||||
elements = self._get_selected_elements_list()
|
||||
if not self.require_selection(min_count=2):
|
||||
return
|
||||
|
||||
changes = AlignmentManager.make_same_height(elements)
|
||||
if changes:
|
||||
cmd = ResizeElementsCommand(changes)
|
||||
self.project.history.execute(cmd)
|
||||
self.update_view()
|
||||
self.show_status(f"Resized {len(elements)} elements to same height", 2000)
|
||||
self._execute_resize(AlignmentManager.make_same_height, "Resized {} elements to same height")
|
||||
|
||||
@ribbon_action(
|
||||
label="Fit Width",
|
||||
@@ -83,30 +100,14 @@ class SizeOperationsMixin:
|
||||
tab="Arrange",
|
||||
group="Size",
|
||||
requires_selection=True,
|
||||
min_selection=1
|
||||
min_selection=1,
|
||||
)
|
||||
def fit_to_width(self):
|
||||
"""Fit selected element to page width"""
|
||||
if not self.require_selection(min_count=1):
|
||||
return
|
||||
|
||||
page = self.get_current_page()
|
||||
if not page:
|
||||
self.show_warning("No Page", "Please create a page first.")
|
||||
return
|
||||
|
||||
# Get the first selected element
|
||||
element = next(iter(self.gl_widget.selected_elements))
|
||||
|
||||
# Fit to page width
|
||||
page_width = page.layout.size[0]
|
||||
change = AlignmentManager.fit_to_page_width(element, page_width)
|
||||
|
||||
if change:
|
||||
cmd = ResizeElementsCommand([change])
|
||||
self.project.history.execute(cmd)
|
||||
self.update_view()
|
||||
self.show_status("Fitted element to page width", 2000)
|
||||
self._execute_fit_to_page(
|
||||
lambda elem, page: AlignmentManager.fit_to_page_width(elem, page.layout.size[0]),
|
||||
"Fitted element to page width",
|
||||
)
|
||||
|
||||
@ribbon_action(
|
||||
label="Fit Height",
|
||||
@@ -114,30 +115,14 @@ class SizeOperationsMixin:
|
||||
tab="Arrange",
|
||||
group="Size",
|
||||
requires_selection=True,
|
||||
min_selection=1
|
||||
min_selection=1,
|
||||
)
|
||||
def fit_to_height(self):
|
||||
"""Fit selected element to page height"""
|
||||
if not self.require_selection(min_count=1):
|
||||
return
|
||||
|
||||
page = self.get_current_page()
|
||||
if not page:
|
||||
self.show_warning("No Page", "Please create a page first.")
|
||||
return
|
||||
|
||||
# Get the first selected element
|
||||
element = next(iter(self.gl_widget.selected_elements))
|
||||
|
||||
# Fit to page height
|
||||
page_height = page.layout.size[1]
|
||||
change = AlignmentManager.fit_to_page_height(element, page_height)
|
||||
|
||||
if change:
|
||||
cmd = ResizeElementsCommand([change])
|
||||
self.project.history.execute(cmd)
|
||||
self.update_view()
|
||||
self.show_status("Fitted element to page height", 2000)
|
||||
self._execute_fit_to_page(
|
||||
lambda elem, page: AlignmentManager.fit_to_page_height(elem, page.layout.size[1]),
|
||||
"Fitted element to page height",
|
||||
)
|
||||
|
||||
@ribbon_action(
|
||||
label="Fit to Page",
|
||||
@@ -145,30 +130,14 @@ class SizeOperationsMixin:
|
||||
tab="Arrange",
|
||||
group="Size",
|
||||
requires_selection=True,
|
||||
min_selection=1
|
||||
min_selection=1,
|
||||
)
|
||||
def fit_to_page(self):
|
||||
"""Fit selected element to page dimensions"""
|
||||
if not self.require_selection(min_count=1):
|
||||
return
|
||||
|
||||
page = self.get_current_page()
|
||||
if not page:
|
||||
self.show_warning("No Page", "Please create a page first.")
|
||||
return
|
||||
|
||||
# Get the first selected element
|
||||
element = next(iter(self.gl_widget.selected_elements))
|
||||
|
||||
# Fit to page
|
||||
page_width, page_height = page.layout.size
|
||||
change = AlignmentManager.fit_to_page(element, page_width, page_height)
|
||||
|
||||
if change:
|
||||
cmd = ResizeElementsCommand([change])
|
||||
self.project.history.execute(cmd)
|
||||
self.update_view()
|
||||
self.show_status("Fitted element to page", 2000)
|
||||
self._execute_fit_to_page(
|
||||
lambda elem, page: AlignmentManager.fit_to_page(elem, page.layout.size[0], page.layout.size[1]),
|
||||
"Fitted element to page",
|
||||
)
|
||||
|
||||
@ribbon_action(
|
||||
label="Expand Image",
|
||||
@@ -176,7 +145,7 @@ class SizeOperationsMixin:
|
||||
tab="Arrange",
|
||||
group="Size",
|
||||
requires_selection=True,
|
||||
min_selection=1
|
||||
min_selection=1,
|
||||
)
|
||||
def expand_image(self):
|
||||
"""Expand selected image to fill available space"""
|
||||
@@ -195,16 +164,11 @@ class SizeOperationsMixin:
|
||||
other_elements = [e for e in page.layout.elements if e is not element]
|
||||
|
||||
# Use configurable min_gap (grid spacing from snapping system, default 10mm)
|
||||
min_gap = getattr(page.layout.snapping_system, 'grid_spacing', 10.0)
|
||||
min_gap = getattr(page.layout.snapping_system, "grid_spacing", 10.0)
|
||||
|
||||
# Expand to bounds
|
||||
page_width, page_height = page.layout.size
|
||||
change = AlignmentManager.expand_to_bounds(
|
||||
element,
|
||||
(page_width, page_height),
|
||||
other_elements,
|
||||
min_gap
|
||||
)
|
||||
change = AlignmentManager.expand_to_bounds(element, (page_width, page_height), other_elements, min_gap)
|
||||
|
||||
if change:
|
||||
cmd = ResizeElementsCommand([change])
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
"""
|
||||
Style operations mixin for pyPhotoAlbum
|
||||
|
||||
Provides ribbon actions for applying visual styles to images:
|
||||
- Rounded corners
|
||||
- Borders
|
||||
- Drop shadows
|
||||
- (Future) Decorative frames
|
||||
"""
|
||||
|
||||
from pyPhotoAlbum.decorators import ribbon_action
|
||||
from pyPhotoAlbum.models import ImageData, ImageStyle
|
||||
|
||||
|
||||
class StyleOperationsMixin:
|
||||
"""Mixin providing element styling operations"""
|
||||
|
||||
def _get_selected_images(self):
|
||||
"""Get list of selected ImageData elements"""
|
||||
if not self.gl_widget.selected_elements:
|
||||
return []
|
||||
return [e for e in self.gl_widget.selected_elements if isinstance(e, ImageData)]
|
||||
|
||||
def _apply_style_change(self, style_updater, description: str):
|
||||
"""
|
||||
Apply a style change to selected images with undo support.
|
||||
|
||||
Args:
|
||||
style_updater: Function that takes an ImageStyle and modifies it
|
||||
description: Description for undo history
|
||||
"""
|
||||
images = self._get_selected_images()
|
||||
if not images:
|
||||
self.show_status("No images selected", 2000) # type: ignore[attr-defined]
|
||||
return
|
||||
|
||||
# Store old styles for undo
|
||||
old_styles = [(img, img.style.copy()) for img in images]
|
||||
|
||||
# Create undo command
|
||||
from pyPhotoAlbum.commands import Command
|
||||
|
||||
class StyleChangeCommand(Command):
|
||||
def __init__(self, old_styles, new_style_updater, desc):
|
||||
self.old_styles = old_styles
|
||||
self.new_style_updater = new_style_updater
|
||||
self.description = desc
|
||||
|
||||
def _invalidate_texture(self, img):
|
||||
"""Invalidate the image texture so it will be regenerated."""
|
||||
# Clear the style hash to force regeneration check
|
||||
if hasattr(img, "_texture_style_hash"):
|
||||
delattr(img, "_texture_style_hash")
|
||||
# Clear async load state so it will reload
|
||||
img._async_load_requested = False
|
||||
# Delete texture if it exists (will be recreated on next render)
|
||||
if hasattr(img, "_texture_id") and img._texture_id:
|
||||
from pyPhotoAlbum.gl_imports import glDeleteTextures
|
||||
|
||||
try:
|
||||
glDeleteTextures([img._texture_id])
|
||||
except Exception:
|
||||
pass # GL context might not be available
|
||||
delattr(img, "_texture_id")
|
||||
|
||||
def execute(self):
|
||||
for img, _ in self.old_styles:
|
||||
self.new_style_updater(img.style)
|
||||
self._invalidate_texture(img)
|
||||
|
||||
def undo(self):
|
||||
for img, old_style in self.old_styles:
|
||||
img.style = old_style.copy()
|
||||
self._invalidate_texture(img)
|
||||
|
||||
def redo(self):
|
||||
self.execute()
|
||||
|
||||
def serialize(self):
|
||||
# Style changes are not serialized (session-only undo)
|
||||
return {"type": "style_change", "description": self.description}
|
||||
|
||||
@staticmethod
|
||||
def deserialize(data, project):
|
||||
# Style changes cannot be deserialized (session-only)
|
||||
return None
|
||||
|
||||
cmd = StyleChangeCommand(old_styles, style_updater, description)
|
||||
self.project.history.execute(cmd) # type: ignore[attr-defined]
|
||||
|
||||
self.update_view() # type: ignore[attr-defined]
|
||||
self.show_status(f"{description} applied to {len(images)} image(s)", 2000) # type: ignore[attr-defined]
|
||||
|
||||
# =========================================================================
|
||||
# Corner Radius
|
||||
# =========================================================================
|
||||
|
||||
@ribbon_action(
|
||||
label="Round Corners",
|
||||
tooltip="Set corner radius for selected images",
|
||||
tab="Style",
|
||||
group="Corners",
|
||||
requires_selection=True,
|
||||
)
|
||||
def show_corner_radius_dialog(self):
|
||||
"""Show dialog to set corner radius"""
|
||||
images = self._get_selected_images()
|
||||
if not images:
|
||||
self.show_status("No images selected", 2000) # type: ignore[attr-defined]
|
||||
return
|
||||
|
||||
from pyPhotoAlbum.dialogs.style_dialogs import CornerRadiusDialog
|
||||
|
||||
# Get current radius from first selected image
|
||||
current_radius = images[0].style.corner_radius
|
||||
|
||||
dialog = CornerRadiusDialog(self, current_radius)
|
||||
if dialog.exec():
|
||||
new_radius = dialog.get_value()
|
||||
self._apply_style_change(
|
||||
lambda style: setattr(style, "corner_radius", new_radius),
|
||||
f"Set corner radius to {new_radius}%",
|
||||
)
|
||||
|
||||
@ribbon_action(
|
||||
label="No Corners",
|
||||
tooltip="Remove rounded corners from selected images",
|
||||
tab="Style",
|
||||
group="Corners",
|
||||
requires_selection=True,
|
||||
)
|
||||
def remove_corner_radius(self):
|
||||
"""Remove corner radius (set to 0)"""
|
||||
self._apply_style_change(
|
||||
lambda style: setattr(style, "corner_radius", 0.0),
|
||||
"Remove corner radius",
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Borders
|
||||
# =========================================================================
|
||||
|
||||
@ribbon_action(
|
||||
label="Border...",
|
||||
tooltip="Set border for selected images",
|
||||
tab="Style",
|
||||
group="Border",
|
||||
requires_selection=True,
|
||||
)
|
||||
def show_border_dialog(self):
|
||||
"""Show dialog to configure border"""
|
||||
images = self._get_selected_images()
|
||||
if not images:
|
||||
self.show_status("No images selected", 2000) # type: ignore[attr-defined]
|
||||
return
|
||||
|
||||
from pyPhotoAlbum.dialogs.style_dialogs import BorderDialog
|
||||
|
||||
# Get current border from first selected image
|
||||
current_style = images[0].style
|
||||
|
||||
dialog = BorderDialog(self, current_style.border_width, current_style.border_color)
|
||||
if dialog.exec():
|
||||
width, color = dialog.get_values()
|
||||
|
||||
def update_border(style):
|
||||
style.border_width = width
|
||||
style.border_color = color
|
||||
|
||||
self._apply_style_change(update_border, f"Set border ({width}mm)")
|
||||
|
||||
@ribbon_action(
|
||||
label="No Border",
|
||||
tooltip="Remove border from selected images",
|
||||
tab="Style",
|
||||
group="Border",
|
||||
requires_selection=True,
|
||||
)
|
||||
def remove_border(self):
|
||||
"""Remove border (set width to 0)"""
|
||||
self._apply_style_change(
|
||||
lambda style: setattr(style, "border_width", 0.0),
|
||||
"Remove border",
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Shadows
|
||||
# =========================================================================
|
||||
|
||||
@ribbon_action(
|
||||
label="Shadow...",
|
||||
tooltip="Configure drop shadow for selected images",
|
||||
tab="Style",
|
||||
group="Effects",
|
||||
requires_selection=True,
|
||||
)
|
||||
def show_shadow_dialog(self):
|
||||
"""Show dialog to configure drop shadow"""
|
||||
images = self._get_selected_images()
|
||||
if not images:
|
||||
self.show_status("No images selected", 2000) # type: ignore[attr-defined]
|
||||
return
|
||||
|
||||
from pyPhotoAlbum.dialogs.style_dialogs import ShadowDialog
|
||||
|
||||
# Get current shadow settings from first selected image
|
||||
current_style = images[0].style
|
||||
|
||||
dialog = ShadowDialog(
|
||||
self,
|
||||
current_style.shadow_enabled,
|
||||
current_style.shadow_offset,
|
||||
current_style.shadow_blur,
|
||||
current_style.shadow_color,
|
||||
)
|
||||
if dialog.exec():
|
||||
enabled, offset, blur, color = dialog.get_values()
|
||||
|
||||
def update_shadow(style):
|
||||
style.shadow_enabled = enabled
|
||||
style.shadow_offset = offset
|
||||
style.shadow_blur = blur
|
||||
style.shadow_color = color
|
||||
|
||||
self._apply_style_change(update_shadow, "Configure shadow")
|
||||
|
||||
@ribbon_action(
|
||||
label="Toggle Shadow",
|
||||
tooltip="Toggle drop shadow on/off for selected images",
|
||||
tab="Style",
|
||||
group="Effects",
|
||||
requires_selection=True,
|
||||
)
|
||||
def toggle_shadow(self):
|
||||
"""Toggle shadow enabled/disabled"""
|
||||
images = self._get_selected_images()
|
||||
if not images:
|
||||
self.show_status("No images selected", 2000) # type: ignore[attr-defined]
|
||||
return
|
||||
|
||||
# Toggle based on first selected image
|
||||
new_state = not images[0].style.shadow_enabled
|
||||
|
||||
self._apply_style_change(
|
||||
lambda style: setattr(style, "shadow_enabled", new_state),
|
||||
"Enable shadow" if new_state else "Disable shadow",
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Style Presets
|
||||
# =========================================================================
|
||||
|
||||
@ribbon_action(
|
||||
label="Polaroid",
|
||||
tooltip="Apply Polaroid-style frame (white border, shadow)",
|
||||
tab="Style",
|
||||
group="Presets",
|
||||
requires_selection=True,
|
||||
)
|
||||
def apply_polaroid_style(self):
|
||||
"""Apply Polaroid-style preset"""
|
||||
|
||||
def apply_preset(style):
|
||||
style.corner_radius = 0.0
|
||||
style.border_width = 3.0 # 3mm white border
|
||||
style.border_color = (255, 255, 255)
|
||||
style.shadow_enabled = True
|
||||
style.shadow_offset = (2.0, 2.0)
|
||||
style.shadow_blur = 4.0
|
||||
style.shadow_color = (0, 0, 0, 100)
|
||||
|
||||
self._apply_style_change(apply_preset, "Apply Polaroid style")
|
||||
|
||||
@ribbon_action(
|
||||
label="Rounded",
|
||||
tooltip="Apply rounded photo style",
|
||||
tab="Style",
|
||||
group="Presets",
|
||||
requires_selection=True,
|
||||
)
|
||||
def apply_rounded_style(self):
|
||||
"""Apply rounded corners preset"""
|
||||
|
||||
def apply_preset(style):
|
||||
style.corner_radius = 10.0 # 10% rounded
|
||||
style.border_width = 0.0
|
||||
style.shadow_enabled = True
|
||||
style.shadow_offset = (1.5, 1.5)
|
||||
style.shadow_blur = 3.0
|
||||
style.shadow_color = (0, 0, 0, 80)
|
||||
|
||||
self._apply_style_change(apply_preset, "Apply rounded style")
|
||||
|
||||
@ribbon_action(
|
||||
label="Clear Style",
|
||||
tooltip="Remove all styling from selected images",
|
||||
tab="Style",
|
||||
group="Presets",
|
||||
requires_selection=True,
|
||||
)
|
||||
def clear_style(self):
|
||||
"""Remove all styling (reset to defaults)"""
|
||||
|
||||
def clear_all(style):
|
||||
style.corner_radius = 0.0
|
||||
style.border_width = 0.0
|
||||
style.border_color = (0, 0, 0)
|
||||
style.shadow_enabled = False
|
||||
style.shadow_offset = (2.0, 2.0)
|
||||
style.shadow_blur = 3.0
|
||||
style.shadow_color = (0, 0, 0, 128)
|
||||
style.frame_style = None
|
||||
style.frame_color = (0, 0, 0)
|
||||
style.frame_corners = (True, True, True, True)
|
||||
|
||||
self._apply_style_change(clear_all, "Clear style")
|
||||
|
||||
# =========================================================================
|
||||
# Decorative Frames
|
||||
# =========================================================================
|
||||
|
||||
@ribbon_action(
|
||||
label="Frame...",
|
||||
tooltip="Add decorative frame to selected images",
|
||||
tab="Style",
|
||||
group="Frame",
|
||||
requires_selection=True,
|
||||
)
|
||||
def show_frame_picker(self):
|
||||
"""Show dialog to select decorative frame"""
|
||||
images = self._get_selected_images()
|
||||
if not images:
|
||||
self.show_status("No images selected", 2000) # type: ignore[attr-defined]
|
||||
return
|
||||
|
||||
from pyPhotoAlbum.dialogs.frame_picker_dialog import FramePickerDialog
|
||||
|
||||
# Get current frame settings from first selected image
|
||||
current_style = images[0].style
|
||||
|
||||
dialog = FramePickerDialog(
|
||||
self,
|
||||
current_frame=current_style.frame_style,
|
||||
current_color=current_style.frame_color,
|
||||
current_corners=current_style.frame_corners,
|
||||
)
|
||||
if dialog.exec():
|
||||
frame_name, color, corners = dialog.get_values()
|
||||
|
||||
def update_frame(style):
|
||||
style.frame_style = frame_name
|
||||
style.frame_color = color
|
||||
style.frame_corners = corners
|
||||
|
||||
desc = f"Apply frame '{frame_name}'" if frame_name else "Remove frame"
|
||||
self._apply_style_change(update_frame, desc)
|
||||
|
||||
@ribbon_action(
|
||||
label="Remove Frame",
|
||||
tooltip="Remove decorative frame from selected images",
|
||||
tab="Style",
|
||||
group="Frame",
|
||||
requires_selection=True,
|
||||
)
|
||||
def remove_frame(self):
|
||||
"""Remove decorative frame"""
|
||||
|
||||
def clear_frame(style):
|
||||
style.frame_style = None
|
||||
style.frame_color = (0, 0, 0)
|
||||
style.frame_corners = (True, True, True, True)
|
||||
|
||||
self._apply_style_change(clear_frame, "Remove frame")
|
||||
@@ -3,82 +3,75 @@ Template operations mixin for pyPhotoAlbum
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QInputDialog, QDialog, QVBoxLayout, QLabel, QComboBox,
|
||||
QRadioButton, QButtonGroup, QPushButton, QHBoxLayout,
|
||||
QDoubleSpinBox
|
||||
QInputDialog,
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QLabel,
|
||||
QComboBox,
|
||||
QRadioButton,
|
||||
QButtonGroup,
|
||||
QPushButton,
|
||||
QHBoxLayout,
|
||||
QDoubleSpinBox,
|
||||
)
|
||||
from pyPhotoAlbum.decorators import ribbon_action, undoable_operation
|
||||
|
||||
|
||||
class TemplateOperationsMixin:
|
||||
"""Mixin providing template-related operations"""
|
||||
|
||||
|
||||
@ribbon_action(
|
||||
label="Save as Template",
|
||||
tooltip="Save current page as a reusable template",
|
||||
tab="Layout",
|
||||
group="Templates",
|
||||
requires_page=True
|
||||
requires_page=True,
|
||||
)
|
||||
def save_page_as_template(self):
|
||||
"""Save current page as a template"""
|
||||
current_page = self.get_current_page()
|
||||
if not current_page:
|
||||
return
|
||||
|
||||
|
||||
# Check if page has any elements
|
||||
if not current_page.layout.elements:
|
||||
self.show_warning("Empty Page", "Cannot save an empty page as a template.")
|
||||
return
|
||||
|
||||
|
||||
# Ask for template name
|
||||
name, ok = QInputDialog.getText(
|
||||
self,
|
||||
"Save Template",
|
||||
"Enter template name:",
|
||||
text=f"Template_{len(self.template_manager.list_templates()) + 1}"
|
||||
text=f"Template_{len(self.template_manager.list_templates()) + 1}",
|
||||
)
|
||||
|
||||
|
||||
if not ok or not name:
|
||||
return
|
||||
|
||||
|
||||
# Ask for optional description
|
||||
description, ok = QInputDialog.getText(
|
||||
self,
|
||||
"Template Description",
|
||||
"Enter description (optional):"
|
||||
)
|
||||
|
||||
description, ok = QInputDialog.getText(self, "Template Description", "Enter description (optional):")
|
||||
|
||||
if not ok:
|
||||
description = ""
|
||||
|
||||
|
||||
try:
|
||||
# Create template from page
|
||||
template = self.template_manager.create_template_from_page(
|
||||
current_page,
|
||||
name,
|
||||
description
|
||||
)
|
||||
|
||||
template = self.template_manager.create_template_from_page(current_page, name, description)
|
||||
|
||||
# Save template
|
||||
self.template_manager.save_template(template)
|
||||
|
||||
self.show_info(
|
||||
"Template Saved",
|
||||
f"Template '{name}' has been saved successfully."
|
||||
)
|
||||
|
||||
|
||||
self.show_info("Template Saved", f"Template '{name}' has been saved successfully.")
|
||||
|
||||
print(f"Saved template: {name}")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
self.show_error("Error", f"Failed to save template: {str(e)}")
|
||||
print(f"Error saving template: {e}")
|
||||
|
||||
|
||||
@ribbon_action(
|
||||
label="New from Template",
|
||||
tooltip="Create a new page from a template",
|
||||
tab="Layout",
|
||||
group="Templates"
|
||||
label="New from Template", tooltip="Create a new page from a template", tab="Layout", group="Templates"
|
||||
)
|
||||
def new_page_from_template(self):
|
||||
"""Create a new page from a template"""
|
||||
@@ -87,8 +80,7 @@ class TemplateOperationsMixin:
|
||||
|
||||
if not templates:
|
||||
self.show_info(
|
||||
"No Templates",
|
||||
"No templates available. Create a template first by using 'Save as Template'."
|
||||
"No Templates", "No templates available. Create a template first by using 'Save as Template'."
|
||||
)
|
||||
return
|
||||
|
||||
@@ -178,7 +170,7 @@ class TemplateOperationsMixin:
|
||||
page_number=new_page_number,
|
||||
target_size_mm=self.project.page_size_mm,
|
||||
scale_mode=scale_mode,
|
||||
margin_percent=margin_percent
|
||||
margin_percent=margin_percent,
|
||||
)
|
||||
|
||||
# Add to project
|
||||
@@ -194,63 +186,62 @@ class TemplateOperationsMixin:
|
||||
except Exception as e:
|
||||
self.show_error("Error", f"Failed to create page from template: {str(e)}")
|
||||
print(f"Error creating page from template: {e}")
|
||||
|
||||
|
||||
@ribbon_action(
|
||||
label="Apply Template",
|
||||
tooltip="Apply a template layout to current page",
|
||||
tab="Layout",
|
||||
group="Templates",
|
||||
requires_page=True
|
||||
requires_page=True,
|
||||
)
|
||||
@undoable_operation(capture='page_elements', description='Apply Template')
|
||||
@undoable_operation(capture="page_elements", description="Apply Template")
|
||||
def apply_template_to_page(self):
|
||||
"""Apply a template to the current page"""
|
||||
current_page = self.get_current_page()
|
||||
if not current_page:
|
||||
return
|
||||
|
||||
|
||||
# Get available templates
|
||||
templates = self.template_manager.list_templates()
|
||||
|
||||
|
||||
if not templates:
|
||||
self.show_info(
|
||||
"No Templates",
|
||||
"No templates available. Create a template first by using 'Save as Template'."
|
||||
"No Templates", "No templates available. Create a template first by using 'Save as Template'."
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
# Create dialog for template application options
|
||||
dialog = QDialog(self)
|
||||
dialog.setWindowTitle("Apply Template")
|
||||
dialog.setMinimumWidth(400)
|
||||
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
|
||||
# Template selection
|
||||
layout.addWidget(QLabel("Select Template:"))
|
||||
template_combo = QComboBox()
|
||||
template_combo.addItems(templates)
|
||||
layout.addWidget(template_combo)
|
||||
|
||||
|
||||
layout.addSpacing(10)
|
||||
|
||||
|
||||
# Mode selection
|
||||
layout.addWidget(QLabel("Mode:"))
|
||||
mode_group = QButtonGroup(dialog)
|
||||
|
||||
|
||||
replace_radio = QRadioButton("Replace with placeholders")
|
||||
replace_radio.setChecked(True)
|
||||
replace_radio.setToolTip("Clear page and add template placeholders")
|
||||
mode_group.addButton(replace_radio, 0)
|
||||
layout.addWidget(replace_radio)
|
||||
|
||||
|
||||
reflow_radio = QRadioButton("Reflow existing content")
|
||||
reflow_radio.setToolTip("Keep existing images and reposition to template slots")
|
||||
mode_group.addButton(reflow_radio, 1)
|
||||
layout.addWidget(reflow_radio)
|
||||
|
||||
|
||||
layout.addSpacing(10)
|
||||
|
||||
|
||||
# Margin/Spacing percentage
|
||||
layout.addWidget(QLabel("Margin/Spacing:"))
|
||||
margin_layout = QHBoxLayout()
|
||||
@@ -264,9 +255,9 @@ class TemplateOperationsMixin:
|
||||
margin_layout.addWidget(margin_spinbox)
|
||||
margin_layout.addStretch()
|
||||
layout.addLayout(margin_layout)
|
||||
|
||||
|
||||
layout.addSpacing(10)
|
||||
|
||||
|
||||
# Scaling selection
|
||||
layout.addWidget(QLabel("Scaling:"))
|
||||
scale_group = QButtonGroup(dialog)
|
||||
@@ -279,13 +270,13 @@ class TemplateOperationsMixin:
|
||||
stretch_radio.setChecked(True)
|
||||
scale_group.addButton(stretch_radio, 1)
|
||||
layout.addWidget(stretch_radio)
|
||||
|
||||
|
||||
center_radio = QRadioButton("Center (no scaling)")
|
||||
scale_group.addButton(center_radio, 2)
|
||||
layout.addWidget(center_radio)
|
||||
|
||||
|
||||
layout.addSpacing(20)
|
||||
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
@@ -293,38 +284,34 @@ class TemplateOperationsMixin:
|
||||
apply_btn = QPushButton("Apply")
|
||||
apply_btn.clicked.connect(dialog.accept)
|
||||
apply_btn.setDefault(True)
|
||||
|
||||
|
||||
button_layout.addStretch()
|
||||
button_layout.addWidget(cancel_btn)
|
||||
button_layout.addWidget(apply_btn)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
|
||||
dialog.setLayout(layout)
|
||||
|
||||
|
||||
# Show dialog
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
|
||||
|
||||
# Get selections
|
||||
template_name = template_combo.currentText()
|
||||
mode_id = mode_group.checkedId()
|
||||
scale_id = scale_group.checkedId()
|
||||
margin_percent = margin_spinbox.value()
|
||||
|
||||
|
||||
mode = "replace" if mode_id == 0 else "reflow"
|
||||
scale_mode = ["proportional", "stretch", "center"][scale_id]
|
||||
|
||||
|
||||
try:
|
||||
# Load template
|
||||
template = self.template_manager.load_template(template_name)
|
||||
|
||||
# Apply template to page
|
||||
self.template_manager.apply_template_to_page(
|
||||
template,
|
||||
current_page,
|
||||
mode=mode,
|
||||
scale_mode=scale_mode,
|
||||
margin_percent=margin_percent
|
||||
template, current_page, mode=mode, scale_mode=scale_mode, margin_percent=margin_percent
|
||||
)
|
||||
|
||||
# Update display
|
||||
@@ -332,7 +319,7 @@ class TemplateOperationsMixin:
|
||||
|
||||
self.show_status(f"Applied template '{template_name}' to current page", 3000)
|
||||
print(f"Applied template '{template_name}' with mode={mode}, scale_mode={scale_mode}")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
self.show_error("Error", f"Failed to apply template: {str(e)}")
|
||||
print(f"Error applying template: {e}")
|
||||
|
||||
@@ -3,18 +3,13 @@ View operations mixin for pyPhotoAlbum
|
||||
"""
|
||||
|
||||
from pyPhotoAlbum.decorators import ribbon_action
|
||||
from pyPhotoAlbum.dialogs import PrintSettingsDialog
|
||||
|
||||
|
||||
class ViewOperationsMixin:
|
||||
"""Mixin providing view-related operations"""
|
||||
|
||||
@ribbon_action(
|
||||
label="Zoom In",
|
||||
tooltip="Zoom in",
|
||||
tab="View",
|
||||
group="Zoom",
|
||||
shortcut="Ctrl++"
|
||||
)
|
||||
|
||||
@ribbon_action(label="Zoom In", tooltip="Zoom in", tab="View", group="Zoom", shortcut="Ctrl++")
|
||||
def zoom_in(self):
|
||||
"""Zoom in"""
|
||||
self.gl_widget.zoom_level *= 1.2
|
||||
@@ -22,14 +17,8 @@ class ViewOperationsMixin:
|
||||
self.gl_widget.zoom_level = 5.0
|
||||
self.update_view()
|
||||
self.show_status(f"Zoom: {int(self.gl_widget.zoom_level * 100)}%", 2000)
|
||||
|
||||
@ribbon_action(
|
||||
label="Zoom Out",
|
||||
tooltip="Zoom out",
|
||||
tab="View",
|
||||
group="Zoom",
|
||||
shortcut="Ctrl+-"
|
||||
)
|
||||
|
||||
@ribbon_action(label="Zoom Out", tooltip="Zoom out", tab="View", group="Zoom", shortcut="Ctrl+-")
|
||||
def zoom_out(self):
|
||||
"""Zoom out"""
|
||||
self.gl_widget.zoom_level /= 1.2
|
||||
@@ -37,46 +26,41 @@ class ViewOperationsMixin:
|
||||
self.gl_widget.zoom_level = 0.1
|
||||
self.update_view()
|
||||
self.show_status(f"Zoom: {int(self.gl_widget.zoom_level * 100)}%", 2000)
|
||||
|
||||
@ribbon_action(
|
||||
label="Fit to Window",
|
||||
tooltip="Fit page to window",
|
||||
tab="View",
|
||||
group="Zoom",
|
||||
shortcut="Ctrl+0"
|
||||
)
|
||||
|
||||
@ribbon_action(label="Fit to Window", tooltip="Fit page to window", tab="View", group="Zoom", shortcut="Ctrl+0")
|
||||
def zoom_fit(self):
|
||||
"""Fit page to window"""
|
||||
if not self.project.pages:
|
||||
return
|
||||
|
||||
|
||||
current_page = self.project.pages[self.gl_widget.current_page_index]
|
||||
page_width_mm = current_page.layout.size[0]
|
||||
page_height_mm = current_page.layout.size[1]
|
||||
|
||||
|
||||
# Convert to pixels
|
||||
dpi = self.project.working_dpi
|
||||
page_width_px = page_width_mm * dpi / 25.4
|
||||
page_height_px = page_height_mm * dpi / 25.4
|
||||
|
||||
|
||||
# Get widget size
|
||||
widget_width = self.gl_widget.width() - 100 # Margins
|
||||
widget_height = self.gl_widget.height() - 100
|
||||
|
||||
|
||||
# Calculate zoom to fit
|
||||
zoom_w = widget_width / page_width_px
|
||||
zoom_h = widget_height / page_height_px
|
||||
|
||||
|
||||
self.gl_widget.zoom_level = min(zoom_w, zoom_h)
|
||||
self.gl_widget.zoom_level = max(0.1, min(5.0, self.gl_widget.zoom_level))
|
||||
self.update_view()
|
||||
self.show_status(f"Zoom: {int(self.gl_widget.zoom_level * 100)}%", 2000)
|
||||
|
||||
|
||||
@ribbon_action(
|
||||
label="Toggle Grid Snap",
|
||||
tooltip="Toggle snapping to grid",
|
||||
tab="View",
|
||||
group="Snapping"
|
||||
label="Grid Snap",
|
||||
tooltip="Enable/disable snapping to grid (Ctrl+G)",
|
||||
tab="Insert",
|
||||
group="Snapping",
|
||||
shortcut="Ctrl+G",
|
||||
)
|
||||
def toggle_grid_snap(self):
|
||||
"""Toggle grid snapping"""
|
||||
@@ -89,12 +73,13 @@ class ViewOperationsMixin:
|
||||
self.update_view()
|
||||
self.show_status(f"Grid snapping {status}", 2000)
|
||||
print(f"Grid snapping {status}")
|
||||
|
||||
|
||||
@ribbon_action(
|
||||
label="Toggle Edge Snap",
|
||||
tooltip="Toggle snapping to page edges",
|
||||
tab="View",
|
||||
group="Snapping"
|
||||
label="Edge Snap",
|
||||
tooltip="Enable/disable snapping to page edges (Ctrl+E)",
|
||||
tab="Insert",
|
||||
group="Snapping",
|
||||
shortcut="Ctrl+E",
|
||||
)
|
||||
def toggle_edge_snap(self):
|
||||
"""Toggle edge snapping"""
|
||||
@@ -107,13 +92,8 @@ class ViewOperationsMixin:
|
||||
self.update_view()
|
||||
self.show_status(f"Edge snapping {status}", 2000)
|
||||
print(f"Edge snapping {status}")
|
||||
|
||||
@ribbon_action(
|
||||
label="Toggle Guide Snap",
|
||||
tooltip="Toggle snapping to guides",
|
||||
tab="View",
|
||||
group="Snapping"
|
||||
)
|
||||
|
||||
@ribbon_action(label="Guide Snap", tooltip="Enable/disable snapping to guides", tab="Insert", group="Snapping")
|
||||
def toggle_guide_snap(self):
|
||||
"""Toggle guide snapping"""
|
||||
if not self.project:
|
||||
@@ -125,13 +105,8 @@ class ViewOperationsMixin:
|
||||
self.update_view()
|
||||
self.show_status(f"Guide snapping {status}", 2000)
|
||||
print(f"Guide snapping {status}")
|
||||
|
||||
@ribbon_action(
|
||||
label="Show Grid",
|
||||
tooltip="Toggle visibility of grid lines",
|
||||
tab="View",
|
||||
group="Snapping"
|
||||
)
|
||||
|
||||
@ribbon_action(label="Show Grid", tooltip="Toggle visibility of grid lines", tab="Insert", group="Snapping")
|
||||
def toggle_show_grid(self):
|
||||
"""Toggle grid visibility"""
|
||||
if not self.project:
|
||||
@@ -145,11 +120,38 @@ class ViewOperationsMixin:
|
||||
print(f"Grid {status}")
|
||||
|
||||
@ribbon_action(
|
||||
label="Show Guides",
|
||||
tooltip="Toggle visibility of guide lines",
|
||||
tab="View",
|
||||
group="Snapping"
|
||||
label="Print Settings...", tooltip="Configure bleed and safe area for all pages", tab="View", group="Guides"
|
||||
)
|
||||
def open_print_settings(self):
|
||||
"""Open the print settings dialog (bleed and safe area)"""
|
||||
if not self.project:
|
||||
return
|
||||
|
||||
dialog = PrintSettingsDialog(self, self.project)
|
||||
if dialog.exec():
|
||||
values = dialog.get_values()
|
||||
self.project.page_bleed_mm = values["page_bleed_mm"]
|
||||
self.project.page_safe_area_mm = values["page_safe_area_mm"]
|
||||
self.update_view()
|
||||
self.show_status(
|
||||
f"Bleed: {values['page_bleed_mm']:.1f}mm, Safe area: {values['page_safe_area_mm']:.1f}mm", 2000
|
||||
)
|
||||
|
||||
@ribbon_action(
|
||||
label="Print Guides", tooltip="Toggle bleed/cut/safe-area guide lines in the editor", tab="View", group="Guides"
|
||||
)
|
||||
def toggle_print_guides(self):
|
||||
"""Toggle print guide lines (bleed/cut/safe area)"""
|
||||
if not self.project:
|
||||
return
|
||||
|
||||
self.project.show_print_guides = not self.project.show_print_guides
|
||||
|
||||
status = "visible" if self.project.show_print_guides else "hidden"
|
||||
self.update_view()
|
||||
self.show_status(f"Print guides {status}", 2000)
|
||||
|
||||
@ribbon_action(label="Show Guides", tooltip="Toggle visibility of guide lines", tab="Insert", group="Snapping")
|
||||
def toggle_snap_lines(self):
|
||||
"""Toggle guide lines visibility"""
|
||||
if not self.project:
|
||||
@@ -161,71 +163,70 @@ class ViewOperationsMixin:
|
||||
self.update_view()
|
||||
self.show_status(f"Guides {status}", 2000)
|
||||
print(f"Guides {status}")
|
||||
|
||||
@ribbon_action(
|
||||
label="Add H Guide",
|
||||
tooltip="Add horizontal guide at page center",
|
||||
tab="View",
|
||||
group="Guides"
|
||||
)
|
||||
|
||||
@ribbon_action(label="Add H Guide", tooltip="Add horizontal guide at page center", tab="View", group="Guides")
|
||||
def add_horizontal_guide(self):
|
||||
"""Add a horizontal guide at page center"""
|
||||
current_page = self.get_current_page()
|
||||
if not current_page:
|
||||
return
|
||||
|
||||
|
||||
# Add guide at vertical center (in mm)
|
||||
center_y = current_page.layout.size[1] / 2.0
|
||||
current_page.layout.snapping_system.add_guide(center_y, 'horizontal')
|
||||
|
||||
current_page.layout.snapping_system.add_guide(center_y, "horizontal")
|
||||
|
||||
self.update_view()
|
||||
self.show_status(f"Added horizontal guide at {center_y:.1f} mm", 2000)
|
||||
print(f"Added horizontal guide at {center_y:.1f} mm")
|
||||
|
||||
@ribbon_action(
|
||||
label="Add V Guide",
|
||||
tooltip="Add vertical guide at page center",
|
||||
tab="View",
|
||||
group="Guides"
|
||||
)
|
||||
|
||||
@ribbon_action(label="Add V Guide", tooltip="Add vertical guide at page center", tab="View", group="Guides")
|
||||
def add_vertical_guide(self):
|
||||
"""Add a vertical guide at page center"""
|
||||
current_page = self.get_current_page()
|
||||
if not current_page:
|
||||
return
|
||||
|
||||
|
||||
# Add guide at horizontal center (in mm)
|
||||
center_x = current_page.layout.size[0] / 2.0
|
||||
current_page.layout.snapping_system.add_guide(center_x, 'vertical')
|
||||
|
||||
current_page.layout.snapping_system.add_guide(center_x, "vertical")
|
||||
|
||||
self.update_view()
|
||||
self.show_status(f"Added vertical guide at {center_x:.1f} mm", 2000)
|
||||
print(f"Added vertical guide at {center_x:.1f} mm")
|
||||
|
||||
@ribbon_action(
|
||||
label="Clear Guides",
|
||||
tooltip="Clear all guides from current page",
|
||||
tab="View",
|
||||
group="Guides"
|
||||
)
|
||||
|
||||
@ribbon_action(label="Clear Guides", tooltip="Clear all guides from current page", tab="View", group="Guides")
|
||||
def clear_guides(self):
|
||||
"""Clear all guides from current page"""
|
||||
current_page = self.get_current_page()
|
||||
if not current_page:
|
||||
return
|
||||
|
||||
|
||||
guide_count = len(current_page.layout.snapping_system.guides)
|
||||
current_page.layout.snapping_system.clear_guides()
|
||||
|
||||
|
||||
self.update_view()
|
||||
self.show_status(f"Cleared {guide_count} guides", 2000)
|
||||
print(f"Cleared {guide_count} guides")
|
||||
|
||||
|
||||
@ribbon_action(
|
||||
label="Set Grid Size...",
|
||||
tooltip="Configure grid spacing for snapping",
|
||||
label="Image Browser",
|
||||
tooltip="Show/hide the image browser panel",
|
||||
tab="View",
|
||||
group="Snapping"
|
||||
group="Panels",
|
||||
shortcut="Ctrl+B",
|
||||
)
|
||||
def toggle_image_browser(self):
|
||||
"""Toggle the thumbnail browser visibility"""
|
||||
if hasattr(self, "_thumbnail_browser"):
|
||||
if self._thumbnail_browser.isVisible():
|
||||
self._thumbnail_browser.hide()
|
||||
self.show_status("Image browser hidden", 2000)
|
||||
else:
|
||||
self._thumbnail_browser.show()
|
||||
self.show_status("Image browser shown", 2000)
|
||||
|
||||
@ribbon_action(
|
||||
label="Grid Settings...", tooltip="Configure grid size and snap threshold", tab="Insert", group="Snapping"
|
||||
)
|
||||
def set_grid_size(self):
|
||||
"""Open dialog to set grid size"""
|
||||
@@ -295,68 +296,3 @@ class ViewOperationsMixin:
|
||||
self.update_view()
|
||||
self.show_status(f"Grid size: {new_grid_size:.1f}mm, Threshold: {new_threshold:.1f}mm", 2000)
|
||||
print(f"Updated grid settings - Size: {new_grid_size:.1f}mm, Threshold: {new_threshold:.1f}mm")
|
||||
|
||||
# ===== Layout Tab Snapping Controls =====
|
||||
# These provide easy access to snapping features during layout work
|
||||
|
||||
@ribbon_action(
|
||||
label="Grid Snap",
|
||||
tooltip="Enable/disable snapping to grid (Ctrl+G)",
|
||||
tab="Layout",
|
||||
group="Snapping",
|
||||
shortcut="Ctrl+G"
|
||||
)
|
||||
def layout_toggle_grid_snap(self):
|
||||
"""Toggle grid snapping (Layout tab)"""
|
||||
self.toggle_grid_snap()
|
||||
|
||||
@ribbon_action(
|
||||
label="Edge Snap",
|
||||
tooltip="Enable/disable snapping to page edges (Ctrl+E)",
|
||||
tab="Layout",
|
||||
group="Snapping",
|
||||
shortcut="Ctrl+E"
|
||||
)
|
||||
def layout_toggle_edge_snap(self):
|
||||
"""Toggle edge snapping (Layout tab)"""
|
||||
self.toggle_edge_snap()
|
||||
|
||||
@ribbon_action(
|
||||
label="Guide Snap",
|
||||
tooltip="Enable/disable snapping to guides",
|
||||
tab="Layout",
|
||||
group="Snapping"
|
||||
)
|
||||
def layout_toggle_guide_snap(self):
|
||||
"""Toggle guide snapping (Layout tab)"""
|
||||
self.toggle_guide_snap()
|
||||
|
||||
@ribbon_action(
|
||||
label="Show Grid",
|
||||
tooltip="Toggle visibility of grid lines",
|
||||
tab="Layout",
|
||||
group="Snapping"
|
||||
)
|
||||
def layout_toggle_show_grid(self):
|
||||
"""Toggle grid visibility (Layout tab)"""
|
||||
self.toggle_show_grid()
|
||||
|
||||
@ribbon_action(
|
||||
label="Show Guides",
|
||||
tooltip="Toggle visibility of guide lines",
|
||||
tab="Layout",
|
||||
group="Snapping"
|
||||
)
|
||||
def layout_toggle_snap_lines(self):
|
||||
"""Toggle guide lines visibility (Layout tab)"""
|
||||
self.toggle_snap_lines()
|
||||
|
||||
@ribbon_action(
|
||||
label="Grid Settings...",
|
||||
tooltip="Configure grid size and snap threshold",
|
||||
tab="Layout",
|
||||
group="Snapping"
|
||||
)
|
||||
def layout_set_grid_size(self):
|
||||
"""Open grid settings dialog (Layout tab)"""
|
||||
self.set_grid_size()
|
||||
|
||||
@@ -8,159 +8,159 @@ from pyPhotoAlbum.commands import ChangeZOrderCommand
|
||||
|
||||
class ZOrderOperationsMixin:
|
||||
"""Mixin providing z-order/layer control operations"""
|
||||
|
||||
|
||||
@ribbon_action(
|
||||
label="Bring to Front",
|
||||
tooltip="Bring selected element to front",
|
||||
tab="Arrange",
|
||||
group="Order",
|
||||
shortcut="Ctrl+Shift+]",
|
||||
requires_selection=True
|
||||
requires_selection=True,
|
||||
)
|
||||
def bring_to_front(self):
|
||||
"""Bring selected element to front (end of list)"""
|
||||
if not self.gl_widget.selected_element:
|
||||
return
|
||||
|
||||
|
||||
current_page = self.get_current_page()
|
||||
if not current_page:
|
||||
return
|
||||
|
||||
|
||||
element = self.gl_widget.selected_element
|
||||
elements = current_page.layout.elements
|
||||
|
||||
|
||||
if element not in elements:
|
||||
return
|
||||
|
||||
|
||||
old_index = elements.index(element)
|
||||
new_index = len(elements) - 1
|
||||
|
||||
|
||||
if old_index == new_index:
|
||||
self.show_status("Element is already at front", 2000)
|
||||
return
|
||||
|
||||
|
||||
# Create and execute command
|
||||
cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index)
|
||||
self.project.history.execute(cmd)
|
||||
|
||||
|
||||
self.update_view()
|
||||
self.show_status("Brought element to front (Ctrl+Z to undo)", 2000)
|
||||
print(f"Brought element to front: {old_index} → {new_index}")
|
||||
|
||||
|
||||
@ribbon_action(
|
||||
label="Send to Back",
|
||||
tooltip="Send selected element to back",
|
||||
tab="Arrange",
|
||||
group="Order",
|
||||
shortcut="Ctrl+Shift+[",
|
||||
requires_selection=True
|
||||
requires_selection=True,
|
||||
)
|
||||
def send_to_back(self):
|
||||
"""Send selected element to back (start of list)"""
|
||||
if not self.gl_widget.selected_element:
|
||||
return
|
||||
|
||||
|
||||
current_page = self.get_current_page()
|
||||
if not current_page:
|
||||
return
|
||||
|
||||
|
||||
element = self.gl_widget.selected_element
|
||||
elements = current_page.layout.elements
|
||||
|
||||
|
||||
if element not in elements:
|
||||
return
|
||||
|
||||
|
||||
old_index = elements.index(element)
|
||||
new_index = 0
|
||||
|
||||
|
||||
if old_index == new_index:
|
||||
self.show_status("Element is already at back", 2000)
|
||||
return
|
||||
|
||||
|
||||
# Create and execute command
|
||||
cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index)
|
||||
self.project.history.execute(cmd)
|
||||
|
||||
|
||||
self.update_view()
|
||||
self.show_status("Sent element to back (Ctrl+Z to undo)", 2000)
|
||||
print(f"Sent element to back: {old_index} → {new_index}")
|
||||
|
||||
|
||||
@ribbon_action(
|
||||
label="Bring Forward",
|
||||
tooltip="Bring selected element forward one layer",
|
||||
tab="Arrange",
|
||||
group="Order",
|
||||
shortcut="Ctrl+]",
|
||||
requires_selection=True
|
||||
requires_selection=True,
|
||||
)
|
||||
def bring_forward(self):
|
||||
"""Move selected element forward one position in list"""
|
||||
if not self.gl_widget.selected_element:
|
||||
return
|
||||
|
||||
|
||||
current_page = self.get_current_page()
|
||||
if not current_page:
|
||||
return
|
||||
|
||||
|
||||
element = self.gl_widget.selected_element
|
||||
elements = current_page.layout.elements
|
||||
|
||||
|
||||
if element not in elements:
|
||||
return
|
||||
|
||||
|
||||
old_index = elements.index(element)
|
||||
new_index = old_index + 1
|
||||
|
||||
|
||||
if new_index >= len(elements):
|
||||
self.show_status("Element is already at front", 2000)
|
||||
return
|
||||
|
||||
|
||||
# Create and execute command
|
||||
cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index)
|
||||
self.project.history.execute(cmd)
|
||||
|
||||
|
||||
self.update_view()
|
||||
self.show_status("Brought element forward (Ctrl+Z to undo)", 2000)
|
||||
print(f"Brought element forward: {old_index} → {new_index}")
|
||||
|
||||
|
||||
@ribbon_action(
|
||||
label="Send Backward",
|
||||
tooltip="Send selected element backward one layer",
|
||||
tab="Arrange",
|
||||
group="Order",
|
||||
shortcut="Ctrl+[",
|
||||
requires_selection=True
|
||||
requires_selection=True,
|
||||
)
|
||||
def send_backward(self):
|
||||
"""Move selected element backward one position in list"""
|
||||
if not self.gl_widget.selected_element:
|
||||
return
|
||||
|
||||
|
||||
current_page = self.get_current_page()
|
||||
if not current_page:
|
||||
return
|
||||
|
||||
|
||||
element = self.gl_widget.selected_element
|
||||
elements = current_page.layout.elements
|
||||
|
||||
|
||||
if element not in elements:
|
||||
return
|
||||
|
||||
|
||||
old_index = elements.index(element)
|
||||
new_index = old_index - 1
|
||||
|
||||
|
||||
if new_index < 0:
|
||||
self.show_status("Element is already at back", 2000)
|
||||
return
|
||||
|
||||
|
||||
# Create and execute command
|
||||
cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index)
|
||||
self.project.history.execute(cmd)
|
||||
|
||||
|
||||
self.update_view()
|
||||
self.show_status("Sent element backward (Ctrl+Z to undo)", 2000)
|
||||
print(f"Sent element backward: {old_index} → {new_index}")
|
||||
|
||||
|
||||
@ribbon_action(
|
||||
label="Swap Order",
|
||||
tooltip="Swap z-order of two selected elements",
|
||||
@@ -168,21 +168,21 @@ class ZOrderOperationsMixin:
|
||||
group="Order",
|
||||
shortcut="Ctrl+Shift+X",
|
||||
requires_selection=True,
|
||||
min_selection=2
|
||||
min_selection=2,
|
||||
)
|
||||
def swap_order(self):
|
||||
"""Swap the z-order of two selected elements"""
|
||||
if len(self.gl_widget.selected_elements) != 2:
|
||||
self.show_status("Please select exactly 2 elements to swap", 2000)
|
||||
return
|
||||
|
||||
|
||||
current_page = self.get_current_page()
|
||||
if not current_page:
|
||||
return
|
||||
|
||||
|
||||
elements = current_page.layout.elements
|
||||
selected = list(self.gl_widget.selected_elements)
|
||||
|
||||
|
||||
# Get indices of both elements
|
||||
try:
|
||||
index1 = elements.index(selected[0])
|
||||
@@ -190,10 +190,10 @@ class ZOrderOperationsMixin:
|
||||
except ValueError:
|
||||
self.show_status("Selected elements not found on current page", 2000)
|
||||
return
|
||||
|
||||
|
||||
# Swap them in the list
|
||||
elements[index1], elements[index2] = elements[index2], elements[index1]
|
||||
|
||||
|
||||
self.update_view()
|
||||
self.show_status(f"Swapped z-order of elements", 2000)
|
||||
print(f"Swapped elements at indices {index1} and {index2}")
|
||||
|
||||
@@ -2,10 +2,25 @@
|
||||
Page navigation mixin for GLWidget - handles page detection and ghost pages
|
||||
"""
|
||||
|
||||
from typing import Optional, Tuple, List
|
||||
from typing import TYPE_CHECKING, Optional, Tuple, List
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from PyQt6.QtWidgets import QMainWindow
|
||||
|
||||
|
||||
class PageNavigationMixin:
|
||||
# Type hints for expected attributes from mixing class
|
||||
pan_offset: Tuple[float, float]
|
||||
zoom_level: float
|
||||
|
||||
def update(self) -> None: # type: ignore[empty-body]
|
||||
"""Expected from QWidget"""
|
||||
...
|
||||
|
||||
def window(self) -> "QMainWindow": # type: ignore[empty-body]
|
||||
"""Expected from QWidget"""
|
||||
...
|
||||
|
||||
"""
|
||||
Mixin providing page navigation and ghost page functionality.
|
||||
|
||||
@@ -33,11 +48,11 @@ class PageNavigationMixin:
|
||||
Returns:
|
||||
Tuple of (page, page_index, renderer) or (None, -1, None) if no page at coordinates
|
||||
"""
|
||||
if not hasattr(self, '_page_renderers') or not self._page_renderers:
|
||||
if not hasattr(self, "_page_renderers") or not self._page_renderers:
|
||||
return None, -1, None
|
||||
|
||||
main_window = self.window()
|
||||
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
|
||||
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
|
||||
return None, -1, None
|
||||
|
||||
# Check each page to find which one contains the coordinates
|
||||
@@ -56,15 +71,23 @@ class PageNavigationMixin:
|
||||
Returns:
|
||||
List of tuples (page_type, page_or_ghost_data, y_offset)
|
||||
"""
|
||||
main_window = self.window()
|
||||
if not hasattr(main_window, 'project'):
|
||||
# Use stored reference to main window
|
||||
main_window = getattr(self, "_main_window", None)
|
||||
if main_window is None:
|
||||
main_window = self.window()
|
||||
|
||||
try:
|
||||
project = main_window.project
|
||||
if not project:
|
||||
return []
|
||||
except (AttributeError, TypeError):
|
||||
return []
|
||||
|
||||
dpi = main_window.project.working_dpi
|
||||
dpi = project.working_dpi
|
||||
|
||||
# Use project's page_spacing_mm setting (default is 10mm = 1cm)
|
||||
# Convert to pixels at working DPI
|
||||
spacing_mm = main_window.project.page_spacing_mm
|
||||
spacing_mm = project.page_spacing_mm
|
||||
spacing_px = spacing_mm * dpi / 25.4
|
||||
|
||||
# Start with a small top margin (5mm)
|
||||
@@ -75,9 +98,9 @@ class PageNavigationMixin:
|
||||
current_y = top_margin_px # Initial top offset in pixels (not screen pixels)
|
||||
|
||||
# First, render cover if it exists
|
||||
for page in main_window.project.pages:
|
||||
for page in project.pages:
|
||||
if page.is_cover:
|
||||
result.append(('page', page, current_y))
|
||||
result.append(("page", page, current_y))
|
||||
|
||||
# Calculate cover height in pixels
|
||||
page_height_mm = page.layout.size[1]
|
||||
@@ -88,10 +111,10 @@ class PageNavigationMixin:
|
||||
break # Only one cover allowed
|
||||
|
||||
# Get page layout with ghosts from project (this excludes cover)
|
||||
layout_with_ghosts = main_window.project.calculate_page_layout_with_ghosts()
|
||||
layout_with_ghosts = project.calculate_page_layout_with_ghosts()
|
||||
|
||||
for page_type, page_obj, logical_pos in layout_with_ghosts:
|
||||
if page_type == 'page':
|
||||
if page_type == "page":
|
||||
# Regular page (single or double spread)
|
||||
result.append((page_type, page_obj, current_y))
|
||||
|
||||
@@ -103,9 +126,9 @@ class PageNavigationMixin:
|
||||
# Move to next position (add height + spacing)
|
||||
current_y += page_height_px + spacing_px
|
||||
|
||||
elif page_type == 'ghost':
|
||||
elif page_type == "ghost":
|
||||
# Ghost page - use default page size
|
||||
page_size_mm = main_window.project.page_size_mm
|
||||
page_size_mm = project.page_size_mm
|
||||
from pyPhotoAlbum.models import GhostPageData
|
||||
|
||||
# Create ghost page data with correct size
|
||||
@@ -131,11 +154,11 @@ class PageNavigationMixin:
|
||||
Returns:
|
||||
bool: True if a ghost page was clicked and a new page was created
|
||||
"""
|
||||
if not hasattr(self, '_page_renderers'):
|
||||
if not hasattr(self, "_page_renderers"):
|
||||
return False
|
||||
|
||||
main_window = self.window()
|
||||
if not hasattr(main_window, 'project'):
|
||||
if not hasattr(main_window, "project"):
|
||||
return False
|
||||
|
||||
# Get page positions which includes ghosts
|
||||
@@ -144,7 +167,7 @@ class PageNavigationMixin:
|
||||
# Check each position for ghost pages
|
||||
for idx, (page_type, page_or_ghost, y_offset) in enumerate(page_positions):
|
||||
# Skip non-ghost pages
|
||||
if page_type != 'ghost':
|
||||
if page_type != "ghost":
|
||||
continue
|
||||
|
||||
ghost = page_or_ghost
|
||||
@@ -156,20 +179,21 @@ class PageNavigationMixin:
|
||||
screen_y = (y_offset * self.zoom_level) + self.pan_offset[1]
|
||||
|
||||
from pyPhotoAlbum.page_renderer import PageRenderer
|
||||
|
||||
renderer = PageRenderer(
|
||||
page_width_mm=ghost_width_mm,
|
||||
page_height_mm=ghost_height_mm,
|
||||
screen_x=screen_x,
|
||||
screen_y=screen_y,
|
||||
dpi=dpi,
|
||||
zoom=self.zoom_level
|
||||
zoom=self.zoom_level,
|
||||
)
|
||||
|
||||
# Check if click is anywhere on the ghost page (entire page is clickable)
|
||||
if renderer.is_point_in_page(x, y):
|
||||
# User clicked the ghost page!
|
||||
# Calculate the insertion index (count real pages before this ghost in page_positions)
|
||||
insert_index = sum(1 for i, (pt, _, _) in enumerate(page_positions) if i < idx and pt == 'page')
|
||||
insert_index = sum(1 for i, (pt, _, _) in enumerate(page_positions) if i < idx and pt == "page")
|
||||
|
||||
print(f"Ghost page clicked at index {insert_index} - inserting new page in place")
|
||||
|
||||
@@ -181,10 +205,9 @@ class PageNavigationMixin:
|
||||
new_page_number = insert_index + 1
|
||||
new_page = Page(
|
||||
layout=PageLayout(
|
||||
width=main_window.project.page_size_mm[0],
|
||||
height=main_window.project.page_size_mm[1]
|
||||
width=main_window.project.page_size_mm[0], height=main_window.project.page_size_mm[1]
|
||||
),
|
||||
page_number=new_page_number
|
||||
page_number=new_page_number,
|
||||
)
|
||||
|
||||
# Insert the page at the correct position
|
||||
@@ -209,10 +232,10 @@ class PageNavigationMixin:
|
||||
y: Screen Y coordinate
|
||||
"""
|
||||
main_window = self.window()
|
||||
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
|
||||
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
|
||||
return
|
||||
|
||||
if not hasattr(self, '_page_renderers') or not self._page_renderers:
|
||||
if not hasattr(self, "_page_renderers") or not self._page_renderers:
|
||||
return
|
||||
|
||||
# Get total page count (accounting for double spreads = 2 pages each)
|
||||
@@ -228,7 +251,7 @@ class PageNavigationMixin:
|
||||
if page.is_double_spread:
|
||||
side = renderer.get_sub_page_at(x, is_facing_page=True)
|
||||
page_nums = page.get_page_numbers()
|
||||
if side == 'left':
|
||||
if side == "left":
|
||||
current_page_info = f"Page {page_nums[0]}"
|
||||
else:
|
||||
current_page_info = f"Page {page_nums[1]}"
|
||||
@@ -237,8 +260,10 @@ class PageNavigationMixin:
|
||||
break
|
||||
|
||||
# Update status bar
|
||||
if hasattr(main_window, 'status_bar'):
|
||||
if hasattr(main_window, "status_bar"):
|
||||
if current_page_info:
|
||||
main_window.status_bar.showMessage(f"{current_page_info} of {total_pages} | Zoom: {int(self.zoom_level * 100)}%")
|
||||
main_window.status_bar.showMessage(
|
||||
f"{current_page_info} of {total_pages} | Zoom: {int(self.zoom_level * 100)}%"
|
||||
)
|
||||
else:
|
||||
main_window.status_bar.showMessage(f"Total pages: {total_pages} | Zoom: {int(self.zoom_level * 100)}%")
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
Rendering mixin for GLWidget - handles OpenGL rendering
|
||||
"""
|
||||
|
||||
from OpenGL.GL import *
|
||||
import math
|
||||
|
||||
from pyPhotoAlbum.gl_imports import *
|
||||
from PyQt6.QtGui import QPainter, QFont, QColor, QPen
|
||||
from PyQt6.QtCore import Qt, QRectF
|
||||
from pyPhotoAlbum.models import TextBoxData
|
||||
@@ -23,8 +25,23 @@ class RenderingMixin:
|
||||
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
|
||||
glLoadIdentity()
|
||||
|
||||
main_window = self.window()
|
||||
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
|
||||
# Use stored reference to main window
|
||||
main_window = getattr(self, "_main_window", None)
|
||||
if main_window is None:
|
||||
# Fallback to window() if _main_window not set
|
||||
main_window = self.window()
|
||||
|
||||
if main_window is None:
|
||||
return
|
||||
|
||||
try:
|
||||
project = main_window.project
|
||||
if not project:
|
||||
return
|
||||
if not project.pages:
|
||||
return
|
||||
except AttributeError:
|
||||
# Project not yet initialized
|
||||
return
|
||||
|
||||
# Set initial zoom and center the page if not done yet
|
||||
@@ -34,11 +51,10 @@ class RenderingMixin:
|
||||
self.initial_zoom_set = True
|
||||
|
||||
# Update scrollbars now that we have content bounds
|
||||
main_window = self.window()
|
||||
if hasattr(main_window, 'update_scrollbars'):
|
||||
main_window.update_scrollbars()
|
||||
if hasattr(self, "_main_window") and hasattr(self._main_window, "update_scrollbars"):
|
||||
self._main_window.update_scrollbars()
|
||||
|
||||
dpi = main_window.project.working_dpi
|
||||
dpi = project.working_dpi
|
||||
|
||||
# Calculate page positions with ghosts
|
||||
page_positions = self._get_page_positions()
|
||||
@@ -50,10 +66,11 @@ class RenderingMixin:
|
||||
PAGE_MARGIN = 50
|
||||
|
||||
# Render all pages
|
||||
pages_rendered = 0
|
||||
for page_info in page_positions:
|
||||
page_type, page_or_ghost, y_offset = page_info
|
||||
|
||||
if page_type == 'page':
|
||||
if page_type == "page":
|
||||
page = page_or_ghost
|
||||
page_width_mm, page_height_mm = page.layout.size
|
||||
|
||||
@@ -66,7 +83,7 @@ class RenderingMixin:
|
||||
screen_x=screen_x,
|
||||
screen_y=screen_y,
|
||||
dpi=dpi,
|
||||
zoom=self.zoom_level
|
||||
zoom=self.zoom_level,
|
||||
)
|
||||
|
||||
self._page_renderers.append((renderer, page))
|
||||
@@ -74,10 +91,15 @@ class RenderingMixin:
|
||||
renderer.begin_render()
|
||||
# Pass widget reference for async loading
|
||||
page.layout._parent_widget = self
|
||||
page.layout.render(dpi=dpi, project=main_window.project)
|
||||
page.layout.render(dpi=dpi, project=project)
|
||||
renderer.end_render()
|
||||
|
||||
elif page_type == 'ghost':
|
||||
# Draw bleed/cut/safe-area guides for this page
|
||||
self._draw_page_print_guides(renderer, project)
|
||||
|
||||
pages_rendered += 1
|
||||
|
||||
elif page_type == "ghost":
|
||||
ghost = page_or_ghost
|
||||
ghost_width_mm, ghost_height_mm = ghost.page_size
|
||||
|
||||
@@ -90,14 +112,14 @@ class RenderingMixin:
|
||||
screen_x=screen_x,
|
||||
screen_y=screen_y,
|
||||
dpi=dpi,
|
||||
zoom=self.zoom_level
|
||||
zoom=self.zoom_level,
|
||||
)
|
||||
|
||||
self._render_ghost_page(ghost, renderer)
|
||||
|
||||
# Update PageRenderer references for selected elements
|
||||
for element in self.selected_elements:
|
||||
if hasattr(element, '_parent_page'):
|
||||
if hasattr(element, "_parent_page"):
|
||||
for renderer, page in self._page_renderers:
|
||||
if page is element._parent_page:
|
||||
element._page_renderer = renderer
|
||||
@@ -107,7 +129,8 @@ class RenderingMixin:
|
||||
for element in self.selected_elements:
|
||||
self._draw_selection_handles(element)
|
||||
|
||||
# Render text overlays
|
||||
# Render text overlays using QPainter
|
||||
# Qt will handle OpenGL/QPainter coordination automatically
|
||||
self._render_text_overlays()
|
||||
|
||||
def _draw_selection_handles(self, element):
|
||||
@@ -116,10 +139,10 @@ class RenderingMixin:
|
||||
return
|
||||
|
||||
main_window = self.window()
|
||||
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
|
||||
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
|
||||
return
|
||||
|
||||
if not hasattr(element, '_page_renderer'):
|
||||
if not hasattr(element, "_page_renderer"):
|
||||
return
|
||||
|
||||
renderer = element._page_renderer
|
||||
@@ -152,7 +175,6 @@ class RenderingMixin:
|
||||
glLineWidth(1.0)
|
||||
|
||||
if self.rotation_mode:
|
||||
import math
|
||||
handle_radius = 6
|
||||
handles = [(x, y), (x + w, y), (x, y + h), (x + w, y + h)]
|
||||
|
||||
@@ -187,10 +209,10 @@ class RenderingMixin:
|
||||
glEnd()
|
||||
else:
|
||||
handles = [
|
||||
(x - handle_size/2, y - handle_size/2),
|
||||
(x + w - handle_size/2, y - handle_size/2),
|
||||
(x - handle_size/2, y + h - handle_size/2),
|
||||
(x + w - handle_size/2, y + h - handle_size/2),
|
||||
(x - handle_size / 2, y - handle_size / 2),
|
||||
(x + w - handle_size / 2, y - handle_size / 2),
|
||||
(x - handle_size / 2, y + h - handle_size / 2),
|
||||
(x + w - handle_size / 2, y + h - handle_size / 2),
|
||||
]
|
||||
|
||||
glColor3f(1.0, 1.0, 1.0)
|
||||
@@ -213,7 +235,7 @@ class RenderingMixin:
|
||||
|
||||
def _render_text_overlays(self):
|
||||
"""Render text content for TextBoxData elements using QPainter overlay"""
|
||||
if not hasattr(self, '_page_renderers') or not self._page_renderers:
|
||||
if not hasattr(self, "_page_renderers") or not self._page_renderers:
|
||||
return
|
||||
|
||||
painter = QPainter(self)
|
||||
@@ -235,41 +257,44 @@ class RenderingMixin:
|
||||
screen_w = w * renderer.zoom
|
||||
screen_h = h * renderer.zoom
|
||||
|
||||
font_family = element.font_settings.get('family', 'Arial')
|
||||
font_size = int(element.font_settings.get('size', 12) * renderer.zoom)
|
||||
font_family = element.font_settings.get("family", "Arial")
|
||||
# Use base font size without zoom - zoom is applied via painter transform
|
||||
font_size = int(element.font_settings.get("size", 12))
|
||||
font = QFont(font_family, font_size)
|
||||
painter.setFont(font)
|
||||
|
||||
font_color = element.font_settings.get('color', (0, 0, 0))
|
||||
font_color = element.font_settings.get("color", (0, 0, 0))
|
||||
if all(isinstance(c, int) and c > 1 for c in font_color):
|
||||
color = QColor(*font_color)
|
||||
else:
|
||||
color = QColor(int(font_color[0] * 255), int(font_color[1] * 255), int(font_color[2] * 255))
|
||||
painter.setPen(QPen(color))
|
||||
|
||||
# Apply zoom via painter transform so font scales consistently with page
|
||||
painter.save()
|
||||
painter.translate(screen_x, screen_y)
|
||||
painter.scale(renderer.zoom, renderer.zoom)
|
||||
|
||||
if element.rotation != 0:
|
||||
painter.save()
|
||||
center_x = screen_x + screen_w / 2
|
||||
center_y = screen_y + screen_h / 2
|
||||
painter.translate(center_x, center_y)
|
||||
painter.translate(w / 2, h / 2)
|
||||
painter.rotate(element.rotation)
|
||||
painter.translate(-screen_w / 2, -screen_h / 2)
|
||||
rect = QRectF(0, 0, screen_w, screen_h)
|
||||
else:
|
||||
rect = QRectF(screen_x, screen_y, screen_w, screen_h)
|
||||
painter.translate(-w / 2, -h / 2)
|
||||
|
||||
rect = QRectF(0, 0, w, h)
|
||||
|
||||
alignment = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop
|
||||
if element.alignment == 'center':
|
||||
if element.alignment == "center":
|
||||
alignment = Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop
|
||||
elif element.alignment == 'right':
|
||||
elif element.alignment == "right":
|
||||
alignment = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop
|
||||
elif element.alignment == "justify":
|
||||
alignment = Qt.AlignmentFlag.AlignJustify | Qt.AlignmentFlag.AlignTop
|
||||
|
||||
text_flags = Qt.TextFlag.TextWordWrap
|
||||
|
||||
painter.drawText(rect, int(alignment | text_flags), element.text_content)
|
||||
|
||||
if element.rotation != 0:
|
||||
painter.restore()
|
||||
painter.restore()
|
||||
|
||||
finally:
|
||||
painter.end()
|
||||
@@ -288,15 +313,98 @@ class RenderingMixin:
|
||||
px, py, pw, ph = ghost_data.get_page_rect()
|
||||
|
||||
screen_x, screen_y = renderer.page_to_screen(px, py)
|
||||
screen_w = pw * renderer.zoom
|
||||
screen_h = ph * renderer.zoom
|
||||
|
||||
font = QFont("Arial", int(16 * renderer.zoom), QFont.Weight.Bold)
|
||||
# Use base font size without zoom - zoom is applied via painter transform
|
||||
font = QFont("Arial", 16, QFont.Weight.Bold)
|
||||
painter.setFont(font)
|
||||
painter.setPen(QColor(120, 120, 120))
|
||||
|
||||
rect = QRectF(screen_x, screen_y, screen_w, screen_h)
|
||||
painter.save()
|
||||
painter.translate(screen_x, screen_y)
|
||||
painter.scale(renderer.zoom, renderer.zoom)
|
||||
|
||||
rect = QRectF(0, 0, pw, ph)
|
||||
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "Click to Add Page")
|
||||
|
||||
painter.restore()
|
||||
|
||||
finally:
|
||||
painter.end()
|
||||
|
||||
def _draw_page_print_guides(self, renderer, project):
|
||||
"""
|
||||
Draw bleed/cut/safe-area guide lines around a page using OpenGL.
|
||||
|
||||
- Green dashed rectangle: bleed boundary (extend backgrounds to here)
|
||||
- Magenta rectangle: cut/trim line (finished page edge, only shown when bleed > 0)
|
||||
- Red rectangle: safe area (keep text and logos inside)
|
||||
|
||||
Lines are only drawn when the corresponding project settings are non-zero.
|
||||
"""
|
||||
if not getattr(project, "show_print_guides", False):
|
||||
return
|
||||
|
||||
bleed_mm = getattr(project, "page_bleed_mm", 0.0)
|
||||
safe_mm = getattr(project, "page_safe_area_mm", 0.0)
|
||||
|
||||
if bleed_mm <= 0.0 and safe_mm <= 0.0:
|
||||
return
|
||||
|
||||
dpi = project.working_dpi
|
||||
zoom = renderer.zoom
|
||||
sx = renderer.screen_x
|
||||
sy = renderer.screen_y
|
||||
sw = renderer.screen_width
|
||||
sh = renderer.screen_height
|
||||
|
||||
# Convert mm to screen pixels
|
||||
bleed_screen = bleed_mm * dpi / 25.4 * zoom
|
||||
safe_screen = safe_mm * dpi / 25.4 * zoom
|
||||
|
||||
glLineWidth(1.0)
|
||||
|
||||
def _draw_rect_outline(x, y, w, h, r, g, b, dashed=False):
|
||||
glColor3f(r, g, b)
|
||||
if dashed:
|
||||
glEnable(GL_LINE_STIPPLE)
|
||||
glLineStipple(1, 0x00FF)
|
||||
else:
|
||||
glDisable(GL_LINE_STIPPLE)
|
||||
glBegin(GL_LINE_LOOP)
|
||||
glVertex2f(x, y)
|
||||
glVertex2f(x + w, y)
|
||||
glVertex2f(x + w, y + h)
|
||||
glVertex2f(x, y + h)
|
||||
glEnd()
|
||||
glDisable(GL_LINE_STIPPLE)
|
||||
|
||||
# Bleed boundary – green dashed rectangle outside the page
|
||||
if bleed_screen > 0:
|
||||
_draw_rect_outline(
|
||||
sx - bleed_screen,
|
||||
sy - bleed_screen,
|
||||
sw + 2 * bleed_screen,
|
||||
sh + 2 * bleed_screen,
|
||||
0.0,
|
||||
0.67,
|
||||
0.0,
|
||||
dashed=True,
|
||||
)
|
||||
|
||||
# Cut/trim line – magenta rectangle at the page edge (only meaningful when bleed > 0)
|
||||
if bleed_screen > 0:
|
||||
_draw_rect_outline(sx, sy, sw, sh, 0.8, 0.0, 0.8)
|
||||
|
||||
# Safe area – red rectangle inside the page
|
||||
if safe_screen > 0:
|
||||
_draw_rect_outline(
|
||||
sx + safe_screen,
|
||||
sy + safe_screen,
|
||||
sw - 2 * safe_screen,
|
||||
sh - 2 * safe_screen,
|
||||
0.8,
|
||||
0.0,
|
||||
0.0,
|
||||
)
|
||||
|
||||
glColor3f(1.0, 1.0, 1.0) # Reset colour
|
||||
|
||||
+129
-30
@@ -2,7 +2,7 @@
|
||||
Viewport mixin for GLWidget - handles zoom and pan
|
||||
"""
|
||||
|
||||
from OpenGL.GL import *
|
||||
from pyPhotoAlbum.gl_imports import *
|
||||
|
||||
|
||||
class ViewportMixin:
|
||||
@@ -21,6 +21,9 @@ class ViewportMixin:
|
||||
self.pan_offset = [0, 0]
|
||||
self.initial_zoom_set = False # Track if we've set initial fit-to-screen zoom
|
||||
|
||||
# Track previous viewport size to detect scrollbar-induced resizes
|
||||
self._last_viewport_size = (0, 0)
|
||||
|
||||
def initializeGL(self):
|
||||
"""Initialize OpenGL resources"""
|
||||
glClearColor(1.0, 1.0, 1.0, 1.0)
|
||||
@@ -34,16 +37,31 @@ class ViewportMixin:
|
||||
glOrtho(0, w, h, 0, -1, 1)
|
||||
glMatrixMode(GL_MODELVIEW)
|
||||
|
||||
# Detect if this is a small resize (likely scrollbar visibility change)
|
||||
# Scrollbars are typically 14-20 pixels wide
|
||||
last_w, last_h = self._last_viewport_size
|
||||
width_change = abs(w - last_w)
|
||||
height_change = abs(h - last_h)
|
||||
is_small_resize = width_change <= 20 and height_change <= 20
|
||||
is_first_resize = last_w == 0 and last_h == 0
|
||||
|
||||
# Recalculate centering if we have a project loaded
|
||||
if self.initial_zoom_set:
|
||||
# Recenter on:
|
||||
# 1. First resize (initial setup)
|
||||
# 2. Large resizes (window resize, NOT scrollbar changes)
|
||||
# Don't recenter on small resizes (scrollbar visibility changes during zoom)
|
||||
if self.initial_zoom_set and (is_first_resize or not is_small_resize):
|
||||
# Maintain current zoom level, just recenter
|
||||
self.pan_offset = self._calculate_center_pan_offset(self.zoom_level)
|
||||
|
||||
# Update tracked viewport size
|
||||
self._last_viewport_size = (w, h)
|
||||
|
||||
self.update()
|
||||
|
||||
# Update scrollbars when viewport size changes
|
||||
main_window = self.window()
|
||||
if hasattr(main_window, 'update_scrollbars'):
|
||||
if hasattr(main_window, "update_scrollbars"):
|
||||
main_window.update_scrollbars()
|
||||
|
||||
def _calculate_fit_to_screen_zoom(self):
|
||||
@@ -54,7 +72,7 @@ class ViewportMixin:
|
||||
float: Zoom level (1.0 = 100%, 0.5 = 50%, etc.)
|
||||
"""
|
||||
main_window = self.window()
|
||||
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
|
||||
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
|
||||
return 1.0
|
||||
|
||||
window_width = self.width()
|
||||
@@ -88,7 +106,7 @@ class ViewportMixin:
|
||||
list: [x_offset, y_offset] to center the page
|
||||
"""
|
||||
main_window = self.window()
|
||||
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
|
||||
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
|
||||
return [0, 0]
|
||||
|
||||
window_width = self.width()
|
||||
@@ -123,8 +141,8 @@ class ViewportMixin:
|
||||
dict: {'min_x', 'max_x', 'min_y', 'max_y', 'width', 'height'} in pixels
|
||||
"""
|
||||
main_window = self.window()
|
||||
if not hasattr(main_window, 'project') or not main_window.project or not main_window.project.pages:
|
||||
return {'min_x': 0, 'max_x': 800, 'min_y': 0, 'max_y': 600, 'width': 800, 'height': 600}
|
||||
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
|
||||
return {"min_x": 0, "max_x": 800, "min_y": 0, "max_y": 600, "width": 800, "height": 600}
|
||||
|
||||
dpi = main_window.project.working_dpi
|
||||
PAGE_MARGIN = 50
|
||||
@@ -149,14 +167,92 @@ class ViewportMixin:
|
||||
total_height += PAGE_MARGIN
|
||||
|
||||
return {
|
||||
'min_x': 0,
|
||||
'max_x': total_width,
|
||||
'min_y': 0,
|
||||
'max_y': total_height,
|
||||
'width': total_width,
|
||||
'height': total_height
|
||||
"min_x": 0,
|
||||
"max_x": total_width,
|
||||
"min_y": 0,
|
||||
"max_y": total_height,
|
||||
"width": total_width,
|
||||
"height": total_height,
|
||||
}
|
||||
|
||||
def _clamp_vertical_pan(self, viewport_height: float) -> float:
|
||||
"""Clamp vertical pan offset and return the original value before clamping."""
|
||||
bounds = self.get_content_bounds()
|
||||
content_height = bounds["height"]
|
||||
|
||||
# Save original for page selection (prevents clamping from changing which page we target)
|
||||
original_pan_y: float = self.pan_offset[1]
|
||||
|
||||
if content_height > viewport_height:
|
||||
max_pan_up = 0 # Can't pan beyond top edge
|
||||
min_pan_up = -(content_height - viewport_height) # Can't pan beyond bottom edge
|
||||
self.pan_offset[1] = max(min_pan_up, min(max_pan_up, self.pan_offset[1]))
|
||||
|
||||
return original_pan_y
|
||||
|
||||
def _build_page_centerlines(self, pages, dpi: float) -> list:
|
||||
"""Build list of (center_y, center_x, width) tuples for each page."""
|
||||
PAGE_MARGIN = 50
|
||||
PAGE_SPACING = 50
|
||||
|
||||
centerlines = []
|
||||
current_y = PAGE_MARGIN
|
||||
|
||||
for page in pages:
|
||||
page_width_mm, page_height_mm = page.layout.size
|
||||
screen_page_width = page_width_mm * dpi / 25.4 * self.zoom_level
|
||||
screen_page_height = page_height_mm * dpi / 25.4 * self.zoom_level
|
||||
|
||||
page_center_y = current_y + screen_page_height / 2
|
||||
page_center_x = PAGE_MARGIN + screen_page_width / 2
|
||||
|
||||
centerlines.append((page_center_y, page_center_x, screen_page_width))
|
||||
current_y += screen_page_height + PAGE_SPACING
|
||||
|
||||
return centerlines
|
||||
|
||||
def _interpolate_target_centerline(self, centerlines: list, viewport_center_y: float) -> tuple:
|
||||
"""Find target centerline by interpolating between pages based on viewport position."""
|
||||
if not centerlines:
|
||||
return 0, 0
|
||||
|
||||
# Find the page index we're at or past
|
||||
page_idx = self._find_page_at_viewport_y(centerlines, viewport_center_y)
|
||||
|
||||
if page_idx == 0:
|
||||
return centerlines[0][1], centerlines[0][2]
|
||||
|
||||
# Interpolate between previous and current page
|
||||
prev_y, prev_x, prev_w = centerlines[page_idx - 1]
|
||||
curr_y, curr_x, curr_w = centerlines[page_idx]
|
||||
|
||||
if curr_y == prev_y:
|
||||
return curr_x, curr_w
|
||||
|
||||
t = max(0, min(1, (viewport_center_y - prev_y) / (curr_y - prev_y)))
|
||||
return prev_x + t * (curr_x - prev_x), prev_w + t * (curr_w - prev_w)
|
||||
|
||||
def _find_page_at_viewport_y(self, centerlines: list, viewport_center_y: float) -> int:
|
||||
"""Find index of page at or after viewport Y position."""
|
||||
for i, (page_y, _, _) in enumerate(centerlines):
|
||||
if viewport_center_y <= page_y:
|
||||
return i
|
||||
return len(centerlines) - 1 # Below all pages - use last
|
||||
|
||||
def _clamp_horizontal_pan(self, viewport_width: float, target_centerline_x: float, target_page_width: float):
|
||||
"""Clamp horizontal pan to keep viewport centered on target page."""
|
||||
ideal_pan_x = viewport_width / 2 - target_centerline_x
|
||||
|
||||
if target_page_width > viewport_width:
|
||||
max_deviation = (target_page_width / 2) + (viewport_width / 4)
|
||||
else:
|
||||
max_deviation = 100 # Small margin to avoid jitter
|
||||
|
||||
min_pan_x = ideal_pan_x - max_deviation
|
||||
max_pan_x = ideal_pan_x + max_deviation
|
||||
|
||||
self.pan_offset[0] = max(min_pan_x, min(max_pan_x, self.pan_offset[0]))
|
||||
|
||||
def clamp_pan_offset(self):
|
||||
"""
|
||||
Clamp pan offset to prevent scrolling beyond content bounds.
|
||||
@@ -164,27 +260,30 @@ class ViewportMixin:
|
||||
Pan offset semantics:
|
||||
- Positive pan_offset = content moved right/down (viewing top-left)
|
||||
- Negative pan_offset = content moved left/up (viewing bottom-right)
|
||||
|
||||
For horizontal clamping, we use a centerline-based approach that interpolates
|
||||
between page centers based on vertical scroll position. This prevents jumps
|
||||
when zooming on pages of different widths.
|
||||
"""
|
||||
bounds = self.get_content_bounds()
|
||||
main_window = self.window()
|
||||
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
|
||||
return
|
||||
|
||||
viewport_width = self.width()
|
||||
viewport_height = self.height()
|
||||
|
||||
content_width = bounds['width']
|
||||
content_height = bounds['height']
|
||||
# Vertical clamping (returns original pan_y for page selection)
|
||||
original_pan_y = self._clamp_vertical_pan(viewport_height)
|
||||
|
||||
# Only clamp if content is larger than viewport
|
||||
# If content is smaller, allow any pan offset (for centering)
|
||||
# Build page centerline data
|
||||
dpi = main_window.project.working_dpi
|
||||
centerlines = self._build_page_centerlines(main_window.project.pages, dpi)
|
||||
if not centerlines:
|
||||
return
|
||||
|
||||
# Find target centerline by interpolating based on viewport position
|
||||
viewport_center_y = -original_pan_y + viewport_height / 2
|
||||
target_x, target_width = self._interpolate_target_centerline(centerlines, viewport_center_y)
|
||||
|
||||
# Horizontal clamping
|
||||
if content_width > viewport_width:
|
||||
# Content is wider than viewport - restrict panning
|
||||
max_pan_left = 0 # Can't pan beyond left edge
|
||||
min_pan_left = -(content_width - viewport_width) # Can't pan beyond right edge
|
||||
self.pan_offset[0] = max(min_pan_left, min(max_pan_left, self.pan_offset[0]))
|
||||
|
||||
# Vertical clamping
|
||||
if content_height > viewport_height:
|
||||
# Content is taller than viewport - restrict panning
|
||||
max_pan_up = 0 # Can't pan beyond top edge
|
||||
min_pan_up = -(content_height - viewport_height) # Can't pan beyond bottom edge
|
||||
self.pan_offset[1] = max(min_pan_up, min(max_pan_up, self.pan_offset[1]))
|
||||
self._clamp_horizontal_pan(viewport_width, target_x, target_width)
|
||||
|
||||
+606
-205
File diff suppressed because it is too large
Load Diff
+67
-52
@@ -2,9 +2,32 @@
|
||||
Page layout and template system for pyPhotoAlbum
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
from typing import List, Dict, Any, Optional, Tuple, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from PyQt6.QtWidgets import QWidget
|
||||
|
||||
from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData
|
||||
from pyPhotoAlbum.snapping import SnappingSystem
|
||||
from pyPhotoAlbum.gl_imports import (
|
||||
glBegin,
|
||||
glEnd,
|
||||
glVertex2f,
|
||||
glColor3f,
|
||||
glColor4f,
|
||||
GL_QUADS,
|
||||
GL_LINE_LOOP,
|
||||
GL_LINES,
|
||||
glLineWidth,
|
||||
glEnable,
|
||||
glDisable,
|
||||
GL_DEPTH_TEST,
|
||||
GL_BLEND,
|
||||
glBlendFunc,
|
||||
GL_SRC_ALPHA,
|
||||
GL_ONE_MINUS_SRC_ALPHA,
|
||||
)
|
||||
|
||||
|
||||
class PageLayout:
|
||||
"""Class to manage page layout and templates"""
|
||||
@@ -12,7 +35,7 @@ class PageLayout:
|
||||
def __init__(self, width: float = 210, height: float = 297, is_facing_page: bool = False):
|
||||
"""
|
||||
Initialize page layout.
|
||||
|
||||
|
||||
Args:
|
||||
width: Width in mm (doubled automatically if is_facing_page=True)
|
||||
height: Height in mm
|
||||
@@ -26,16 +49,18 @@ class PageLayout:
|
||||
self.background_color = (1.0, 1.0, 1.0) # White background
|
||||
self.snapping_system = SnappingSystem()
|
||||
self.show_snap_lines = True # Show snap lines while dragging
|
||||
self._parent_widget: Optional["QWidget"] = None # Set by renderer
|
||||
|
||||
def add_element(self, element: BaseLayoutElement):
|
||||
"""Add a layout element to the page"""
|
||||
self.elements.append(element)
|
||||
if element not in self.elements:
|
||||
self.elements.append(element)
|
||||
|
||||
def remove_element(self, element: BaseLayoutElement):
|
||||
"""Remove a layout element from the page"""
|
||||
self.elements.remove(element)
|
||||
|
||||
def set_grid_layout(self, grid: 'GridLayout'):
|
||||
def set_grid_layout(self, grid: "GridLayout"):
|
||||
"""Set a grid layout for the page"""
|
||||
self.grid_layout = grid
|
||||
|
||||
@@ -50,19 +75,17 @@ class PageLayout:
|
||||
dpi: Working DPI for converting mm to pixels
|
||||
project: Optional project instance for global snapping settings
|
||||
"""
|
||||
from OpenGL.GL import glBegin, glEnd, glVertex2f, glColor3f, GL_QUADS, GL_LINE_LOOP, GL_LINES, glLineWidth, glDisable, glEnable, GL_DEPTH_TEST
|
||||
|
||||
# Disable depth testing for 2D rendering
|
||||
glDisable(GL_DEPTH_TEST)
|
||||
|
||||
|
||||
# Convert size from mm to pixels based on DPI
|
||||
width_px = self.size[0] * dpi / 25.4
|
||||
height_px = self.size[1] * dpi / 25.4
|
||||
|
||||
|
||||
# All rendering is at page origin (0, 0) in page-local coordinates
|
||||
page_x = 0
|
||||
page_y = 0
|
||||
|
||||
|
||||
# Draw drop shadow FIRST (behind everything)
|
||||
shadow_offset = 5
|
||||
glColor3f(0.5, 0.5, 0.5)
|
||||
@@ -72,14 +95,14 @@ class PageLayout:
|
||||
glVertex2f(page_x + width_px + shadow_offset, page_y + height_px + shadow_offset)
|
||||
glVertex2f(page_x + shadow_offset, page_y + height_px + shadow_offset)
|
||||
glEnd()
|
||||
|
||||
|
||||
glBegin(GL_QUADS)
|
||||
glVertex2f(page_x + width_px, page_y + shadow_offset)
|
||||
glVertex2f(page_x + width_px + shadow_offset, page_y + shadow_offset)
|
||||
glVertex2f(page_x + width_px + shadow_offset, page_y + height_px)
|
||||
glVertex2f(page_x + width_px, page_y + height_px)
|
||||
glEnd()
|
||||
|
||||
|
||||
# Draw page background (slightly off-white to distinguish from canvas)
|
||||
glColor3f(0.98, 0.98, 0.98)
|
||||
glBegin(GL_QUADS)
|
||||
@@ -93,26 +116,27 @@ class PageLayout:
|
||||
# For ImageData elements, request async loading if available
|
||||
for element in self.elements:
|
||||
# Check if this is an ImageData element that needs async loading
|
||||
if isinstance(element, ImageData) and not hasattr(element, '_texture_id'):
|
||||
if isinstance(element, ImageData) and not hasattr(element, "_texture_id"):
|
||||
# Try to get async loader from a parent widget
|
||||
if hasattr(self, '_async_loader'):
|
||||
if hasattr(self, "_async_loader"):
|
||||
loader = self._async_loader
|
||||
elif hasattr(self, '_parent_widget') and hasattr(self._parent_widget, 'async_image_loader'):
|
||||
loader = self._parent_widget.async_image_loader
|
||||
elif hasattr(self, "_parent_widget") and hasattr(self._parent_widget, "async_image_loader"):
|
||||
loader = self._parent_widget.async_image_loader # type: ignore[union-attr]
|
||||
else:
|
||||
loader = None
|
||||
|
||||
# Request async load if loader is available and not already requested
|
||||
if loader and not element._async_load_requested:
|
||||
from pyPhotoAlbum.async_backend import LoadPriority
|
||||
|
||||
# Determine priority based on visibility (HIGH for now, can be refined)
|
||||
if hasattr(self._parent_widget, 'request_image_load'):
|
||||
self._parent_widget.request_image_load(element, priority=LoadPriority.HIGH)
|
||||
if hasattr(self._parent_widget, "request_image_load"):
|
||||
self._parent_widget.request_image_load(element, priority=LoadPriority.HIGH) # type: ignore[union-attr]
|
||||
element._async_load_requested = True
|
||||
element._async_loading = True
|
||||
|
||||
element.render()
|
||||
|
||||
|
||||
# Draw page border LAST (on top of everything)
|
||||
glColor3f(0.7, 0.7, 0.7)
|
||||
glLineWidth(2.0)
|
||||
@@ -123,7 +147,7 @@ class PageLayout:
|
||||
glVertex2f(page_x, page_y + height_px)
|
||||
glEnd()
|
||||
glLineWidth(1.0)
|
||||
|
||||
|
||||
# Draw center line for facing pages
|
||||
if self.is_facing_page:
|
||||
center_x = page_x + (width_px / 2)
|
||||
@@ -134,19 +158,15 @@ class PageLayout:
|
||||
glVertex2f(center_x, page_y + height_px)
|
||||
glEnd()
|
||||
glLineWidth(1.0)
|
||||
|
||||
|
||||
# Always render snap lines (grid shows when show_grid is on, guides show when show_snap_lines is on)
|
||||
self._render_snap_lines(dpi, page_x, page_y, project)
|
||||
|
||||
# Re-enable depth testing
|
||||
glEnable(GL_DEPTH_TEST)
|
||||
|
||||
|
||||
def _render_snap_lines(self, dpi: int, page_x: float, page_y: float, project=None):
|
||||
"""Render snap lines (grid, edges, guides)"""
|
||||
from OpenGL.GL import (glColor3f, glColor4f, glLineWidth, glBegin, glEnd,
|
||||
glVertex2f, GL_LINES, glEnable, glDisable, GL_BLEND,
|
||||
glBlendFunc, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
|
||||
|
||||
# Use project settings if available, otherwise fall back to local snapping_system
|
||||
if project:
|
||||
# Use project-level global settings
|
||||
@@ -169,6 +189,7 @@ class PageLayout:
|
||||
|
||||
# Create a temporary snapping system with project settings to get snap lines
|
||||
from pyPhotoAlbum.snapping import SnappingSystem
|
||||
|
||||
temp_snap_sys = SnappingSystem(snap_threshold_mm=snap_threshold_mm)
|
||||
temp_snap_sys.grid_size_mm = grid_size_mm
|
||||
temp_snap_sys.snap_to_grid = snap_to_grid
|
||||
@@ -178,17 +199,13 @@ class PageLayout:
|
||||
|
||||
snap_lines = temp_snap_sys.get_snap_lines(self.size, dpi)
|
||||
|
||||
# Enable alpha blending for transparency
|
||||
glEnable(GL_BLEND)
|
||||
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
|
||||
|
||||
# Draw grid lines (darker gray with transparency) - visible when show_grid is enabled
|
||||
if show_grid and snap_lines['grid']:
|
||||
glColor4f(0.6, 0.6, 0.6, 0.4) # Gray with 40% opacity
|
||||
# Draw grid lines (light gray, fully opaque) - visible when show_grid is enabled
|
||||
if show_grid and snap_lines["grid"]:
|
||||
glColor3f(0.8, 0.8, 0.8) # Light gray, fully opaque
|
||||
glLineWidth(1.0)
|
||||
for orientation, position in snap_lines['grid']:
|
||||
for orientation, position in snap_lines["grid"]:
|
||||
glBegin(GL_LINES)
|
||||
if orientation == 'vertical':
|
||||
if orientation == "vertical":
|
||||
glVertex2f(page_x + position, page_y)
|
||||
glVertex2f(page_x + position, page_y + self.size[1] * dpi / 25.4)
|
||||
else: # horizontal
|
||||
@@ -196,13 +213,13 @@ class PageLayout:
|
||||
glVertex2f(page_x + self.size[0] * dpi / 25.4, page_y + position)
|
||||
glEnd()
|
||||
|
||||
# Draw guides (cyan, more visible with transparency) - only show when show_snap_lines is on
|
||||
if show_snap_lines and snap_lines['guides']:
|
||||
glColor4f(0.0, 0.7, 0.9, 0.8) # Cyan with 80% opacity
|
||||
# Draw guides (cyan, fully opaque) - only show when show_snap_lines is on
|
||||
if show_snap_lines and snap_lines["guides"]:
|
||||
glColor3f(0.0, 0.7, 0.9) # Cyan, fully opaque
|
||||
glLineWidth(1.5)
|
||||
for orientation, position in snap_lines['guides']:
|
||||
for orientation, position in snap_lines["guides"]:
|
||||
glBegin(GL_LINES)
|
||||
if orientation == 'vertical':
|
||||
if orientation == "vertical":
|
||||
glVertex2f(page_x + position, page_y)
|
||||
glVertex2f(page_x + position, page_y + self.size[1] * dpi / 25.4)
|
||||
else: # horizontal
|
||||
@@ -211,7 +228,6 @@ class PageLayout:
|
||||
glEnd()
|
||||
|
||||
glLineWidth(1.0)
|
||||
glDisable(GL_BLEND)
|
||||
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
"""Serialize page layout to dictionary"""
|
||||
@@ -223,7 +239,7 @@ class PageLayout:
|
||||
"elements": [elem.serialize() for elem in self.elements],
|
||||
"grid_layout": self.grid_layout.serialize() if self.grid_layout else None,
|
||||
"snapping_system": self.snapping_system.serialize(),
|
||||
"show_snap_lines": self.show_snap_lines
|
||||
"show_snap_lines": self.show_snap_lines,
|
||||
}
|
||||
|
||||
def deserialize(self, data: Dict[str, Any]):
|
||||
@@ -236,9 +252,10 @@ class PageLayout:
|
||||
|
||||
# Deserialize elements and sort by z_index to establish list order
|
||||
# This ensures backward compatibility with projects that used z_index
|
||||
elem_list = []
|
||||
elem_list: List[BaseLayoutElement] = []
|
||||
for elem_data in data.get("elements", []):
|
||||
elem_type = elem_data.get("type")
|
||||
elem: BaseLayoutElement
|
||||
if elem_type == "image":
|
||||
elem = ImageData()
|
||||
elif elem_type == "placeholder":
|
||||
@@ -250,7 +267,7 @@ class PageLayout:
|
||||
|
||||
elem.deserialize(elem_data)
|
||||
elem_list.append(elem)
|
||||
|
||||
|
||||
# Sort by z_index to establish proper list order (lower z_index = earlier in list = behind)
|
||||
elem_list.sort(key=lambda e: e.z_index)
|
||||
self.elements = elem_list
|
||||
@@ -260,14 +277,15 @@ class PageLayout:
|
||||
if grid_data:
|
||||
self.grid_layout = GridLayout()
|
||||
self.grid_layout.deserialize(grid_data)
|
||||
|
||||
|
||||
# Deserialize snapping system
|
||||
snap_data = data.get("snapping_system")
|
||||
if snap_data:
|
||||
self.snapping_system.deserialize(snap_data)
|
||||
|
||||
|
||||
self.show_snap_lines = data.get("show_snap_lines", True)
|
||||
|
||||
|
||||
class GridLayout:
|
||||
"""Class to manage grid layouts"""
|
||||
|
||||
@@ -281,7 +299,9 @@ class GridLayout:
|
||||
"""Merge cells in the grid"""
|
||||
self.merged_cells.append((row, col))
|
||||
|
||||
def get_cell_position(self, row: int, col: int, page_width: float = 800, page_height: float = 600) -> Tuple[float, float]:
|
||||
def get_cell_position(
|
||||
self, row: int, col: int, page_width: float = 800, page_height: float = 600
|
||||
) -> Tuple[float, float]:
|
||||
"""Get the position of a grid cell"""
|
||||
cell_width = (page_width - (self.spacing * (self.columns + 1))) / self.columns
|
||||
cell_height = (page_height - (self.spacing * (self.rows + 1))) / self.rows
|
||||
@@ -300,12 +320,7 @@ class GridLayout:
|
||||
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
"""Serialize grid layout to dictionary"""
|
||||
return {
|
||||
"rows": self.rows,
|
||||
"columns": self.columns,
|
||||
"spacing": self.spacing,
|
||||
"merged_cells": self.merged_cells
|
||||
}
|
||||
return {"rows": self.rows, "columns": self.columns, "spacing": self.spacing, "merged_cells": self.merged_cells}
|
||||
|
||||
def deserialize(self, data: Dict[str, Any]):
|
||||
"""Deserialize from dictionary"""
|
||||
|
||||
@@ -11,27 +11,23 @@ Coordinate Systems:
|
||||
"""
|
||||
|
||||
from typing import Tuple, Optional
|
||||
from OpenGL.GL import glPushMatrix, glPopMatrix, glScalef, glTranslatef
|
||||
from pyPhotoAlbum.gl_imports import glPushMatrix, glPopMatrix, glScalef, glTranslatef
|
||||
|
||||
|
||||
class PageRenderer:
|
||||
"""
|
||||
Handles rendering and coordinate transformations for a single page.
|
||||
|
||||
|
||||
This class encapsulates all coordinate transformations needed to render
|
||||
a page and its elements consistently.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
page_width_mm: float,
|
||||
page_height_mm: float,
|
||||
screen_x: float,
|
||||
screen_y: float,
|
||||
dpi: int,
|
||||
zoom: float):
|
||||
|
||||
def __init__(
|
||||
self, page_width_mm: float, page_height_mm: float, screen_x: float, screen_y: float, dpi: int, zoom: float
|
||||
):
|
||||
"""
|
||||
Initialize a page renderer.
|
||||
|
||||
|
||||
Args:
|
||||
page_width_mm: Page width in millimeters
|
||||
page_height_mm: Page height in millimeters
|
||||
@@ -46,81 +42,83 @@ class PageRenderer:
|
||||
self.screen_y = screen_y
|
||||
self.dpi = dpi
|
||||
self.zoom = zoom
|
||||
|
||||
|
||||
# Calculate page dimensions in pixels
|
||||
self.page_width_px = page_width_mm * dpi / 25.4
|
||||
self.page_height_px = page_height_mm * dpi / 25.4
|
||||
|
||||
|
||||
# Calculate screen dimensions (with zoom applied)
|
||||
self.screen_width = self.page_width_px * zoom
|
||||
self.screen_height = self.page_height_px * zoom
|
||||
|
||||
|
||||
def page_to_screen(self, page_x: float, page_y: float) -> Tuple[float, float]:
|
||||
"""
|
||||
Convert page-local coordinates (in pixels) to screen coordinates.
|
||||
|
||||
|
||||
Args:
|
||||
page_x: X coordinate in page-local space (pixels)
|
||||
page_y: Y coordinate in page-local space (pixels)
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (screen_x, screen_y)
|
||||
"""
|
||||
screen_x = self.screen_x + page_x * self.zoom
|
||||
screen_y = self.screen_y + page_y * self.zoom
|
||||
return (screen_x, screen_y)
|
||||
|
||||
|
||||
def screen_to_page(self, screen_x: float, screen_y: float) -> Tuple[float, float]:
|
||||
"""
|
||||
Convert screen coordinates to page-local coordinates (in pixels).
|
||||
|
||||
|
||||
Args:
|
||||
screen_x: X coordinate in screen space
|
||||
screen_y: Y coordinate in screen space
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (page_x, page_y) in pixels, or None if outside page bounds
|
||||
"""
|
||||
page_x = (screen_x - self.screen_x) / self.zoom
|
||||
page_y = (screen_y - self.screen_y) / self.zoom
|
||||
return (page_x, page_y)
|
||||
|
||||
|
||||
def is_point_in_page(self, screen_x: float, screen_y: float) -> bool:
|
||||
"""
|
||||
Check if a screen coordinate is within the page bounds.
|
||||
|
||||
|
||||
Args:
|
||||
screen_x: X coordinate in screen space
|
||||
screen_y: Y coordinate in screen space
|
||||
|
||||
|
||||
Returns:
|
||||
True if the point is within the page bounds
|
||||
"""
|
||||
return (self.screen_x <= screen_x <= self.screen_x + self.screen_width and
|
||||
self.screen_y <= screen_y <= self.screen_y + self.screen_height)
|
||||
|
||||
return (
|
||||
self.screen_x <= screen_x <= self.screen_x + self.screen_width
|
||||
and self.screen_y <= screen_y <= self.screen_y + self.screen_height
|
||||
)
|
||||
|
||||
def get_sub_page_at(self, screen_x: float, is_facing_page: bool) -> Optional[str]:
|
||||
"""
|
||||
For facing page spreads, determine if mouse is on left or right page.
|
||||
|
||||
|
||||
Args:
|
||||
screen_x: X coordinate in screen space
|
||||
is_facing_page: Whether this is a facing page spread
|
||||
|
||||
|
||||
Returns:
|
||||
'left' or 'right' for facing pages, None for single pages
|
||||
"""
|
||||
if not is_facing_page:
|
||||
return None
|
||||
|
||||
|
||||
# Calculate the center line of the spread
|
||||
center_x = self.screen_x + self.screen_width / 2
|
||||
|
||||
|
||||
if screen_x < center_x:
|
||||
return 'left'
|
||||
return "left"
|
||||
else:
|
||||
return 'right'
|
||||
|
||||
return "right"
|
||||
|
||||
def begin_render(self):
|
||||
"""
|
||||
Set up OpenGL transformations for rendering this page.
|
||||
@@ -131,27 +129,27 @@ class PageRenderer:
|
||||
glScalef(self.zoom, self.zoom, 1.0)
|
||||
# Translate to page position (in zoomed coordinates)
|
||||
glTranslatef(self.screen_x / self.zoom, self.screen_y / self.zoom, 0.0)
|
||||
|
||||
|
||||
def end_render(self):
|
||||
"""
|
||||
Clean up OpenGL transformations after rendering this page.
|
||||
Call this after rendering page content.
|
||||
"""
|
||||
glPopMatrix()
|
||||
|
||||
|
||||
def get_page_bounds_screen(self) -> Tuple[float, float, float, float]:
|
||||
"""
|
||||
Get the page bounds in screen coordinates.
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (x, y, width, height) in screen space
|
||||
"""
|
||||
return (self.screen_x, self.screen_y, self.screen_width, self.screen_height)
|
||||
|
||||
|
||||
def get_page_bounds_page(self) -> Tuple[float, float, float, float]:
|
||||
"""
|
||||
Get the page bounds in page-local coordinates.
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (x, y, width, height) in page-local space (pixels)
|
||||
"""
|
||||
|
||||
+981
-405
File diff suppressed because it is too large
Load Diff
+165
-67
@@ -4,18 +4,22 @@ Project and page management for pyPhotoAlbum
|
||||
|
||||
import os
|
||||
import math
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import List, Dict, Any, Optional, Tuple, Union
|
||||
from pyPhotoAlbum.page_layout import PageLayout
|
||||
from pyPhotoAlbum.commands import CommandHistory
|
||||
from pyPhotoAlbum.asset_manager import AssetManager
|
||||
|
||||
|
||||
class Page:
|
||||
"""Class representing a single page in the photo album"""
|
||||
|
||||
def __init__(self, layout: Optional[PageLayout] = None, page_number: int = 1, is_double_spread: bool = False):
|
||||
"""
|
||||
Initialize a page.
|
||||
|
||||
|
||||
Args:
|
||||
layout: PageLayout instance (created automatically if None)
|
||||
page_number: The page number (for spreads, this is the left page number)
|
||||
@@ -25,7 +29,19 @@ class Page:
|
||||
self.is_cover = False
|
||||
self.is_double_spread = is_double_spread
|
||||
self.manually_sized = False # Track if user manually changed page size
|
||||
|
||||
|
||||
# UUID for merge conflict resolution (v3.0+)
|
||||
self.uuid = str(uuid.uuid4())
|
||||
|
||||
# Timestamps for merge conflict resolution (v3.0+)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
self.created = now
|
||||
self.last_modified = now
|
||||
|
||||
# Deletion tracking for merge (v3.0+)
|
||||
self.deleted = False
|
||||
self.deleted_at: Optional[str] = None
|
||||
|
||||
# Create layout with appropriate width
|
||||
if layout is None:
|
||||
self.layout = PageLayout(is_facing_page=is_double_spread)
|
||||
@@ -37,21 +53,21 @@ class Page:
|
||||
self.layout.is_facing_page = is_double_spread
|
||||
height = self.layout.size[1]
|
||||
# Use the base_width if available, otherwise derive it
|
||||
if hasattr(self.layout, 'base_width'):
|
||||
if hasattr(self.layout, "base_width"):
|
||||
base_width = self.layout.base_width
|
||||
else:
|
||||
# If base_width not set, assume current width is correct
|
||||
# and derive base_width from current state
|
||||
base_width = self.layout.size[0] / 2 if not is_double_spread else self.layout.size[0]
|
||||
self.layout.base_width = base_width
|
||||
|
||||
|
||||
# Set the new width based on facing page state
|
||||
self.layout.size = (base_width * 2 if is_double_spread else base_width, height)
|
||||
|
||||
|
||||
def get_page_numbers(self) -> List[int]:
|
||||
"""
|
||||
Get the page numbers this page represents.
|
||||
|
||||
|
||||
Returns:
|
||||
List of page numbers (2 numbers for spreads, 1 for single pages)
|
||||
"""
|
||||
@@ -59,16 +75,26 @@ class Page:
|
||||
return [self.page_number, self.page_number + 1]
|
||||
else:
|
||||
return [self.page_number]
|
||||
|
||||
|
||||
def get_page_count(self) -> int:
|
||||
"""
|
||||
Get the number of physical pages this represents.
|
||||
|
||||
|
||||
Returns:
|
||||
2 for spreads, 1 for single pages
|
||||
"""
|
||||
return 2 if self.is_double_spread else 1
|
||||
|
||||
def mark_modified(self):
|
||||
"""Update the last_modified timestamp to now."""
|
||||
self.last_modified = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def mark_deleted(self):
|
||||
"""Mark this page as deleted."""
|
||||
self.deleted = True
|
||||
self.deleted_at = datetime.now(timezone.utc).isoformat()
|
||||
self.mark_modified()
|
||||
|
||||
def render(self):
|
||||
"""Render the entire page"""
|
||||
print(f"Rendering page {self.page_number}")
|
||||
@@ -81,7 +107,13 @@ class Page:
|
||||
"is_cover": self.is_cover,
|
||||
"is_double_spread": self.is_double_spread,
|
||||
"manually_sized": self.manually_sized,
|
||||
"layout": self.layout.serialize()
|
||||
"layout": self.layout.serialize(),
|
||||
# v3.0+ fields
|
||||
"uuid": self.uuid,
|
||||
"created": self.created,
|
||||
"last_modified": self.last_modified,
|
||||
"deleted": self.deleted,
|
||||
"deleted_at": self.deleted_at,
|
||||
}
|
||||
|
||||
def deserialize(self, data: Dict[str, Any]):
|
||||
@@ -91,10 +123,19 @@ class Page:
|
||||
self.is_double_spread = data.get("is_double_spread", False)
|
||||
self.manually_sized = data.get("manually_sized", False)
|
||||
|
||||
# v3.0+ fields (with backwards compatibility)
|
||||
self.uuid = data.get("uuid", str(uuid.uuid4()))
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
self.created = data.get("created", now)
|
||||
self.last_modified = data.get("last_modified", now)
|
||||
self.deleted = data.get("deleted", False)
|
||||
self.deleted_at = data.get("deleted_at", None)
|
||||
|
||||
layout_data = data.get("layout", {})
|
||||
self.layout = PageLayout()
|
||||
self.layout.deserialize(layout_data)
|
||||
|
||||
|
||||
class Project:
|
||||
"""Class representing the entire photo album project"""
|
||||
|
||||
@@ -110,18 +151,32 @@ class Project:
|
||||
self.export_dpi = 300 # Default export DPI
|
||||
self.page_spacing_mm = 10.0 # Default spacing between pages (1cm)
|
||||
|
||||
# Project ID for merge detection (v3.0+)
|
||||
# Projects with same ID should be merged, different IDs should be concatenated
|
||||
self.project_id = str(uuid.uuid4())
|
||||
|
||||
# Timestamps for project-level changes (v3.0+)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
self.created = now
|
||||
self.last_modified = now
|
||||
|
||||
# Cover configuration
|
||||
self.has_cover = False # Whether project has a cover
|
||||
self.paper_thickness_mm = 0.2 # Paper thickness for spine calculation (default 0.2mm)
|
||||
self.cover_bleed_mm = 0.0 # Bleed margin for cover (default 0mm)
|
||||
self.binding_type = "saddle_stitch" # Binding type for spine calculation
|
||||
|
||||
# Print guide configuration
|
||||
self.page_bleed_mm = 0.0 # Bleed margin for interior pages (default 0mm, e.g. 3mm for printing)
|
||||
self.page_safe_area_mm = 5.0 # Safe area margin inside cut line (default 5mm)
|
||||
self.show_print_guides = False # Show bleed/cut/safe-area guides in the editor
|
||||
|
||||
# Embedded templates - templates that travel with the project
|
||||
self.embedded_templates: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# Temporary directory management (if loaded from .ppz)
|
||||
# Using TemporaryDirectory instance that auto-cleans on deletion
|
||||
self._temp_dir = None
|
||||
self._temp_dir: Optional[TemporaryDirectory[str]] = None
|
||||
|
||||
# Global snapping settings (apply to all pages)
|
||||
self.snap_to_grid = False
|
||||
@@ -135,16 +190,46 @@ class Project:
|
||||
# Initialize asset manager
|
||||
self.asset_manager = AssetManager(self.folder_path)
|
||||
|
||||
# Initialize command history with asset manager
|
||||
self.history = CommandHistory(max_history=100)
|
||||
self.history.asset_manager = self.asset_manager
|
||||
# Initialize command history with asset manager and project reference
|
||||
self.history = CommandHistory(max_history=100, asset_manager=self.asset_manager, project=self)
|
||||
|
||||
def add_page(self, page: Page):
|
||||
"""Add a page to the project"""
|
||||
self.pages.append(page)
|
||||
# Track unsaved changes
|
||||
self._dirty = False
|
||||
self.file_path = None # Path to the saved .ppz file
|
||||
|
||||
def mark_dirty(self):
|
||||
"""Mark the project as having unsaved changes."""
|
||||
self._dirty = True
|
||||
self.mark_modified()
|
||||
|
||||
def mark_clean(self):
|
||||
"""Mark the project as saved (no unsaved changes)."""
|
||||
self._dirty = False
|
||||
|
||||
def is_dirty(self) -> bool:
|
||||
"""Check if the project has unsaved changes."""
|
||||
return self._dirty
|
||||
|
||||
def mark_modified(self):
|
||||
"""Update the last_modified timestamp to now."""
|
||||
self.last_modified = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def add_page(self, page: Page, index: Optional[int] = None):
|
||||
"""
|
||||
Add a page to the project.
|
||||
|
||||
Args:
|
||||
page: The page to add
|
||||
index: Optional index to insert at. If None, appends to end.
|
||||
"""
|
||||
if index is None:
|
||||
self.pages.append(page)
|
||||
else:
|
||||
self.pages.insert(index, page)
|
||||
# Update cover dimensions if we have a cover
|
||||
if self.has_cover and self.pages:
|
||||
self.update_cover_dimensions()
|
||||
self.mark_dirty()
|
||||
|
||||
def remove_page(self, page: Page):
|
||||
"""Remove a page from the project"""
|
||||
@@ -152,37 +237,34 @@ class Project:
|
||||
# Update cover dimensions if we have a cover
|
||||
if self.has_cover and self.pages:
|
||||
self.update_cover_dimensions()
|
||||
|
||||
self.mark_dirty()
|
||||
|
||||
def calculate_spine_width(self) -> float:
|
||||
"""
|
||||
Calculate spine width based on page count and paper thickness.
|
||||
|
||||
|
||||
For saddle stitch binding:
|
||||
- Each sheet = 4 pages (2 pages per side when folded)
|
||||
- Spine width = (Number of sheets × Paper thickness × 2)
|
||||
|
||||
|
||||
Returns:
|
||||
Spine width in mm
|
||||
"""
|
||||
if not self.has_cover:
|
||||
return 0.0
|
||||
|
||||
|
||||
# Count content pages (excluding cover)
|
||||
content_page_count = sum(
|
||||
page.get_page_count()
|
||||
for page in self.pages
|
||||
if not page.is_cover
|
||||
)
|
||||
|
||||
content_page_count = sum(page.get_page_count() for page in self.pages if not page.is_cover)
|
||||
|
||||
if self.binding_type == "saddle_stitch":
|
||||
# Calculate number of sheets (each sheet = 4 pages)
|
||||
sheets = math.ceil(content_page_count / 4)
|
||||
# Spine width = sheets × paper thickness × 2 (folded)
|
||||
spine_width = sheets * self.paper_thickness_mm * 2
|
||||
return spine_width
|
||||
|
||||
|
||||
return 0.0
|
||||
|
||||
|
||||
def update_cover_dimensions(self):
|
||||
"""
|
||||
Update cover page dimensions based on current page count and settings.
|
||||
@@ -190,66 +272,68 @@ class Project:
|
||||
"""
|
||||
if not self.has_cover or not self.pages:
|
||||
return
|
||||
|
||||
|
||||
# Find cover page (should be first page)
|
||||
cover_page = None
|
||||
for page in self.pages:
|
||||
if page.is_cover:
|
||||
cover_page = page
|
||||
break
|
||||
|
||||
|
||||
if not cover_page:
|
||||
return
|
||||
|
||||
|
||||
# Get standard page dimensions
|
||||
page_width_mm, page_height_mm = self.page_size_mm
|
||||
|
||||
|
||||
# Calculate spine width
|
||||
spine_width = self.calculate_spine_width()
|
||||
|
||||
|
||||
# Calculate cover dimensions
|
||||
# Cover = Front + Spine + Back + Bleed on all sides
|
||||
cover_width = (page_width_mm * 2) + spine_width + (self.cover_bleed_mm * 2)
|
||||
cover_height = page_height_mm + (self.cover_bleed_mm * 2)
|
||||
|
||||
|
||||
# Update cover page layout
|
||||
cover_page.layout.size = (cover_width, cover_height)
|
||||
cover_page.layout.base_width = page_width_mm # Store base width for reference
|
||||
cover_page.manually_sized = True # Mark as manually sized
|
||||
|
||||
print(f"Cover dimensions updated: {cover_width:.1f} × {cover_height:.1f} mm "
|
||||
f"(Front: {page_width_mm}, Spine: {spine_width:.2f}, Back: {page_width_mm}, "
|
||||
f"Bleed: {self.cover_bleed_mm})")
|
||||
|
||||
|
||||
print(
|
||||
f"Cover dimensions updated: {cover_width:.1f} × {cover_height:.1f} mm "
|
||||
f"(Front: {page_width_mm}, Spine: {spine_width:.2f}, Back: {page_width_mm}, "
|
||||
f"Bleed: {self.cover_bleed_mm})"
|
||||
)
|
||||
|
||||
def get_page_display_name(self, page: Page) -> str:
|
||||
"""
|
||||
Get display name for a page.
|
||||
|
||||
|
||||
Args:
|
||||
page: The page to get the display name for
|
||||
|
||||
|
||||
Returns:
|
||||
Display name like "Cover", "Page 1", "Pages 1-2", etc.
|
||||
"""
|
||||
if page.is_cover:
|
||||
return "Cover"
|
||||
|
||||
|
||||
# Calculate adjusted page number (excluding cover from count)
|
||||
adjusted_num = page.page_number
|
||||
if self.has_cover:
|
||||
# Subtract 1 to account for cover
|
||||
adjusted_num = page.page_number - 1
|
||||
|
||||
|
||||
if page.is_double_spread:
|
||||
return f"Pages {adjusted_num}-{adjusted_num + 1}"
|
||||
else:
|
||||
return f"Page {adjusted_num}"
|
||||
|
||||
def calculate_page_layout_with_ghosts(self) -> List[Tuple[str, Any, int]]:
|
||||
|
||||
def calculate_page_layout_with_ghosts(self) -> List[Tuple[str, Optional["Page"], int]]:
|
||||
"""
|
||||
Calculate page layout including ghost pages for alignment.
|
||||
Excludes cover from spread calculations.
|
||||
|
||||
|
||||
Returns:
|
||||
List of tuples (page_type, page_or_ghost, logical_position)
|
||||
where page_type is 'page' or 'ghost',
|
||||
@@ -257,10 +341,10 @@ class Project:
|
||||
logical_position is the position in the album (1=right, 2=left, etc.)
|
||||
"""
|
||||
from pyPhotoAlbum.models import GhostPageData
|
||||
|
||||
layout = []
|
||||
|
||||
layout: list[tuple[str, Optional["Page"], int]] = []
|
||||
current_position = 1 # Start at position 1 (right page)
|
||||
|
||||
|
||||
for page in self.pages:
|
||||
# Skip cover in spread calculations
|
||||
if page.is_cover:
|
||||
@@ -276,32 +360,32 @@ class Project:
|
||||
elif not page.is_double_spread and current_position % 2 == 1:
|
||||
# Current position is odd (right page), single page is fine
|
||||
pass
|
||||
|
||||
|
||||
# Actually, let me reconsider the logic:
|
||||
# In a photobook:
|
||||
# - Position 1 is the right page (when opened, first content page)
|
||||
# - Position 2 is the left page of the next spread
|
||||
# - Position 3 is the right page of the next spread
|
||||
# - etc.
|
||||
#
|
||||
#
|
||||
# Double spreads occupy TWO positions (both left and right of a spread)
|
||||
# They must start on an even position (left side) so they span across both pages
|
||||
|
||||
|
||||
# Check if this is a double spread starting at an odd position
|
||||
if page.is_double_spread and current_position % 2 == 1:
|
||||
# Need to insert a ghost page to push the double spread to next position
|
||||
layout.append(('ghost', None, current_position))
|
||||
layout.append(("ghost", None, current_position))
|
||||
current_position += 1
|
||||
|
||||
|
||||
# Add the actual page
|
||||
layout.append(('page', page, current_position))
|
||||
|
||||
layout.append(("page", page, current_position))
|
||||
|
||||
# Update position based on page type
|
||||
if page.is_double_spread:
|
||||
current_position += 2 # Double spread takes 2 positions
|
||||
else:
|
||||
current_position += 1 # Single page takes 1 position
|
||||
|
||||
|
||||
return layout
|
||||
|
||||
def render_all_pages(self):
|
||||
@@ -325,6 +409,9 @@ class Project:
|
||||
"paper_thickness_mm": self.paper_thickness_mm,
|
||||
"cover_bleed_mm": self.cover_bleed_mm,
|
||||
"binding_type": self.binding_type,
|
||||
"page_bleed_mm": self.page_bleed_mm,
|
||||
"page_safe_area_mm": self.page_safe_area_mm,
|
||||
"show_print_guides": self.show_print_guides,
|
||||
"embedded_templates": self.embedded_templates,
|
||||
"snap_to_grid": self.snap_to_grid,
|
||||
"snap_to_edges": self.snap_to_edges,
|
||||
@@ -335,7 +422,11 @@ class Project:
|
||||
"show_snap_lines": self.show_snap_lines,
|
||||
"pages": [page.serialize() for page in self.pages],
|
||||
"history": self.history.serialize(),
|
||||
"asset_manager": self.asset_manager.serialize()
|
||||
"asset_manager": self.asset_manager.serialize(),
|
||||
# v3.0+ fields
|
||||
"project_id": self.project_id,
|
||||
"created": self.created,
|
||||
"last_modified": self.last_modified,
|
||||
}
|
||||
|
||||
def deserialize(self, data: Dict[str, Any]):
|
||||
@@ -353,6 +444,9 @@ class Project:
|
||||
self.paper_thickness_mm = data.get("paper_thickness_mm", 0.2)
|
||||
self.cover_bleed_mm = data.get("cover_bleed_mm", 0.0)
|
||||
self.binding_type = data.get("binding_type", "saddle_stitch")
|
||||
self.page_bleed_mm = data.get("page_bleed_mm", 0.0)
|
||||
self.page_safe_area_mm = data.get("page_safe_area_mm", 5.0)
|
||||
self.show_print_guides = data.get("show_print_guides", False)
|
||||
|
||||
# Deserialize embedded templates
|
||||
self.embedded_templates = data.get("embedded_templates", {})
|
||||
@@ -365,7 +459,13 @@ class Project:
|
||||
self.snap_threshold_mm = data.get("snap_threshold_mm", 5.0)
|
||||
self.show_grid = data.get("show_grid", False)
|
||||
self.show_snap_lines = data.get("show_snap_lines", True)
|
||||
|
||||
|
||||
# v3.0+ fields (with backwards compatibility)
|
||||
self.project_id = data.get("project_id", str(uuid.uuid4()))
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
self.created = data.get("created", now)
|
||||
self.last_modified = data.get("last_modified", now)
|
||||
|
||||
self.pages = []
|
||||
|
||||
# Deserialize asset manager
|
||||
@@ -373,22 +473,20 @@ class Project:
|
||||
asset_data = data.get("asset_manager")
|
||||
if asset_data:
|
||||
self.asset_manager.deserialize(asset_data)
|
||||
|
||||
|
||||
# Deserialize pages
|
||||
for page_data in data.get("pages", []):
|
||||
page = Page()
|
||||
page.deserialize(page_data)
|
||||
self.pages.append(page)
|
||||
|
||||
# Deserialize command history with asset manager
|
||||
|
||||
# Deserialize command history with asset manager and project reference
|
||||
history_data = data.get("history")
|
||||
if history_data:
|
||||
self.history = CommandHistory(max_history=100)
|
||||
self.history.asset_manager = self.asset_manager
|
||||
self.history = CommandHistory(max_history=100, asset_manager=self.asset_manager, project=self)
|
||||
self.history.deserialize(history_data, self)
|
||||
else:
|
||||
self.history = CommandHistory(max_history=100)
|
||||
self.history.asset_manager = self.asset_manager
|
||||
self.history = CommandHistory(max_history=100, asset_manager=self.asset_manager, project=self)
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
|
||||
+213
-104
@@ -7,17 +7,17 @@ import json
|
||||
import zipfile
|
||||
import shutil
|
||||
import tempfile
|
||||
from typing import Optional, Tuple
|
||||
import threading
|
||||
from typing import Optional, Tuple, Callable
|
||||
from pathlib import Path
|
||||
from pyPhotoAlbum.project import Project
|
||||
from pyPhotoAlbum.version_manager import (
|
||||
CURRENT_DATA_VERSION,
|
||||
check_version_compatibility,
|
||||
VersionCompatibility,
|
||||
DataMigration
|
||||
DataMigration,
|
||||
)
|
||||
|
||||
|
||||
# Legacy constant for backward compatibility
|
||||
SERIALIZATION_VERSION = CURRENT_DATA_VERSION
|
||||
|
||||
@@ -44,7 +44,7 @@ def _import_external_images(project: Project):
|
||||
# Absolute path - definitely external
|
||||
is_external = True
|
||||
external_path = element.image_path
|
||||
elif not element.image_path.startswith('assets/'):
|
||||
elif not element.image_path.startswith("assets/"):
|
||||
# Relative path but not in assets folder
|
||||
# Check if it exists relative to project folder
|
||||
full_path = os.path.join(project.folder_path, element.image_path)
|
||||
@@ -91,15 +91,15 @@ def _normalize_asset_paths(project: Project, project_folder: str):
|
||||
original_path = element.image_path
|
||||
|
||||
# Skip if already a simple relative path (assets/...)
|
||||
if not os.path.isabs(original_path) and not original_path.startswith('./projects/'):
|
||||
if not os.path.isabs(original_path) and not original_path.startswith("./projects/"):
|
||||
continue
|
||||
|
||||
# Try to extract just the filename or relative path from assets folder
|
||||
# Pattern 1: "./projects/XXX/assets/filename.jpg" -> "assets/filename.jpg"
|
||||
if '/assets/' in original_path:
|
||||
parts = original_path.split('/assets/')
|
||||
if "/assets/" in original_path:
|
||||
parts = original_path.split("/assets/")
|
||||
if len(parts) == 2:
|
||||
new_path = os.path.join('assets', parts[1])
|
||||
new_path = os.path.join("assets", parts[1])
|
||||
element.image_path = new_path
|
||||
normalized_count += 1
|
||||
print(f"Normalized path: {original_path} -> {new_path}")
|
||||
@@ -133,8 +133,8 @@ def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
try:
|
||||
# Ensure .ppz extension
|
||||
if not zip_path.lower().endswith('.ppz'):
|
||||
zip_path += '.ppz'
|
||||
if not zip_path.lower().endswith(".ppz"):
|
||||
zip_path += ".ppz"
|
||||
|
||||
# Check for and import any external images before saving
|
||||
_import_external_images(project)
|
||||
@@ -143,14 +143,14 @@ def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]:
|
||||
project_data = project.serialize()
|
||||
|
||||
# Add version information
|
||||
project_data['serialization_version'] = SERIALIZATION_VERSION # Legacy field
|
||||
project_data['data_version'] = CURRENT_DATA_VERSION # New versioning system
|
||||
project_data["serialization_version"] = SERIALIZATION_VERSION # Legacy field
|
||||
project_data["data_version"] = CURRENT_DATA_VERSION # New versioning system
|
||||
|
||||
# Create ZIP file
|
||||
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||||
# Write project.json
|
||||
project_json = json.dumps(project_data, indent=2)
|
||||
zipf.writestr('project.json', project_json)
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
|
||||
# Write project.json with stable sorting for git-friendly diffs
|
||||
project_json = json.dumps(project_data, indent=2, sort_keys=True)
|
||||
zipf.writestr("project.json", project_json)
|
||||
|
||||
# Add all files from the assets folder
|
||||
assets_folder = project.asset_manager.assets_folder
|
||||
@@ -171,7 +171,128 @@ def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]:
|
||||
return False, error_msg
|
||||
|
||||
|
||||
def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Tuple[Optional[Project], Optional[str]]:
|
||||
def save_to_zip_async(
|
||||
project: Project,
|
||||
zip_path: str,
|
||||
on_complete: Optional[Callable[[bool, Optional[str]], None]] = None,
|
||||
on_progress: Optional[Callable[[int, str], None]] = None,
|
||||
) -> threading.Thread:
|
||||
"""
|
||||
Save a project to a ZIP file asynchronously in a background thread.
|
||||
|
||||
This provides instant UI responsiveness by:
|
||||
1. Immediately serializing project.json to a temp folder (fast)
|
||||
2. Creating the ZIP file in a background thread (slow)
|
||||
3. Calling on_complete when done
|
||||
|
||||
Args:
|
||||
project: The Project instance to save
|
||||
zip_path: Path where the ZIP file should be created
|
||||
on_complete: Optional callback(success: bool, error_msg: Optional[str])
|
||||
called when save completes
|
||||
on_progress: Optional callback(progress: int, message: str) where
|
||||
progress is 0-100 and message describes current step
|
||||
|
||||
Returns:
|
||||
The background thread (already started)
|
||||
"""
|
||||
# Ensure .ppz extension
|
||||
final_zip_path = zip_path
|
||||
if not final_zip_path.lower().endswith(".ppz"):
|
||||
final_zip_path += ".ppz"
|
||||
|
||||
# ---- Work done on the CALLING (main) thread ----
|
||||
# Serialization is pure Python and holds the GIL, but it's fast.
|
||||
# Doing it here keeps the background thread to file-I/O only, which
|
||||
# releases the GIL and keeps the UI responsive.
|
||||
if on_progress:
|
||||
on_progress(0, "Preparing to save...")
|
||||
|
||||
_import_external_images(project)
|
||||
|
||||
if on_progress:
|
||||
on_progress(10, "Serializing project data...")
|
||||
project_data = project.serialize()
|
||||
project_data["serialization_version"] = SERIALIZATION_VERSION
|
||||
project_data["data_version"] = CURRENT_DATA_VERSION
|
||||
project_json_str = json.dumps(project_data, indent=2, sort_keys=True)
|
||||
|
||||
# Collect the asset file list now so the background thread doesn't
|
||||
# need to touch the (potentially temporary) project folder.
|
||||
assets_folder = project.asset_manager.assets_folder
|
||||
folder_path = project.folder_path
|
||||
asset_files: list[tuple[str, str]] = []
|
||||
if os.path.exists(assets_folder):
|
||||
for root, _dirs, files in os.walk(assets_folder):
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
arcname = os.path.relpath(file_path, folder_path)
|
||||
asset_files.append((file_path, arcname))
|
||||
|
||||
total_files = 1 + len(asset_files) # project.json + assets
|
||||
|
||||
if on_progress:
|
||||
on_progress(20, f"Starting background write ({total_files} files)...")
|
||||
|
||||
# ---- Work done on the BACKGROUND thread ----
|
||||
# Only file I/O here — zipfile/zlib/shutil all release the GIL.
|
||||
def _background_save():
|
||||
"""Background thread: write ZIP file from pre-serialized data."""
|
||||
temp_dir = None
|
||||
try:
|
||||
temp_dir = tempfile.mkdtemp(prefix="pyPhotoAlbum_save_")
|
||||
temp_zip_path = os.path.join(temp_dir, "project.ppz")
|
||||
|
||||
if on_progress:
|
||||
on_progress(25, f"Creating ZIP archive ({total_files} files)...")
|
||||
|
||||
with zipfile.ZipFile(temp_zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
|
||||
zipf.writestr("project.json", project_json_str)
|
||||
|
||||
if asset_files:
|
||||
progress_range = 90 - 25
|
||||
for idx, (file_path, arcname) in enumerate(asset_files):
|
||||
zipf.write(file_path, arcname)
|
||||
if idx % 10 == 0 or idx == len(asset_files) - 1:
|
||||
progress = 25 + int((idx + 1) / len(asset_files) * progress_range)
|
||||
if on_progress:
|
||||
on_progress(progress, f"Adding assets... ({idx + 1}/{len(asset_files)})")
|
||||
|
||||
if on_progress:
|
||||
on_progress(95, "Finalizing save...")
|
||||
|
||||
os.makedirs(os.path.dirname(os.path.abspath(final_zip_path)), exist_ok=True)
|
||||
if os.path.exists(final_zip_path):
|
||||
os.remove(final_zip_path)
|
||||
shutil.move(temp_zip_path, final_zip_path)
|
||||
|
||||
if on_progress:
|
||||
on_progress(100, "Save complete!")
|
||||
|
||||
print(f"Project saved to {final_zip_path}")
|
||||
|
||||
if on_complete:
|
||||
on_complete(True, None)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error saving project: {str(e)}"
|
||||
print(error_msg)
|
||||
if on_complete:
|
||||
on_complete(False, error_msg)
|
||||
|
||||
finally:
|
||||
if temp_dir and os.path.exists(temp_dir):
|
||||
try:
|
||||
shutil.rmtree(temp_dir)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
save_thread = threading.Thread(target=_background_save, daemon=True)
|
||||
save_thread.start()
|
||||
return save_thread
|
||||
|
||||
|
||||
def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Project:
|
||||
"""
|
||||
Load a project from a ZIP file.
|
||||
|
||||
@@ -181,120 +302,108 @@ def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Tuple[Opti
|
||||
directory that will be cleaned up when the project is closed.
|
||||
|
||||
Returns:
|
||||
Tuple of (project: Optional[Project], error_message: Optional[str])
|
||||
Project instance (raises exception on error)
|
||||
"""
|
||||
try:
|
||||
if not os.path.exists(zip_path):
|
||||
return None, f"ZIP file not found: {zip_path}"
|
||||
if not os.path.exists(zip_path):
|
||||
raise FileNotFoundError(f"ZIP file not found: {zip_path}")
|
||||
|
||||
# Track if we created a temp directory
|
||||
temp_dir_obj = None
|
||||
# Track if we created a temp directory
|
||||
temp_dir_obj = None
|
||||
|
||||
# Determine extraction directory
|
||||
if extract_to is None:
|
||||
# Create a temporary directory using TemporaryDirectory
|
||||
# This will be attached to the Project and auto-cleaned on deletion
|
||||
zip_basename = os.path.splitext(os.path.basename(zip_path))[0]
|
||||
temp_dir_obj = tempfile.TemporaryDirectory(prefix=f"pyPhotoAlbum_{zip_basename}_")
|
||||
extract_to = temp_dir_obj.name
|
||||
else:
|
||||
# Create extraction directory if it doesn't exist
|
||||
os.makedirs(extract_to, exist_ok=True)
|
||||
|
||||
# Extract ZIP contents
|
||||
with zipfile.ZipFile(zip_path, 'r') as zipf:
|
||||
zipf.extractall(extract_to)
|
||||
|
||||
# Load project.json
|
||||
project_json_path = os.path.join(extract_to, 'project.json')
|
||||
if not os.path.exists(project_json_path):
|
||||
return None, "Invalid project file: project.json not found"
|
||||
|
||||
with open(project_json_path, 'r') as f:
|
||||
project_data = json.load(f)
|
||||
# Determine extraction directory
|
||||
if extract_to is None:
|
||||
# Create a temporary directory using TemporaryDirectory
|
||||
# This will be attached to the Project and auto-cleaned on deletion
|
||||
zip_basename = os.path.splitext(os.path.basename(zip_path))[0]
|
||||
temp_dir_obj = tempfile.TemporaryDirectory(prefix=f"pyPhotoAlbum_{zip_basename}_")
|
||||
extract_to = temp_dir_obj.name
|
||||
else:
|
||||
# Create extraction directory if it doesn't exist
|
||||
os.makedirs(extract_to, exist_ok=True)
|
||||
|
||||
# Check version compatibility
|
||||
# Try new version field first, fall back to legacy field
|
||||
file_version = project_data.get('data_version', project_data.get('serialization_version', '1.0'))
|
||||
# Extract ZIP contents
|
||||
with zipfile.ZipFile(zip_path, "r") as zipf:
|
||||
zipf.extractall(extract_to)
|
||||
|
||||
# Check if version is compatible
|
||||
is_compatible, error_msg = check_version_compatibility(file_version, zip_path)
|
||||
if not is_compatible:
|
||||
return None, error_msg
|
||||
# Load project.json
|
||||
project_json_path = os.path.join(extract_to, "project.json")
|
||||
if not os.path.exists(project_json_path):
|
||||
raise ValueError("Invalid project file: project.json not found")
|
||||
|
||||
# Apply migrations if needed
|
||||
if VersionCompatibility.needs_migration(file_version):
|
||||
print(f"Migrating project from version {file_version} to {CURRENT_DATA_VERSION}...")
|
||||
try:
|
||||
project_data = DataMigration.migrate(project_data, file_version, CURRENT_DATA_VERSION)
|
||||
print(f"Migration completed successfully")
|
||||
except Exception as e:
|
||||
error_msg = f"Migration failed: {str(e)}"
|
||||
print(error_msg)
|
||||
return None, error_msg
|
||||
elif file_version != CURRENT_DATA_VERSION:
|
||||
print(f"Note: Loading project with version {file_version}, current version is {CURRENT_DATA_VERSION}")
|
||||
with open(project_json_path, "r") as f:
|
||||
project_data = json.load(f)
|
||||
|
||||
# Create new project
|
||||
project_name = project_data.get('name', 'Untitled Project')
|
||||
project = Project(name=project_name, folder_path=extract_to)
|
||||
# Check version compatibility
|
||||
# Try new version field first, fall back to legacy field
|
||||
file_version = project_data.get("data_version", project_data.get("serialization_version", "1.0"))
|
||||
|
||||
# Deserialize project data
|
||||
project.deserialize(project_data)
|
||||
# Check if version is compatible
|
||||
is_compatible, error_msg = check_version_compatibility(file_version, zip_path)
|
||||
if not is_compatible:
|
||||
raise ValueError(error_msg)
|
||||
|
||||
# Update folder path to extraction location
|
||||
project.folder_path = extract_to
|
||||
project.asset_manager.project_folder = extract_to
|
||||
project.asset_manager.assets_folder = os.path.join(extract_to, "assets")
|
||||
# Apply migrations if needed
|
||||
if VersionCompatibility.needs_migration(file_version):
|
||||
print(f"Migrating project from version {file_version} to {CURRENT_DATA_VERSION}...")
|
||||
project_data = DataMigration.migrate(project_data, file_version, CURRENT_DATA_VERSION)
|
||||
print(f"Migration completed successfully")
|
||||
elif file_version != CURRENT_DATA_VERSION:
|
||||
print(f"Note: Loading project with version {file_version}, current version is {CURRENT_DATA_VERSION}")
|
||||
|
||||
# Attach temporary directory to project (if we created one)
|
||||
# The TemporaryDirectory will auto-cleanup when the project is deleted
|
||||
if temp_dir_obj is not None:
|
||||
project._temp_dir = temp_dir_obj
|
||||
print(f"Project loaded to temporary directory: {extract_to}")
|
||||
# Create new project
|
||||
project_name = project_data.get("name", "Untitled Project")
|
||||
project = Project(name=project_name, folder_path=extract_to)
|
||||
|
||||
# Normalize asset paths in all ImageData elements
|
||||
# This fixes old projects that have absolute or wrong relative paths
|
||||
_normalize_asset_paths(project, extract_to)
|
||||
# Deserialize project data
|
||||
project.deserialize(project_data)
|
||||
|
||||
# Set asset resolution context for ImageData rendering
|
||||
# Include the directory containing the .ppz file as a search path
|
||||
from pyPhotoAlbum.models import set_asset_resolution_context
|
||||
zip_directory = os.path.dirname(os.path.abspath(zip_path))
|
||||
set_asset_resolution_context(extract_to, additional_search_paths=[zip_directory])
|
||||
# Update folder path to extraction location
|
||||
project.folder_path = extract_to
|
||||
project.asset_manager.project_folder = extract_to
|
||||
project.asset_manager.assets_folder = os.path.join(extract_to, "assets")
|
||||
|
||||
print(f"Project loaded from {zip_path} to {extract_to}")
|
||||
print(f"Additional search path: {zip_directory}")
|
||||
return project, None
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error loading project: {str(e)}"
|
||||
print(error_msg)
|
||||
return None, error_msg
|
||||
# Attach temporary directory to project (if we created one)
|
||||
# The TemporaryDirectory will auto-cleanup when the project is deleted
|
||||
if temp_dir_obj is not None:
|
||||
project._temp_dir = temp_dir_obj
|
||||
print(f"Project loaded to temporary directory: {extract_to}")
|
||||
|
||||
# Normalize asset paths in all ImageData elements
|
||||
# This fixes old projects that have absolute or wrong relative paths
|
||||
_normalize_asset_paths(project, extract_to)
|
||||
|
||||
# Set asset resolution context for ImageData rendering
|
||||
# Only set project folder - search paths are reserved for healing functionality
|
||||
from pyPhotoAlbum.models import set_asset_resolution_context
|
||||
|
||||
set_asset_resolution_context(extract_to)
|
||||
|
||||
print(f"Project loaded from {zip_path} to {extract_to}")
|
||||
return project
|
||||
|
||||
|
||||
def get_project_info(zip_path: str) -> Optional[dict]:
|
||||
"""
|
||||
Get basic information about a project without fully loading it.
|
||||
|
||||
|
||||
Args:
|
||||
zip_path: Path to the ZIP file
|
||||
|
||||
|
||||
Returns:
|
||||
Dictionary with project info, or None if error
|
||||
"""
|
||||
try:
|
||||
with zipfile.ZipFile(zip_path, 'r') as zipf:
|
||||
with zipfile.ZipFile(zip_path, "r") as zipf:
|
||||
# Read project.json
|
||||
project_json = zipf.read('project.json').decode('utf-8')
|
||||
project_json = zipf.read("project.json").decode("utf-8")
|
||||
project_data = json.loads(project_json)
|
||||
|
||||
|
||||
return {
|
||||
'name': project_data.get('name', 'Unknown'),
|
||||
'version': project_data.get('serialization_version', 'Unknown'),
|
||||
'page_count': len(project_data.get('pages', [])),
|
||||
'page_size_mm': project_data.get('page_size_mm', (0, 0)),
|
||||
'working_dpi': project_data.get('working_dpi', 300),
|
||||
"name": project_data.get("name", "Unknown"),
|
||||
"version": project_data.get("serialization_version", "Unknown"),
|
||||
"page_count": len(project_data.get("pages", [])),
|
||||
"page_size_mm": project_data.get("page_size_mm", (0, 0)),
|
||||
"working_dpi": project_data.get("working_dpi", 300),
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error reading project info: {e}")
|
||||
|
||||
@@ -12,14 +12,14 @@ from collections import defaultdict
|
||||
def build_ribbon_config(window_class: Type) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract decorated methods and build ribbon configuration.
|
||||
|
||||
|
||||
This function scans all methods in the window class and its mixins
|
||||
for methods decorated with @ribbon_action, then builds a nested
|
||||
configuration structure suitable for the RibbonWidget.
|
||||
|
||||
|
||||
Args:
|
||||
window_class: The MainWindow class with decorated methods
|
||||
|
||||
|
||||
Returns:
|
||||
Dictionary containing the ribbon configuration with structure:
|
||||
{
|
||||
@@ -41,60 +41,62 @@ def build_ribbon_config(window_class: Type) -> Dict[str, Any]:
|
||||
}
|
||||
"""
|
||||
# Structure to collect actions by tab and group
|
||||
tabs = defaultdict(lambda: defaultdict(list))
|
||||
|
||||
tabs: Dict[str, Dict[str, List[Dict[str, Any]]]] = defaultdict(lambda: defaultdict(list))
|
||||
|
||||
# Scan all methods in the class and its bases (mixins)
|
||||
for attr_name in dir(window_class):
|
||||
try:
|
||||
attr = getattr(window_class, attr_name)
|
||||
|
||||
|
||||
# Check if this attribute has ribbon action metadata
|
||||
if hasattr(attr, '_ribbon_action'):
|
||||
if hasattr(attr, "_ribbon_action"):
|
||||
action_data = attr._ribbon_action
|
||||
|
||||
|
||||
# Extract tab and group information
|
||||
tab_name = action_data['tab']
|
||||
group_name = action_data['group']
|
||||
|
||||
tab_name = action_data["tab"]
|
||||
group_name = action_data["group"]
|
||||
|
||||
# Add action to the appropriate tab and group
|
||||
tabs[tab_name][group_name].append({
|
||||
'label': action_data['label'],
|
||||
'action': action_data['action'],
|
||||
'tooltip': action_data['tooltip'],
|
||||
'icon': action_data.get('icon'),
|
||||
'shortcut': action_data.get('shortcut'),
|
||||
})
|
||||
tabs[tab_name][group_name].append(
|
||||
{
|
||||
"label": action_data["label"],
|
||||
"action": action_data["action"],
|
||||
"tooltip": action_data["tooltip"],
|
||||
"icon": action_data.get("icon"),
|
||||
"shortcut": action_data.get("shortcut"),
|
||||
}
|
||||
)
|
||||
except (AttributeError, TypeError):
|
||||
# Skip attributes that can't be inspected
|
||||
continue
|
||||
|
||||
|
||||
# Convert to the expected ribbon config format
|
||||
ribbon_config = {}
|
||||
|
||||
|
||||
# Define tab order (tabs will appear in this order)
|
||||
tab_order = ['Home', 'Insert', 'Layout', 'Arrange', 'View', 'Export']
|
||||
|
||||
tab_order = ["Home", "Insert", "Layout", "Arrange", "Style", "View"]
|
||||
|
||||
# Add tabs in the defined order, then add any remaining tabs
|
||||
all_tabs = list(tabs.keys())
|
||||
ordered_tabs = [t for t in tab_order if t in all_tabs]
|
||||
ordered_tabs.extend([t for t in all_tabs if t not in tab_order])
|
||||
|
||||
|
||||
for tab_name in ordered_tabs:
|
||||
groups_dict = tabs[tab_name]
|
||||
|
||||
|
||||
# Convert groups dictionary to list format
|
||||
groups_list = []
|
||||
|
||||
|
||||
# Define group order per tab (if needed)
|
||||
group_orders = {
|
||||
'Home': ['File', 'Edit'],
|
||||
'Insert': ['Media'],
|
||||
'Layout': ['Navigation', 'Page', 'Templates'],
|
||||
'Arrange': ['Align', 'Size', 'Distribute'],
|
||||
'View': ['Zoom'],
|
||||
'Export': ['Export'],
|
||||
"Home": ["File", "Edit"],
|
||||
"Insert": ["Media", "Snapping"],
|
||||
"Layout": ["Page", "Templates"],
|
||||
"Arrange": ["Align", "Distribute", "Size", "Order", "Transform"],
|
||||
"Style": ["Corners", "Border", "Effects", "Frame", "Presets"],
|
||||
"View": ["Zoom", "Guides"],
|
||||
}
|
||||
|
||||
|
||||
# Get the group order for this tab, or use alphabetical
|
||||
if tab_name in group_orders:
|
||||
group_order = group_orders[tab_name]
|
||||
@@ -103,139 +105,128 @@ def build_ribbon_config(window_class: Type) -> Dict[str, Any]:
|
||||
group_order.extend([g for g in all_groups if g not in group_order])
|
||||
else:
|
||||
group_order = sorted(groups_dict.keys())
|
||||
|
||||
|
||||
for group_name in group_order:
|
||||
if group_name in groups_dict:
|
||||
actions = groups_dict[group_name]
|
||||
groups_list.append({
|
||||
'name': group_name,
|
||||
'actions': actions
|
||||
})
|
||||
|
||||
ribbon_config[tab_name] = {
|
||||
'groups': groups_list
|
||||
}
|
||||
|
||||
groups_list.append({"name": group_name, "actions": actions})
|
||||
|
||||
ribbon_config[tab_name] = {"groups": groups_list}
|
||||
|
||||
return ribbon_config
|
||||
|
||||
|
||||
def get_keyboard_shortcuts(window_class: Type) -> Dict[str, str]:
|
||||
"""
|
||||
Extract keyboard shortcuts from decorated methods.
|
||||
|
||||
|
||||
Args:
|
||||
window_class: The MainWindow class with decorated methods
|
||||
|
||||
|
||||
Returns:
|
||||
Dictionary mapping shortcut strings to method names
|
||||
Example: {'Ctrl+N': 'new_project', 'Ctrl+S': 'save_project'}
|
||||
"""
|
||||
shortcuts = {}
|
||||
|
||||
|
||||
for attr_name in dir(window_class):
|
||||
try:
|
||||
attr = getattr(window_class, attr_name)
|
||||
|
||||
if hasattr(attr, '_ribbon_action'):
|
||||
|
||||
if hasattr(attr, "_ribbon_action"):
|
||||
action_data = attr._ribbon_action
|
||||
shortcut = action_data.get('shortcut')
|
||||
|
||||
shortcut = action_data.get("shortcut")
|
||||
|
||||
if shortcut:
|
||||
shortcuts[shortcut] = action_data['action']
|
||||
shortcuts[shortcut] = action_data["action"]
|
||||
except (AttributeError, TypeError):
|
||||
continue
|
||||
|
||||
|
||||
return shortcuts
|
||||
|
||||
|
||||
def validate_ribbon_config(config: Dict[str, Any]) -> List[str]:
|
||||
"""
|
||||
Validate the ribbon configuration structure.
|
||||
|
||||
|
||||
Args:
|
||||
config: The ribbon configuration dictionary
|
||||
|
||||
|
||||
Returns:
|
||||
List of validation error messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
|
||||
if not isinstance(config, dict):
|
||||
errors.append("Config must be a dictionary")
|
||||
return errors
|
||||
|
||||
|
||||
for tab_name, tab_data in config.items():
|
||||
if not isinstance(tab_data, dict):
|
||||
errors.append(f"Tab '{tab_name}' data must be a dictionary")
|
||||
continue
|
||||
|
||||
if 'groups' not in tab_data:
|
||||
|
||||
if "groups" not in tab_data:
|
||||
errors.append(f"Tab '{tab_name}' missing 'groups' key")
|
||||
continue
|
||||
|
||||
groups = tab_data['groups']
|
||||
|
||||
groups = tab_data["groups"]
|
||||
if not isinstance(groups, list):
|
||||
errors.append(f"Tab '{tab_name}' groups must be a list")
|
||||
continue
|
||||
|
||||
|
||||
for i, group in enumerate(groups):
|
||||
if not isinstance(group, dict):
|
||||
errors.append(f"Tab '{tab_name}' group {i} must be a dictionary")
|
||||
continue
|
||||
|
||||
if 'name' not in group:
|
||||
|
||||
if "name" not in group:
|
||||
errors.append(f"Tab '{tab_name}' group {i} missing 'name'")
|
||||
|
||||
if 'actions' not in group:
|
||||
|
||||
if "actions" not in group:
|
||||
errors.append(f"Tab '{tab_name}' group {i} missing 'actions'")
|
||||
continue
|
||||
|
||||
actions = group['actions']
|
||||
|
||||
actions = group["actions"]
|
||||
if not isinstance(actions, list):
|
||||
errors.append(f"Tab '{tab_name}' group {i} actions must be a list")
|
||||
continue
|
||||
|
||||
|
||||
for j, action in enumerate(actions):
|
||||
if not isinstance(action, dict):
|
||||
errors.append(f"Tab '{tab_name}' group {i} action {j} must be a dictionary")
|
||||
continue
|
||||
|
||||
required_keys = ['label', 'action', 'tooltip']
|
||||
|
||||
required_keys = ["label", "action", "tooltip"]
|
||||
for key in required_keys:
|
||||
if key not in action:
|
||||
errors.append(
|
||||
f"Tab '{tab_name}' group {i} action {j} missing '{key}'"
|
||||
)
|
||||
|
||||
errors.append(f"Tab '{tab_name}' group {i} action {j} missing '{key}'")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def print_ribbon_summary(config: Dict[str, Any]):
|
||||
"""
|
||||
Print a summary of the ribbon configuration.
|
||||
|
||||
|
||||
Args:
|
||||
config: The ribbon configuration dictionary
|
||||
"""
|
||||
print("\n=== Ribbon Configuration Summary ===\n")
|
||||
|
||||
|
||||
total_tabs = len(config)
|
||||
total_groups = sum(len(tab_data['groups']) for tab_data in config.values())
|
||||
total_actions = sum(
|
||||
len(group['actions'])
|
||||
for tab_data in config.values()
|
||||
for group in tab_data['groups']
|
||||
)
|
||||
|
||||
total_groups = sum(len(tab_data["groups"]) for tab_data in config.values())
|
||||
total_actions = sum(len(group["actions"]) for tab_data in config.values() for group in tab_data["groups"])
|
||||
|
||||
print(f"Total Tabs: {total_tabs}")
|
||||
print(f"Total Groups: {total_groups}")
|
||||
print(f"Total Actions: {total_actions}\n")
|
||||
|
||||
|
||||
for tab_name, tab_data in config.items():
|
||||
print(f"📑 {tab_name}")
|
||||
for group in tab_data['groups']:
|
||||
for group in tab_data["groups"]:
|
||||
print(f" 📦 {group['name']} ({len(group['actions'])} actions)")
|
||||
for action in group['actions']:
|
||||
shortcut = f" ({action['shortcut']})" if action.get('shortcut') else ""
|
||||
for action in group["actions"]:
|
||||
shortcut = f" ({action['shortcut']})" if action.get("shortcut") else ""
|
||||
print(f" • {action['label']}{shortcut}")
|
||||
print()
|
||||
|
||||
@@ -17,10 +17,11 @@ class RibbonWidget(QWidget):
|
||||
# Use provided config or fall back to importing the old one
|
||||
if ribbon_config is None:
|
||||
from ribbon_config import RIBBON_CONFIG
|
||||
|
||||
self.ribbon_config = RIBBON_CONFIG
|
||||
else:
|
||||
self.ribbon_config = ribbon_config
|
||||
|
||||
|
||||
# Main layout
|
||||
main_layout = QVBoxLayout()
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -106,7 +107,8 @@ class RibbonWidget(QWidget):
|
||||
# Connect to action
|
||||
action_name = action_config.get("action")
|
||||
if action_name:
|
||||
button.clicked.connect(lambda: self._execute_action(action_name))
|
||||
# Use default argument to capture action_name by value, not by reference
|
||||
button.clicked.connect(lambda checked, name=action_name: self._execute_action(name))
|
||||
|
||||
return button
|
||||
|
||||
|
||||
+137
-215
@@ -3,39 +3,49 @@ Snapping system for pyPhotoAlbum
|
||||
Provides grid snapping, edge snapping, and custom guide snapping
|
||||
"""
|
||||
|
||||
from typing import List, Tuple, Optional
|
||||
import math
|
||||
from typing import Any, Dict, List, Tuple, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Guide:
|
||||
"""Represents a snapping guide (vertical or horizontal line)"""
|
||||
|
||||
position: float # Position in mm
|
||||
orientation: str # 'vertical' or 'horizontal'
|
||||
|
||||
|
||||
def serialize(self) -> dict:
|
||||
"""Serialize guide to dictionary"""
|
||||
return {
|
||||
"position": self.position,
|
||||
"orientation": self.orientation
|
||||
}
|
||||
|
||||
return {"position": self.position, "orientation": self.orientation}
|
||||
|
||||
@staticmethod
|
||||
def deserialize(data: dict) -> 'Guide':
|
||||
def deserialize(data: dict) -> "Guide":
|
||||
"""Deserialize guide from dictionary"""
|
||||
return Guide(
|
||||
position=data.get("position", 0),
|
||||
orientation=data.get("orientation", "vertical")
|
||||
)
|
||||
return Guide(position=data.get("position", 0), orientation=data.get("orientation", "vertical"))
|
||||
|
||||
|
||||
@dataclass
|
||||
class SnapResizeParams:
|
||||
"""Parameters for snap resize operations"""
|
||||
|
||||
position: Tuple[float, float]
|
||||
size: Tuple[float, float]
|
||||
dx: float
|
||||
dy: float
|
||||
resize_handle: str
|
||||
page_size: Tuple[float, float]
|
||||
dpi: int = 300
|
||||
project: Optional[Any] = None
|
||||
|
||||
|
||||
class SnappingSystem:
|
||||
"""Manages snapping behavior for layout elements"""
|
||||
|
||||
|
||||
def __init__(self, snap_threshold_mm: float = 5.0):
|
||||
"""
|
||||
Initialize snapping system
|
||||
|
||||
|
||||
Args:
|
||||
snap_threshold_mm: Distance in mm within which snapping occurs
|
||||
"""
|
||||
@@ -45,28 +55,30 @@ class SnappingSystem:
|
||||
self.snap_to_edges = True
|
||||
self.snap_to_guides = True
|
||||
self.guides: List[Guide] = []
|
||||
|
||||
|
||||
def add_guide(self, position: float, orientation: str):
|
||||
"""Add a new guide"""
|
||||
guide = Guide(position=position, orientation=orientation)
|
||||
self.guides.append(guide)
|
||||
return guide
|
||||
|
||||
|
||||
def remove_guide(self, guide: Guide):
|
||||
"""Remove a guide"""
|
||||
if guide in self.guides:
|
||||
self.guides.remove(guide)
|
||||
|
||||
|
||||
def clear_guides(self):
|
||||
"""Remove all guides"""
|
||||
self.guides.clear()
|
||||
|
||||
def snap_position(self,
|
||||
position: Tuple[float, float],
|
||||
size: Tuple[float, float],
|
||||
page_size: Tuple[float, float],
|
||||
dpi: int = 300,
|
||||
project=None) -> Tuple[float, float]:
|
||||
|
||||
def snap_position(
|
||||
self,
|
||||
position: Tuple[float, float],
|
||||
size: Tuple[float, float],
|
||||
page_size: Tuple[float, float],
|
||||
dpi: int = 300,
|
||||
project=None,
|
||||
) -> Tuple[float, float]:
|
||||
"""
|
||||
Apply snapping to a position using combined distance threshold
|
||||
|
||||
@@ -80,8 +92,6 @@ class SnappingSystem:
|
||||
Returns:
|
||||
Snapped position (x, y) in pixels
|
||||
"""
|
||||
import math
|
||||
|
||||
x, y = position
|
||||
width, height = size
|
||||
page_width_mm, page_height_mm = page_size
|
||||
@@ -102,43 +112,47 @@ class SnappingSystem:
|
||||
|
||||
# Convert threshold from mm to pixels
|
||||
snap_threshold_px = snap_threshold_mm * dpi / 25.4
|
||||
|
||||
|
||||
# Collect all potential snap points for both edges of the element
|
||||
snap_points = []
|
||||
|
||||
|
||||
# 1. Page edge snap points
|
||||
if snap_to_edges:
|
||||
page_width_px = page_width_mm * dpi / 25.4
|
||||
page_height_px = page_height_mm * dpi / 25.4
|
||||
|
||||
# Corners where element's top-left can snap
|
||||
snap_points.extend([
|
||||
(0, 0), # Top-left corner
|
||||
(page_width_px - width, 0), # Top-right corner
|
||||
(0, page_height_px - height), # Bottom-left corner
|
||||
(page_width_px - width, page_height_px - height), # Bottom-right corner
|
||||
])
|
||||
snap_points.extend(
|
||||
[
|
||||
(0, 0), # Top-left corner
|
||||
(page_width_px - width, 0), # Top-right corner
|
||||
(0, page_height_px - height), # Bottom-left corner
|
||||
(page_width_px - width, page_height_px - height), # Bottom-right corner
|
||||
]
|
||||
)
|
||||
|
||||
# Edge positions (element aligned to edge on one axis)
|
||||
snap_points.extend([
|
||||
(0, y), # Left edge
|
||||
(page_width_px - width, y), # Right edge
|
||||
(x, 0), # Top edge
|
||||
(x, page_height_px - height), # Bottom edge
|
||||
])
|
||||
snap_points.extend(
|
||||
[
|
||||
(0, y), # Left edge
|
||||
(page_width_px - width, y), # Right edge
|
||||
(x, 0), # Top edge
|
||||
(x, page_height_px - height), # Bottom edge
|
||||
]
|
||||
)
|
||||
|
||||
# 2. Grid snap points
|
||||
if snap_to_grid:
|
||||
grid_size_px = grid_size_mm * dpi / 25.4
|
||||
page_width_px = page_width_mm * dpi / 25.4
|
||||
page_height_px = page_height_mm * dpi / 25.4
|
||||
|
||||
|
||||
# Calculate grid intersection points within range
|
||||
x_start = max(0, int((x - snap_threshold_px) / grid_size_px)) * grid_size_px
|
||||
x_end = min(page_width_px, int((x + snap_threshold_px) / grid_size_px + 1) * grid_size_px)
|
||||
y_start = max(0, int((y - snap_threshold_px) / grid_size_px)) * grid_size_px
|
||||
y_end = min(page_height_px, int((y + snap_threshold_px) / grid_size_px + 1) * grid_size_px)
|
||||
|
||||
|
||||
grid_x = x_start
|
||||
while grid_x <= x_end:
|
||||
grid_y = y_start
|
||||
@@ -151,12 +165,12 @@ class SnappingSystem:
|
||||
snap_points.append((grid_x, grid_y - height))
|
||||
grid_y += grid_size_px
|
||||
grid_x += grid_size_px
|
||||
|
||||
|
||||
# 3. Guide snap points
|
||||
if snap_to_guides:
|
||||
vertical_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == 'vertical']
|
||||
horizontal_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == 'horizontal']
|
||||
|
||||
vertical_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == "vertical"]
|
||||
horizontal_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == "horizontal"]
|
||||
|
||||
# Guide intersections (when both vertical and horizontal guides exist)
|
||||
for vg in vertical_guides:
|
||||
for hg in horizontal_guides:
|
||||
@@ -165,92 +179,77 @@ class SnappingSystem:
|
||||
snap_points.append((vg - width, hg))
|
||||
snap_points.append((vg, hg - height))
|
||||
snap_points.append((vg - width, hg - height))
|
||||
|
||||
|
||||
# Find the nearest snap point using Euclidean distance
|
||||
best_snap_point = None
|
||||
best_distance = snap_threshold_px
|
||||
|
||||
|
||||
for snap_x, snap_y in snap_points:
|
||||
distance = math.sqrt((x - snap_x) ** 2 + (y - snap_y) ** 2)
|
||||
if distance < best_distance:
|
||||
best_snap_point = (snap_x, snap_y)
|
||||
best_distance = distance
|
||||
|
||||
|
||||
# Return snapped position or original position
|
||||
if best_snap_point:
|
||||
return best_snap_point
|
||||
else:
|
||||
return (x, y)
|
||||
|
||||
def snap_resize(self,
|
||||
position: Tuple[float, float],
|
||||
size: Tuple[float, float],
|
||||
dx: float,
|
||||
dy: float,
|
||||
resize_handle: str,
|
||||
page_size: Tuple[float, float],
|
||||
dpi: int = 300,
|
||||
project=None) -> Tuple[Tuple[float, float], Tuple[float, float]]:
|
||||
|
||||
def snap_resize(self, params: SnapResizeParams) -> Tuple[Tuple[float, float], Tuple[float, float]]:
|
||||
"""
|
||||
Apply snapping during resize operations
|
||||
|
||||
Args:
|
||||
position: Current position (x, y) in pixels
|
||||
size: Current size (width, height) in pixels
|
||||
dx: Delta x movement in pixels
|
||||
dy: Delta y movement in pixels
|
||||
resize_handle: Which handle is being dragged ('nw', 'ne', 'sw', 'se', 'n', 's', 'e', 'w')
|
||||
page_size: Page size (width, height) in mm
|
||||
dpi: DPI for conversion
|
||||
project: Optional project for global snapping settings
|
||||
params: SnapResizeParams containing all resize parameters
|
||||
|
||||
Returns:
|
||||
Tuple of (snapped_position, snapped_size) in pixels
|
||||
"""
|
||||
x, y = position
|
||||
width, height = size
|
||||
page_width_mm, page_height_mm = page_size
|
||||
x, y = params.position
|
||||
width, height = params.size
|
||||
page_width_mm, page_height_mm = params.page_size
|
||||
|
||||
# Use project settings if available, otherwise use local settings
|
||||
if project:
|
||||
snap_threshold_mm = project.snap_threshold_mm
|
||||
if params.project:
|
||||
snap_threshold_mm = params.project.snap_threshold_mm
|
||||
else:
|
||||
snap_threshold_mm = self.snap_threshold_mm
|
||||
|
||||
# Convert threshold from mm to pixels
|
||||
snap_threshold_px = snap_threshold_mm * dpi / 25.4
|
||||
|
||||
snap_threshold_px = snap_threshold_mm * params.dpi / 25.4
|
||||
|
||||
# Calculate new position and size based on resize handle
|
||||
new_x, new_y = x, y
|
||||
new_width, new_height = width, height
|
||||
|
||||
|
||||
# Apply resize based on handle
|
||||
if resize_handle in ['nw', 'n', 'ne']:
|
||||
if params.resize_handle in ["nw", "n", "ne"]:
|
||||
# Top edge moving
|
||||
new_y = y + dy
|
||||
new_height = height - dy
|
||||
|
||||
if resize_handle in ['sw', 's', 'se']:
|
||||
new_y = y + params.dy
|
||||
new_height = height - params.dy
|
||||
|
||||
if params.resize_handle in ["sw", "s", "se"]:
|
||||
# Bottom edge moving
|
||||
new_height = height + dy
|
||||
|
||||
if resize_handle in ['nw', 'w', 'sw']:
|
||||
new_height = height + params.dy
|
||||
|
||||
if params.resize_handle in ["nw", "w", "sw"]:
|
||||
# Left edge moving
|
||||
new_x = x + dx
|
||||
new_width = width - dx
|
||||
|
||||
if resize_handle in ['ne', 'e', 'se']:
|
||||
new_x = x + params.dx
|
||||
new_width = width - params.dx
|
||||
|
||||
if params.resize_handle in ["ne", "e", "se"]:
|
||||
# Right edge moving
|
||||
new_width = width + dx
|
||||
|
||||
new_width = width + params.dx
|
||||
|
||||
# Now apply snapping to the edges that are being moved
|
||||
# Use _snap_edge_to_targets consistently for all edges
|
||||
|
||||
|
||||
# Snap left edge (for nw, w, sw handles)
|
||||
if resize_handle in ['nw', 'w', 'sw']:
|
||||
if params.resize_handle in ["nw", "w", "sw"]:
|
||||
# Try to snap the left edge
|
||||
snapped_left = self._snap_edge_to_targets(
|
||||
new_x, page_width_mm, dpi, snap_threshold_px, 'vertical', project
|
||||
new_x, page_width_mm, params.dpi, snap_threshold_px, "vertical", params.project
|
||||
)
|
||||
if snapped_left is not None:
|
||||
# Adjust width to compensate for position change
|
||||
@@ -259,21 +258,21 @@ class SnappingSystem:
|
||||
new_width += width_adjustment
|
||||
|
||||
# Snap right edge (for ne, e, se handles)
|
||||
if resize_handle in ['ne', 'e', 'se']:
|
||||
if params.resize_handle in ["ne", "e", "se"]:
|
||||
# Calculate right edge position
|
||||
right_edge = new_x + new_width
|
||||
# Try to snap the right edge
|
||||
snapped_right = self._snap_edge_to_targets(
|
||||
right_edge, page_width_mm, dpi, snap_threshold_px, 'vertical', project
|
||||
right_edge, page_width_mm, params.dpi, snap_threshold_px, "vertical", params.project
|
||||
)
|
||||
if snapped_right is not None:
|
||||
new_width = snapped_right - new_x
|
||||
|
||||
# Snap top edge (for nw, n, ne handles)
|
||||
if resize_handle in ['nw', 'n', 'ne']:
|
||||
if params.resize_handle in ["nw", "n", "ne"]:
|
||||
# Try to snap the top edge
|
||||
snapped_top = self._snap_edge_to_targets(
|
||||
new_y, page_height_mm, dpi, snap_threshold_px, 'horizontal', project
|
||||
new_y, page_height_mm, params.dpi, snap_threshold_px, "horizontal", params.project
|
||||
)
|
||||
if snapped_top is not None:
|
||||
# Adjust height to compensate for position change
|
||||
@@ -282,30 +281,32 @@ class SnappingSystem:
|
||||
new_height += height_adjustment
|
||||
|
||||
# Snap bottom edge (for sw, s, se handles)
|
||||
if resize_handle in ['sw', 's', 'se']:
|
||||
if params.resize_handle in ["sw", "s", "se"]:
|
||||
# Calculate bottom edge position
|
||||
bottom_edge = new_y + new_height
|
||||
# Try to snap the bottom edge
|
||||
snapped_bottom = self._snap_edge_to_targets(
|
||||
bottom_edge, page_height_mm, dpi, snap_threshold_px, 'horizontal', project
|
||||
bottom_edge, page_height_mm, params.dpi, snap_threshold_px, "horizontal", params.project
|
||||
)
|
||||
if snapped_bottom is not None:
|
||||
new_height = snapped_bottom - new_y
|
||||
|
||||
|
||||
# Ensure minimum size
|
||||
min_size = 10 # Minimum 10 pixels
|
||||
new_width = max(new_width, min_size)
|
||||
new_height = max(new_height, min_size)
|
||||
|
||||
|
||||
return ((new_x, new_y), (new_width, new_height))
|
||||
|
||||
def _snap_edge_to_targets(self,
|
||||
edge_position: float,
|
||||
page_size_mm: float,
|
||||
dpi: int,
|
||||
snap_threshold_px: float,
|
||||
orientation: str,
|
||||
project=None) -> Optional[float]:
|
||||
|
||||
def _snap_edge_to_targets(
|
||||
self,
|
||||
edge_position: float,
|
||||
page_size_mm: float,
|
||||
dpi: int,
|
||||
snap_threshold_px: float,
|
||||
orientation: str,
|
||||
project=None,
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
Snap an edge position to available targets (grid, edges, guides)
|
||||
|
||||
@@ -332,12 +333,12 @@ class SnappingSystem:
|
||||
snap_to_guides = self.snap_to_guides
|
||||
grid_size_mm = self.grid_size_mm
|
||||
|
||||
snap_candidates = []
|
||||
snap_candidates: List[Tuple[float, float]] = []
|
||||
|
||||
# 1. Page edge snapping
|
||||
if snap_to_edges:
|
||||
# Snap to start edge (0)
|
||||
snap_candidates.append((0, abs(edge_position - 0)))
|
||||
snap_candidates.append((0.0, abs(edge_position - 0)))
|
||||
|
||||
# Snap to end edge
|
||||
page_size_px = page_size_mm * dpi / 25.4
|
||||
@@ -357,144 +358,65 @@ class SnappingSystem:
|
||||
if guide.orientation == orientation:
|
||||
guide_pos_px = guide.position * dpi / 25.4
|
||||
snap_candidates.append((guide_pos_px, abs(edge_position - guide_pos_px)))
|
||||
|
||||
|
||||
# Find the best snap candidate within threshold
|
||||
best_snap = None
|
||||
best_distance = snap_threshold_px
|
||||
|
||||
|
||||
for snap_pos, distance in snap_candidates:
|
||||
if distance < best_distance:
|
||||
best_snap = snap_pos
|
||||
best_distance = distance
|
||||
|
||||
|
||||
return best_snap
|
||||
|
||||
def _snap_axis(self,
|
||||
position: float,
|
||||
size: float,
|
||||
page_size_mm: float,
|
||||
dpi: int,
|
||||
snap_threshold_px: float,
|
||||
orientation: str) -> float:
|
||||
"""
|
||||
Snap along a single axis
|
||||
|
||||
Args:
|
||||
position: Current position along axis in pixels
|
||||
size: Element size along axis in pixels
|
||||
page_size_mm: Page size along axis in mm
|
||||
dpi: DPI for conversion
|
||||
snap_threshold_px: Snap threshold in pixels
|
||||
orientation: 'vertical' for x-axis, 'horizontal' for y-axis
|
||||
|
||||
Returns:
|
||||
Snapped position in pixels
|
||||
"""
|
||||
snap_candidates = []
|
||||
|
||||
# 1. Page edge snapping
|
||||
if self.snap_to_edges:
|
||||
# Snap to start edge (0)
|
||||
snap_candidates.append((0, abs(position - 0)))
|
||||
|
||||
# Snap to end edge
|
||||
page_size_px = page_size_mm * dpi / 25.4
|
||||
snap_candidates.append((page_size_px - size, abs(position - (page_size_px - size))))
|
||||
|
||||
# Also snap element's far edge to page edge
|
||||
snap_candidates.append((page_size_px - size, abs((position + size) - page_size_px)))
|
||||
|
||||
# 2. Grid snapping
|
||||
if self.snap_to_grid:
|
||||
grid_size_px = self.grid_size_mm * dpi / 25.4
|
||||
|
||||
# Snap to nearest grid line
|
||||
nearest_grid = round(position / grid_size_px) * grid_size_px
|
||||
snap_candidates.append((nearest_grid, abs(position - nearest_grid)))
|
||||
|
||||
# Also try snapping element's far edge to grid
|
||||
element_end = position + size
|
||||
nearest_grid_end = round(element_end / grid_size_px) * grid_size_px
|
||||
snap_candidates.append((nearest_grid_end - size, abs(element_end - nearest_grid_end)))
|
||||
|
||||
# 3. Guide snapping
|
||||
if self.snap_to_guides:
|
||||
for guide in self.guides:
|
||||
if guide.orientation == orientation:
|
||||
guide_pos_px = guide.position * dpi / 25.4
|
||||
|
||||
# Snap start edge to guide
|
||||
snap_candidates.append((guide_pos_px, abs(position - guide_pos_px)))
|
||||
|
||||
# Snap end edge to guide
|
||||
element_end = position + size
|
||||
snap_candidates.append((guide_pos_px - size, abs(element_end - guide_pos_px)))
|
||||
|
||||
# Find the best snap candidate within threshold
|
||||
best_snap = None
|
||||
best_distance = snap_threshold_px
|
||||
|
||||
for snap_pos, distance in snap_candidates:
|
||||
if distance < best_distance:
|
||||
best_snap = snap_pos
|
||||
best_distance = distance
|
||||
|
||||
return best_snap if best_snap is not None else position
|
||||
|
||||
|
||||
def get_snap_lines(self, page_size: Tuple[float, float], dpi: int = 300) -> dict:
|
||||
"""
|
||||
Get all snap lines for visualization
|
||||
|
||||
|
||||
Args:
|
||||
page_size: Page size (width, height) in mm
|
||||
dpi: DPI for conversion
|
||||
|
||||
|
||||
Returns:
|
||||
Dictionary with 'grid', 'edges', and 'guides' lists
|
||||
"""
|
||||
page_width_mm, page_height_mm = page_size
|
||||
page_width_px = page_width_mm * dpi / 25.4
|
||||
page_height_px = page_height_mm * dpi / 25.4
|
||||
|
||||
result = {
|
||||
'grid': [],
|
||||
'edges': [],
|
||||
'guides': []
|
||||
}
|
||||
|
||||
|
||||
result: Dict[str, List[Tuple[str, float]]] = {"grid": [], "edges": [], "guides": []}
|
||||
|
||||
# Grid lines
|
||||
if self.snap_to_grid:
|
||||
grid_size_px = self.grid_size_mm * dpi / 25.4
|
||||
|
||||
|
||||
# Vertical grid lines
|
||||
x = 0
|
||||
x: float = 0
|
||||
while x <= page_width_px:
|
||||
result['grid'].append(('vertical', x))
|
||||
result["grid"].append(("vertical", x))
|
||||
x += grid_size_px
|
||||
|
||||
|
||||
# Horizontal grid lines
|
||||
y = 0
|
||||
y: float = 0
|
||||
while y <= page_height_px:
|
||||
result['grid'].append(('horizontal', y))
|
||||
result["grid"].append(("horizontal", y))
|
||||
y += grid_size_px
|
||||
|
||||
|
||||
# Edge lines
|
||||
if self.snap_to_edges:
|
||||
result['edges'].extend([
|
||||
('vertical', 0),
|
||||
('vertical', page_width_px),
|
||||
('horizontal', 0),
|
||||
('horizontal', page_height_px)
|
||||
])
|
||||
|
||||
result["edges"].extend(
|
||||
[("vertical", 0), ("vertical", page_width_px), ("horizontal", 0), ("horizontal", page_height_px)]
|
||||
)
|
||||
|
||||
# Guide lines
|
||||
if self.snap_to_guides:
|
||||
for guide in self.guides:
|
||||
guide_pos_px = guide.position * dpi / 25.4
|
||||
result['guides'].append((guide.orientation, guide_pos_px))
|
||||
|
||||
result["guides"].append((guide.orientation, guide_pos_px))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def serialize(self) -> dict:
|
||||
"""Serialize snapping system to dictionary"""
|
||||
return {
|
||||
@@ -503,9 +425,9 @@ class SnappingSystem:
|
||||
"snap_to_grid": self.snap_to_grid,
|
||||
"snap_to_edges": self.snap_to_edges,
|
||||
"snap_to_guides": self.snap_to_guides,
|
||||
"guides": [guide.serialize() for guide in self.guides]
|
||||
"guides": [guide.serialize() for guide in self.guides],
|
||||
}
|
||||
|
||||
|
||||
def deserialize(self, data: dict):
|
||||
"""Deserialize from dictionary"""
|
||||
self.snap_threshold_mm = data.get("snap_threshold_mm", 5.0)
|
||||
@@ -513,7 +435,7 @@ class SnappingSystem:
|
||||
self.snap_to_grid = data.get("snap_to_grid", False)
|
||||
self.snap_to_edges = data.get("snap_to_edges", True)
|
||||
self.snap_to_guides = data.get("snap_to_guides", True)
|
||||
|
||||
|
||||
self.guides = []
|
||||
for guide_data in data.get("guides", []):
|
||||
self.guides.append(Guide.deserialize(guide_data))
|
||||
|
||||
@@ -14,7 +14,9 @@ from pyPhotoAlbum.project import Page
|
||||
class Template:
|
||||
"""Class representing a page layout template"""
|
||||
|
||||
def __init__(self, name: str = "Untitled Template", description: str = "", page_size_mm: Tuple[float, float] = (210, 297)):
|
||||
def __init__(
|
||||
self, name: str = "Untitled Template", description: str = "", page_size_mm: Tuple[float, float] = (210, 297)
|
||||
):
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.page_size_mm = page_size_mm
|
||||
@@ -30,21 +32,22 @@ class Template:
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"page_size_mm": self.page_size_mm,
|
||||
"elements": [elem.serialize() for elem in self.elements]
|
||||
"elements": [elem.serialize() for elem in self.elements],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Template':
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "Template":
|
||||
"""Deserialize template from dictionary"""
|
||||
template = cls(
|
||||
name=data.get("name", "Untitled Template"),
|
||||
description=data.get("description", ""),
|
||||
page_size_mm=tuple(data.get("page_size_mm", (210, 297)))
|
||||
page_size_mm=tuple(data.get("page_size_mm", (210, 297))),
|
||||
)
|
||||
|
||||
# Deserialize elements
|
||||
for elem_data in data.get("elements", []):
|
||||
elem_type = elem_data.get("type")
|
||||
elem: BaseLayoutElement
|
||||
if elem_type == "placeholder":
|
||||
elem = PlaceholderData()
|
||||
elif elem_type == "textbox":
|
||||
@@ -59,13 +62,13 @@ class Template:
|
||||
|
||||
def save_to_file(self, file_path: str):
|
||||
"""Save template to JSON file"""
|
||||
with open(file_path, 'w') as f:
|
||||
with open(file_path, "w") as f:
|
||||
json.dump(self.to_dict(), f, indent=2)
|
||||
|
||||
@classmethod
|
||||
def load_from_file(cls, file_path: str) -> 'Template':
|
||||
def load_from_file(cls, file_path: str) -> "Template":
|
||||
"""Load template from JSON file"""
|
||||
with open(file_path, 'r') as f:
|
||||
with open(file_path, "r") as f:
|
||||
data = json.load(f)
|
||||
return cls.from_dict(data)
|
||||
|
||||
@@ -94,7 +97,7 @@ class TemplateManager:
|
||||
def _ensure_templates_directory(self):
|
||||
"""Create templates directory if it doesn't exist"""
|
||||
self.templates_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# Also ensure built-in templates directory exists
|
||||
builtin_dir = self._get_builtin_templates_directory()
|
||||
builtin_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -102,33 +105,33 @@ class TemplateManager:
|
||||
def list_templates(self) -> List[str]:
|
||||
"""List all available template names (embedded + user + built-in)"""
|
||||
templates = []
|
||||
|
||||
|
||||
# List embedded templates (priority)
|
||||
if self.project and self.project.embedded_templates:
|
||||
for template_name in self.project.embedded_templates.keys():
|
||||
templates.append(f"[Embedded] {template_name}")
|
||||
|
||||
|
||||
# List user templates
|
||||
if self.templates_dir.exists():
|
||||
for file in self.templates_dir.glob("*.json"):
|
||||
templates.append(file.stem)
|
||||
|
||||
|
||||
# List built-in templates
|
||||
builtin_dir = self._get_builtin_templates_directory()
|
||||
if builtin_dir.exists():
|
||||
for file in builtin_dir.glob("*.json"):
|
||||
template_name = f"[Built-in] {file.stem}"
|
||||
templates.append(template_name)
|
||||
|
||||
|
||||
return sorted(templates)
|
||||
|
||||
def load_template(self, name: str) -> Template:
|
||||
"""
|
||||
Load a template by name with priority: embedded > user > built-in.
|
||||
|
||||
|
||||
Args:
|
||||
name: Template name (may include prefix like '[Embedded]' or '[Built-in]')
|
||||
|
||||
|
||||
Returns:
|
||||
Template instance
|
||||
"""
|
||||
@@ -139,12 +142,12 @@ class TemplateManager:
|
||||
template_data = self.project.embedded_templates[actual_name]
|
||||
return Template.from_dict(template_data)
|
||||
raise FileNotFoundError(f"Embedded template '{actual_name}' not found")
|
||||
|
||||
|
||||
# Check embedded templates even without prefix (for backward compatibility)
|
||||
if self.project and name in self.project.embedded_templates:
|
||||
template_data = self.project.embedded_templates[name]
|
||||
return Template.from_dict(template_data)
|
||||
|
||||
|
||||
# Check if it's a built-in template
|
||||
if name.startswith("[Built-in] "):
|
||||
actual_name = name.replace("[Built-in] ", "")
|
||||
@@ -152,16 +155,16 @@ class TemplateManager:
|
||||
else:
|
||||
# User template
|
||||
template_path = self.templates_dir / f"{name}.json"
|
||||
|
||||
|
||||
if not template_path.exists():
|
||||
raise FileNotFoundError(f"Template '{name}' not found")
|
||||
|
||||
|
||||
return Template.load_from_file(str(template_path))
|
||||
|
||||
def save_template(self, template: Template, embed_in_project: bool = False):
|
||||
"""
|
||||
Save a template to filesystem or embed in project.
|
||||
|
||||
|
||||
Args:
|
||||
template: Template to save
|
||||
embed_in_project: If True, embed in project instead of saving to filesystem
|
||||
@@ -179,7 +182,7 @@ class TemplateManager:
|
||||
"""Delete a template (embedded or user templates only)"""
|
||||
if name.startswith("[Built-in] "):
|
||||
raise PermissionError("Cannot delete built-in templates")
|
||||
|
||||
|
||||
# Check if it's an embedded template
|
||||
if name.startswith("[Embedded] "):
|
||||
actual_name = name.replace("[Embedded] ", "")
|
||||
@@ -188,7 +191,7 @@ class TemplateManager:
|
||||
print(f"Removed embedded template '{actual_name}'")
|
||||
return
|
||||
raise FileNotFoundError(f"Embedded template '{actual_name}' not found")
|
||||
|
||||
|
||||
# User template from filesystem
|
||||
template_path = self.templates_dir / f"{name}.json"
|
||||
if template_path.exists():
|
||||
@@ -197,26 +200,22 @@ class TemplateManager:
|
||||
def embed_template(self, template: Template):
|
||||
"""
|
||||
Embed a template in the project.
|
||||
|
||||
|
||||
Args:
|
||||
template: Template to embed
|
||||
"""
|
||||
if not self.project:
|
||||
raise RuntimeError("No project associated with this TemplateManager")
|
||||
|
||||
|
||||
self.project.embedded_templates[template.name] = template.to_dict()
|
||||
print(f"Embedded template '{template.name}' in project")
|
||||
|
||||
|
||||
def create_template_from_page(self, page: Page, name: str, description: str = "") -> Template:
|
||||
"""
|
||||
Create a template from an existing page.
|
||||
Converts all ImageData elements to PlaceholderData.
|
||||
"""
|
||||
template = Template(
|
||||
name=name,
|
||||
description=description,
|
||||
page_size_mm=page.layout.size
|
||||
)
|
||||
template = Template(name=name, description=description, page_size_mm=page.layout.size)
|
||||
|
||||
# Convert elements
|
||||
for element in page.layout.elements:
|
||||
@@ -229,7 +228,7 @@ class TemplateManager:
|
||||
width=element.size[0],
|
||||
height=element.size[1],
|
||||
rotation=element.rotation,
|
||||
z_index=element.z_index
|
||||
z_index=element.z_index,
|
||||
)
|
||||
template.add_element(placeholder)
|
||||
elif isinstance(element, TextBoxData):
|
||||
@@ -243,7 +242,7 @@ class TemplateManager:
|
||||
width=element.size[0],
|
||||
height=element.size[1],
|
||||
rotation=element.rotation,
|
||||
z_index=element.z_index
|
||||
z_index=element.z_index,
|
||||
)
|
||||
template.add_element(text_box)
|
||||
elif isinstance(element, PlaceholderData):
|
||||
@@ -256,7 +255,7 @@ class TemplateManager:
|
||||
width=element.size[0],
|
||||
height=element.size[1],
|
||||
rotation=element.rotation,
|
||||
z_index=element.z_index
|
||||
z_index=element.z_index,
|
||||
)
|
||||
template.add_element(placeholder)
|
||||
|
||||
@@ -268,32 +267,32 @@ class TemplateManager:
|
||||
from_size: Tuple[float, float],
|
||||
to_size: Tuple[float, float],
|
||||
scale_mode: str = "proportional",
|
||||
margin_percent: float = 0.0
|
||||
margin_percent: float = 0.0,
|
||||
) -> List[BaseLayoutElement]:
|
||||
"""
|
||||
Scale template elements to fit target page size with adjustable margins.
|
||||
|
||||
|
||||
Args:
|
||||
elements: List of elements to scale
|
||||
from_size: Original template size (width, height) in mm
|
||||
to_size: Target page size (width, height) in mm
|
||||
scale_mode: "proportional", "stretch", or "center"
|
||||
margin_percent: Percentage of page size to use for margins (0-10%)
|
||||
|
||||
|
||||
Returns:
|
||||
List of scaled elements
|
||||
"""
|
||||
from_width, from_height = from_size
|
||||
to_width, to_height = to_size
|
||||
|
||||
|
||||
# Calculate target margins from percentage
|
||||
margin_x = to_width * (margin_percent / 100.0)
|
||||
margin_y = to_height * (margin_percent / 100.0)
|
||||
|
||||
|
||||
# Available content area after margins
|
||||
content_width = to_width - (2 * margin_x)
|
||||
content_height = to_height - (2 * margin_y)
|
||||
|
||||
|
||||
# Calculate scale factors based on mode
|
||||
if scale_mode == "stretch":
|
||||
# Stretch to fill content area independently in each dimension
|
||||
@@ -318,19 +317,19 @@ class TemplateManager:
|
||||
offset_x = (to_width - from_width) / 2
|
||||
offset_y = (to_height - from_height) / 2
|
||||
|
||||
scaled_elements = []
|
||||
scaled_elements: List[BaseLayoutElement] = []
|
||||
for element in elements:
|
||||
# Create a new element of the same type
|
||||
new_elem: BaseLayoutElement
|
||||
if isinstance(element, PlaceholderData):
|
||||
new_elem = PlaceholderData(
|
||||
placeholder_type=element.placeholder_type,
|
||||
default_content=element.default_content
|
||||
placeholder_type=element.placeholder_type, default_content=element.default_content
|
||||
)
|
||||
elif isinstance(element, TextBoxData):
|
||||
new_elem = TextBoxData(
|
||||
text_content=element.text_content,
|
||||
font_settings=element.font_settings.copy() if element.font_settings else None,
|
||||
alignment=element.alignment
|
||||
alignment=element.alignment,
|
||||
)
|
||||
else:
|
||||
continue # Skip other types
|
||||
@@ -339,14 +338,8 @@ class TemplateManager:
|
||||
old_x, old_y = element.position
|
||||
old_w, old_h = element.size
|
||||
|
||||
new_elem.position = (
|
||||
old_x * scale_x + offset_x,
|
||||
old_y * scale_y + offset_y
|
||||
)
|
||||
new_elem.size = (
|
||||
old_w * scale_x,
|
||||
old_h * scale_y
|
||||
)
|
||||
new_elem.position = (old_x * scale_x + offset_x, old_y * scale_y + offset_y)
|
||||
new_elem.size = (old_w * scale_x, old_h * scale_y)
|
||||
new_elem.rotation = element.rotation
|
||||
new_elem.z_index = element.z_index
|
||||
|
||||
@@ -362,15 +355,9 @@ class TemplateManager:
|
||||
|
||||
for elem in scaled_elements:
|
||||
# Convert position from mm to pixels
|
||||
elem.position = (
|
||||
elem.position[0] * mm_to_px,
|
||||
elem.position[1] * mm_to_px
|
||||
)
|
||||
elem.position = (elem.position[0] * mm_to_px, elem.position[1] * mm_to_px)
|
||||
# Convert size from mm to pixels
|
||||
elem.size = (
|
||||
elem.size[0] * mm_to_px,
|
||||
elem.size[1] * mm_to_px
|
||||
)
|
||||
elem.size = (elem.size[0] * mm_to_px, elem.size[1] * mm_to_px)
|
||||
|
||||
return scaled_elements
|
||||
|
||||
@@ -381,15 +368,15 @@ class TemplateManager:
|
||||
mode: str = "replace",
|
||||
scale_mode: str = "proportional",
|
||||
margin_percent: float = 2.5,
|
||||
auto_embed: bool = True
|
||||
auto_embed: bool = True,
|
||||
):
|
||||
"""
|
||||
Apply template to an existing page with adjustable margins.
|
||||
|
||||
|
||||
Args:
|
||||
template: Template to apply
|
||||
page: Target page
|
||||
mode: "replace" to clear page and add placeholders,
|
||||
mode: "replace" to clear page and add placeholders,
|
||||
"reflow" to keep existing content and reposition
|
||||
scale_mode: "proportional", "stretch", or "center"
|
||||
margin_percent: Percentage of page size to use for margins (0-10%)
|
||||
@@ -399,44 +386,36 @@ class TemplateManager:
|
||||
if auto_embed and self.project:
|
||||
if template.name not in self.project.embedded_templates:
|
||||
self.embed_template(template)
|
||||
|
||||
|
||||
if mode == "replace":
|
||||
# Clear existing elements
|
||||
page.layout.elements.clear()
|
||||
|
||||
|
||||
# Scale template elements to fit page
|
||||
scaled_elements = self.scale_template_elements(
|
||||
template.elements,
|
||||
template.page_size_mm,
|
||||
page.layout.size,
|
||||
scale_mode,
|
||||
margin_percent
|
||||
template.elements, template.page_size_mm, page.layout.size, scale_mode, margin_percent
|
||||
)
|
||||
|
||||
|
||||
# Add scaled elements to page
|
||||
for element in scaled_elements:
|
||||
page.layout.add_element(element)
|
||||
|
||||
|
||||
elif mode == "reflow":
|
||||
# Keep existing content but reposition to template slots
|
||||
existing_images = [e for e in page.layout.elements if isinstance(e, ImageData)]
|
||||
existing_text = [e for e in page.layout.elements if isinstance(e, TextBoxData)]
|
||||
|
||||
|
||||
# Get template placeholders (scaled)
|
||||
scaled_elements = self.scale_template_elements(
|
||||
template.elements,
|
||||
template.page_size_mm,
|
||||
page.layout.size,
|
||||
scale_mode,
|
||||
margin_percent
|
||||
template.elements, template.page_size_mm, page.layout.size, scale_mode, margin_percent
|
||||
)
|
||||
|
||||
|
||||
template_placeholders = [e for e in scaled_elements if isinstance(e, PlaceholderData)]
|
||||
template_text = [e for e in scaled_elements if isinstance(e, TextBoxData)]
|
||||
|
||||
|
||||
# Clear page
|
||||
page.layout.elements.clear()
|
||||
|
||||
|
||||
# Reflow images into placeholder slots
|
||||
for i, placeholder in enumerate(template_placeholders):
|
||||
if i < len(existing_images):
|
||||
@@ -449,11 +428,11 @@ class TemplateManager:
|
||||
else:
|
||||
# Add placeholder if no more images
|
||||
page.layout.add_element(placeholder)
|
||||
|
||||
|
||||
# Add remaining images (if any) at their original positions
|
||||
for img in existing_images[len(template_placeholders):]:
|
||||
for img in existing_images[len(template_placeholders) :]:
|
||||
page.layout.add_element(img)
|
||||
|
||||
|
||||
# Add template text boxes
|
||||
for text_elem in template_text:
|
||||
page.layout.add_element(text_elem)
|
||||
@@ -465,7 +444,7 @@ class TemplateManager:
|
||||
target_size_mm: Optional[Tuple[float, float]] = None,
|
||||
scale_mode: str = "proportional",
|
||||
margin_percent: float = 2.5,
|
||||
auto_embed: bool = True
|
||||
auto_embed: bool = True,
|
||||
) -> Page:
|
||||
"""
|
||||
Create a new page from a template.
|
||||
@@ -494,11 +473,7 @@ class TemplateManager:
|
||||
page_size = target_size_mm
|
||||
# Scale template elements with margins
|
||||
elements = self.scale_template_elements(
|
||||
template.elements,
|
||||
template.page_size_mm,
|
||||
target_size_mm,
|
||||
scale_mode,
|
||||
margin_percent
|
||||
template.elements, template.page_size_mm, target_size_mm, scale_mode, margin_percent
|
||||
)
|
||||
|
||||
# Create new page layout
|
||||
|
||||
@@ -3,8 +3,15 @@ Text editing dialog for pyPhotoAlbum
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QPushButton,
|
||||
QTextEdit, QLabel, QComboBox, QSpinBox, QColorDialog
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QPushButton,
|
||||
QTextEdit,
|
||||
QLabel,
|
||||
QComboBox,
|
||||
QSpinBox,
|
||||
QColorDialog,
|
||||
)
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtGui import QFont, QColor
|
||||
@@ -12,144 +19,135 @@ from PyQt6.QtGui import QFont, QColor
|
||||
|
||||
class TextEditDialog(QDialog):
|
||||
"""Dialog for editing text box content and properties"""
|
||||
|
||||
|
||||
def __init__(self, text_element, parent=None):
|
||||
super().__init__(parent)
|
||||
self.text_element = text_element
|
||||
self.setWindowTitle("Edit Text")
|
||||
self.resize(500, 400)
|
||||
|
||||
|
||||
# Create UI
|
||||
self._init_ui()
|
||||
|
||||
|
||||
# Load current values
|
||||
self._load_values()
|
||||
|
||||
|
||||
def _init_ui(self):
|
||||
"""Initialize the user interface"""
|
||||
layout = QVBoxLayout()
|
||||
|
||||
|
||||
# Text editor
|
||||
text_label = QLabel("Text:")
|
||||
self.text_edit = QTextEdit()
|
||||
self.text_edit.setAcceptRichText(False) # Plain text only
|
||||
layout.addWidget(text_label)
|
||||
layout.addWidget(self.text_edit)
|
||||
|
||||
|
||||
# Font settings
|
||||
font_layout = QHBoxLayout()
|
||||
|
||||
|
||||
# Font family
|
||||
font_layout.addWidget(QLabel("Font:"))
|
||||
self.font_combo = QComboBox()
|
||||
self.font_combo.addItems([
|
||||
"Arial", "Times New Roman", "Courier New",
|
||||
"Helvetica", "Verdana", "Georgia", "Comic Sans MS"
|
||||
])
|
||||
self.font_combo.addItems(
|
||||
["Arial", "Times New Roman", "Courier New", "Helvetica", "Verdana", "Georgia", "Comic Sans MS"]
|
||||
)
|
||||
font_layout.addWidget(self.font_combo)
|
||||
|
||||
|
||||
# Font size
|
||||
font_layout.addWidget(QLabel("Size:"))
|
||||
self.font_size_spin = QSpinBox()
|
||||
self.font_size_spin.setRange(6, 72)
|
||||
self.font_size_spin.setValue(12)
|
||||
font_layout.addWidget(self.font_size_spin)
|
||||
|
||||
|
||||
# Text color
|
||||
self.color_button = QPushButton("Color")
|
||||
self.color_button.clicked.connect(self._choose_color)
|
||||
self.current_color = QColor(0, 0, 0) # Default black
|
||||
font_layout.addWidget(self.color_button)
|
||||
|
||||
|
||||
font_layout.addStretch()
|
||||
layout.addLayout(font_layout)
|
||||
|
||||
|
||||
# Alignment
|
||||
alignment_layout = QHBoxLayout()
|
||||
alignment_layout.addWidget(QLabel("Alignment:"))
|
||||
self.alignment_combo = QComboBox()
|
||||
self.alignment_combo.addItems(["left", "center", "right"])
|
||||
self.alignment_combo.addItems(["left", "center", "right", "justify"])
|
||||
alignment_layout.addWidget(self.alignment_combo)
|
||||
alignment_layout.addStretch()
|
||||
layout.addLayout(alignment_layout)
|
||||
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.addStretch()
|
||||
|
||||
|
||||
cancel_button = QPushButton("Cancel")
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
button_layout.addWidget(cancel_button)
|
||||
|
||||
|
||||
ok_button = QPushButton("OK")
|
||||
ok_button.clicked.connect(self.accept)
|
||||
ok_button.setDefault(True)
|
||||
button_layout.addWidget(ok_button)
|
||||
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
|
||||
def _load_values(self):
|
||||
"""Load current values from text element"""
|
||||
# Load text content
|
||||
self.text_edit.setPlainText(self.text_element.text_content)
|
||||
|
||||
|
||||
# Load font settings
|
||||
font_family = self.text_element.font_settings.get('family', 'Arial')
|
||||
font_family = self.text_element.font_settings.get("family", "Arial")
|
||||
index = self.font_combo.findText(font_family)
|
||||
if index >= 0:
|
||||
self.font_combo.setCurrentIndex(index)
|
||||
|
||||
font_size = self.text_element.font_settings.get('size', 12)
|
||||
|
||||
font_size = self.text_element.font_settings.get("size", 12)
|
||||
self.font_size_spin.setValue(int(font_size))
|
||||
|
||||
|
||||
# Load color
|
||||
color = self.text_element.font_settings.get('color', (0, 0, 0))
|
||||
color = self.text_element.font_settings.get("color", (0, 0, 0))
|
||||
if all(isinstance(c, int) and c > 1 for c in color):
|
||||
# Color in 0-255 range
|
||||
self.current_color = QColor(*color)
|
||||
else:
|
||||
# Color in 0-1 range
|
||||
self.current_color = QColor(
|
||||
int(color[0] * 255),
|
||||
int(color[1] * 255),
|
||||
int(color[2] * 255)
|
||||
)
|
||||
self.current_color = QColor(int(color[0] * 255), int(color[1] * 255), int(color[2] * 255))
|
||||
self._update_color_button()
|
||||
|
||||
|
||||
# Load alignment
|
||||
alignment = self.text_element.alignment
|
||||
index = self.alignment_combo.findText(alignment)
|
||||
if index >= 0:
|
||||
self.alignment_combo.setCurrentIndex(index)
|
||||
|
||||
|
||||
def _choose_color(self):
|
||||
"""Open color picker dialog"""
|
||||
color = QColorDialog.getColor(self.current_color, self, "Choose Text Color")
|
||||
if color.isValid():
|
||||
self.current_color = color
|
||||
self._update_color_button()
|
||||
|
||||
|
||||
def _update_color_button(self):
|
||||
"""Update color button appearance"""
|
||||
self.color_button.setStyleSheet(
|
||||
f"background-color: {self.current_color.name()}; "
|
||||
f"color: {'white' if self.current_color.lightness() < 128 else 'black'};"
|
||||
)
|
||||
|
||||
|
||||
def get_values(self):
|
||||
"""Get the edited values"""
|
||||
return {
|
||||
'text_content': self.text_edit.toPlainText(),
|
||||
'font_settings': {
|
||||
'family': self.font_combo.currentText(),
|
||||
'size': self.font_size_spin.value(),
|
||||
'color': (
|
||||
self.current_color.red(),
|
||||
self.current_color.green(),
|
||||
self.current_color.blue()
|
||||
)
|
||||
"text_content": self.text_edit.toPlainText(),
|
||||
"font_settings": {
|
||||
"family": self.font_combo.currentText(),
|
||||
"size": self.font_size_spin.value(),
|
||||
"color": (self.current_color.red(), self.current_color.green(), self.current_color.blue()),
|
||||
},
|
||||
'alignment': self.alignment_combo.currentText()
|
||||
"alignment": self.alignment_combo.currentText(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,899 @@
|
||||
"""
|
||||
Thumbnail Browser Widget - displays thumbnails from a folder for drag-and-drop into album.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Tuple
|
||||
|
||||
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QFileDialog, QDockWidget, QScrollBar
|
||||
from PyQt6.QtCore import Qt, QSize, QMimeData, QUrl, QPoint
|
||||
from PyQt6.QtGui import QDrag, QCursor, QPainter, QFont, QColor
|
||||
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
|
||||
|
||||
from pyPhotoAlbum.gl_imports import *
|
||||
from pyPhotoAlbum.mixins.viewport import ViewportMixin
|
||||
|
||||
IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp"]
|
||||
|
||||
|
||||
class DateHeader:
|
||||
"""Represents a date separator header in the thumbnail list."""
|
||||
|
||||
def __init__(self, date_text: str, y_position: float):
|
||||
self.date_text = date_text
|
||||
self.y = y_position
|
||||
self.height = 30.0 # Height of the header bar
|
||||
|
||||
|
||||
class ThumbnailItem:
|
||||
"""Represents a thumbnail with position and path information."""
|
||||
|
||||
def __init__(self, image_path: str, grid_pos: Tuple[int, int], thumbnail_size: float = 100.0):
|
||||
self.image_path = image_path
|
||||
self.grid_row, self.grid_col = grid_pos
|
||||
self.thumbnail_size = thumbnail_size
|
||||
self.is_used_in_project = False # Will be updated when checking against project
|
||||
|
||||
# Position in mm (will be calculated based on grid)
|
||||
spacing = 10.0 # mm spacing between thumbnails
|
||||
self.x = self.grid_col * (self.thumbnail_size + spacing) + spacing
|
||||
self.y = self.grid_row * (self.thumbnail_size + spacing) + spacing
|
||||
|
||||
# Texture info (loaded async)
|
||||
self._texture_id = None
|
||||
self._pending_pil_image = None
|
||||
self._async_loading = False
|
||||
self._img_width = None
|
||||
self._img_height = None
|
||||
|
||||
def get_bounds(self) -> Tuple[float, float, float, float]:
|
||||
"""Return (x, y, width, height) bounds."""
|
||||
return (self.x, self.y, self.thumbnail_size, self.thumbnail_size)
|
||||
|
||||
def contains_point(self, x: float, y: float) -> bool:
|
||||
"""Check if point is inside this thumbnail."""
|
||||
return self.x <= x <= self.x + self.thumbnail_size and self.y <= y <= self.y + self.thumbnail_size
|
||||
|
||||
|
||||
class ThumbnailGLWidget(QOpenGLWidget):
|
||||
"""
|
||||
OpenGL widget that displays thumbnails in a grid.
|
||||
Uses the same async loading and texture system as the main canvas.
|
||||
"""
|
||||
|
||||
def __init__(self, main_window=None):
|
||||
super().__init__()
|
||||
|
||||
self.thumbnails: List[ThumbnailItem] = []
|
||||
self.date_headers: List[DateHeader] = []
|
||||
self.current_folder: Optional[Path] = None
|
||||
|
||||
# Store reference to main window
|
||||
self._main_window = main_window
|
||||
|
||||
# Viewport state
|
||||
self.zoom_level = 1.0
|
||||
self.pan_offset = (0, 0)
|
||||
|
||||
# Dragging state
|
||||
self.drag_start_pos = None
|
||||
self.dragging_thumbnail = None
|
||||
|
||||
# Scrollbar (created but managed by parent)
|
||||
self.scrollbar = None
|
||||
self._updating_scrollbar = False # Flag to prevent circular updates
|
||||
|
||||
# Sort mode (set by parent dock)
|
||||
self.sort_mode = "name"
|
||||
self._get_image_date_func = None # Function to get date from parent
|
||||
|
||||
# Enable OpenGL
|
||||
self.setMinimumSize(QSize(250, 300))
|
||||
|
||||
def window(self):
|
||||
"""Override window() to return stored main_window reference."""
|
||||
return self._main_window if self._main_window else super().window()
|
||||
|
||||
def update(self):
|
||||
"""Override update to batch repaints for better performance."""
|
||||
# Just schedule the update - Qt will automatically batch multiple
|
||||
# update() calls into a single paintGL() invocation
|
||||
super().update()
|
||||
|
||||
def initializeGL(self):
|
||||
"""Initialize OpenGL context."""
|
||||
glClearColor(0.95, 0.95, 0.95, 1.0) # Light gray background
|
||||
glEnable(GL_BLEND)
|
||||
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
|
||||
glEnable(GL_TEXTURE_2D)
|
||||
|
||||
def resizeGL(self, w, h):
|
||||
"""Handle resize events."""
|
||||
glViewport(0, 0, w, h)
|
||||
glMatrixMode(GL_PROJECTION)
|
||||
glLoadIdentity()
|
||||
glOrtho(0, w, h, 0, -1, 1) # 2D orthographic projection
|
||||
glMatrixMode(GL_MODELVIEW)
|
||||
|
||||
# Rearrange thumbnails to fit new width
|
||||
if hasattr(self, "image_files") and self.image_files:
|
||||
self._arrange_thumbnails()
|
||||
else:
|
||||
# Still update scrollbar even if no thumbnails
|
||||
self._update_scrollbar_range()
|
||||
|
||||
def paintGL(self):
|
||||
"""Render thumbnails."""
|
||||
glClear(GL_COLOR_BUFFER_BIT)
|
||||
glLoadIdentity()
|
||||
|
||||
if not self.thumbnails:
|
||||
return
|
||||
|
||||
# Apply zoom and pan
|
||||
glTranslatef(self.pan_offset[0], self.pan_offset[1], 0)
|
||||
glScalef(self.zoom_level, self.zoom_level, 1.0)
|
||||
|
||||
# Render date headers first (so they appear behind thumbnails)
|
||||
for header in self.date_headers:
|
||||
self._render_date_header(header)
|
||||
|
||||
# Render each thumbnail (placeholders or textures)
|
||||
for thumb in self.thumbnails:
|
||||
self._render_thumbnail(thumb)
|
||||
|
||||
def paintEvent(self, event):
|
||||
"""Override paintEvent to add text labels after OpenGL rendering."""
|
||||
# Call the default OpenGL paint
|
||||
super().paintEvent(event)
|
||||
|
||||
# Draw text labels for date headers using QPainter
|
||||
if self.date_headers and self.sort_mode == "date":
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
|
||||
# Set font for date labels
|
||||
font = QFont("Arial", 11, QFont.Weight.Bold)
|
||||
painter.setFont(font)
|
||||
painter.setPen(QColor(255, 255, 255)) # White text
|
||||
|
||||
for header in self.date_headers:
|
||||
# Transform header position to screen coordinates
|
||||
screen_y = header.y * self.zoom_level + self.pan_offset[1]
|
||||
screen_h = header.height * self.zoom_level
|
||||
|
||||
# Only draw if header is visible
|
||||
if screen_y + screen_h >= 0 and screen_y <= self.height():
|
||||
# Draw text centered vertically in the header bar
|
||||
text_y = int(screen_y + screen_h / 2)
|
||||
painter.drawText(10, text_y + 5, header.date_text)
|
||||
|
||||
painter.end()
|
||||
|
||||
def _render_thumbnail(self, thumb: ThumbnailItem):
|
||||
"""Render a single thumbnail using placeholder pattern."""
|
||||
x, y, w, h = thumb.get_bounds()
|
||||
|
||||
# If we have a pending image, convert it to texture (happens once per image)
|
||||
if hasattr(thumb, "_pending_pil_image") and thumb._pending_pil_image is not None:
|
||||
self._create_texture_for_thumbnail(thumb)
|
||||
|
||||
# Render based on state: texture, loading placeholder, or empty placeholder
|
||||
if thumb._texture_id:
|
||||
# Calculate aspect-ratio-corrected dimensions
|
||||
if hasattr(thumb, "_img_width") and hasattr(thumb, "_img_height"):
|
||||
img_aspect = thumb._img_width / thumb._img_height
|
||||
thumb_aspect = w / h
|
||||
|
||||
if img_aspect > thumb_aspect:
|
||||
# Image is wider - fit to width
|
||||
render_w = w
|
||||
render_h = w / img_aspect
|
||||
render_x = x
|
||||
render_y = y + (h - render_h) / 2
|
||||
else:
|
||||
# Image is taller - fit to height
|
||||
render_h = h
|
||||
render_w = h * img_aspect
|
||||
render_x = x + (w - render_w) / 2
|
||||
render_y = y
|
||||
else:
|
||||
# No aspect ratio info, use full bounds
|
||||
render_x, render_y, render_w, render_h = x, y, w, h
|
||||
|
||||
# Render actual texture
|
||||
glEnable(GL_TEXTURE_2D)
|
||||
glBindTexture(GL_TEXTURE_2D, thumb._texture_id)
|
||||
|
||||
# If used in project, desaturate by tinting grey
|
||||
if thumb.is_used_in_project:
|
||||
glColor4f(0.5, 0.5, 0.5, 0.6) # Grey tint + partial transparency
|
||||
else:
|
||||
glColor4f(1.0, 1.0, 1.0, 1.0)
|
||||
|
||||
glBegin(GL_QUADS)
|
||||
glTexCoord2f(0.0, 0.0)
|
||||
glVertex2f(render_x, render_y)
|
||||
glTexCoord2f(1.0, 0.0)
|
||||
glVertex2f(render_x + render_w, render_y)
|
||||
glTexCoord2f(1.0, 1.0)
|
||||
glVertex2f(render_x + render_w, render_y + render_h)
|
||||
glTexCoord2f(0.0, 1.0)
|
||||
glVertex2f(render_x, render_y + render_h)
|
||||
glEnd()
|
||||
|
||||
glDisable(GL_TEXTURE_2D)
|
||||
else:
|
||||
# Render placeholder (grey box while loading or if load failed)
|
||||
glColor3f(0.8, 0.8, 0.8)
|
||||
glBegin(GL_QUADS)
|
||||
glVertex2f(x, y)
|
||||
glVertex2f(x + w, y)
|
||||
glVertex2f(x + w, y + h)
|
||||
glVertex2f(x, y + h)
|
||||
glEnd()
|
||||
|
||||
# Border
|
||||
glColor3f(0.5, 0.5, 0.5)
|
||||
glLineWidth(1.0)
|
||||
glBegin(GL_LINE_LOOP)
|
||||
glVertex2f(x, y)
|
||||
glVertex2f(x + w, y)
|
||||
glVertex2f(x + w, y + h)
|
||||
glVertex2f(x, y + h)
|
||||
glEnd()
|
||||
|
||||
def _render_date_header(self, header: DateHeader):
|
||||
"""Render a date separator header."""
|
||||
# Calculate full width bar
|
||||
widget_width = self.width() / self.zoom_level
|
||||
x = 0
|
||||
y = header.y
|
||||
w = widget_width
|
||||
h = header.height
|
||||
|
||||
# Draw background bar (dark blue-gray)
|
||||
glColor3f(0.3, 0.4, 0.5)
|
||||
glBegin(GL_QUADS)
|
||||
glVertex2f(x, y)
|
||||
glVertex2f(x + w, y)
|
||||
glVertex2f(x + w, y + h)
|
||||
glVertex2f(x, y + h)
|
||||
glEnd()
|
||||
|
||||
# Draw bottom border
|
||||
glColor3f(0.2, 0.3, 0.4)
|
||||
glLineWidth(2.0)
|
||||
glBegin(GL_LINES)
|
||||
glVertex2f(x, y + h)
|
||||
glVertex2f(x + w, y + h)
|
||||
glEnd()
|
||||
|
||||
# Note: Text rendering would require QPainter overlay
|
||||
# For now, the colored bar serves as a visual separator
|
||||
# Text will be added using QPainter in a future enhancement
|
||||
|
||||
def _create_texture_for_thumbnail(self, thumb: ThumbnailItem):
|
||||
"""Create OpenGL texture from pending PIL image."""
|
||||
if not thumb._pending_pil_image:
|
||||
return False
|
||||
|
||||
try:
|
||||
pil_image = thumb._pending_pil_image
|
||||
|
||||
# Ensure RGBA
|
||||
if pil_image.mode != "RGBA":
|
||||
pil_image = pil_image.convert("RGBA")
|
||||
|
||||
# Delete old texture
|
||||
if thumb._texture_id:
|
||||
glDeleteTextures([thumb._texture_id])
|
||||
|
||||
# Create texture
|
||||
img_data = pil_image.tobytes()
|
||||
texture_id = glGenTextures(1)
|
||||
glBindTexture(GL_TEXTURE_2D, texture_id)
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
|
||||
glTexImage2D(
|
||||
GL_TEXTURE_2D, 0, GL_RGBA, pil_image.width, pil_image.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, img_data
|
||||
)
|
||||
|
||||
thumb._texture_id = texture_id
|
||||
thumb._img_width = pil_image.width
|
||||
thumb._img_height = pil_image.height
|
||||
thumb._pending_pil_image = None
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error creating texture for thumbnail: {e}")
|
||||
thumb._pending_pil_image = None
|
||||
return False
|
||||
|
||||
def load_folder(self, folder_path: Path):
|
||||
"""Load thumbnails from a folder."""
|
||||
self.current_folder = folder_path
|
||||
|
||||
# Find all image files
|
||||
self.image_files: list[Path] = []
|
||||
for ext in IMAGE_EXTENSIONS:
|
||||
self.image_files.extend(folder_path.glob(f"*{ext}"))
|
||||
self.image_files.extend(folder_path.glob(f"*{ext.upper()}"))
|
||||
|
||||
self.image_files.sort()
|
||||
|
||||
# Arrange thumbnails based on current widget size and zoom
|
||||
self._arrange_thumbnails()
|
||||
|
||||
# Update which images are already in use
|
||||
self.update_used_images()
|
||||
|
||||
self.update()
|
||||
|
||||
def _arrange_thumbnails(self):
|
||||
"""Arrange thumbnails in a grid based on widget width and zoom level."""
|
||||
if not hasattr(self, "image_files") or not self.image_files:
|
||||
self.thumbnails.clear()
|
||||
return
|
||||
|
||||
# Calculate number of columns that fit
|
||||
widget_width = self.width()
|
||||
if widget_width <= 0:
|
||||
widget_width = 250 # Default minimum width
|
||||
|
||||
# Thumbnail size in screen pixels (affected by zoom)
|
||||
thumb_size_screen = 100.0 * self.zoom_level
|
||||
spacing_screen = 10.0 * self.zoom_level
|
||||
|
||||
# Calculate columns
|
||||
columns = max(1, int((widget_width - spacing_screen) / (thumb_size_screen + spacing_screen)))
|
||||
|
||||
# Calculate total grid width to center it
|
||||
spacing = 10.0
|
||||
grid_width = columns * (100.0 + spacing) - spacing # Total width in base units
|
||||
# Horizontal offset to center the grid
|
||||
h_offset = max(0, (widget_width / self.zoom_level - grid_width) / 2)
|
||||
|
||||
# Build a map of existing thumbnails by path to reuse them
|
||||
existing_thumbs = {thumb.image_path: thumb for thumb in self.thumbnails}
|
||||
|
||||
# Clear lists but reuse thumbnail objects
|
||||
self.thumbnails.clear()
|
||||
self.date_headers.clear()
|
||||
|
||||
# For date mode: track current date and positioning
|
||||
current_date_str = None
|
||||
section_start_y = spacing
|
||||
row_in_section = 0
|
||||
col = 0
|
||||
|
||||
for idx, image_file in enumerate(self.image_files):
|
||||
image_path = str(image_file)
|
||||
|
||||
# Check if we need a date header (only in date sort mode)
|
||||
if self.sort_mode == "date" and self._get_image_date_func:
|
||||
from datetime import datetime
|
||||
|
||||
timestamp = self._get_image_date_func(image_file)
|
||||
date_obj = datetime.fromtimestamp(timestamp)
|
||||
date_str = date_obj.strftime("%B %d, %Y") # e.g., "December 13, 2025"
|
||||
|
||||
if date_str != current_date_str:
|
||||
# Starting a new date section
|
||||
if current_date_str is not None:
|
||||
# Not the first section - calculate where this section starts
|
||||
# It should start after the last thumbnail of the previous section
|
||||
if self.thumbnails:
|
||||
last_thumb = self.thumbnails[-1]
|
||||
# Start after the last row of previous section
|
||||
last_row_y = last_thumb.y + last_thumb.thumbnail_size
|
||||
section_start_y = last_row_y + spacing * 2 # Extra spacing between sections
|
||||
|
||||
# Add header at section start
|
||||
header = DateHeader(date_str, section_start_y)
|
||||
self.date_headers.append(header)
|
||||
|
||||
# Update section_start_y to after the header
|
||||
section_start_y += header.height + spacing
|
||||
|
||||
current_date_str = date_str
|
||||
row_in_section = 0
|
||||
col = 0
|
||||
|
||||
# Calculate position
|
||||
if self.sort_mode == "date":
|
||||
# In date mode: position relative to section start
|
||||
row = row_in_section
|
||||
thumb_y = section_start_y + row * (100.0 + spacing)
|
||||
else:
|
||||
# In other modes: simple grid based on overall index
|
||||
row = idx // columns
|
||||
thumb_y = row * (100.0 + spacing) + spacing
|
||||
|
||||
# Calculate X position (always centered)
|
||||
thumb_x = h_offset + col * (100.0 + spacing) + spacing
|
||||
|
||||
# Reuse existing thumbnail if available, otherwise create new
|
||||
if image_path in existing_thumbs:
|
||||
thumb = existing_thumbs[image_path]
|
||||
thumb.grid_row = row
|
||||
thumb.grid_col = col
|
||||
thumb.x = thumb_x
|
||||
thumb.y = thumb_y
|
||||
else:
|
||||
# Create new placeholder thumbnail
|
||||
thumb = ThumbnailItem(image_path, (row, col))
|
||||
thumb.x = thumb_x
|
||||
thumb.y = thumb_y
|
||||
# Request async load
|
||||
self._request_thumbnail_load(thumb)
|
||||
|
||||
self.thumbnails.append(thumb)
|
||||
|
||||
# Update column and row counters
|
||||
col += 1
|
||||
if col >= columns:
|
||||
col = 0
|
||||
row_in_section += 1
|
||||
|
||||
# Update scrollbar range after arranging
|
||||
self._update_scrollbar_range()
|
||||
|
||||
def _update_scrollbar_range(self):
|
||||
"""Update scrollbar range based on content height."""
|
||||
if not self.scrollbar or self._updating_scrollbar:
|
||||
return
|
||||
|
||||
if not self.thumbnails:
|
||||
self.scrollbar.setRange(0, 0)
|
||||
self.scrollbar.setPageStep(self.height())
|
||||
return
|
||||
|
||||
# Calculate total content height
|
||||
if self.thumbnails:
|
||||
# Find the maximum Y position
|
||||
max_y = max(thumb.y + thumb.thumbnail_size for thumb in self.thumbnails)
|
||||
content_height = max_y * self.zoom_level
|
||||
else:
|
||||
content_height = 0
|
||||
|
||||
# Visible height
|
||||
visible_height = self.height()
|
||||
|
||||
# Scrollable range
|
||||
scroll_range = max(0, int(content_height - visible_height))
|
||||
|
||||
self._updating_scrollbar = True
|
||||
self.scrollbar.setRange(0, scroll_range)
|
||||
self.scrollbar.setPageStep(visible_height)
|
||||
self.scrollbar.setSingleStep(int(visible_height / 10)) # 10% of visible height per step
|
||||
|
||||
# Update scrollbar position based on current pan
|
||||
scroll_pos = int(-self.pan_offset[1])
|
||||
self.scrollbar.setValue(scroll_pos)
|
||||
self._updating_scrollbar = False
|
||||
|
||||
def _on_scrollbar_changed(self, value):
|
||||
"""Handle scrollbar value change."""
|
||||
if self._updating_scrollbar:
|
||||
return
|
||||
|
||||
# Update pan offset based on scrollbar value
|
||||
self.pan_offset = (0, -value)
|
||||
self.update()
|
||||
|
||||
def _update_scrollbar_position(self):
|
||||
"""Update scrollbar position based on current pan offset."""
|
||||
if not self.scrollbar or self._updating_scrollbar:
|
||||
return
|
||||
|
||||
self._updating_scrollbar = True
|
||||
scroll_pos = int(-self.pan_offset[1])
|
||||
self.scrollbar.setValue(scroll_pos)
|
||||
self._updating_scrollbar = False
|
||||
|
||||
def update_used_images(self):
|
||||
"""Update which thumbnails are already used in the project."""
|
||||
# Get reference to main window's project
|
||||
main_window = self.window()
|
||||
if not hasattr(main_window, "project") or not main_window.project:
|
||||
return
|
||||
|
||||
project = main_window.project
|
||||
|
||||
# Collect all image paths used in the project
|
||||
used_paths = set()
|
||||
for page in project.pages:
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
for element in page.layout.elements:
|
||||
if isinstance(element, ImageData) and element.image_path:
|
||||
# Resolve to absolute path for comparison
|
||||
abs_path = element.resolve_image_path()
|
||||
if abs_path:
|
||||
used_paths.add(abs_path)
|
||||
|
||||
# Mark thumbnails as used
|
||||
for thumb in self.thumbnails:
|
||||
thumb.is_used_in_project = thumb.image_path in used_paths
|
||||
|
||||
def _request_thumbnail_load(self, thumb: ThumbnailItem):
|
||||
"""Request async load for a thumbnail using main window's loader."""
|
||||
# Skip if already loading or loaded
|
||||
if thumb._async_loading or thumb._texture_id:
|
||||
return
|
||||
|
||||
# Get main window's async loader
|
||||
main_window = self.window()
|
||||
if not main_window or not hasattr(main_window, "_gl_widget"):
|
||||
return
|
||||
|
||||
gl_widget = main_window._gl_widget
|
||||
if not hasattr(gl_widget, "async_image_loader"):
|
||||
return
|
||||
|
||||
from pyPhotoAlbum.async_backend import LoadPriority
|
||||
|
||||
try:
|
||||
# Mark as loading to prevent duplicate requests
|
||||
thumb._async_loading = True
|
||||
|
||||
# Request load through main window's async loader
|
||||
# Use LOW priority for thumbnails to not interfere with main canvas
|
||||
gl_widget.async_image_loader.request_load(
|
||||
Path(thumb.image_path),
|
||||
priority=LoadPriority.LOW,
|
||||
target_size=(200, 200), # Small thumbnails
|
||||
user_data=thumb,
|
||||
)
|
||||
except RuntimeError:
|
||||
thumb._async_loading = False # Reset on error
|
||||
|
||||
def _on_image_loaded(self, path: Path, image, user_data):
|
||||
"""Handle async image loaded - sets pending image on the placeholder."""
|
||||
if isinstance(user_data, ThumbnailItem):
|
||||
# Store the loaded image in the placeholder
|
||||
user_data._pending_pil_image = image
|
||||
user_data._img_width = image.width
|
||||
user_data._img_height = image.height
|
||||
user_data._async_loading = False
|
||||
|
||||
# Schedule a repaint (will be batched if many images load quickly)
|
||||
self.update()
|
||||
|
||||
def _on_image_load_failed(self, path: Path, error_msg: str, user_data):
|
||||
"""Handle async image load failure."""
|
||||
pass # Silently ignore load failures for thumbnails
|
||||
|
||||
def screen_to_viewport(self, screen_x: int, screen_y: int) -> Tuple[float, float]:
|
||||
"""Convert screen coordinates to viewport coordinates (accounting for zoom/pan)."""
|
||||
vp_x = (screen_x - self.pan_offset[0]) / self.zoom_level
|
||||
vp_y = (screen_y - self.pan_offset[1]) / self.zoom_level
|
||||
return vp_x, vp_y
|
||||
|
||||
def get_thumbnail_at(self, screen_x: int, screen_y: int) -> Optional[ThumbnailItem]:
|
||||
"""Get thumbnail at screen position."""
|
||||
vp_x, vp_y = self.screen_to_viewport(screen_x, screen_y)
|
||||
|
||||
for thumb in self.thumbnails:
|
||||
if thumb.contains_point(vp_x, vp_y):
|
||||
return thumb
|
||||
|
||||
return None
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
"""Handle mouse press for drag."""
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self.drag_start_pos = event.pos()
|
||||
self.dragging_thumbnail = self.get_thumbnail_at(event.pos().x(), event.pos().y())
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
"""Handle mouse move for drag or pan."""
|
||||
if not (event.buttons() & Qt.MouseButton.LeftButton):
|
||||
return
|
||||
|
||||
if self.drag_start_pos is None:
|
||||
return
|
||||
|
||||
# Check if we should start dragging a thumbnail
|
||||
if self.dragging_thumbnail:
|
||||
# Start drag operation
|
||||
drag = QDrag(self)
|
||||
mime_data = QMimeData()
|
||||
|
||||
# Set file URL for the drag
|
||||
url = QUrl.fromLocalFile(self.dragging_thumbnail.image_path)
|
||||
mime_data.setUrls([url])
|
||||
|
||||
drag.setMimeData(mime_data)
|
||||
|
||||
# Execute drag (this blocks until drop or cancel)
|
||||
drag.exec(Qt.DropAction.CopyAction)
|
||||
|
||||
# Reset drag state
|
||||
self.drag_start_pos = None
|
||||
self.dragging_thumbnail = None
|
||||
else:
|
||||
# Pan the view (right-click or middle-click drag)
|
||||
# Only allow vertical panning - grid is always horizontally centered
|
||||
delta = event.pos() - self.drag_start_pos
|
||||
self.pan_offset = (0, self.pan_offset[1] + delta.y()) # No horizontal pan - grid is centered
|
||||
self.drag_start_pos = event.pos()
|
||||
self._update_scrollbar_position()
|
||||
self.update()
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
"""Handle mouse release."""
|
||||
self.drag_start_pos = None
|
||||
self.dragging_thumbnail = None
|
||||
|
||||
def wheelEvent(self, event):
|
||||
"""Handle mouse wheel for scrolling (or zooming with Ctrl)."""
|
||||
delta = event.angleDelta().y()
|
||||
|
||||
# Check if Ctrl is pressed for zooming
|
||||
if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
||||
# Zoom mode
|
||||
mouse_y = event.position().y()
|
||||
|
||||
zoom_factor = 1.1 if delta > 0 else 0.9
|
||||
|
||||
# Calculate vertical world position before zoom
|
||||
world_y = (mouse_y - self.pan_offset[1]) / self.zoom_level
|
||||
|
||||
# Apply zoom
|
||||
old_zoom = self.zoom_level
|
||||
self.zoom_level *= zoom_factor
|
||||
self.zoom_level = max(0.1, min(5.0, self.zoom_level)) # Clamp
|
||||
|
||||
# Rearrange thumbnails if zoom level changed significantly
|
||||
# This recalculates horizontal centering
|
||||
if abs(self.zoom_level - old_zoom) > 0.01:
|
||||
self._arrange_thumbnails()
|
||||
|
||||
# Adjust vertical pan to keep mouse position fixed
|
||||
# Keep horizontal pan at 0 (grid is always horizontally centered)
|
||||
self.pan_offset = (
|
||||
0, # No horizontal pan - grid is centered in _arrange_thumbnails
|
||||
mouse_y - world_y * self.zoom_level,
|
||||
)
|
||||
else:
|
||||
# Scroll mode - scroll vertically only
|
||||
scroll_amount = delta * 0.5 # Adjust sensitivity
|
||||
self.pan_offset = (0, self.pan_offset[1] + scroll_amount) # No horizontal pan
|
||||
|
||||
self._update_scrollbar_position()
|
||||
self.update()
|
||||
|
||||
|
||||
class ThumbnailBrowserDock(QDockWidget):
|
||||
"""
|
||||
Dockable widget containing the thumbnail browser.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__("Image Browser", parent)
|
||||
|
||||
# Create main widget
|
||||
main_widget = QWidget()
|
||||
layout = QVBoxLayout(main_widget)
|
||||
layout.setContentsMargins(5, 5, 5, 5)
|
||||
layout.setSpacing(5)
|
||||
|
||||
# Header with folder selection
|
||||
header_layout = QHBoxLayout()
|
||||
|
||||
self.folder_label = QLabel("No folder selected")
|
||||
self.folder_label.setStyleSheet("font-weight: bold; padding: 5px;")
|
||||
header_layout.addWidget(self.folder_label)
|
||||
|
||||
self.select_folder_btn = QPushButton("Select Folder...")
|
||||
self.select_folder_btn.clicked.connect(self._select_folder)
|
||||
header_layout.addWidget(self.select_folder_btn)
|
||||
|
||||
layout.addLayout(header_layout)
|
||||
|
||||
# Sort toolbar
|
||||
sort_layout = QHBoxLayout()
|
||||
sort_layout.setContentsMargins(5, 0, 5, 5)
|
||||
|
||||
sort_label = QLabel("Sort by:")
|
||||
sort_layout.addWidget(sort_label)
|
||||
|
||||
self.sort_name_btn = QPushButton("Name")
|
||||
self.sort_name_btn.setCheckable(True)
|
||||
self.sort_name_btn.setChecked(True) # Default sort
|
||||
self.sort_name_btn.clicked.connect(lambda: self._sort_by("name"))
|
||||
sort_layout.addWidget(self.sort_name_btn)
|
||||
|
||||
self.sort_date_btn = QPushButton("Date")
|
||||
self.sort_date_btn.setCheckable(True)
|
||||
self.sort_date_btn.clicked.connect(lambda: self._sort_by("date"))
|
||||
sort_layout.addWidget(self.sort_date_btn)
|
||||
|
||||
self.sort_camera_btn = QPushButton("Camera")
|
||||
self.sort_camera_btn.setCheckable(True)
|
||||
self.sort_camera_btn.clicked.connect(lambda: self._sort_by("camera"))
|
||||
sort_layout.addWidget(self.sort_camera_btn)
|
||||
|
||||
sort_layout.addStretch()
|
||||
|
||||
layout.addLayout(sort_layout)
|
||||
|
||||
# Track current sort mode
|
||||
self.current_sort = "name"
|
||||
|
||||
# Create horizontal layout for GL widget and scrollbar
|
||||
browser_layout = QHBoxLayout()
|
||||
browser_layout.setContentsMargins(0, 0, 0, 0)
|
||||
browser_layout.setSpacing(0)
|
||||
|
||||
# GL Widget for thumbnails
|
||||
self.gl_widget = ThumbnailGLWidget(main_window=parent)
|
||||
browser_layout.addWidget(self.gl_widget)
|
||||
|
||||
# Vertical scrollbar
|
||||
self.scrollbar = QScrollBar(Qt.Orientation.Vertical)
|
||||
self.scrollbar.valueChanged.connect(self.gl_widget._on_scrollbar_changed)
|
||||
browser_layout.addWidget(self.scrollbar)
|
||||
|
||||
# Connect scrollbar to GL widget
|
||||
self.gl_widget.scrollbar = self.scrollbar
|
||||
|
||||
layout.addLayout(browser_layout)
|
||||
|
||||
self.setWidget(main_widget)
|
||||
|
||||
# Dock settings
|
||||
self.setAllowedAreas(Qt.DockWidgetArea.LeftDockWidgetArea | Qt.DockWidgetArea.RightDockWidgetArea)
|
||||
self.setFeatures(
|
||||
QDockWidget.DockWidgetFeature.DockWidgetClosable
|
||||
| QDockWidget.DockWidgetFeature.DockWidgetMovable
|
||||
| QDockWidget.DockWidgetFeature.DockWidgetFloatable
|
||||
)
|
||||
|
||||
# Connect to main window's async loader when shown
|
||||
self._connect_async_loader()
|
||||
|
||||
def _connect_async_loader(self):
|
||||
"""Connect to main window's async image loader."""
|
||||
main_window = self.window()
|
||||
if not hasattr(main_window, "_gl_widget"):
|
||||
return
|
||||
|
||||
gl_widget = main_window._gl_widget
|
||||
if not hasattr(gl_widget, "async_image_loader"):
|
||||
return
|
||||
|
||||
# Avoid duplicate connections
|
||||
if hasattr(self, "_async_connected") and self._async_connected:
|
||||
return
|
||||
|
||||
try:
|
||||
# Connect signals
|
||||
gl_widget.async_image_loader.image_loaded.connect(self.gl_widget._on_image_loaded)
|
||||
gl_widget.async_image_loader.load_failed.connect(self.gl_widget._on_image_load_failed)
|
||||
self._async_connected = True
|
||||
except Exception:
|
||||
pass # Silently handle connection errors
|
||||
|
||||
def showEvent(self, event):
|
||||
"""Handle show event."""
|
||||
super().showEvent(event)
|
||||
# Ensure async loader is connected when shown
|
||||
self._connect_async_loader()
|
||||
|
||||
def _select_folder(self):
|
||||
"""Open dialog to select folder."""
|
||||
folder_path = QFileDialog.getExistingDirectory(
|
||||
self,
|
||||
"Select Image Folder",
|
||||
str(self.gl_widget.current_folder) if self.gl_widget.current_folder else str(Path.home()),
|
||||
QFileDialog.Option.ShowDirsOnly,
|
||||
)
|
||||
|
||||
if folder_path:
|
||||
self.load_folder(Path(folder_path))
|
||||
|
||||
def load_folder(self, folder_path: Path):
|
||||
"""Load thumbnails from folder."""
|
||||
self.folder_label.setText(f"Folder: {folder_path.name}")
|
||||
self.gl_widget.load_folder(folder_path)
|
||||
# Apply current sort after loading
|
||||
self._apply_sort()
|
||||
self.gl_widget._arrange_thumbnails()
|
||||
self.gl_widget.update_used_images()
|
||||
self.gl_widget.update()
|
||||
|
||||
def _sort_by(self, sort_mode: str):
|
||||
"""Sort thumbnails by the specified mode."""
|
||||
# Update button states (only one can be checked)
|
||||
self.sort_name_btn.setChecked(sort_mode == "name")
|
||||
self.sort_date_btn.setChecked(sort_mode == "date")
|
||||
self.sort_camera_btn.setChecked(sort_mode == "camera")
|
||||
|
||||
self.current_sort = sort_mode
|
||||
|
||||
# Re-sort the image files in the GL widget
|
||||
if hasattr(self.gl_widget, "image_files") and self.gl_widget.image_files:
|
||||
self._apply_sort()
|
||||
# Re-arrange thumbnails with new order
|
||||
self.gl_widget._arrange_thumbnails()
|
||||
self.gl_widget.update_used_images()
|
||||
self.gl_widget.update()
|
||||
|
||||
def _apply_sort(self):
|
||||
"""Apply current sort mode to image files."""
|
||||
if not hasattr(self.gl_widget, "image_files") or not self.gl_widget.image_files:
|
||||
return
|
||||
if self.current_sort == "name":
|
||||
# Sort by filename only (not full path)
|
||||
self.gl_widget.image_files.sort(key=lambda p: p.name.lower())
|
||||
# Clear date headers for non-date sorts
|
||||
self.gl_widget.date_headers.clear()
|
||||
# Reset sort mode in GL widget
|
||||
self.gl_widget.sort_mode = "name"
|
||||
self.gl_widget._get_image_date_func = None
|
||||
elif self.current_sort == "date":
|
||||
# Sort by file modification time (or EXIF date if available)
|
||||
self.gl_widget.image_files.sort(key=self._get_image_date)
|
||||
# Date headers will be created during _arrange_thumbnails
|
||||
self.gl_widget.sort_mode = "date"
|
||||
self.gl_widget._get_image_date_func = self._get_image_date
|
||||
elif self.current_sort == "camera":
|
||||
# Sort by camera model from EXIF
|
||||
self.gl_widget.image_files.sort(key=self._get_camera_model)
|
||||
# Clear date headers for non-date sorts
|
||||
self.gl_widget.date_headers.clear()
|
||||
# Reset sort mode in GL widget
|
||||
self.gl_widget.sort_mode = "camera"
|
||||
self.gl_widget._get_image_date_func = None
|
||||
|
||||
def _get_image_date(self, image_path: Path) -> float:
|
||||
"""Get image date from EXIF or file modification time."""
|
||||
try:
|
||||
from PIL import Image
|
||||
from PIL.ExifTags import TAGS
|
||||
|
||||
with Image.open(image_path) as img:
|
||||
exif = img.getexif()
|
||||
if exif:
|
||||
# Look for DateTimeOriginal (when photo was taken)
|
||||
for tag_id, value in exif.items():
|
||||
tag = TAGS.get(tag_id, tag_id)
|
||||
if tag == "DateTimeOriginal":
|
||||
# Convert EXIF date format "2023:12:13 14:30:00" to timestamp
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
dt = datetime.strptime(value, "%Y:%m:%d %H:%M:%S")
|
||||
return dt.timestamp()
|
||||
except:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to file modification time
|
||||
return image_path.stat().st_mtime
|
||||
|
||||
def _get_camera_model(self, image_path: Path) -> str:
|
||||
"""Get camera model from EXIF metadata."""
|
||||
try:
|
||||
from PIL import Image
|
||||
from PIL.ExifTags import TAGS
|
||||
|
||||
with Image.open(image_path) as img:
|
||||
exif = img.getexif()
|
||||
if exif:
|
||||
# Look for camera model
|
||||
for tag_id, value in exif.items():
|
||||
tag = TAGS.get(tag_id, tag_id)
|
||||
if tag == "Model":
|
||||
return str(value).strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to filename if no EXIF data
|
||||
return image_path.name.lower()
|
||||
@@ -2,12 +2,13 @@
|
||||
Version management and migration system for pyPhotoAlbum projects
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional, Callable, List
|
||||
import os
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any, Optional, Callable, List
|
||||
|
||||
# Current data version - increment when making breaking changes to data format
|
||||
CURRENT_DATA_VERSION = "2.0"
|
||||
CURRENT_DATA_VERSION = "3.0"
|
||||
|
||||
# Version history and compatibility information
|
||||
VERSION_HISTORY = {
|
||||
@@ -22,10 +23,21 @@ VERSION_HISTORY = {
|
||||
"released": "2025-01-11",
|
||||
"breaking_changes": [
|
||||
"Asset paths changed from absolute/full-project-relative to project-relative",
|
||||
"Added automatic path normalization for legacy projects"
|
||||
"Added automatic path normalization for legacy projects",
|
||||
],
|
||||
"compatible_with": ["1.0", "2.0"], # 2.0 can read 1.0 with migration
|
||||
}
|
||||
},
|
||||
"3.0": {
|
||||
"description": "Added merge conflict resolution support with UUIDs, timestamps, and project IDs",
|
||||
"released": "2025-01-22",
|
||||
"breaking_changes": [
|
||||
"Added required UUID fields to all pages and elements",
|
||||
"Added created/last_modified timestamps to projects, pages, and elements",
|
||||
"Added project_id for merge detection (same ID = merge, different ID = concatenate)",
|
||||
"Added deletion tracking (deleted flag and deleted_at timestamp)",
|
||||
],
|
||||
"compatible_with": ["1.0", "2.0", "3.0"], # 3.0 can read older versions with migration
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -107,9 +119,11 @@ class DataMigration:
|
||||
@classmethod
|
||||
def register_migration(cls, from_version: str, to_version: str):
|
||||
"""Decorator to register a migration function"""
|
||||
|
||||
def decorator(func):
|
||||
cls._migrations[(from_version, to_version)] = func
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
@classmethod
|
||||
@@ -151,6 +165,7 @@ class DataMigration:
|
||||
|
||||
# Register migrations
|
||||
|
||||
|
||||
@DataMigration.register_migration("1.0", "2.0")
|
||||
def migrate_1_0_to_2_0(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -163,7 +178,7 @@ def migrate_1_0_to_2_0(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
print("Migration 1.0 → 2.0: Asset paths will be normalized during load")
|
||||
|
||||
# Update version in data
|
||||
data['data_version'] = "2.0"
|
||||
data["data_version"] = "2.0"
|
||||
|
||||
# Note: Actual path normalization is handled in load_from_zip
|
||||
# This migration mainly updates the version number
|
||||
@@ -171,6 +186,78 @@ def migrate_1_0_to_2_0(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return data
|
||||
|
||||
|
||||
@DataMigration.register_migration("2.0", "3.0")
|
||||
def migrate_2_0_to_3_0(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Migrate from version 2.0 to 3.0.
|
||||
|
||||
Main changes:
|
||||
- Add UUIDs to all pages and elements
|
||||
- Add timestamps (created, last_modified) to project, pages, and elements
|
||||
- Add project_id to project
|
||||
- Add deletion tracking (deleted, deleted_at) to pages and elements
|
||||
"""
|
||||
print("Migration 2.0 → 3.0: Adding UUIDs, timestamps, and project_id")
|
||||
|
||||
# Get current timestamp for migration
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Add project-level fields
|
||||
if "project_id" not in data:
|
||||
data["project_id"] = str(uuid.uuid4())
|
||||
print(f" Generated project_id: {data['project_id']}")
|
||||
|
||||
if "created" not in data:
|
||||
data["created"] = now
|
||||
|
||||
if "last_modified" not in data:
|
||||
data["last_modified"] = now
|
||||
|
||||
# Migrate pages
|
||||
for page_data in data.get("pages", []):
|
||||
# Add UUID
|
||||
if "uuid" not in page_data:
|
||||
page_data["uuid"] = str(uuid.uuid4())
|
||||
|
||||
# Add timestamps
|
||||
if "created" not in page_data:
|
||||
page_data["created"] = now
|
||||
if "last_modified" not in page_data:
|
||||
page_data["last_modified"] = now
|
||||
|
||||
# Add deletion tracking
|
||||
if "deleted" not in page_data:
|
||||
page_data["deleted"] = False
|
||||
if "deleted_at" not in page_data:
|
||||
page_data["deleted_at"] = None
|
||||
|
||||
# Migrate elements in page layout
|
||||
layout_data = page_data.get("layout", {})
|
||||
for element_data in layout_data.get("elements", []):
|
||||
# Add UUID
|
||||
if "uuid" not in element_data:
|
||||
element_data["uuid"] = str(uuid.uuid4())
|
||||
|
||||
# Add timestamps
|
||||
if "created" not in element_data:
|
||||
element_data["created"] = now
|
||||
if "last_modified" not in element_data:
|
||||
element_data["last_modified"] = now
|
||||
|
||||
# Add deletion tracking
|
||||
if "deleted" not in element_data:
|
||||
element_data["deleted"] = False
|
||||
if "deleted_at" not in element_data:
|
||||
element_data["deleted_at"] = None
|
||||
|
||||
# Update version
|
||||
data["data_version"] = "3.0"
|
||||
|
||||
print(f" Migrated {len(data.get('pages', []))} pages to v3.0")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def check_version_compatibility(file_version: str, file_path: str = "") -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Check version compatibility and provide user-friendly messages.
|
||||
@@ -202,7 +289,7 @@ def check_version_compatibility(file_version: str, file_path: str = "") -> tuple
|
||||
error_msg += f"File version info:\n"
|
||||
error_msg += f" Description: {file_info.get('description', 'Unknown')}\n"
|
||||
error_msg += f" Released: {file_info.get('released', 'Unknown')}\n"
|
||||
breaking_changes = file_info.get('breaking_changes', [])
|
||||
breaking_changes = file_info.get("breaking_changes", [])
|
||||
if breaking_changes:
|
||||
error_msg += f" Breaking changes:\n"
|
||||
for change in breaking_changes:
|
||||
@@ -227,7 +314,7 @@ def format_version_info() -> str:
|
||||
info.append(f" Description: {version_info.get('description', 'Unknown')}")
|
||||
info.append(f" Released: {version_info.get('released', 'Unknown')}")
|
||||
|
||||
breaking_changes = version_info.get('breaking_changes', [])
|
||||
breaking_changes = version_info.get("breaking_changes", [])
|
||||
if breaking_changes:
|
||||
info.append(f" Breaking changes:")
|
||||
for change in breaking_changes:
|
||||
|
||||
@@ -31,6 +31,7 @@ dependencies = [
|
||||
"Pillow>=8.0.0",
|
||||
"reportlab>=3.5.0",
|
||||
"lxml>=4.6.0",
|
||||
"pypdf>=4.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -39,6 +40,7 @@ dev = [
|
||||
"pytest-qt>=4.2.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
"pytest-mock>=3.10.0",
|
||||
"pdfplumber>=0.10.0",
|
||||
"flake8>=5.0.0",
|
||||
"black>=22.0.0",
|
||||
"mypy>=0.990",
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script to verify asset loading fix and version handling
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pyPhotoAlbum.project_serializer import load_from_zip
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
from pyPhotoAlbum.version_manager import format_version_info
|
||||
|
||||
# Path to test file
|
||||
test_file = "/home/dtourolle/Nextcloud/Photo Gallery/gr58/Album_pytool.ppz"
|
||||
|
||||
print("=" * 70)
|
||||
print("Testing asset loading fix and version handling")
|
||||
print("=" * 70)
|
||||
print()
|
||||
print(format_version_info())
|
||||
print()
|
||||
print("=" * 70)
|
||||
print(f"Loading: {test_file}")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
# Load project
|
||||
project, error = load_from_zip(test_file)
|
||||
|
||||
if error:
|
||||
print(f"ERROR: {error}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Project loaded: {project.name}")
|
||||
print(f"Project folder: {project.folder_path}")
|
||||
print(f"Assets folder: {project.asset_manager.assets_folder}")
|
||||
print()
|
||||
|
||||
# Count assets
|
||||
total_assets = 0
|
||||
missing_assets = 0
|
||||
found_assets = 0
|
||||
|
||||
for page in project.pages:
|
||||
for element in page.layout.elements:
|
||||
if isinstance(element, ImageData) and element.image_path:
|
||||
total_assets += 1
|
||||
|
||||
# Check if asset exists
|
||||
if os.path.isabs(element.image_path):
|
||||
full_path = element.image_path
|
||||
else:
|
||||
full_path = os.path.join(project.folder_path, element.image_path)
|
||||
|
||||
if os.path.exists(full_path):
|
||||
found_assets += 1
|
||||
print(f"✓ Found: {element.image_path}")
|
||||
else:
|
||||
missing_assets += 1
|
||||
print(f"✗ Missing: {element.image_path}")
|
||||
|
||||
print()
|
||||
print(f"Results:")
|
||||
print(f" Total assets: {total_assets}")
|
||||
print(f" Found: {found_assets}")
|
||||
print(f" Missing: {missing_assets}")
|
||||
|
||||
if missing_assets == 0:
|
||||
print()
|
||||
print("SUCCESS! All assets loaded correctly.")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print()
|
||||
print(f"PARTIAL: {missing_assets} assets still missing.")
|
||||
sys.exit(1)
|
||||
@@ -1,134 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Test to verify async loading doesn't block the main thread.
|
||||
|
||||
This test demonstrates that the UI remains responsive during image loading.
|
||||
"""
|
||||
|
||||
import time
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
from PyQt6.QtCore import QTimer
|
||||
|
||||
from pyPhotoAlbum.async_backend import AsyncImageLoader, ImageCache, LoadPriority
|
||||
|
||||
|
||||
def test_nonblocking_load():
|
||||
"""Test that async image loading doesn't block the main thread"""
|
||||
|
||||
print("Testing non-blocking async image loading...")
|
||||
|
||||
# Track if main thread stays responsive
|
||||
main_thread_ticks = []
|
||||
|
||||
def main_thread_tick():
|
||||
"""This should continue running during async loads"""
|
||||
main_thread_ticks.append(time.time())
|
||||
print(f"✓ Main thread tick {len(main_thread_ticks)} (responsive!)")
|
||||
|
||||
# Create Qt application
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
# Create async loader
|
||||
cache = ImageCache(max_memory_mb=128)
|
||||
loader = AsyncImageLoader(cache=cache, max_workers=2)
|
||||
|
||||
# Track loaded images
|
||||
loaded_images = []
|
||||
|
||||
def on_image_loaded(path, image, user_data):
|
||||
loaded_images.append(path)
|
||||
print(f"✓ Loaded: {path} (size: {image.size})")
|
||||
|
||||
def on_load_failed(path, error_msg, user_data):
|
||||
print(f"✗ Failed: {path} - {error_msg}")
|
||||
|
||||
loader.image_loaded.connect(on_image_loaded)
|
||||
loader.load_failed.connect(on_load_failed)
|
||||
|
||||
# Start the async loader
|
||||
loader.start()
|
||||
print("✓ Async loader started")
|
||||
|
||||
# Request some image loads (these would normally block for 50-500ms each)
|
||||
test_images = [
|
||||
Path("assets/sample1.jpg"),
|
||||
Path("assets/sample2.jpg"),
|
||||
Path("assets/sample3.jpg"),
|
||||
]
|
||||
|
||||
print(f"\nRequesting {len(test_images)} image loads...")
|
||||
for img_path in test_images:
|
||||
loader.request_load(img_path, priority=LoadPriority.HIGH)
|
||||
print(f" → Queued: {img_path}")
|
||||
|
||||
print("\nMain thread should remain responsive while images load in background...")
|
||||
|
||||
# Setup main thread ticker (should run continuously)
|
||||
ticker = QTimer()
|
||||
ticker.timeout.connect(main_thread_tick)
|
||||
ticker.start(100) # Tick every 100ms
|
||||
|
||||
# Setup test timeout
|
||||
def check_completion():
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
if len(loaded_images) >= len(test_images):
|
||||
print(f"\n✓ All images loaded in {elapsed:.2f}s")
|
||||
print(f"✓ Main thread ticked {len(main_thread_ticks)} times during loading")
|
||||
|
||||
if len(main_thread_ticks) >= 3:
|
||||
print("✓ SUCCESS: Main thread remained responsive!")
|
||||
else:
|
||||
print("✗ FAIL: Main thread was blocked!")
|
||||
|
||||
# Cleanup
|
||||
ticker.stop()
|
||||
loader.stop()
|
||||
app.quit()
|
||||
|
||||
elif elapsed > 10.0:
|
||||
print(f"\n✗ Timeout: Only loaded {len(loaded_images)}/{len(test_images)} images")
|
||||
ticker.stop()
|
||||
loader.stop()
|
||||
app.quit()
|
||||
|
||||
# Check completion every 200ms
|
||||
completion_timer = QTimer()
|
||||
completion_timer.timeout.connect(check_completion)
|
||||
completion_timer.start(200)
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Run Qt event loop (this should NOT block)
|
||||
app.exec()
|
||||
|
||||
print("\nTest completed!")
|
||||
|
||||
# Report results
|
||||
print(f"\nResults:")
|
||||
print(f" Images loaded: {len(loaded_images)}/{len(test_images)}")
|
||||
print(f" Main thread ticks: {len(main_thread_ticks)}")
|
||||
print(f" Cache stats: {cache.get_stats()}")
|
||||
|
||||
return len(main_thread_ticks) >= 3 # Success if main thread ticked at least 3 times
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 60)
|
||||
print("Async Non-Blocking Test")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
success = test_nonblocking_load()
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
if success:
|
||||
print("✓ TEST PASSED: Async loading is non-blocking")
|
||||
else:
|
||||
print("✗ TEST FAILED: Main thread was blocked")
|
||||
print("=" * 60)
|
||||
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -1,50 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test to demonstrate the asset drop bug
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from pyPhotoAlbum.project import Project, Page
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
def test_direct_path_assignment():
|
||||
"""Simulate what happens when you drop an image on existing element"""
|
||||
|
||||
project = Project("Test Direct Path")
|
||||
page = Page()
|
||||
project.add_page(page)
|
||||
|
||||
# Add an image element
|
||||
img = ImageData()
|
||||
img.position = (10, 10)
|
||||
img.size = (50, 50)
|
||||
page.layout.add_element(img)
|
||||
|
||||
# Simulate dropping a new image on existing element (line 77 in asset_drop.py)
|
||||
external_image = "/home/dtourolle/Pictures/some_photo.jpg"
|
||||
print(f"\nSimulating drop on existing image element...")
|
||||
print(f"Setting image_path directly to: {external_image}")
|
||||
img.image_path = external_image # BUG: Not imported!
|
||||
|
||||
# Check assets folder
|
||||
assets = os.listdir(project.asset_manager.assets_folder) if os.path.exists(project.asset_manager.assets_folder) else []
|
||||
print(f"\nAssets in folder: {len(assets)}")
|
||||
print(f" {assets if assets else '(empty)'}")
|
||||
|
||||
# The image path in the element points to external file
|
||||
print(f"\nImage path in element: {img.image_path}")
|
||||
print(f" Is absolute path: {os.path.isabs(img.image_path)}")
|
||||
|
||||
if os.path.isabs(img.image_path):
|
||||
print("\n❌ BUG CONFIRMED: Image path is absolute, not copied to assets!")
|
||||
print(" When saved to .ppz, this external file will NOT be included.")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_direct_path_assignment()
|
||||
@@ -1,155 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test to verify the heal function can fix old files with missing assets
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from pyPhotoAlbum.project import Project, Page
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip
|
||||
|
||||
def test_heal_external_paths():
|
||||
"""Test healing a project with external (absolute) image paths"""
|
||||
|
||||
print("=" * 70)
|
||||
print("Test: Healing External Image Paths")
|
||||
print("=" * 70)
|
||||
|
||||
# Create a test image in a temp location
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Create a test image file
|
||||
test_image_source = "./projects/project_with_image/assets/test_image.jpg"
|
||||
external_image_path = os.path.join(temp_dir, "external_photo.jpg")
|
||||
shutil.copy2(test_image_source, external_image_path)
|
||||
|
||||
print(f"\n1. Created external image at: {external_image_path}")
|
||||
|
||||
# Create a project
|
||||
project = Project("Test Heal")
|
||||
page = Page()
|
||||
project.add_page(page)
|
||||
|
||||
# Add an image element with ABSOLUTE path (simulating the bug)
|
||||
img = ImageData()
|
||||
img.image_path = external_image_path # BUG: Absolute path!
|
||||
img.position = (10, 10)
|
||||
img.size = (50, 50)
|
||||
page.layout.add_element(img)
|
||||
|
||||
print(f"2. Created project with absolute path: {img.image_path}")
|
||||
|
||||
# Save to zip
|
||||
with tempfile.NamedTemporaryFile(suffix='.ppz', delete=False) as tmp:
|
||||
zip_path = tmp.name
|
||||
|
||||
save_to_zip(project, zip_path)
|
||||
print(f"3. Saved to: {zip_path}")
|
||||
|
||||
# Check what was saved
|
||||
import zipfile
|
||||
with zipfile.ZipFile(zip_path, 'r') as zf:
|
||||
files = zf.namelist()
|
||||
asset_files = [f for f in files if f.startswith('assets/')]
|
||||
print(f"\n4. Assets in zip: {len(asset_files)}")
|
||||
if len(asset_files) == 0:
|
||||
print(" ❌ No assets saved (expected - this is the bug!)")
|
||||
else:
|
||||
print(f" ✅ Assets: {asset_files}")
|
||||
|
||||
# Load the project
|
||||
loaded_project, error = load_from_zip(zip_path)
|
||||
if not loaded_project:
|
||||
print(f" ❌ Failed to load: {error}")
|
||||
return False
|
||||
|
||||
print(f"\n5. Loaded project from zip")
|
||||
|
||||
# Check for missing assets
|
||||
from pyPhotoAlbum.models import ImageData as ImageDataCheck
|
||||
missing_count = 0
|
||||
for page in loaded_project.pages:
|
||||
for element in page.layout.elements:
|
||||
if isinstance(element, ImageDataCheck) and element.image_path:
|
||||
if os.path.isabs(element.image_path):
|
||||
full_path = element.image_path
|
||||
else:
|
||||
full_path = os.path.join(loaded_project.folder_path, element.image_path)
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
missing_count += 1
|
||||
print(f" ❌ Missing: {element.image_path}")
|
||||
|
||||
print(f"\n6. Missing assets detected: {missing_count}")
|
||||
|
||||
if missing_count == 0:
|
||||
print(" ⚠️ No missing assets - test may not be accurate")
|
||||
return False
|
||||
|
||||
# Now test the healing logic
|
||||
print(f"\n7. Testing heal function...")
|
||||
|
||||
# Simulate what the heal dialog does
|
||||
from pyPhotoAlbum.models import set_asset_resolution_context
|
||||
|
||||
search_paths = [temp_dir] # The directory where our external image is
|
||||
set_asset_resolution_context(loaded_project.folder_path, search_paths)
|
||||
|
||||
healed_count = 0
|
||||
for page in loaded_project.pages:
|
||||
for element in page.layout.elements:
|
||||
if isinstance(element, ImageDataCheck) and element.image_path:
|
||||
# Check if missing
|
||||
if os.path.isabs(element.image_path):
|
||||
full_path = element.image_path
|
||||
else:
|
||||
full_path = os.path.join(loaded_project.folder_path, element.image_path)
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
# Try to find and import
|
||||
filename = os.path.basename(element.image_path)
|
||||
for search_path in search_paths:
|
||||
candidate = os.path.join(search_path, filename)
|
||||
if os.path.exists(candidate):
|
||||
# Import it!
|
||||
new_asset_path = loaded_project.asset_manager.import_asset(candidate)
|
||||
element.image_path = new_asset_path
|
||||
healed_count += 1
|
||||
print(f" ✅ Healed: {filename} → {new_asset_path}")
|
||||
break
|
||||
|
||||
print(f"\n8. Healed {healed_count} asset(s)")
|
||||
|
||||
# Save the healed project
|
||||
healed_zip_path = zip_path.replace('.ppz', '_healed.ppz')
|
||||
save_to_zip(loaded_project, healed_zip_path)
|
||||
print(f"9. Saved healed project to: {healed_zip_path}")
|
||||
|
||||
# Verify the healed version has assets
|
||||
with zipfile.ZipFile(healed_zip_path, 'r') as zf:
|
||||
files = zf.namelist()
|
||||
asset_files = [f for f in files if f.startswith('assets/')]
|
||||
print(f"\n10. Assets in healed zip: {len(asset_files)}")
|
||||
for asset in asset_files:
|
||||
print(f" - {asset}")
|
||||
|
||||
# Cleanup
|
||||
loaded_project.cleanup()
|
||||
os.unlink(zip_path)
|
||||
os.unlink(healed_zip_path)
|
||||
|
||||
if healed_count > 0 and len(asset_files) > 0:
|
||||
print("\n✅ HEAL FUNCTION WORKS - Old files can be fixed!")
|
||||
return True
|
||||
else:
|
||||
print("\n❌ Healing did not work as expected")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = test_heal_external_paths()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -1,116 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for the loading widget functionality
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from PyQt6.QtWidgets import QApplication, QMainWindow, QPushButton, QVBoxLayout, QWidget
|
||||
from PyQt6.QtCore import QTimer
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from pyPhotoAlbum.loading_widget import LoadingWidget
|
||||
|
||||
|
||||
class TestWindow(QMainWindow):
|
||||
"""Test window for loading widget"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Loading Widget Test")
|
||||
self.resize(800, 600)
|
||||
|
||||
# Central widget
|
||||
central = QWidget()
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Test buttons
|
||||
btn1 = QPushButton("Show Loading (Determinate)")
|
||||
btn1.clicked.connect(self.test_determinate)
|
||||
layout.addWidget(btn1)
|
||||
|
||||
btn2 = QPushButton("Show Loading (Indeterminate)")
|
||||
btn2.clicked.connect(self.test_indeterminate)
|
||||
layout.addWidget(btn2)
|
||||
|
||||
btn3 = QPushButton("Simulate File Loading")
|
||||
btn3.clicked.connect(self.simulate_file_loading)
|
||||
layout.addWidget(btn3)
|
||||
|
||||
central.setLayout(layout)
|
||||
self.setCentralWidget(central)
|
||||
|
||||
# Create loading widget
|
||||
self.loading_widget = LoadingWidget(self)
|
||||
|
||||
# Timer for progress simulation
|
||||
self.timer = QTimer()
|
||||
self.timer.timeout.connect(self.update_progress)
|
||||
self.progress = 0
|
||||
|
||||
def test_determinate(self):
|
||||
"""Test determinate progress"""
|
||||
self.loading_widget.show_loading("Loading...")
|
||||
self.loading_widget.set_indeterminate(False)
|
||||
self.loading_widget.set_progress(50, 100)
|
||||
|
||||
# Auto hide after 3 seconds
|
||||
QTimer.singleShot(3000, self.loading_widget.hide_loading)
|
||||
|
||||
def test_indeterminate(self):
|
||||
"""Test indeterminate progress"""
|
||||
self.loading_widget.show_loading("Processing...")
|
||||
self.loading_widget.set_indeterminate(True)
|
||||
|
||||
# Auto hide after 3 seconds
|
||||
QTimer.singleShot(3000, self.loading_widget.hide_loading)
|
||||
|
||||
def simulate_file_loading(self):
|
||||
"""Simulate file loading with progress"""
|
||||
self.progress = 0
|
||||
self.loading_widget.show_loading("Extracting files...")
|
||||
self.loading_widget.set_indeterminate(False)
|
||||
self.timer.start(100) # Update every 100ms
|
||||
|
||||
def update_progress(self):
|
||||
"""Update progress during simulation"""
|
||||
self.progress += 5
|
||||
|
||||
if self.progress <= 40:
|
||||
self.loading_widget.set_status(f"Extracting files... ({self.progress}%)")
|
||||
elif self.progress <= 70:
|
||||
self.loading_widget.set_status("Loading project data...")
|
||||
elif self.progress <= 95:
|
||||
self.loading_widget.set_status("Normalizing asset paths...")
|
||||
else:
|
||||
self.loading_widget.set_status("Loading complete!")
|
||||
|
||||
self.loading_widget.set_progress(self.progress, 100)
|
||||
|
||||
if self.progress >= 100:
|
||||
self.timer.stop()
|
||||
QTimer.singleShot(500, self.loading_widget.hide_loading)
|
||||
|
||||
def resizeEvent(self, event):
|
||||
"""Handle resize"""
|
||||
super().resizeEvent(event)
|
||||
self.loading_widget.resizeParent()
|
||||
|
||||
|
||||
def main():
|
||||
"""Run test"""
|
||||
app = QApplication(sys.argv)
|
||||
window = TestWindow()
|
||||
window.show()
|
||||
|
||||
print("Loading widget test window opened")
|
||||
print("Click buttons to test different loading states")
|
||||
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,90 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script to verify Page Setup functionality
|
||||
"""
|
||||
|
||||
import sys
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
from pyPhotoAlbum.project import Project, Page
|
||||
from pyPhotoAlbum.page_layout import PageLayout
|
||||
|
||||
|
||||
def test_page_setup_behavior():
|
||||
"""Test that page setup works correctly with multiple pages"""
|
||||
|
||||
# Create a project with default size
|
||||
project = Project("Test Project")
|
||||
print(f"Initial project default page size: {project.page_size_mm}")
|
||||
|
||||
# Add first page - should use project default
|
||||
page1_layout = PageLayout(width=project.page_size_mm[0], height=project.page_size_mm[1])
|
||||
page1 = Page(layout=page1_layout, page_number=1)
|
||||
page1.manually_sized = False
|
||||
project.add_page(page1)
|
||||
print(f"Page 1 size: {page1.layout.size}, manually_sized: {page1.manually_sized}")
|
||||
|
||||
# Add second page - should also use project default
|
||||
page2_layout = PageLayout(width=project.page_size_mm[0], height=project.page_size_mm[1])
|
||||
page2 = Page(layout=page2_layout, page_number=2)
|
||||
page2.manually_sized = False
|
||||
project.add_page(page2)
|
||||
print(f"Page 2 size: {page2.layout.size}, manually_sized: {page2.manually_sized}")
|
||||
|
||||
# Simulate changing page 1 size without setting as default
|
||||
print("\n--- Simulating Page Setup on Page 1 (without setting as default) ---")
|
||||
page1.layout.size = (200, 200)
|
||||
page1.layout.base_width = 200
|
||||
page1.manually_sized = True
|
||||
print(f"Page 1 size after change: {page1.layout.size}, manually_sized: {page1.manually_sized}")
|
||||
print(f"Project default still: {project.page_size_mm}")
|
||||
print(f"Page 2 unchanged: {page2.layout.size}, manually_sized: {page2.manually_sized}")
|
||||
|
||||
# Add third page - should use original project default
|
||||
page3_layout = PageLayout(width=project.page_size_mm[0], height=project.page_size_mm[1])
|
||||
page3 = Page(layout=page3_layout, page_number=3)
|
||||
page3.manually_sized = False
|
||||
project.add_page(page3)
|
||||
print(f"Page 3 size: {page3.layout.size}, manually_sized: {page3.manually_sized}")
|
||||
|
||||
# Simulate changing page 2 size AND setting as default
|
||||
print("\n--- Simulating Page Setup on Page 2 (with setting as default) ---")
|
||||
page2.layout.size = (250, 250)
|
||||
page2.layout.base_width = 250
|
||||
page2.manually_sized = True
|
||||
project.page_size_mm = (250, 250) # Set as default
|
||||
print(f"Page 2 size after change: {page2.layout.size}, manually_sized: {page2.manually_sized}")
|
||||
print(f"Project default updated to: {project.page_size_mm}")
|
||||
|
||||
# Add fourth page - should use NEW project default
|
||||
page4_layout = PageLayout(width=project.page_size_mm[0], height=project.page_size_mm[1])
|
||||
page4 = Page(layout=page4_layout, page_number=4)
|
||||
page4.manually_sized = False
|
||||
project.add_page(page4)
|
||||
print(f"Page 4 size: {page4.layout.size}, manually_sized: {page4.manually_sized}")
|
||||
|
||||
# Test double spread
|
||||
print("\n--- Testing Double Spread ---")
|
||||
page5_layout = PageLayout(width=project.page_size_mm[0], height=project.page_size_mm[1], is_facing_page=True)
|
||||
page5 = Page(layout=page5_layout, page_number=5, is_double_spread=True)
|
||||
page5.manually_sized = False
|
||||
project.add_page(page5)
|
||||
print(f"Page 5 (double spread) size: {page5.layout.size}, base_width: {page5.layout.base_width}")
|
||||
print(f"Expected: width should be 2x base_width = {project.page_size_mm[0] * 2}")
|
||||
|
||||
print("\n--- Summary ---")
|
||||
for i, page in enumerate(project.pages, 1):
|
||||
spread_info = " (double spread)" if page.is_double_spread else ""
|
||||
manual_info = " *manually sized*" if page.manually_sized else ""
|
||||
print(f"Page {i}: {page.layout.size}{spread_info}{manual_info}")
|
||||
print(f"Project default: {project.page_size_mm}")
|
||||
|
||||
print("\n✓ All tests passed!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Initialize Qt application (needed for PyQt6 widgets even in tests)
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
test_page_setup_behavior()
|
||||
|
||||
print("\nTest completed successfully!")
|
||||
@@ -1,65 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test version round-trip: save with v2.0, load with v2.0 (no migration needed)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pyPhotoAlbum.project import Project
|
||||
from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip
|
||||
from pyPhotoAlbum.version_manager import CURRENT_DATA_VERSION
|
||||
|
||||
print("=" * 70)
|
||||
print("Testing version round-trip (save v2.0, load v2.0)")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
# Create a temporary directory for testing
|
||||
temp_dir = tempfile.mkdtemp(prefix="pyphotos_test_")
|
||||
test_ppz = os.path.join(temp_dir, "test_project.ppz")
|
||||
|
||||
try:
|
||||
# Create a new project
|
||||
print("Creating new project...")
|
||||
project = Project("Test Project")
|
||||
print(f" Project folder: {project.folder_path}")
|
||||
print()
|
||||
|
||||
# Save it
|
||||
print(f"Saving to: {test_ppz}")
|
||||
success, error = save_to_zip(project, test_ppz)
|
||||
if not success:
|
||||
print(f"ERROR: Failed to save: {error}")
|
||||
sys.exit(1)
|
||||
print(" Saved successfully!")
|
||||
print()
|
||||
|
||||
# Load it back
|
||||
print(f"Loading from: {test_ppz}")
|
||||
loaded_project, error = load_from_zip(test_ppz)
|
||||
if error:
|
||||
print(f"ERROR: Failed to load: {error}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f" Loaded successfully!")
|
||||
print(f" Project name: {loaded_project.name}")
|
||||
print(f" Project folder: {loaded_project.folder_path}")
|
||||
print()
|
||||
|
||||
# Check that it's version 2.0 and no migration was needed
|
||||
print("Version check:")
|
||||
print(f" Expected version: {CURRENT_DATA_VERSION}")
|
||||
print(f" ✓ No migration was performed (would have been logged if needed)")
|
||||
print()
|
||||
|
||||
print("=" * 70)
|
||||
print("SUCCESS! Version round-trip test passed.")
|
||||
print("=" * 70)
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
import shutil
|
||||
if os.path.exists(temp_dir):
|
||||
shutil.rmtree(temp_dir)
|
||||
print(f"\nCleaned up test directory: {temp_dir}")
|
||||
@@ -1,139 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script to verify that images are being embedded in .ppz files
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import zipfile
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
# Add project to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from pyPhotoAlbum.project import Project, Page
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip
|
||||
|
||||
def test_zip_embedding():
|
||||
"""Test that images are properly embedded in the zip file"""
|
||||
|
||||
# Create a test project with an image
|
||||
project = Project("Test Embedding")
|
||||
page = Page()
|
||||
project.add_page(page)
|
||||
|
||||
# Use an existing test image
|
||||
test_image = "./projects/project_with_image/assets/test_image.jpg"
|
||||
if not os.path.exists(test_image):
|
||||
print(f"ERROR: Test image not found: {test_image}")
|
||||
return False
|
||||
|
||||
print(f"Using test image: {test_image}")
|
||||
print(f"Test image size: {os.path.getsize(test_image)} bytes")
|
||||
|
||||
# Import the image through the asset manager
|
||||
print("\n1. Importing image through asset manager...")
|
||||
asset_path = project.asset_manager.import_asset(test_image)
|
||||
print(f" Asset path: {asset_path}")
|
||||
|
||||
# Check that the asset was copied
|
||||
full_asset_path = project.asset_manager.get_absolute_path(asset_path)
|
||||
if os.path.exists(full_asset_path):
|
||||
print(f" ✓ Asset exists at: {full_asset_path}")
|
||||
print(f" Asset size: {os.path.getsize(full_asset_path)} bytes")
|
||||
else:
|
||||
print(f" ✗ ERROR: Asset not found at {full_asset_path}")
|
||||
return False
|
||||
|
||||
# Add image to page
|
||||
img = ImageData()
|
||||
img.image_path = asset_path
|
||||
img.position = (10, 10)
|
||||
img.size = (50, 50)
|
||||
page.layout.add_element(img)
|
||||
|
||||
# Save to a temporary zip file
|
||||
print("\n2. Saving project to .ppz file...")
|
||||
with tempfile.NamedTemporaryFile(suffix='.ppz', delete=False) as tmp:
|
||||
zip_path = tmp.name
|
||||
|
||||
success, error = save_to_zip(project, zip_path)
|
||||
if not success:
|
||||
print(f" ✗ ERROR: Failed to save: {error}")
|
||||
return False
|
||||
|
||||
print(f" ✓ Saved to: {zip_path}")
|
||||
zip_size = os.path.getsize(zip_path)
|
||||
print(f" Zip file size: {zip_size:,} bytes")
|
||||
|
||||
# Inspect the zip file contents
|
||||
print("\n3. Inspecting zip file contents...")
|
||||
with zipfile.ZipFile(zip_path, 'r') as zf:
|
||||
files = zf.namelist()
|
||||
print(f" Files in zip: {len(files)}")
|
||||
for fname in files:
|
||||
info = zf.getinfo(fname)
|
||||
print(f" - {fname} ({info.file_size:,} bytes)")
|
||||
|
||||
# Check if assets are included
|
||||
asset_files = [f for f in files if f.startswith('assets/')]
|
||||
print(f"\n4. Checking for embedded assets...")
|
||||
print(f" Assets found: {len(asset_files)}")
|
||||
|
||||
if len(asset_files) == 0:
|
||||
print(" ✗ ERROR: No assets embedded in zip file!")
|
||||
print(f"\n DEBUG INFO:")
|
||||
print(f" Project folder: {project.folder_path}")
|
||||
print(f" Assets folder: {project.asset_manager.assets_folder}")
|
||||
print(f" Assets folder exists: {os.path.exists(project.asset_manager.assets_folder)}")
|
||||
|
||||
if os.path.exists(project.asset_manager.assets_folder):
|
||||
assets = os.listdir(project.asset_manager.assets_folder)
|
||||
print(f" Files in assets folder: {assets}")
|
||||
|
||||
# Cleanup
|
||||
os.unlink(zip_path)
|
||||
return False
|
||||
|
||||
print(f" ✓ Found {len(asset_files)} asset file(s) in zip")
|
||||
|
||||
# Load the project back
|
||||
print("\n5. Loading project from zip...")
|
||||
loaded_project, error = load_from_zip(zip_path)
|
||||
if loaded_project is None:
|
||||
print(f" ✗ ERROR: Failed to load: {error}")
|
||||
os.unlink(zip_path)
|
||||
return False
|
||||
|
||||
print(f" ✓ Loaded project: {loaded_project.name}")
|
||||
|
||||
# Check that the image is accessible
|
||||
print("\n6. Verifying loaded image...")
|
||||
if loaded_project.pages and loaded_project.pages[0].layout.elements:
|
||||
img_elem = loaded_project.pages[0].layout.elements[0]
|
||||
if isinstance(img_elem, ImageData):
|
||||
loaded_img_path = loaded_project.asset_manager.get_absolute_path(img_elem.image_path)
|
||||
if os.path.exists(loaded_img_path):
|
||||
print(f" ✓ Image accessible at: {loaded_img_path}")
|
||||
print(f" Image size: {os.path.getsize(loaded_img_path)} bytes")
|
||||
else:
|
||||
print(f" ✗ ERROR: Image not found at {loaded_img_path}")
|
||||
os.unlink(zip_path)
|
||||
return False
|
||||
|
||||
# Cleanup
|
||||
os.unlink(zip_path)
|
||||
loaded_project.cleanup()
|
||||
|
||||
print("\n✅ ALL TESTS PASSED - Images are being embedded correctly!")
|
||||
return True
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 70)
|
||||
print("Testing .ppz file image embedding")
|
||||
print("=" * 70)
|
||||
|
||||
success = test_zip_embedding()
|
||||
sys.exit(0 if success else 1)
|
||||
+151
-23
@@ -15,9 +15,9 @@ from pyPhotoAlbum.project import Project, Page
|
||||
@pytest.fixture
|
||||
def temp_image_file():
|
||||
"""Create a temporary test image file"""
|
||||
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f:
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
|
||||
# Create a simple test image
|
||||
img = Image.new('RGB', (100, 100), color='red')
|
||||
img = Image.new("RGB", (100, 100), color="red")
|
||||
img.save(f.name)
|
||||
yield f.name
|
||||
# Cleanup
|
||||
@@ -37,37 +37,19 @@ def temp_dir():
|
||||
@pytest.fixture
|
||||
def sample_image_data(temp_image_file):
|
||||
"""Create a sample ImageData instance"""
|
||||
return ImageData(
|
||||
image_path=temp_image_file,
|
||||
x=10.0,
|
||||
y=20.0,
|
||||
width=100.0,
|
||||
height=150.0
|
||||
)
|
||||
return ImageData(image_path=temp_image_file, x=10.0, y=20.0, width=100.0, height=150.0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_placeholder_data():
|
||||
"""Create a sample PlaceholderData instance"""
|
||||
return PlaceholderData(
|
||||
placeholder_type="image",
|
||||
x=50.0,
|
||||
y=60.0,
|
||||
width=200.0,
|
||||
height=150.0
|
||||
)
|
||||
return PlaceholderData(placeholder_type="image", x=50.0, y=60.0, width=200.0, height=150.0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_textbox_data():
|
||||
"""Create a sample TextBoxData instance"""
|
||||
return TextBoxData(
|
||||
text_content="Sample Text",
|
||||
x=30.0,
|
||||
y=40.0,
|
||||
width=150.0,
|
||||
height=50.0
|
||||
)
|
||||
return TextBoxData(text_content="Sample Text", x=30.0, y=40.0, width=150.0, height=50.0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -103,3 +85,149 @@ def populated_page_layout(sample_image_data, sample_placeholder_data, sample_tex
|
||||
layout.add_element(sample_placeholder_data)
|
||||
layout.add_element(sample_textbox_data)
|
||||
return layout
|
||||
|
||||
|
||||
# GL Widget fixtures (moved from test_gl_widget_fixtures.py)
|
||||
|
||||
from unittest.mock import Mock, MagicMock
|
||||
from PyQt6.QtCore import Qt, QPointF, QPoint
|
||||
from PyQt6.QtGui import QMouseEvent, QWheelEvent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_main_window():
|
||||
"""Create a mock main window with a basic project"""
|
||||
window = Mock()
|
||||
window.project = Project(name="Test Project")
|
||||
|
||||
# Add a test page
|
||||
page = Page(layout=PageLayout(width=210, height=297), page_number=1) # A4 size in mm
|
||||
window.project.pages.append(page)
|
||||
window.project.working_dpi = 96
|
||||
window.project.page_size_mm = (210, 297)
|
||||
window.project.page_spacing_mm = 10
|
||||
|
||||
# Mock status bar
|
||||
window.status_bar = Mock()
|
||||
window.status_bar.showMessage = Mock()
|
||||
window.show_status = Mock()
|
||||
|
||||
return window
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_image_element():
|
||||
"""Create a sample ImageData element for testing"""
|
||||
return ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150, z_index=1)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_placeholder_element():
|
||||
"""Create a sample PlaceholderData element for testing"""
|
||||
return PlaceholderData(x=50, y=50, width=100, height=100, z_index=0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_textbox_element():
|
||||
"""Create a sample TextBoxData element for testing"""
|
||||
return TextBoxData(x=10, y=10, width=180, height=50, text_content="Test Text", z_index=2)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_page_renderer():
|
||||
"""Create a mock PageRenderer
|
||||
|
||||
NOTE: This fixture contains simplified coordinate conversion logic for testing.
|
||||
It is NOT a replacement for testing with the real PageRenderer in integration tests.
|
||||
"""
|
||||
renderer = Mock()
|
||||
renderer.screen_x = 50
|
||||
renderer.screen_y = 50
|
||||
renderer.zoom = 1.0
|
||||
renderer.dpi = 96
|
||||
|
||||
# Mock coordinate conversion methods
|
||||
def page_to_screen(x, y):
|
||||
return (renderer.screen_x + x * renderer.zoom, renderer.screen_y + y * renderer.zoom)
|
||||
|
||||
def screen_to_page(x, y):
|
||||
return ((x - renderer.screen_x) / renderer.zoom, (y - renderer.screen_y) / renderer.zoom)
|
||||
|
||||
def is_point_in_page(x, y):
|
||||
# Simple bounds check (assume 210mm x 297mm page at 96 DPI)
|
||||
page_width_px = 210 * 96 / 25.4
|
||||
page_height_px = 297 * 96 / 25.4
|
||||
return (
|
||||
renderer.screen_x <= x <= renderer.screen_x + page_width_px * renderer.zoom
|
||||
and renderer.screen_y <= y <= renderer.screen_y + page_height_px * renderer.zoom
|
||||
)
|
||||
|
||||
renderer.page_to_screen = page_to_screen
|
||||
renderer.screen_to_page = screen_to_page
|
||||
renderer.is_point_in_page = is_point_in_page
|
||||
|
||||
return renderer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_mouse_event():
|
||||
"""Factory fixture for creating QMouseEvent objects"""
|
||||
|
||||
def _create_event(event_type, x, y, button=Qt.MouseButton.LeftButton, modifiers=Qt.KeyboardModifier.NoModifier):
|
||||
"""Create a QMouseEvent for testing
|
||||
|
||||
Args:
|
||||
event_type: QEvent.Type (MouseButtonPress, MouseButtonRelease, MouseMove)
|
||||
x, y: Position coordinates
|
||||
button: Mouse button
|
||||
modifiers: Keyboard modifiers
|
||||
"""
|
||||
pos = QPointF(x, y)
|
||||
return QMouseEvent(event_type, pos, button, button, modifiers)
|
||||
|
||||
return _create_event
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_wheel_event():
|
||||
"""Factory fixture for creating QWheelEvent objects"""
|
||||
|
||||
def _create_event(x, y, delta_y=120, modifiers=Qt.KeyboardModifier.NoModifier):
|
||||
"""Create a QWheelEvent for testing
|
||||
|
||||
Args:
|
||||
x, y: Position coordinates
|
||||
delta_y: Wheel delta (positive = scroll up, negative = scroll down)
|
||||
modifiers: Keyboard modifiers (e.g., ControlModifier for zoom)
|
||||
"""
|
||||
pos = QPointF(x, y)
|
||||
global_pos = QPoint(int(x), int(y))
|
||||
angle_delta = QPoint(0, delta_y)
|
||||
|
||||
return QWheelEvent(
|
||||
pos,
|
||||
global_pos,
|
||||
QPoint(0, 0),
|
||||
angle_delta,
|
||||
Qt.MouseButton.NoButton,
|
||||
modifiers,
|
||||
Qt.ScrollPhase.NoScrollPhase,
|
||||
False,
|
||||
)
|
||||
|
||||
return _create_event
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def populated_page():
|
||||
"""Create a page with multiple elements for testing"""
|
||||
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||
|
||||
# Add various elements
|
||||
page.layout.add_element(ImageData(image_path="img1.jpg", x=10, y=10, width=100, height=75, z_index=0))
|
||||
|
||||
page.layout.add_element(PlaceholderData(x=120, y=10, width=80, height=60, z_index=1))
|
||||
|
||||
page.layout.add_element(TextBoxData(x=10, y=100, width=190, height=40, text_content="Sample Text", z_index=2))
|
||||
|
||||
return page
|
||||
|
||||
Regular → Executable
+87
-91
@@ -20,7 +20,7 @@ class TestAlignmentManager:
|
||||
"""Test get_bounds with single element"""
|
||||
elem = ImageData(x=10, y=20, width=100, height=50)
|
||||
bounds = AlignmentManager.get_bounds([elem])
|
||||
|
||||
|
||||
# min_x, min_y, max_x, max_y
|
||||
assert bounds == (10, 20, 110, 70)
|
||||
|
||||
@@ -29,9 +29,9 @@ class TestAlignmentManager:
|
||||
elem1 = ImageData(x=10, y=20, width=100, height=50)
|
||||
elem2 = ImageData(x=50, y=10, width=80, height=60)
|
||||
elem3 = ImageData(x=5, y=30, width=90, height=40)
|
||||
|
||||
|
||||
bounds = AlignmentManager.get_bounds([elem1, elem2, elem3])
|
||||
|
||||
|
||||
# min_x = 5, min_y = 10, max_x = 130 (50+80), max_y = 70 (10+60 or 20+50)
|
||||
assert bounds[0] == 5 # min_x
|
||||
assert bounds[1] == 10 # min_y
|
||||
@@ -55,14 +55,14 @@ class TestAlignmentManager:
|
||||
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
||||
elem2 = ImageData(x=30, y=40, width=80, height=60)
|
||||
elem3 = ImageData(x=70, y=60, width=90, height=40)
|
||||
|
||||
|
||||
changes = AlignmentManager.align_left([elem1, elem2, elem3])
|
||||
|
||||
|
||||
# All should align to x=30 (leftmost)
|
||||
assert elem1.position == (30, 20)
|
||||
assert elem2.position == (30, 40)
|
||||
assert elem3.position == (30, 60)
|
||||
|
||||
|
||||
# Check undo information
|
||||
assert len(changes) == 3
|
||||
assert changes[0] == (elem1, (50, 20))
|
||||
@@ -72,16 +72,16 @@ class TestAlignmentManager:
|
||||
def test_align_right_multiple_elements(self):
|
||||
"""Test align_right with multiple elements"""
|
||||
elem1 = ImageData(x=50, y=20, width=100, height=50) # right edge at 150
|
||||
elem2 = ImageData(x=30, y=40, width=80, height=60) # right edge at 110
|
||||
elem3 = ImageData(x=70, y=60, width=90, height=40) # right edge at 160
|
||||
|
||||
elem2 = ImageData(x=30, y=40, width=80, height=60) # right edge at 110
|
||||
elem3 = ImageData(x=70, y=60, width=90, height=40) # right edge at 160
|
||||
|
||||
changes = AlignmentManager.align_right([elem1, elem2, elem3])
|
||||
|
||||
|
||||
# All right edges should align to x=160 (rightmost)
|
||||
assert elem1.position[0] == 60 # 160 - 100
|
||||
assert elem2.position[0] == 80 # 160 - 80
|
||||
assert elem3.position[0] == 70 # 160 - 90
|
||||
|
||||
|
||||
# Y positions should not change
|
||||
assert elem1.position[1] == 20
|
||||
assert elem2.position[1] == 40
|
||||
@@ -92,14 +92,14 @@ class TestAlignmentManager:
|
||||
elem1 = ImageData(x=50, y=30, width=100, height=50)
|
||||
elem2 = ImageData(x=30, y=20, width=80, height=60)
|
||||
elem3 = ImageData(x=70, y=40, width=90, height=40)
|
||||
|
||||
|
||||
changes = AlignmentManager.align_top([elem1, elem2, elem3])
|
||||
|
||||
|
||||
# All should align to y=20 (topmost)
|
||||
assert elem1.position[1] == 20
|
||||
assert elem2.position[1] == 20
|
||||
assert elem3.position[1] == 20
|
||||
|
||||
|
||||
# X positions should not change
|
||||
assert elem1.position[0] == 50
|
||||
assert elem2.position[0] == 30
|
||||
@@ -108,16 +108,16 @@ class TestAlignmentManager:
|
||||
def test_align_bottom_multiple_elements(self):
|
||||
"""Test align_bottom with multiple elements"""
|
||||
elem1 = ImageData(x=50, y=30, width=100, height=50) # bottom at 80
|
||||
elem2 = ImageData(x=30, y=20, width=80, height=60) # bottom at 80
|
||||
elem3 = ImageData(x=70, y=40, width=90, height=50) # bottom at 90
|
||||
|
||||
elem2 = ImageData(x=30, y=20, width=80, height=60) # bottom at 80
|
||||
elem3 = ImageData(x=70, y=40, width=90, height=50) # bottom at 90
|
||||
|
||||
changes = AlignmentManager.align_bottom([elem1, elem2, elem3])
|
||||
|
||||
|
||||
# All bottom edges should align to y=90 (bottommost)
|
||||
assert elem1.position[1] == 40 # 90 - 50
|
||||
assert elem2.position[1] == 30 # 90 - 60
|
||||
assert elem3.position[1] == 40 # 90 - 50
|
||||
|
||||
|
||||
# X positions should not change
|
||||
assert elem1.position[0] == 50
|
||||
assert elem2.position[0] == 30
|
||||
@@ -125,18 +125,18 @@ class TestAlignmentManager:
|
||||
|
||||
def test_align_horizontal_center_multiple_elements(self):
|
||||
"""Test align_horizontal_center with multiple elements"""
|
||||
elem1 = ImageData(x=50, y=20, width=100, height=50) # center at 100
|
||||
elem2 = ImageData(x=30, y=40, width=80, height=60) # center at 70
|
||||
elem3 = ImageData(x=70, y=60, width=60, height=40) # center at 100
|
||||
|
||||
elem1 = ImageData(x=50, y=20, width=100, height=50) # center at 100
|
||||
elem2 = ImageData(x=30, y=40, width=80, height=60) # center at 70
|
||||
elem3 = ImageData(x=70, y=60, width=60, height=40) # center at 100
|
||||
|
||||
changes = AlignmentManager.align_horizontal_center([elem1, elem2, elem3])
|
||||
|
||||
|
||||
# Average center = (100 + 70 + 100) / 3 = 90
|
||||
# All elements should center at x=90
|
||||
assert abs(elem1.position[0] + elem1.size[0]/2 - 90) < 0.01
|
||||
assert abs(elem2.position[0] + elem2.size[0]/2 - 90) < 0.01
|
||||
assert abs(elem3.position[0] + elem3.size[0]/2 - 90) < 0.01
|
||||
|
||||
assert abs(elem1.position[0] + elem1.size[0] / 2 - 90) < 0.01
|
||||
assert abs(elem2.position[0] + elem2.size[0] / 2 - 90) < 0.01
|
||||
assert abs(elem3.position[0] + elem3.size[0] / 2 - 90) < 0.01
|
||||
|
||||
# Y positions should not change
|
||||
assert elem1.position[1] == 20
|
||||
assert elem2.position[1] == 40
|
||||
@@ -144,18 +144,18 @@ class TestAlignmentManager:
|
||||
|
||||
def test_align_vertical_center_multiple_elements(self):
|
||||
"""Test align_vertical_center with multiple elements"""
|
||||
elem1 = ImageData(x=50, y=20, width=100, height=50) # center at 45
|
||||
elem2 = ImageData(x=30, y=40, width=80, height=60) # center at 70
|
||||
elem3 = ImageData(x=70, y=30, width=60, height=40) # center at 50
|
||||
|
||||
elem1 = ImageData(x=50, y=20, width=100, height=50) # center at 45
|
||||
elem2 = ImageData(x=30, y=40, width=80, height=60) # center at 70
|
||||
elem3 = ImageData(x=70, y=30, width=60, height=40) # center at 50
|
||||
|
||||
changes = AlignmentManager.align_vertical_center([elem1, elem2, elem3])
|
||||
|
||||
|
||||
# Average center = (45 + 70 + 50) / 3 = 55
|
||||
# All elements should center at y=55
|
||||
assert abs(elem1.position[1] + elem1.size[1]/2 - 55) < 0.01
|
||||
assert abs(elem2.position[1] + elem2.size[1]/2 - 55) < 0.01
|
||||
assert abs(elem3.position[1] + elem3.size[1]/2 - 55) < 0.01
|
||||
|
||||
assert abs(elem1.position[1] + elem1.size[1] / 2 - 55) < 0.01
|
||||
assert abs(elem2.position[1] + elem2.size[1] / 2 - 55) < 0.01
|
||||
assert abs(elem3.position[1] + elem3.size[1] / 2 - 55) < 0.01
|
||||
|
||||
# X positions should not change
|
||||
assert elem1.position[0] == 50
|
||||
assert elem2.position[0] == 30
|
||||
@@ -178,14 +178,14 @@ class TestAlignmentManager:
|
||||
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
||||
elem2 = ImageData(x=30, y=40, width=80, height=60)
|
||||
elem3 = ImageData(x=70, y=60, width=90, height=40)
|
||||
|
||||
|
||||
changes = AlignmentManager.make_same_size([elem1, elem2, elem3])
|
||||
|
||||
|
||||
# All should match elem1's size
|
||||
assert elem1.size == (100, 50)
|
||||
assert elem2.size == (100, 50)
|
||||
assert elem3.size == (100, 50)
|
||||
|
||||
|
||||
# Check undo information (only elem2 and elem3 change)
|
||||
assert len(changes) == 2
|
||||
assert changes[0][0] == elem2
|
||||
@@ -198,14 +198,14 @@ class TestAlignmentManager:
|
||||
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
||||
elem2 = ImageData(x=30, y=40, width=80, height=60)
|
||||
elem3 = ImageData(x=70, y=60, width=90, height=40)
|
||||
|
||||
|
||||
changes = AlignmentManager.make_same_width([elem1, elem2, elem3])
|
||||
|
||||
|
||||
# All widths should match elem1
|
||||
assert elem1.size[0] == 100
|
||||
assert elem2.size[0] == 100
|
||||
assert elem3.size[0] == 100
|
||||
|
||||
|
||||
# Heights should not change
|
||||
assert elem1.size[1] == 50
|
||||
assert elem2.size[1] == 60
|
||||
@@ -216,14 +216,14 @@ class TestAlignmentManager:
|
||||
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
||||
elem2 = ImageData(x=30, y=40, width=80, height=60)
|
||||
elem3 = ImageData(x=70, y=60, width=90, height=40)
|
||||
|
||||
|
||||
changes = AlignmentManager.make_same_height([elem1, elem2, elem3])
|
||||
|
||||
|
||||
# All heights should match elem1
|
||||
assert elem1.size[1] == 50
|
||||
assert elem2.size[1] == 50
|
||||
assert elem3.size[1] == 50
|
||||
|
||||
|
||||
# Widths should not change
|
||||
assert elem1.size[0] == 100
|
||||
assert elem2.size[0] == 80
|
||||
@@ -233,7 +233,7 @@ class TestAlignmentManager:
|
||||
"""Test distribute_horizontally with less than 3 elements"""
|
||||
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
||||
elem2 = ImageData(x=30, y=40, width=80, height=60)
|
||||
|
||||
|
||||
changes = AlignmentManager.distribute_horizontally([elem1, elem2])
|
||||
assert changes == []
|
||||
|
||||
@@ -242,15 +242,15 @@ class TestAlignmentManager:
|
||||
elem1 = ImageData(x=0, y=20, width=100, height=50)
|
||||
elem2 = ImageData(x=50, y=40, width=80, height=60)
|
||||
elem3 = ImageData(x=200, y=60, width=90, height=40)
|
||||
|
||||
|
||||
changes = AlignmentManager.distribute_horizontally([elem1, elem2, elem3])
|
||||
|
||||
|
||||
# Elements should be distributed evenly by their left edges
|
||||
# min_x = 0, max_x = 200, span = 200
|
||||
# spacing = 200 / (3-1) = 100
|
||||
positions = [elem.position[0] for elem in [elem1, elem2, elem3]]
|
||||
sorted_positions = sorted(positions)
|
||||
|
||||
|
||||
assert sorted_positions[0] == 0
|
||||
assert sorted_positions[1] == 100
|
||||
assert sorted_positions[2] == 200
|
||||
@@ -260,15 +260,15 @@ class TestAlignmentManager:
|
||||
elem1 = ImageData(x=20, y=0, width=100, height=50)
|
||||
elem2 = ImageData(x=40, y=50, width=80, height=60)
|
||||
elem3 = ImageData(x=60, y=300, width=90, height=40)
|
||||
|
||||
|
||||
changes = AlignmentManager.distribute_vertically([elem1, elem2, elem3])
|
||||
|
||||
|
||||
# Elements should be distributed evenly by their top edges
|
||||
# min_y = 0, max_y = 300, span = 300
|
||||
# spacing = 300 / (3-1) = 150
|
||||
positions = [elem.position[1] for elem in [elem1, elem2, elem3]]
|
||||
sorted_positions = sorted(positions)
|
||||
|
||||
|
||||
assert sorted_positions[0] == 0
|
||||
assert sorted_positions[1] == 150
|
||||
assert sorted_positions[2] == 300
|
||||
@@ -277,7 +277,7 @@ class TestAlignmentManager:
|
||||
"""Test space_horizontally with less than 3 elements"""
|
||||
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
||||
elem2 = ImageData(x=200, y=40, width=80, height=60)
|
||||
|
||||
|
||||
changes = AlignmentManager.space_horizontally([elem1, elem2])
|
||||
assert changes == []
|
||||
|
||||
@@ -286,17 +286,17 @@ class TestAlignmentManager:
|
||||
elem1 = ImageData(x=0, y=20, width=100, height=50)
|
||||
elem2 = ImageData(x=150, y=40, width=50, height=60)
|
||||
elem3 = ImageData(x=250, y=60, width=100, height=40)
|
||||
|
||||
|
||||
changes = AlignmentManager.space_horizontally([elem1, elem2, elem3])
|
||||
|
||||
|
||||
# Total width = 100 + 50 + 100 = 250
|
||||
# Span = 0 to 350 (250 + 100 from elem3)
|
||||
# Available space = 350 - 0 - 250 = 100
|
||||
# Spacing = 100 / (3-1) = 50
|
||||
|
||||
|
||||
# After sorting by x: elem1 at 0, elem2 after 100+50=150, elem3 after 150+50+50=250
|
||||
sorted_elements = sorted([elem1, elem2, elem3], key=lambda e: e.position[0])
|
||||
|
||||
|
||||
assert sorted_elements[0].position[0] == 0
|
||||
assert sorted_elements[1].position[0] == 150 # 0 + 100 + 50
|
||||
assert sorted_elements[2].position[0] == 250 # 150 + 50 + 50
|
||||
@@ -306,17 +306,17 @@ class TestAlignmentManager:
|
||||
elem1 = ImageData(x=20, y=0, width=100, height=50)
|
||||
elem2 = ImageData(x=40, y=100, width=80, height=30)
|
||||
elem3 = ImageData(x=60, y=200, width=90, height=50)
|
||||
|
||||
|
||||
changes = AlignmentManager.space_vertically([elem1, elem2, elem3])
|
||||
|
||||
|
||||
# Total height = 50 + 30 + 50 = 130
|
||||
# Span = 0 to 250 (200 + 50 from elem3)
|
||||
# Available space = 250 - 0 - 130 = 120
|
||||
# Spacing = 120 / (3-1) = 60
|
||||
|
||||
|
||||
# After sorting by y: elem1 at 0, elem2 after 50+60=110, elem3 after 110+30+60=200
|
||||
sorted_elements = sorted([elem1, elem2, elem3], key=lambda e: e.position[1])
|
||||
|
||||
|
||||
assert sorted_elements[0].position[1] == 0
|
||||
assert sorted_elements[1].position[1] == 110 # 0 + 50 + 60
|
||||
assert sorted_elements[2].position[1] == 200 # 110 + 30 + 60
|
||||
@@ -326,10 +326,10 @@ class TestAlignmentManager:
|
||||
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
||||
elem2 = PlaceholderData(placeholder_type="image", x=30, y=40, width=80, height=60)
|
||||
elem3 = TextBoxData(text_content="Test", x=70, y=60, width=90, height=40)
|
||||
|
||||
|
||||
# Test align_left
|
||||
changes = AlignmentManager.align_left([elem1, elem2, elem3])
|
||||
|
||||
|
||||
assert elem1.position[0] == 30
|
||||
assert elem2.position[0] == 30
|
||||
assert elem3.position[0] == 30
|
||||
@@ -339,23 +339,23 @@ class TestAlignmentManager:
|
||||
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
||||
elem2 = ImageData(x=30, y=40, width=80, height=60)
|
||||
elem3 = ImageData(x=70, y=60, width=90, height=40)
|
||||
|
||||
|
||||
# Test position changes
|
||||
changes = AlignmentManager.align_left([elem1, elem2, elem3])
|
||||
|
||||
|
||||
for change in changes:
|
||||
assert len(change) == 2 # (element, old_position)
|
||||
assert isinstance(change[0], ImageData)
|
||||
assert isinstance(change[1], tuple)
|
||||
assert len(change[1]) == 2 # (x, y)
|
||||
|
||||
|
||||
# Test size changes
|
||||
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
||||
elem2 = ImageData(x=30, y=40, width=80, height=60)
|
||||
elem3 = ImageData(x=70, y=60, width=90, height=40)
|
||||
|
||||
|
||||
changes = AlignmentManager.make_same_size([elem1, elem2, elem3])
|
||||
|
||||
|
||||
for change in changes:
|
||||
assert len(change) == 3 # (element, old_position, old_size)
|
||||
assert isinstance(change[0], ImageData)
|
||||
@@ -368,15 +368,15 @@ class TestAlignmentManager:
|
||||
"""Test that alignment operations only change intended properties"""
|
||||
elem1 = ImageData(x=50, y=20, width=100, height=50, rotation=45, z_index=5)
|
||||
elem2 = ImageData(x=30, y=40, width=80, height=60, rotation=90, z_index=3)
|
||||
|
||||
|
||||
AlignmentManager.align_left([elem1, elem2])
|
||||
|
||||
|
||||
# Rotation and z_index should not change
|
||||
assert elem1.rotation == 45
|
||||
assert elem1.z_index == 5
|
||||
assert elem2.rotation == 90
|
||||
assert elem2.z_index == 3
|
||||
|
||||
|
||||
# Heights should not change
|
||||
assert elem1.size[1] == 50
|
||||
assert elem2.size[1] == 60
|
||||
@@ -387,10 +387,10 @@ class TestAlignmentManager:
|
||||
elem3 = ImageData(x=200, y=60, width=90, height=40)
|
||||
elem1 = ImageData(x=0, y=20, width=100, height=50)
|
||||
elem2 = ImageData(x=100, y=40, width=80, height=60)
|
||||
|
||||
|
||||
# Pass in random order
|
||||
changes = AlignmentManager.distribute_horizontally([elem3, elem1, elem2])
|
||||
|
||||
|
||||
# Should still distribute correctly
|
||||
positions = sorted([elem1.position[0], elem2.position[0], elem3.position[0]])
|
||||
assert positions[0] == 0
|
||||
@@ -402,21 +402,21 @@ class TestAlignmentManager:
|
||||
elem1 = ImageData(x=0, y=0, width=50, height=50)
|
||||
elem2 = ImageData(x=100, y=0, width=100, height=50)
|
||||
elem3 = ImageData(x=250, y=0, width=75, height=50)
|
||||
|
||||
|
||||
changes = AlignmentManager.space_horizontally([elem1, elem2, elem3])
|
||||
|
||||
|
||||
# Calculate expected spacing
|
||||
# Total width = 50 + 100 + 75 = 225
|
||||
# rightmost edge = 250 + 75 = 325
|
||||
# Available space = 325 - 0 - 225 = 100
|
||||
# Spacing = 100 / 2 = 50
|
||||
|
||||
|
||||
sorted_elements = sorted([elem1, elem2, elem3], key=lambda e: e.position[0])
|
||||
|
||||
|
||||
# Verify spacing between elements is equal
|
||||
gap1 = sorted_elements[1].position[0] - (sorted_elements[0].position[0] + sorted_elements[0].size[0])
|
||||
gap2 = sorted_elements[2].position[0] - (sorted_elements[1].position[0] + sorted_elements[1].size[0])
|
||||
|
||||
|
||||
assert abs(gap1 - 50) < 0.01
|
||||
assert abs(gap2 - 50) < 0.01
|
||||
|
||||
@@ -452,7 +452,7 @@ class TestAlignmentManager:
|
||||
assert len(changes) == 1
|
||||
assert changes[0][0] == elem
|
||||
assert changes[0][1] == (100, 80) # old position
|
||||
assert changes[0][2] == (20, 15) # old size
|
||||
assert changes[0][2] == (20, 15) # old size
|
||||
|
||||
def test_maximize_pattern_two_elements_horizontal(self):
|
||||
"""Test maximize_pattern with two elements side by side"""
|
||||
@@ -469,11 +469,11 @@ class TestAlignmentManager:
|
||||
# Elements should not overlap (min_gap = 2.0)
|
||||
gap_x = max(
|
||||
elem2.position[0] - (elem1.position[0] + elem1.size[0]),
|
||||
elem1.position[0] - (elem2.position[0] + elem2.size[0])
|
||||
elem1.position[0] - (elem2.position[0] + elem2.size[0]),
|
||||
)
|
||||
gap_y = max(
|
||||
elem2.position[1] - (elem1.position[1] + elem1.size[1]),
|
||||
elem1.position[1] - (elem2.position[1] + elem2.size[1])
|
||||
elem1.position[1] - (elem2.position[1] + elem2.size[1]),
|
||||
)
|
||||
|
||||
# Either horizontal or vertical gap should be >= min_gap
|
||||
@@ -510,11 +510,11 @@ class TestAlignmentManager:
|
||||
# Calculate gaps between rectangles
|
||||
gap_x = max(
|
||||
elem_b.position[0] - (elem_a.position[0] + elem_a.size[0]),
|
||||
elem_a.position[0] - (elem_b.position[0] + elem_b.size[0])
|
||||
elem_a.position[0] - (elem_b.position[0] + elem_b.size[0]),
|
||||
)
|
||||
gap_y = max(
|
||||
elem_b.position[1] - (elem_a.position[1] + elem_a.size[1]),
|
||||
elem_a.position[1] - (elem_b.position[1] + elem_b.size[1])
|
||||
elem_a.position[1] - (elem_b.position[1] + elem_b.size[1]),
|
||||
)
|
||||
|
||||
# At least one gap should be >= min_gap
|
||||
@@ -564,11 +564,7 @@ class TestAlignmentManager:
|
||||
elem4 = ImageData(x=140, y=90, width=10, height=10)
|
||||
page_size = (160, 110)
|
||||
|
||||
changes = AlignmentManager.maximize_pattern(
|
||||
[elem1, elem2, elem3, elem4],
|
||||
page_size,
|
||||
min_gap=2.0
|
||||
)
|
||||
changes = AlignmentManager.maximize_pattern([elem1, elem2, elem3, elem4], page_size, min_gap=2.0)
|
||||
|
||||
# All elements should grow
|
||||
for elem in [elem1, elem2, elem3, elem4]:
|
||||
@@ -584,11 +580,11 @@ class TestAlignmentManager:
|
||||
|
||||
gap_x = max(
|
||||
elem_b.position[0] - (elem_a.position[0] + elem_a.size[0]),
|
||||
elem_a.position[0] - (elem_b.position[0] + elem_b.size[0])
|
||||
elem_a.position[0] - (elem_b.position[0] + elem_b.size[0]),
|
||||
)
|
||||
gap_y = max(
|
||||
elem_b.position[1] - (elem_a.position[1] + elem_a.size[1]),
|
||||
elem_a.position[1] - (elem_b.position[1] + elem_b.size[1])
|
||||
elem_a.position[1] - (elem_b.position[1] + elem_b.size[1]),
|
||||
)
|
||||
|
||||
assert gap_x >= 2.0 or gap_y >= 2.0
|
||||
|
||||
Regular → Executable
+17
-41
@@ -81,7 +81,7 @@ class TestGetSelectedElementsList:
|
||||
class TestAlignLeft:
|
||||
"""Test align_left method"""
|
||||
|
||||
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
|
||||
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
|
||||
def test_align_left_success(self, mock_manager, qtbot):
|
||||
"""Test aligning elements to the left"""
|
||||
window = TestAlignmentWindow()
|
||||
@@ -93,10 +93,7 @@ class TestAlignLeft:
|
||||
window.gl_widget.selected_elements = {element1, element2}
|
||||
|
||||
# Mock AlignmentManager to return changes
|
||||
mock_manager.align_left.return_value = [
|
||||
(element1, (100, 0)),
|
||||
(element2, (200, 100))
|
||||
]
|
||||
mock_manager.align_left.return_value = [(element1, (100, 0)), (element2, (200, 100))]
|
||||
|
||||
window.align_left()
|
||||
|
||||
@@ -106,7 +103,7 @@ class TestAlignLeft:
|
||||
assert "aligned" in window._status_message.lower()
|
||||
assert "left" in window._status_message.lower()
|
||||
|
||||
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
|
||||
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
|
||||
def test_align_left_no_changes(self, mock_manager, qtbot):
|
||||
"""Test align left when no changes needed"""
|
||||
window = TestAlignmentWindow()
|
||||
@@ -143,7 +140,7 @@ class TestAlignLeft:
|
||||
class TestAlignRight:
|
||||
"""Test align_right method"""
|
||||
|
||||
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
|
||||
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
|
||||
def test_align_right_success(self, mock_manager, qtbot):
|
||||
"""Test aligning elements to the right"""
|
||||
window = TestAlignmentWindow()
|
||||
@@ -154,10 +151,7 @@ class TestAlignRight:
|
||||
|
||||
window.gl_widget.selected_elements = {element1, element2}
|
||||
|
||||
mock_manager.align_right.return_value = [
|
||||
(element1, (100, 0)),
|
||||
(element2, (200, 100))
|
||||
]
|
||||
mock_manager.align_right.return_value = [(element1, (100, 0)), (element2, (200, 100))]
|
||||
|
||||
window.align_right()
|
||||
|
||||
@@ -169,7 +163,7 @@ class TestAlignRight:
|
||||
class TestAlignTop:
|
||||
"""Test align_top method"""
|
||||
|
||||
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
|
||||
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
|
||||
def test_align_top_success(self, mock_manager, qtbot):
|
||||
"""Test aligning elements to the top"""
|
||||
window = TestAlignmentWindow()
|
||||
@@ -180,10 +174,7 @@ class TestAlignTop:
|
||||
|
||||
window.gl_widget.selected_elements = {element1, element2}
|
||||
|
||||
mock_manager.align_top.return_value = [
|
||||
(element1, (0, 50)),
|
||||
(element2, (100, 100))
|
||||
]
|
||||
mock_manager.align_top.return_value = [(element1, (0, 50)), (element2, (100, 100))]
|
||||
|
||||
window.align_top()
|
||||
|
||||
@@ -195,7 +186,7 @@ class TestAlignTop:
|
||||
class TestAlignBottom:
|
||||
"""Test align_bottom method"""
|
||||
|
||||
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
|
||||
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
|
||||
def test_align_bottom_success(self, mock_manager, qtbot):
|
||||
"""Test aligning elements to the bottom"""
|
||||
window = TestAlignmentWindow()
|
||||
@@ -206,10 +197,7 @@ class TestAlignBottom:
|
||||
|
||||
window.gl_widget.selected_elements = {element1, element2}
|
||||
|
||||
mock_manager.align_bottom.return_value = [
|
||||
(element1, (0, 50)),
|
||||
(element2, (100, 100))
|
||||
]
|
||||
mock_manager.align_bottom.return_value = [(element1, (0, 50)), (element2, (100, 100))]
|
||||
|
||||
window.align_bottom()
|
||||
|
||||
@@ -221,7 +209,7 @@ class TestAlignBottom:
|
||||
class TestAlignHorizontalCenter:
|
||||
"""Test align_horizontal_center method"""
|
||||
|
||||
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
|
||||
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
|
||||
def test_align_horizontal_center_success(self, mock_manager, qtbot):
|
||||
"""Test aligning elements to horizontal center"""
|
||||
window = TestAlignmentWindow()
|
||||
@@ -232,10 +220,7 @@ class TestAlignHorizontalCenter:
|
||||
|
||||
window.gl_widget.selected_elements = {element1, element2}
|
||||
|
||||
mock_manager.align_horizontal_center.return_value = [
|
||||
(element1, (0, 0)),
|
||||
(element2, (200, 100))
|
||||
]
|
||||
mock_manager.align_horizontal_center.return_value = [(element1, (0, 0)), (element2, (200, 100))]
|
||||
|
||||
window.align_horizontal_center()
|
||||
|
||||
@@ -247,7 +232,7 @@ class TestAlignHorizontalCenter:
|
||||
class TestAlignVerticalCenter:
|
||||
"""Test align_vertical_center method"""
|
||||
|
||||
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
|
||||
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
|
||||
def test_align_vertical_center_success(self, mock_manager, qtbot):
|
||||
"""Test aligning elements to vertical center"""
|
||||
window = TestAlignmentWindow()
|
||||
@@ -258,10 +243,7 @@ class TestAlignVerticalCenter:
|
||||
|
||||
window.gl_widget.selected_elements = {element1, element2}
|
||||
|
||||
mock_manager.align_vertical_center.return_value = [
|
||||
(element1, (0, 0)),
|
||||
(element2, (100, 200))
|
||||
]
|
||||
mock_manager.align_vertical_center.return_value = [(element1, (0, 0)), (element2, (100, 200))]
|
||||
|
||||
window.align_vertical_center()
|
||||
|
||||
@@ -273,7 +255,7 @@ class TestAlignVerticalCenter:
|
||||
class TestAlignmentCommandPattern:
|
||||
"""Test alignment operations with command pattern for undo/redo"""
|
||||
|
||||
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
|
||||
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
|
||||
def test_alignment_creates_command(self, mock_manager, qtbot):
|
||||
"""Test that alignment creates a command for undo"""
|
||||
window = TestAlignmentWindow()
|
||||
@@ -284,10 +266,7 @@ class TestAlignmentCommandPattern:
|
||||
|
||||
window.gl_widget.selected_elements = {element1, element2}
|
||||
|
||||
mock_manager.align_left.return_value = [
|
||||
(element1, (100, 0)),
|
||||
(element2, (200, 100))
|
||||
]
|
||||
mock_manager.align_left.return_value = [(element1, (100, 0)), (element2, (200, 100))]
|
||||
|
||||
# Should have no commands initially
|
||||
assert not window.project.history.can_undo()
|
||||
@@ -297,7 +276,7 @@ class TestAlignmentCommandPattern:
|
||||
# Should have created a command
|
||||
assert window.project.history.can_undo()
|
||||
|
||||
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
|
||||
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
|
||||
def test_alignment_undo_redo(self, mock_manager, qtbot):
|
||||
"""Test that alignment can be undone and redone"""
|
||||
window = TestAlignmentWindow()
|
||||
@@ -309,10 +288,7 @@ class TestAlignmentCommandPattern:
|
||||
window.gl_widget.selected_elements = {element1, element2}
|
||||
|
||||
# Mock alignment to return changes (command will handle actual moves)
|
||||
mock_manager.align_top.return_value = [
|
||||
(element1, (100, 0)),
|
||||
(element2, (200, 100))
|
||||
]
|
||||
mock_manager.align_top.return_value = [(element1, (100, 0)), (element2, (200, 100))]
|
||||
|
||||
# Execute alignment - command created
|
||||
window.align_top()
|
||||
|
||||
Regular → Executable
+264
-11
@@ -8,6 +8,7 @@ from PyQt6.QtCore import QMimeData, QUrl, QPoint
|
||||
from PyQt6.QtGui import QDragEnterEvent, QDragMoveEvent, QDropEvent
|
||||
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
|
||||
from pyPhotoAlbum.mixins.asset_drop import AssetDropMixin
|
||||
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
|
||||
from pyPhotoAlbum.mixins.viewport import ViewportMixin
|
||||
from pyPhotoAlbum.mixins.page_navigation import PageNavigationMixin
|
||||
from pyPhotoAlbum.project import Project, Page
|
||||
@@ -16,14 +17,21 @@ from pyPhotoAlbum.models import ImageData
|
||||
|
||||
|
||||
# Create test widget combining necessary mixins
|
||||
class TestAssetDropWidget(AssetDropMixin, PageNavigationMixin, ViewportMixin, QOpenGLWidget):
|
||||
"""Test widget combining asset drop, page navigation, and viewport mixins"""
|
||||
class TestAssetDropWidget(AssetDropMixin, AssetPathMixin, PageNavigationMixin, ViewportMixin, QOpenGLWidget):
|
||||
"""Test widget combining asset drop, asset path, page navigation, and viewport mixins"""
|
||||
|
||||
def _get_element_at(self, x, y):
|
||||
"""Mock implementation for testing"""
|
||||
# Will be overridden in tests that need it
|
||||
return None
|
||||
|
||||
def _get_project_folder(self):
|
||||
"""Override to access project via window mock"""
|
||||
main_window = self.window()
|
||||
if hasattr(main_window, "project") and main_window.project:
|
||||
return getattr(main_window.project, "folder_path", None)
|
||||
return None
|
||||
|
||||
|
||||
class TestAssetDropInitialization:
|
||||
"""Test AssetDropMixin initialization"""
|
||||
@@ -35,7 +43,7 @@ class TestAssetDropInitialization:
|
||||
|
||||
# Should accept drops (set in GLWidget.__init__)
|
||||
# This is a property of the widget, not the mixin
|
||||
assert hasattr(widget, 'acceptDrops')
|
||||
assert hasattr(widget, "acceptDrops")
|
||||
|
||||
|
||||
class TestDragEnterEvent:
|
||||
@@ -133,7 +141,7 @@ class TestDragMoveEvent:
|
||||
class TestDropEvent:
|
||||
"""Test dropEvent method"""
|
||||
|
||||
@patch('pyPhotoAlbum.mixins.asset_drop.AddElementCommand')
|
||||
@patch("pyPhotoAlbum.mixins.asset_drop.AddElementCommand")
|
||||
def test_drop_creates_image_element(self, mock_cmd_class, qtbot):
|
||||
"""Test dropping image file creates ImageData element"""
|
||||
widget = TestAssetDropWidget()
|
||||
@@ -231,7 +239,7 @@ class TestDropEvent:
|
||||
|
||||
# Create a real test image file
|
||||
test_image = tmp_path / "test_image.jpg"
|
||||
test_image.write_bytes(b'\xFF\xD8\xFF\xE0' + b'\x00' * 100) # Minimal JPEG header
|
||||
test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100) # Minimal JPEG header
|
||||
|
||||
# Setup project with page containing placeholder
|
||||
mock_window = Mock()
|
||||
@@ -240,6 +248,7 @@ class TestDropEvent:
|
||||
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||
|
||||
from pyPhotoAlbum.models import PlaceholderData
|
||||
|
||||
placeholder = PlaceholderData(x=100, y=100, width=200, height=150)
|
||||
page.layout.elements.append(placeholder)
|
||||
|
||||
@@ -272,7 +281,7 @@ class TestDropEvent:
|
||||
# Image path should now be in assets folder (imported)
|
||||
assert page.layout.elements[0].image_path.startswith("assets/")
|
||||
|
||||
@patch('pyPhotoAlbum.mixins.asset_drop.AddElementCommand')
|
||||
@patch("pyPhotoAlbum.mixins.asset_drop.AddElementCommand")
|
||||
def test_drop_multiple_files(self, mock_cmd_class, qtbot):
|
||||
"""Test dropping first image from multiple files"""
|
||||
widget = TestAssetDropWidget()
|
||||
@@ -303,11 +312,13 @@ class TestDropEvent:
|
||||
|
||||
# Create drop event with multiple files (only first is used)
|
||||
mime_data = QMimeData()
|
||||
mime_data.setUrls([
|
||||
QUrl.fromLocalFile("/path/to/image1.jpg"),
|
||||
QUrl.fromLocalFile("/path/to/image2.png"),
|
||||
QUrl.fromLocalFile("/path/to/image3.jpg")
|
||||
])
|
||||
mime_data.setUrls(
|
||||
[
|
||||
QUrl.fromLocalFile("/path/to/image1.jpg"),
|
||||
QUrl.fromLocalFile("/path/to/image2.png"),
|
||||
QUrl.fromLocalFile("/path/to/image3.jpg"),
|
||||
]
|
||||
)
|
||||
|
||||
event = Mock()
|
||||
event.mimeData = Mock(return_value=mime_data)
|
||||
@@ -347,3 +358,245 @@ class TestDropEvent:
|
||||
# Should still accept event and call update
|
||||
assert event.acceptProposedAction.called
|
||||
assert widget.update.called
|
||||
|
||||
def test_drop_on_existing_image_updates_it(self, qtbot, tmp_path):
|
||||
"""Test dropping on existing ImageData updates its image path"""
|
||||
widget = TestAssetDropWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.update = Mock()
|
||||
|
||||
# Create a real test image file
|
||||
test_image = tmp_path / "new_image.jpg"
|
||||
test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
|
||||
|
||||
# Setup project with page containing existing ImageData
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
mock_window.project.working_dpi = 96
|
||||
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||
|
||||
existing_image = ImageData(image_path="assets/old_image.jpg", x=100, y=100, width=200, height=150)
|
||||
page.layout.elements.append(existing_image)
|
||||
mock_window.project.pages = [page]
|
||||
|
||||
# Mock asset manager
|
||||
mock_window.project.asset_manager = Mock()
|
||||
mock_window.project.asset_manager.import_asset = Mock(return_value="assets/new_image.jpg")
|
||||
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
widget._get_element_at = Mock(return_value=existing_image)
|
||||
|
||||
mime_data = QMimeData()
|
||||
mime_data.setUrls([QUrl.fromLocalFile(str(test_image))])
|
||||
|
||||
event = Mock()
|
||||
event.mimeData = Mock(return_value=mime_data)
|
||||
event.position = Mock(return_value=QPoint(150, 150))
|
||||
event.acceptProposedAction = Mock()
|
||||
|
||||
widget.dropEvent(event)
|
||||
|
||||
# Should update existing ImageData's path
|
||||
assert existing_image.image_path == "assets/new_image.jpg"
|
||||
assert mock_window.project.asset_manager.import_asset.called
|
||||
|
||||
def test_drop_with_asset_import_failure(self, qtbot, tmp_path):
|
||||
"""Test dropping handles asset import errors gracefully"""
|
||||
widget = TestAssetDropWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.update = Mock()
|
||||
|
||||
test_image = tmp_path / "test.jpg"
|
||||
test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
|
||||
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||
|
||||
existing_image = ImageData(image_path="assets/old.jpg", x=100, y=100, width=200, height=150)
|
||||
page.layout.elements.append(existing_image)
|
||||
mock_window.project.pages = [page]
|
||||
|
||||
# Mock asset manager to raise exception
|
||||
mock_window.project.asset_manager = Mock()
|
||||
mock_window.project.asset_manager.import_asset = Mock(side_effect=Exception("Import failed"))
|
||||
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
widget._get_element_at = Mock(return_value=existing_image)
|
||||
|
||||
mime_data = QMimeData()
|
||||
mime_data.setUrls([QUrl.fromLocalFile(str(test_image))])
|
||||
|
||||
event = Mock()
|
||||
event.mimeData = Mock(return_value=mime_data)
|
||||
event.position = Mock(return_value=QPoint(150, 150))
|
||||
event.acceptProposedAction = Mock()
|
||||
|
||||
# Should not crash, should handle error gracefully
|
||||
widget.dropEvent(event)
|
||||
|
||||
# Original path should remain unchanged
|
||||
assert existing_image.image_path == "assets/old.jpg"
|
||||
assert event.acceptProposedAction.called
|
||||
|
||||
def test_drop_with_corrupted_image_uses_defaults(self, qtbot, tmp_path):
|
||||
"""Test dropping corrupted image uses default dimensions"""
|
||||
widget = TestAssetDropWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.zoom_level = 1.0
|
||||
widget.pan_offset = [0, 0]
|
||||
widget.update = Mock()
|
||||
|
||||
# Create a corrupted/invalid image file
|
||||
corrupted_image = tmp_path / "corrupted.jpg"
|
||||
corrupted_image.write_bytes(b"not a valid image")
|
||||
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
mock_window.project.working_dpi = 96
|
||||
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||
mock_window.project.pages = [page]
|
||||
|
||||
mock_window.project.asset_manager = Mock()
|
||||
mock_window.project.asset_manager.import_asset = Mock(return_value="assets/corrupted.jpg")
|
||||
mock_window.project.history = Mock()
|
||||
|
||||
mock_renderer = Mock()
|
||||
mock_renderer.is_point_in_page = Mock(return_value=True)
|
||||
mock_renderer.screen_to_page = Mock(return_value=(100, 100))
|
||||
|
||||
widget._get_page_at = Mock(return_value=(page, 0, mock_renderer))
|
||||
widget._page_renderers = [(mock_renderer, page)]
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
widget._get_element_at = Mock(return_value=None)
|
||||
|
||||
mime_data = QMimeData()
|
||||
mime_data.setUrls([QUrl.fromLocalFile(str(corrupted_image))])
|
||||
|
||||
event = Mock()
|
||||
event.mimeData = Mock(return_value=mime_data)
|
||||
event.position = Mock(return_value=QPoint(150, 150))
|
||||
event.acceptProposedAction = Mock()
|
||||
|
||||
widget.dropEvent(event)
|
||||
|
||||
# Should use default dimensions (200, 150) from _calculate_image_dimensions
|
||||
# Check that AddElementCommand was called with an ImageData
|
||||
from pyPhotoAlbum.commands import AddElementCommand
|
||||
|
||||
with patch("pyPhotoAlbum.mixins.asset_drop.AddElementCommand") as mock_cmd:
|
||||
# Re-run to check the call
|
||||
widget.dropEvent(event)
|
||||
assert mock_cmd.called
|
||||
|
||||
|
||||
class TestDragMoveEventEdgeCases:
|
||||
"""Test edge cases for dragMoveEvent"""
|
||||
|
||||
def test_drag_move_rejects_no_urls(self, qtbot):
|
||||
"""Test dragMoveEvent rejects events without URLs"""
|
||||
widget = TestAssetDropWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
mime_data = QMimeData()
|
||||
# No URLs set
|
||||
|
||||
event = Mock()
|
||||
event.mimeData = Mock(return_value=mime_data)
|
||||
event.acceptProposedAction = Mock()
|
||||
event.ignore = Mock()
|
||||
|
||||
widget.dragMoveEvent(event)
|
||||
|
||||
# Should ignore the event
|
||||
assert event.ignore.called
|
||||
assert not event.acceptProposedAction.called
|
||||
|
||||
|
||||
class TestExtractImagePathEdgeCases:
|
||||
"""Test edge cases for _extract_image_path"""
|
||||
|
||||
def test_drop_ignores_non_image_urls(self, qtbot):
|
||||
"""Test dropping non-image files is ignored"""
|
||||
widget = TestAssetDropWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.update = Mock()
|
||||
|
||||
mime_data = QMimeData()
|
||||
mime_data.setUrls([QUrl.fromLocalFile("/path/to/document.pdf"), QUrl.fromLocalFile("/path/to/file.txt")])
|
||||
|
||||
event = Mock()
|
||||
event.mimeData = Mock(return_value=mime_data)
|
||||
event.position = Mock(return_value=QPoint(150, 150))
|
||||
event.ignore = Mock()
|
||||
event.acceptProposedAction = Mock()
|
||||
|
||||
widget.dropEvent(event)
|
||||
|
||||
# Should ignore event since no valid image files
|
||||
assert event.ignore.called
|
||||
assert not event.acceptProposedAction.called
|
||||
|
||||
def test_drop_ignores_empty_urls(self, qtbot):
|
||||
"""Test dropping with no URLs is ignored"""
|
||||
widget = TestAssetDropWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.update = Mock()
|
||||
|
||||
mime_data = QMimeData()
|
||||
# No URLs at all
|
||||
|
||||
event = Mock()
|
||||
event.mimeData = Mock(return_value=mime_data)
|
||||
event.position = Mock(return_value=QPoint(150, 150))
|
||||
event.ignore = Mock()
|
||||
event.acceptProposedAction = Mock()
|
||||
|
||||
widget.dropEvent(event)
|
||||
|
||||
# Should ignore event
|
||||
assert event.ignore.called
|
||||
assert not event.acceptProposedAction.called
|
||||
|
||||
|
||||
class TestPlaceholderReplacementEdgeCases:
|
||||
"""Test edge cases for placeholder replacement"""
|
||||
|
||||
def test_replace_placeholder_with_no_pages(self, qtbot, tmp_path):
|
||||
"""Test replacing placeholder when project has no pages"""
|
||||
widget = TestAssetDropWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.update = Mock()
|
||||
|
||||
test_image = tmp_path / "test.jpg"
|
||||
test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
|
||||
|
||||
# Setup project WITHOUT pages
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
mock_window.project.working_dpi = 96
|
||||
mock_window.project.pages = [] # Empty pages list
|
||||
|
||||
from pyPhotoAlbum.models import PlaceholderData
|
||||
|
||||
placeholder = PlaceholderData(x=100, y=100, width=200, height=150)
|
||||
|
||||
mock_window.project.asset_manager = Mock()
|
||||
mock_window.project.asset_manager.import_asset = Mock(return_value="assets/test.jpg")
|
||||
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
widget._get_element_at = Mock(return_value=placeholder)
|
||||
|
||||
mime_data = QMimeData()
|
||||
mime_data.setUrls([QUrl.fromLocalFile(str(test_image))])
|
||||
|
||||
event = Mock()
|
||||
event.mimeData = Mock(return_value=mime_data)
|
||||
event.position = Mock(return_value=QPoint(150, 150))
|
||||
event.acceptProposedAction = Mock()
|
||||
|
||||
# Should not crash when trying to replace placeholder
|
||||
widget.dropEvent(event)
|
||||
|
||||
# Event should still be accepted
|
||||
assert event.acceptProposedAction.called
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user