first commit
This commit is contained in:
commit
46585228fd
38
.gitea/workflows/lint.yml
Normal file
38
.gitea/workflows/lint.yml
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
name: Lint
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install flake8 black mypy
|
||||||
|
|
||||||
|
- name: Run flake8
|
||||||
|
run: |
|
||||||
|
# Stop the build if there are Python syntax errors or undefined names
|
||||||
|
flake8 pyPhotoAlbum --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||||
|
# Exit-zero treats all errors as warnings
|
||||||
|
flake8 pyPhotoAlbum --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
||||||
|
|
||||||
|
- name: Check formatting with black
|
||||||
|
run: |
|
||||||
|
black --check pyPhotoAlbum
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Type check with mypy
|
||||||
|
run: |
|
||||||
|
mypy pyPhotoAlbum --ignore-missing-imports
|
||||||
|
continue-on-error: true
|
||||||
42
.gitea/workflows/tests.yml
Normal file
42
.gitea/workflows/tests.yml
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
name: Tests
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ['3.9', '3.10', '3.11']
|
||||||
|
|
||||||
|
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 system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libgl1-mesa-glx xvfb libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0
|
||||||
|
|
||||||
|
- name: Install Python dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
|
||||||
|
- name: Run tests with coverage
|
||||||
|
run: |
|
||||||
|
xvfb-run -a pytest --cov=pyPhotoAlbum --cov-report=xml --cov-report=term-missing
|
||||||
|
env:
|
||||||
|
QT_QPA_PLATFORM: offscreen
|
||||||
|
|
||||||
|
- name: Upload coverage reports
|
||||||
|
if: matrix.python-version == '3.11'
|
||||||
|
uses: codecov/codecov-action@v3
|
||||||
|
with:
|
||||||
|
file: ./coverage.xml
|
||||||
|
fail_ci_if_error: false
|
||||||
137
.gitignore
vendored
Normal file
137
.gitignore
vendored
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
pip-wheel-metadata/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
Pipfile.lock
|
||||||
|
|
||||||
|
# PEP 582
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Project specific
|
||||||
|
projects/
|
||||||
|
*.ppz # pyPhotoAlbum project ZIP files (user projects)
|
||||||
|
*.pyc
|
||||||
|
.vscode/settings.json
|
||||||
646
README.md
Normal file
646
README.md
Normal file
@ -0,0 +1,646 @@
|
|||||||
|
# pyPhotoAlbum
|
||||||
|
|
||||||
|
A Python-based desktop application for designing photo albums with an intuitive interface and professional PDF export capabilities.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
pyPhotoAlbum is a photo album design tool built with PyQt6 and OpenGL, offering a powerful yet user-friendly interface for creating custom photo layouts. It supports drag-and-drop image placement, template-based designs, and high-quality PDF export.
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
- **Visual Editor**: OpenGL-accelerated rendering with real-time preview
|
||||||
|
- **Drag & Drop**: Direct image import from file explorer
|
||||||
|
- **Template System**: Create and reuse page layouts
|
||||||
|
- **Smart Layout Tools**: Alignment, distribution, and sizing operations
|
||||||
|
- **Asset Management**: Automatic image organization with reference counting
|
||||||
|
- **Project Files**: Save/load projects in portable ZIP format (.ppz)
|
||||||
|
- **PDF Export**: High-quality export with configurable DPI
|
||||||
|
- **Undo/Redo**: Complete command history for all operations
|
||||||
|
- **Double-Page Spreads**: Design facing pages for book-style albums
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
- Python 3.9 or higher
|
||||||
|
- PyQt6
|
||||||
|
- PyOpenGL
|
||||||
|
- Pillow
|
||||||
|
- ReportLab
|
||||||
|
- lxml
|
||||||
|
|
||||||
|
### Install from Source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://github.com/yourusername/pyPhotoAlbum.git
|
||||||
|
cd pyPhotoAlbum
|
||||||
|
|
||||||
|
# Create and activate virtual environment
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||||
|
|
||||||
|
# Install the package
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install for Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install with development dependencies
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Running the Application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pyphotoalbum
|
||||||
|
```
|
||||||
|
|
||||||
|
Or run directly from source:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python pyPhotoAlbum/main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Basic Usage Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pyPhotoAlbum.project import Project, Page
|
||||||
|
from pyPhotoAlbum.page_layout import PageLayout
|
||||||
|
from pyPhotoAlbum.models import ImageData
|
||||||
|
|
||||||
|
# Create a new project
|
||||||
|
project = Project(name="My Photo Album")
|
||||||
|
project.page_size_mm = (210, 297) # A4 size
|
||||||
|
project.working_dpi = 300
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
# Add page to project
|
||||||
|
page = Page(layout=layout, page_number=1)
|
||||||
|
project.add_page(page)
|
||||||
|
|
||||||
|
# Save project
|
||||||
|
from pyPhotoAlbum.project_serializer import save_to_zip
|
||||||
|
success, error = save_to_zip(project, "my_album.ppz")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### 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
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
├── 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/ # Operation mixins
|
||||||
|
│ ├── base.py
|
||||||
|
│ └── operations/
|
||||||
|
│ ├── 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
|
||||||
|
examples/ # Usage examples
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Code Style
|
||||||
|
|
||||||
|
The project uses:
|
||||||
|
- **Black** for code formatting (line length: 120)
|
||||||
|
- **Flake8** for linting
|
||||||
|
- **MyPy** for type checking
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Format code
|
||||||
|
black pyPhotoAlbum tests
|
||||||
|
|
||||||
|
# Run linter
|
||||||
|
flake8 pyPhotoAlbum tests
|
||||||
|
|
||||||
|
# Type checking
|
||||||
|
mypy pyPhotoAlbum
|
||||||
|
```
|
||||||
|
|
||||||
|
### Continuous Integration
|
||||||
|
|
||||||
|
GitHub Actions / Gitea Actions workflows:
|
||||||
|
- Run tests on Python 3.9, 3.10, 3.11
|
||||||
|
- Check code quality with linters
|
||||||
|
- Generate coverage reports
|
||||||
|
|
||||||
|
### Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Write tests for new features
|
||||||
|
4. Ensure all tests pass
|
||||||
|
5. Submit a pull request
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
See the `examples/` directory for complete working examples:
|
||||||
|
|
||||||
|
- `basic_usage.py` - Creating projects and adding images
|
||||||
|
- `template_example.py` - Working with templates
|
||||||
|
- `generate_screenshots.py` - Creating documentation screenshots
|
||||||
|
|
||||||
|
Run examples:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd examples
|
||||||
|
python basic_usage.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Key Classes
|
||||||
|
|
||||||
|
#### BaseLayoutElement (Abstract)
|
||||||
|
Base class for all layout elements.
|
||||||
|
|
||||||
|
**Methods:**
|
||||||
|
- `render()` - Render element using OpenGL
|
||||||
|
- `serialize() -> Dict` - Convert to dictionary
|
||||||
|
- `deserialize(data: Dict)` - Load from dictionary
|
||||||
|
|
||||||
|
**Attributes:**
|
||||||
|
- `position: Tuple[float, float]` - (x, y) in mm
|
||||||
|
- `size: Tuple[float, float]` - (width, height) in mm
|
||||||
|
- `rotation: float` - Rotation angle in degrees
|
||||||
|
- `z_index: int` - Layer order
|
||||||
|
|
||||||
|
#### ImageData
|
||||||
|
Image element with crop support.
|
||||||
|
|
||||||
|
**Constructor:**
|
||||||
|
```python
|
||||||
|
ImageData(
|
||||||
|
image_path: str = "",
|
||||||
|
crop_info: Tuple = (0, 0, 1, 1),
|
||||||
|
x: float = 0,
|
||||||
|
y: float = 0,
|
||||||
|
width: float = 100,
|
||||||
|
height: float = 100,
|
||||||
|
rotation: float = 0,
|
||||||
|
z_index: int = 0
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### TextBoxData
|
||||||
|
Text element with formatting.
|
||||||
|
|
||||||
|
**Constructor:**
|
||||||
|
```python
|
||||||
|
TextBoxData(
|
||||||
|
text_content: str = "",
|
||||||
|
font_settings: Dict = None,
|
||||||
|
alignment: str = "left",
|
||||||
|
x: float = 0,
|
||||||
|
y: float = 0,
|
||||||
|
width: float = 100,
|
||||||
|
height: float = 100
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Font Settings:**
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"family": "Arial",
|
||||||
|
"size": 12,
|
||||||
|
"color": (0, 0, 0) # RGB tuple
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Project
|
||||||
|
Main project container.
|
||||||
|
|
||||||
|
**Methods:**
|
||||||
|
- `add_page(page: Page)` - Add page to project
|
||||||
|
- `remove_page(page: Page)` - Remove page
|
||||||
|
- `serialize() -> Dict` - Save to dictionary
|
||||||
|
- `deserialize(data: Dict)` - Load from dictionary
|
||||||
|
|
||||||
|
**Attributes:**
|
||||||
|
- `name: str` - Project name
|
||||||
|
- `pages: List[Page]` - List of pages
|
||||||
|
- `page_size_mm: Tuple[float, float]` - Page dimensions
|
||||||
|
- `working_dpi: int` - Display DPI
|
||||||
|
- `export_dpi: int` - Export DPI
|
||||||
|
- `asset_manager: AssetManager` - Asset handler
|
||||||
|
- `history: CommandHistory` - Undo/redo history
|
||||||
|
|
||||||
|
#### Page
|
||||||
|
Single page in project.
|
||||||
|
|
||||||
|
**Constructor:**
|
||||||
|
```python
|
||||||
|
Page(
|
||||||
|
layout: PageLayout = None,
|
||||||
|
page_number: int = 1,
|
||||||
|
is_double_spread: bool = False
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### PageLayout
|
||||||
|
Element container for a page.
|
||||||
|
|
||||||
|
**Methods:**
|
||||||
|
- `add_element(element: BaseLayoutElement)` - Add element
|
||||||
|
- `remove_element(element: BaseLayoutElement)` - Remove element
|
||||||
|
- `render(dpi: int)` - Render all elements
|
||||||
|
|
||||||
|
**Attributes:**
|
||||||
|
- `elements: List[BaseLayoutElement]` - Page elements
|
||||||
|
- `width: float` - Page width in mm
|
||||||
|
- `height: float` - Page height in mm
|
||||||
|
|
||||||
|
## Keyboard Shortcuts
|
||||||
|
|
||||||
|
- `Ctrl+Z` - Undo
|
||||||
|
- `Ctrl+Y` - Redo
|
||||||
|
- `Ctrl+S` - Save project
|
||||||
|
- `Ctrl+O` - Open project
|
||||||
|
- `Ctrl+N` - New project
|
||||||
|
- `Ctrl+E` - Export to PDF
|
||||||
|
- `Delete` - Delete selected element
|
||||||
|
- `Arrow Keys` - Move selected element
|
||||||
|
- `Shift+Arrow Keys` - Resize selected element
|
||||||
|
- `Ctrl+D` - Duplicate selected element
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License.
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Documentation: [Link to docs]
|
||||||
|
- Issue Tracker: [Link to issues]
|
||||||
|
- Changelog: [Link to changelog]
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
Built with:
|
||||||
|
- PyQt6 for the GUI framework
|
||||||
|
- OpenGL for hardware-accelerated rendering
|
||||||
|
- ReportLab for PDF generation
|
||||||
|
- Pillow for image processing
|
||||||
90
examples/README.md
Normal file
90
examples/README.md
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
# pyPhotoAlbum Examples
|
||||||
|
|
||||||
|
This directory contains working examples demonstrating various features of pyPhotoAlbum.
|
||||||
|
|
||||||
|
## Available Examples
|
||||||
|
|
||||||
|
### basic_usage.py
|
||||||
|
Demonstrates the fundamentals of creating a photo album project:
|
||||||
|
- Creating a project
|
||||||
|
- Adding pages with images
|
||||||
|
- Working with text boxes
|
||||||
|
- Saving and loading projects
|
||||||
|
- Asset management
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
python basic_usage.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### template_example.py
|
||||||
|
Shows how to work with the template system:
|
||||||
|
- Creating custom templates
|
||||||
|
- Applying templates to pages
|
||||||
|
- Using built-in templates
|
||||||
|
- Template scaling modes
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
python template_example.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### generate_screenshots.py
|
||||||
|
Script to generate documentation screenshots programmatically:
|
||||||
|
- Creates example projects
|
||||||
|
- Captures screenshots for documentation
|
||||||
|
- Demonstrates various layouts
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
python generate_screenshots.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates screenshots in `examples/screenshots/` directory.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
Make sure you have pyPhotoAlbum installed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From the project root
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sample Images
|
||||||
|
|
||||||
|
The examples use placeholder images. To use your own:
|
||||||
|
|
||||||
|
1. Create an `images/` directory in the examples folder
|
||||||
|
2. Add your sample images
|
||||||
|
3. Update the image paths in the example scripts
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Examples will create:
|
||||||
|
- `examples/output/` - Generated project files (.ppz)
|
||||||
|
- `examples/screenshots/` - Documentation screenshots
|
||||||
|
- `examples/pdfs/` - Exported PDFs
|
||||||
|
|
||||||
|
These directories are created automatically when you run the examples.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The examples use realistic page sizes (A4, square formats)
|
||||||
|
- DPI settings match typical print requirements (300 DPI)
|
||||||
|
- All examples include error handling and cleanup
|
||||||
|
- Examples demonstrate both programmatic and template-based workflows
|
||||||
|
|
||||||
|
## Regenerating Documentation Screenshots
|
||||||
|
|
||||||
|
To update screenshots for the documentation:
|
||||||
|
|
||||||
|
1. Run `python generate_screenshots.py`
|
||||||
|
2. Review generated images in `screenshots/`
|
||||||
|
3. Copy needed screenshots to documentation
|
||||||
|
|
||||||
|
The script generates:
|
||||||
|
- UI screenshots
|
||||||
|
- Layout examples
|
||||||
|
- Template demonstrations
|
||||||
|
- Feature showcases
|
||||||
426
examples/basic_usage.py
Normal file
426
examples/basic_usage.py
Normal file
@ -0,0 +1,426 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Basic Usage Example for pyPhotoAlbum
|
||||||
|
|
||||||
|
This example demonstrates:
|
||||||
|
- Creating a new project
|
||||||
|
- Adding pages with images and text
|
||||||
|
- Working with the asset manager
|
||||||
|
- Saving and loading projects
|
||||||
|
- Basic element manipulation
|
||||||
|
|
||||||
|
Based on unit test examples from the pyPhotoAlbum test suite.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add parent directory to path to import pyPhotoAlbum
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from pyPhotoAlbum.project import Project, Page
|
||||||
|
from pyPhotoAlbum.page_layout import PageLayout
|
||||||
|
from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
|
||||||
|
from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip, get_project_info
|
||||||
|
from pyPhotoAlbum.pdf_exporter import PDFExporter
|
||||||
|
|
||||||
|
|
||||||
|
def create_sample_image(path: str, color: str = 'blue', size: tuple = (400, 300)):
|
||||||
|
"""Create a sample image for testing"""
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
img = Image.new('RGB', size, color=color)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# Draw a border
|
||||||
|
border_width = 10
|
||||||
|
draw.rectangle(
|
||||||
|
[(border_width, border_width), (size[0]-border_width, size[1]-border_width)],
|
||||||
|
outline='white',
|
||||||
|
width=5
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add some text
|
||||||
|
text = f"{color.upper()}\n{size[0]}x{size[1]}"
|
||||||
|
try:
|
||||||
|
# Try to use a nice font
|
||||||
|
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 36)
|
||||||
|
except:
|
||||||
|
# Fallback to default
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
|
||||||
|
# Calculate text position (center)
|
||||||
|
bbox = draw.textbbox((0, 0), text, font=font)
|
||||||
|
text_width = bbox[2] - bbox[0]
|
||||||
|
text_height = bbox[3] - bbox[1]
|
||||||
|
x = (size[0] - text_width) // 2
|
||||||
|
y = (size[1] - text_height) // 2
|
||||||
|
|
||||||
|
draw.text((x, y), text, fill='white', font=font)
|
||||||
|
|
||||||
|
img.save(path)
|
||||||
|
print(f"Created sample image: {path}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Could not create sample image: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def example_1_create_basic_project():
|
||||||
|
"""
|
||||||
|
Example 1: Create a basic project with one page and an image
|
||||||
|
|
||||||
|
This demonstrates the fundamental workflow based on test_project.py
|
||||||
|
"""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("Example 1: Creating a Basic Project")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
# Create output directory
|
||||||
|
output_dir = Path(__file__).parent / "output"
|
||||||
|
output_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Create a temporary directory for the project
|
||||||
|
temp_dir = tempfile.mkdtemp(prefix="photo_album_")
|
||||||
|
project_folder = os.path.join(temp_dir, "my_album")
|
||||||
|
|
||||||
|
print(f"\nProject folder: {project_folder}")
|
||||||
|
|
||||||
|
# Create a new project (from test_project.py)
|
||||||
|
project = Project(name="My First Album", folder_path=project_folder)
|
||||||
|
project.page_size_mm = (210, 297) # A4 size
|
||||||
|
project.working_dpi = 300
|
||||||
|
project.export_dpi = 300
|
||||||
|
|
||||||
|
print(f"Created project: {project.name}")
|
||||||
|
print(f"Page size: {project.page_size_mm[0]}mm x {project.page_size_mm[1]}mm")
|
||||||
|
print(f"DPI: {project.working_dpi}")
|
||||||
|
|
||||||
|
# Create a sample image
|
||||||
|
image_path = output_dir / "sample_photo.jpg"
|
||||||
|
create_sample_image(str(image_path), color='blue', size=(800, 600))
|
||||||
|
|
||||||
|
# Import the image into the project (from test_project_serialization.py)
|
||||||
|
imported_path = project.asset_manager.import_asset(str(image_path))
|
||||||
|
print(f"\nImported asset: {imported_path}")
|
||||||
|
|
||||||
|
# Create a page layout (from test_project.py)
|
||||||
|
layout = PageLayout(width=210, height=297)
|
||||||
|
|
||||||
|
# Add an image element (from test_models.py)
|
||||||
|
image = ImageData(
|
||||||
|
image_path=imported_path,
|
||||||
|
x=10.0,
|
||||||
|
y=10.0,
|
||||||
|
width=190.0,
|
||||||
|
height=140.0,
|
||||||
|
rotation=0,
|
||||||
|
z_index=0
|
||||||
|
)
|
||||||
|
layout.add_element(image)
|
||||||
|
print(f"Added image at position ({image.position[0]}, {image.position[1]})")
|
||||||
|
print(f"Image size: {image.size[0]}mm x {image.size[1]}mm")
|
||||||
|
|
||||||
|
# Add a text box (from test_models.py)
|
||||||
|
textbox = TextBoxData(
|
||||||
|
text_content="My First Photo Album",
|
||||||
|
font_settings={"family": "Arial", "size": 24, "color": (0, 0, 0)},
|
||||||
|
alignment="center",
|
||||||
|
x=10.0,
|
||||||
|
y=160.0,
|
||||||
|
width=190.0,
|
||||||
|
height=30.0
|
||||||
|
)
|
||||||
|
layout.add_element(textbox)
|
||||||
|
print(f"Added text box: '{textbox.text_content}'")
|
||||||
|
|
||||||
|
# Create a page and add it to the project
|
||||||
|
page = Page(layout=layout, page_number=1)
|
||||||
|
project.add_page(page)
|
||||||
|
print(f"\nAdded page {page.page_number} to project")
|
||||||
|
print(f"Total pages: {len(project.pages)}")
|
||||||
|
print(f"Total elements on page: {len(page.layout.elements)}")
|
||||||
|
|
||||||
|
# Save the project (from test_project_serialization.py)
|
||||||
|
output_path = output_dir / "basic_project.ppz"
|
||||||
|
print(f"\nSaving project to: {output_path}")
|
||||||
|
success, error = save_to_zip(project, str(output_path))
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print("Project saved successfully!")
|
||||||
|
|
||||||
|
# Get project info without loading (from test_project_serialization.py)
|
||||||
|
info = get_project_info(str(output_path))
|
||||||
|
if info:
|
||||||
|
print(f"\nProject Info:")
|
||||||
|
print(f" Name: {info['name']}")
|
||||||
|
print(f" Pages: {info['page_count']}")
|
||||||
|
print(f" Version: {info['version']}")
|
||||||
|
print(f" Working DPI: {info['working_dpi']}")
|
||||||
|
else:
|
||||||
|
print(f"Error saving project: {error}")
|
||||||
|
|
||||||
|
return str(output_path)
|
||||||
|
|
||||||
|
|
||||||
|
def example_2_load_and_modify_project(project_path: str):
|
||||||
|
"""
|
||||||
|
Example 2: Load an existing project and add more pages
|
||||||
|
|
||||||
|
Based on test_project_serialization.py
|
||||||
|
"""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("Example 2: Loading and Modifying a Project")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
# Load the project (from test_project_serialization.py)
|
||||||
|
print(f"\nLoading project from: {project_path}")
|
||||||
|
loaded_project, error = load_from_zip(project_path)
|
||||||
|
|
||||||
|
if not loaded_project:
|
||||||
|
print(f"Error loading project: {error}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Loaded project: {loaded_project.name}")
|
||||||
|
print(f"Pages: {len(loaded_project.pages)}")
|
||||||
|
|
||||||
|
# Create output directory
|
||||||
|
output_dir = Path(__file__).parent / "output"
|
||||||
|
|
||||||
|
# Create more sample images
|
||||||
|
for i in range(2, 4):
|
||||||
|
image_path = output_dir / f"sample_photo_{i}.jpg"
|
||||||
|
colors = ['red', 'green']
|
||||||
|
create_sample_image(str(image_path), color=colors[i-2], size=(600, 800))
|
||||||
|
|
||||||
|
# Import the image
|
||||||
|
imported_path = loaded_project.asset_manager.import_asset(str(image_path))
|
||||||
|
|
||||||
|
# Create a new page with the image (from test_project.py)
|
||||||
|
layout = PageLayout(width=210, height=297)
|
||||||
|
image = ImageData(
|
||||||
|
image_path=imported_path,
|
||||||
|
x=20.0 + i*5,
|
||||||
|
y=20.0 + i*5,
|
||||||
|
width=170.0,
|
||||||
|
height=230.0
|
||||||
|
)
|
||||||
|
layout.add_element(image)
|
||||||
|
|
||||||
|
# Add caption
|
||||||
|
caption = TextBoxData(
|
||||||
|
text_content=f"Page {i}",
|
||||||
|
font_settings={"family": "Arial", "size": 18, "color": (0, 0, 0)},
|
||||||
|
alignment="center",
|
||||||
|
x=20.0 + i*5,
|
||||||
|
y=260.0,
|
||||||
|
width=170.0,
|
||||||
|
height=25.0
|
||||||
|
)
|
||||||
|
layout.add_element(caption)
|
||||||
|
|
||||||
|
page = Page(layout=layout, page_number=i)
|
||||||
|
loaded_project.add_page(page)
|
||||||
|
print(f"Added page {i} with {len(page.layout.elements)} elements")
|
||||||
|
|
||||||
|
# Save the modified project
|
||||||
|
modified_path = output_dir / "modified_project.ppz"
|
||||||
|
print(f"\nSaving modified project to: {modified_path}")
|
||||||
|
success, error = save_to_zip(loaded_project, str(modified_path))
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print("Modified project saved successfully!")
|
||||||
|
print(f"Total pages: {len(loaded_project.pages)}")
|
||||||
|
else:
|
||||||
|
print(f"Error saving: {error}")
|
||||||
|
|
||||||
|
return str(modified_path)
|
||||||
|
|
||||||
|
|
||||||
|
def example_3_serialization_roundtrip():
|
||||||
|
"""
|
||||||
|
Example 3: Demonstrate serialization/deserialization
|
||||||
|
|
||||||
|
Based on test_models.py serialization tests
|
||||||
|
"""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("Example 3: Serialization Round-Trip")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
# Create an image element (from test_models.py)
|
||||||
|
print("\n1. Creating ImageData element...")
|
||||||
|
original_image = ImageData(
|
||||||
|
image_path="test.jpg",
|
||||||
|
x=50.0,
|
||||||
|
y=60.0,
|
||||||
|
width=300.0,
|
||||||
|
height=200.0,
|
||||||
|
rotation=15.0,
|
||||||
|
z_index=2,
|
||||||
|
crop_info=(0.1, 0.1, 0.9, 0.9)
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f" Position: {original_image.position}")
|
||||||
|
print(f" Size: {original_image.size}")
|
||||||
|
print(f" Rotation: {original_image.rotation}°")
|
||||||
|
print(f" Z-index: {original_image.z_index}")
|
||||||
|
|
||||||
|
# Serialize (from test_models.py)
|
||||||
|
print("\n2. Serializing to dictionary...")
|
||||||
|
data = original_image.serialize()
|
||||||
|
print(f" Serialized data keys: {list(data.keys())}")
|
||||||
|
print(f" Type: {data['type']}")
|
||||||
|
|
||||||
|
# Deserialize (from test_models.py)
|
||||||
|
print("\n3. Deserializing from dictionary...")
|
||||||
|
restored_image = ImageData()
|
||||||
|
restored_image.deserialize(data)
|
||||||
|
|
||||||
|
print(f" Position: {restored_image.position}")
|
||||||
|
print(f" Size: {restored_image.size}")
|
||||||
|
print(f" Rotation: {restored_image.rotation}°")
|
||||||
|
|
||||||
|
# Verify round-trip
|
||||||
|
print("\n4. Verifying round-trip...")
|
||||||
|
assert restored_image.position == original_image.position
|
||||||
|
assert restored_image.size == original_image.size
|
||||||
|
assert restored_image.rotation == original_image.rotation
|
||||||
|
assert restored_image.z_index == original_image.z_index
|
||||||
|
assert restored_image.crop_info == original_image.crop_info
|
||||||
|
print(" Round-trip successful!")
|
||||||
|
|
||||||
|
# Do the same for TextBoxData (from test_models.py)
|
||||||
|
print("\n5. Testing TextBoxData serialization...")
|
||||||
|
font_settings = {"family": "Georgia", "size": 20, "color": (255, 255, 0)}
|
||||||
|
original_text = TextBoxData(
|
||||||
|
text_content="Round Trip Test",
|
||||||
|
font_settings=font_settings,
|
||||||
|
alignment="center",
|
||||||
|
x=85.0,
|
||||||
|
y=95.0,
|
||||||
|
width=320.0,
|
||||||
|
height=120.0,
|
||||||
|
rotation=25.0,
|
||||||
|
z_index=9
|
||||||
|
)
|
||||||
|
|
||||||
|
data = original_text.serialize()
|
||||||
|
restored_text = TextBoxData()
|
||||||
|
restored_text.deserialize(data)
|
||||||
|
|
||||||
|
assert restored_text.text_content == original_text.text_content
|
||||||
|
assert restored_text.alignment == original_text.alignment
|
||||||
|
assert restored_text.position == original_text.position
|
||||||
|
print(" TextBoxData round-trip successful!")
|
||||||
|
|
||||||
|
|
||||||
|
def example_4_export_to_pdf():
|
||||||
|
"""
|
||||||
|
Example 4: Export a project to PDF
|
||||||
|
|
||||||
|
Based on pdf_exporter.py usage
|
||||||
|
"""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("Example 4: Export to PDF")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
# Create a simple project
|
||||||
|
output_dir = Path(__file__).parent / "output"
|
||||||
|
output_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
temp_dir = tempfile.mkdtemp(prefix="photo_album_pdf_")
|
||||||
|
project_folder = os.path.join(temp_dir, "pdf_export")
|
||||||
|
|
||||||
|
project = Project(name="PDF Export Example", folder_path=project_folder)
|
||||||
|
project.page_size_mm = (140, 140) # Square format
|
||||||
|
project.working_dpi = 300
|
||||||
|
project.export_dpi = 300
|
||||||
|
|
||||||
|
print(f"\nCreating project with {project.page_size_mm[0]}x{project.page_size_mm[1]}mm pages")
|
||||||
|
|
||||||
|
# Create multiple pages with different colored images
|
||||||
|
colors = ['red', 'green', 'blue', 'yellow']
|
||||||
|
for i, color in enumerate(colors, 1):
|
||||||
|
# Create sample image
|
||||||
|
image_path = output_dir / f"pdf_sample_{color}.jpg"
|
||||||
|
create_sample_image(str(image_path), color=color, size=(600, 600))
|
||||||
|
|
||||||
|
# Import and add to page
|
||||||
|
imported_path = project.asset_manager.import_asset(str(image_path))
|
||||||
|
|
||||||
|
layout = PageLayout(width=140, height=140)
|
||||||
|
image = ImageData(
|
||||||
|
image_path=imported_path,
|
||||||
|
x=10.0,
|
||||||
|
y=10.0,
|
||||||
|
width=120.0,
|
||||||
|
height=120.0
|
||||||
|
)
|
||||||
|
layout.add_element(image)
|
||||||
|
|
||||||
|
page = Page(layout=layout, page_number=i)
|
||||||
|
project.add_page(page)
|
||||||
|
|
||||||
|
print(f"Created {len(project.pages)} pages")
|
||||||
|
|
||||||
|
# Export to PDF
|
||||||
|
pdf_path = output_dir / "example_album.pdf"
|
||||||
|
print(f"\nExporting to PDF: {pdf_path}")
|
||||||
|
|
||||||
|
exporter = PDFExporter(project, export_dpi=300)
|
||||||
|
|
||||||
|
def progress_callback(current, total):
|
||||||
|
print(f" Exporting page {current}/{total}...")
|
||||||
|
|
||||||
|
success, errors = exporter.export(
|
||||||
|
output_path=str(pdf_path),
|
||||||
|
progress_callback=progress_callback
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print(f"\nPDF exported successfully!")
|
||||||
|
print(f"File size: {os.path.getsize(pdf_path) / 1024:.1f} KB")
|
||||||
|
else:
|
||||||
|
print(f"\nErrors during export:")
|
||||||
|
for error in errors:
|
||||||
|
print(f" - {error}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run all examples"""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("pyPhotoAlbum - Basic Usage Examples")
|
||||||
|
print("="*60)
|
||||||
|
print("\nThese examples demonstrate core functionality using")
|
||||||
|
print("code patterns from the pyPhotoAlbum unit tests.\n")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Example 1: Create a basic project
|
||||||
|
project_path = example_1_create_basic_project()
|
||||||
|
|
||||||
|
# Example 2: Load and modify
|
||||||
|
if project_path and os.path.exists(project_path):
|
||||||
|
example_2_load_and_modify_project(project_path)
|
||||||
|
|
||||||
|
# Example 3: Serialization
|
||||||
|
example_3_serialization_roundtrip()
|
||||||
|
|
||||||
|
# Example 4: PDF export
|
||||||
|
example_4_export_to_pdf()
|
||||||
|
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("All examples completed successfully!")
|
||||||
|
print("="*60)
|
||||||
|
print(f"\nOutput files are in: {Path(__file__).parent / 'output'}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\nError running examples: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
427
examples/generate_screenshots.py
Normal file
427
examples/generate_screenshots.py
Normal file
@ -0,0 +1,427 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Generate Documentation Screenshots
|
||||||
|
|
||||||
|
This script creates visual examples for documentation purposes:
|
||||||
|
- Sample layouts
|
||||||
|
- Template demonstrations
|
||||||
|
- Feature showcases
|
||||||
|
- UI mockups
|
||||||
|
|
||||||
|
Note: This creates programmatic representations rather than actual screenshots.
|
||||||
|
For real screenshots, run the application and use a screen capture tool.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add parent directory to path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from pyPhotoAlbum.project import Project, Page
|
||||||
|
from pyPhotoAlbum.page_layout import PageLayout
|
||||||
|
from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
|
||||||
|
from pyPhotoAlbum.project_serializer import save_to_zip
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
|
||||||
|
def create_visual_mockup(path: str, title: str, description: str, elements: list):
|
||||||
|
"""
|
||||||
|
Create a visual mockup image showing a layout
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Output path for the image
|
||||||
|
title: Title for the mockup
|
||||||
|
description: Description text
|
||||||
|
elements: List of (type, x, y, w, h, label) tuples
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
# Create canvas (A4 aspect ratio)
|
||||||
|
width, height = 800, 1132
|
||||||
|
img = Image.new('RGB', (width, height), color='white')
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# Try to load a nice font
|
||||||
|
try:
|
||||||
|
title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 32)
|
||||||
|
desc_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 18)
|
||||||
|
label_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 14)
|
||||||
|
except:
|
||||||
|
title_font = ImageFont.load_default()
|
||||||
|
desc_font = ImageFont.load_default()
|
||||||
|
label_font = ImageFont.load_default()
|
||||||
|
|
||||||
|
# Draw title
|
||||||
|
draw.text((20, 20), title, fill='black', font=title_font)
|
||||||
|
|
||||||
|
# Draw description
|
||||||
|
draw.text((20, 65), description, fill='gray', font=desc_font)
|
||||||
|
|
||||||
|
# Draw page boundary
|
||||||
|
page_margin = 50
|
||||||
|
page_top = 120
|
||||||
|
page_bottom = height - 50
|
||||||
|
page_width = width - 2 * page_margin
|
||||||
|
page_height = page_bottom - page_top
|
||||||
|
|
||||||
|
# Light gray page background
|
||||||
|
draw.rectangle(
|
||||||
|
[(page_margin, page_top), (width - page_margin, page_bottom)],
|
||||||
|
fill='#f5f5f5',
|
||||||
|
outline='#cccccc',
|
||||||
|
width=2
|
||||||
|
)
|
||||||
|
|
||||||
|
# Draw elements
|
||||||
|
colors = {
|
||||||
|
'image': '#e3f2fd', # Light blue
|
||||||
|
'text': '#fff9c4', # Light yellow
|
||||||
|
'placeholder': '#e8f5e9' # Light green
|
||||||
|
}
|
||||||
|
|
||||||
|
for elem_type, x, y, w, h, label in elements:
|
||||||
|
# Convert from mm to pixels (rough approximation)
|
||||||
|
px = page_margin + (x / 210) * page_width
|
||||||
|
py = page_top + (y / 297) * page_height
|
||||||
|
pw = (w / 210) * page_width
|
||||||
|
ph = (h / 297) * page_height
|
||||||
|
|
||||||
|
# Draw element
|
||||||
|
color = colors.get(elem_type, '#eeeeee')
|
||||||
|
draw.rectangle(
|
||||||
|
[(px, py), (px + pw, py + ph)],
|
||||||
|
fill=color,
|
||||||
|
outline='#666666',
|
||||||
|
width=2
|
||||||
|
)
|
||||||
|
|
||||||
|
# Draw label
|
||||||
|
if label:
|
||||||
|
label_bbox = draw.textbbox((0, 0), label, font=label_font)
|
||||||
|
label_w = label_bbox[2] - label_bbox[0]
|
||||||
|
label_h = label_bbox[3] - label_bbox[1]
|
||||||
|
label_x = px + (pw - label_w) / 2
|
||||||
|
label_y = py + (ph - label_h) / 2
|
||||||
|
draw.text((label_x, label_y), label, fill='#333333', font=label_font)
|
||||||
|
|
||||||
|
# Save
|
||||||
|
img.save(path)
|
||||||
|
print(f" Created: {path}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Error creating mockup: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def generate_layout_examples():
|
||||||
|
"""Generate example layout screenshots"""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("Generating Layout Examples")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
output_dir = Path(__file__).parent / "screenshots"
|
||||||
|
output_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Example 1: Single large image
|
||||||
|
print("\n1. Single Large Image Layout")
|
||||||
|
create_visual_mockup(
|
||||||
|
str(output_dir / "layout_single_large.png"),
|
||||||
|
"Single Large Image Layout",
|
||||||
|
"One large image with caption",
|
||||||
|
[
|
||||||
|
('image', 10, 10, 190, 250, 'Large Image'),
|
||||||
|
('text', 10, 270, 190, 20, 'Caption Text')
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Example 2: Grid 2x2
|
||||||
|
print("\n2. Grid 2x2 Layout")
|
||||||
|
create_visual_mockup(
|
||||||
|
str(output_dir / "layout_grid_2x2.png"),
|
||||||
|
"Grid 2x2 Layout",
|
||||||
|
"Four images in a 2x2 grid",
|
||||||
|
[
|
||||||
|
('image', 10, 10, 95, 95, 'Image 1'),
|
||||||
|
('image', 105, 10, 95, 95, 'Image 2'),
|
||||||
|
('image', 10, 110, 95, 95, 'Image 3'),
|
||||||
|
('image', 105, 110, 95, 95, 'Image 4')
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Example 3: Mixed layout
|
||||||
|
print("\n3. Mixed Layout")
|
||||||
|
create_visual_mockup(
|
||||||
|
str(output_dir / "layout_mixed.png"),
|
||||||
|
"Mixed Layout",
|
||||||
|
"Combination of images and text boxes",
|
||||||
|
[
|
||||||
|
('image', 10, 10, 190, 120, 'Header Image'),
|
||||||
|
('text', 10, 140, 190, 25, 'Title'),
|
||||||
|
('image', 10, 175, 90, 90, 'Image 1'),
|
||||||
|
('image', 110, 175, 90, 90, 'Image 2'),
|
||||||
|
('text', 10, 270, 190, 15, 'Footer')
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Example 4: Template with placeholders
|
||||||
|
print("\n4. Template with Placeholders")
|
||||||
|
create_visual_mockup(
|
||||||
|
str(output_dir / "layout_template.png"),
|
||||||
|
"Template Layout",
|
||||||
|
"Page template with placeholder blocks",
|
||||||
|
[
|
||||||
|
('placeholder', 10, 10, 190, 140, 'Drop Image Here'),
|
||||||
|
('placeholder', 10, 160, 90, 90, 'Image'),
|
||||||
|
('placeholder', 110, 160, 90, 90, 'Image'),
|
||||||
|
('text', 10, 260, 190, 25, 'Text Box')
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_feature_examples():
|
||||||
|
"""Generate feature demonstration screenshots"""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("Generating Feature Examples")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
output_dir = Path(__file__).parent / "screenshots"
|
||||||
|
output_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Alignment demonstration
|
||||||
|
print("\n1. Alignment Features")
|
||||||
|
create_visual_mockup(
|
||||||
|
str(output_dir / "feature_alignment.png"),
|
||||||
|
"Alignment Tools",
|
||||||
|
"Align left, center, right, and distribute",
|
||||||
|
[
|
||||||
|
('image', 10, 10, 50, 50, 'Img 1'),
|
||||||
|
('image', 70, 10, 50, 50, 'Img 2'),
|
||||||
|
('image', 130, 10, 50, 50, 'Img 3'),
|
||||||
|
('text', 10, 80, 170, 15, 'Aligned and distributed horizontally')
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sizing demonstration
|
||||||
|
print("\n2. Sizing Features")
|
||||||
|
create_visual_mockup(
|
||||||
|
str(output_dir / "feature_sizing.png"),
|
||||||
|
"Sizing Tools",
|
||||||
|
"Make same size, width, or height",
|
||||||
|
[
|
||||||
|
('image', 10, 10, 80, 80, 'Same Size'),
|
||||||
|
('image', 100, 10, 80, 80, 'Same Size'),
|
||||||
|
('image', 10, 100, 80, 60, 'Same Width'),
|
||||||
|
('image', 100, 100, 80, 80, 'Same Width')
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Z-order demonstration
|
||||||
|
print("\n3. Z-Order (Layering)")
|
||||||
|
create_visual_mockup(
|
||||||
|
str(output_dir / "feature_zorder.png"),
|
||||||
|
"Z-Order / Layering",
|
||||||
|
"Elements can be ordered in layers",
|
||||||
|
[
|
||||||
|
('image', 30, 30, 100, 100, 'Background'),
|
||||||
|
('image', 60, 60, 100, 100, 'Middle'),
|
||||||
|
('image', 90, 90, 100, 100, 'Foreground')
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_workflow_example():
|
||||||
|
"""Generate a complete workflow example"""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("Generating Workflow Example")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
output_dir = Path(__file__).parent / "screenshots"
|
||||||
|
output_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Step 1: Empty page
|
||||||
|
print("\n1. Start with empty page")
|
||||||
|
create_visual_mockup(
|
||||||
|
str(output_dir / "workflow_01_empty.png"),
|
||||||
|
"Step 1: Empty Page",
|
||||||
|
"Start with a blank page",
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 2: Apply template
|
||||||
|
print("\n2. Apply template")
|
||||||
|
create_visual_mockup(
|
||||||
|
str(output_dir / "workflow_02_template.png"),
|
||||||
|
"Step 2: Apply Template",
|
||||||
|
"Add placeholder blocks using a template",
|
||||||
|
[
|
||||||
|
('placeholder', 10, 10, 90, 90, 'Drop Here'),
|
||||||
|
('placeholder', 110, 10, 90, 90, 'Drop Here'),
|
||||||
|
('placeholder', 10, 110, 90, 90, 'Drop Here'),
|
||||||
|
('placeholder', 110, 110, 90, 90, 'Drop Here')
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 3: Add images
|
||||||
|
print("\n3. Add images")
|
||||||
|
create_visual_mockup(
|
||||||
|
str(output_dir / "workflow_03_images.png"),
|
||||||
|
"Step 3: Add Images",
|
||||||
|
"Drag and drop images onto placeholders",
|
||||||
|
[
|
||||||
|
('image', 10, 10, 90, 90, 'Photo 1'),
|
||||||
|
('image', 110, 10, 90, 90, 'Photo 2'),
|
||||||
|
('image', 10, 110, 90, 90, 'Photo 3'),
|
||||||
|
('placeholder', 110, 110, 90, 90, 'Drop Here')
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 4: Final layout
|
||||||
|
print("\n4. Final layout")
|
||||||
|
create_visual_mockup(
|
||||||
|
str(output_dir / "workflow_04_final.png"),
|
||||||
|
"Step 4: Final Layout",
|
||||||
|
"Complete page with all images and text",
|
||||||
|
[
|
||||||
|
('image', 10, 10, 90, 90, 'Photo 1'),
|
||||||
|
('image', 110, 10, 90, 90, 'Photo 2'),
|
||||||
|
('image', 10, 110, 90, 90, 'Photo 3'),
|
||||||
|
('image', 110, 110, 90, 90, 'Photo 4'),
|
||||||
|
('text', 10, 210, 190, 20, 'My Photo Album - Summer 2024')
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_template_examples():
|
||||||
|
"""Generate template system examples"""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("Generating Template Examples")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
output_dir = Path(__file__).parent / "screenshots"
|
||||||
|
output_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Grid template
|
||||||
|
print("\n1. Grid_2x2 Template")
|
||||||
|
create_visual_mockup(
|
||||||
|
str(output_dir / "template_grid_2x2.png"),
|
||||||
|
"Grid_2x2 Template",
|
||||||
|
"Built-in 2x2 grid template",
|
||||||
|
[
|
||||||
|
('placeholder', 10, 10, 95, 133, ''),
|
||||||
|
('placeholder', 105, 10, 95, 133, ''),
|
||||||
|
('placeholder', 10, 143, 95, 134, ''),
|
||||||
|
('placeholder', 105, 143, 95, 134, '')
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Single large template
|
||||||
|
print("\n2. Single_Large Template")
|
||||||
|
create_visual_mockup(
|
||||||
|
str(output_dir / "template_single_large.png"),
|
||||||
|
"Single_Large Template",
|
||||||
|
"Built-in single large image template",
|
||||||
|
[
|
||||||
|
('placeholder', 10, 10, 190, 240, 'Large Image'),
|
||||||
|
('text', 10, 260, 190, 25, 'Title')
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_project_structure_diagram():
|
||||||
|
"""Generate project structure visualization"""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("Generating Project Structure Diagram")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
output_dir = Path(__file__).parent / "screenshots"
|
||||||
|
output_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
width, height = 800, 600
|
||||||
|
img = Image.new('RGB', (width, height), color='white')
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
try:
|
||||||
|
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 16)
|
||||||
|
title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 20)
|
||||||
|
except:
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
title_font = ImageFont.load_default()
|
||||||
|
|
||||||
|
# Title
|
||||||
|
draw.text((20, 20), "pyPhotoAlbum Project Structure", fill='black', font=title_font)
|
||||||
|
|
||||||
|
# Draw structure
|
||||||
|
structure = [
|
||||||
|
(50, "Project", 60),
|
||||||
|
(100, "├─ Page 1", 100),
|
||||||
|
(150, "│ ├─ PageLayout", 140),
|
||||||
|
(200, "│ │ ├─ ImageData", 180),
|
||||||
|
(250, "│ │ ├─ TextBoxData", 220),
|
||||||
|
(300, "│ │ └─ PlaceholderData", 260),
|
||||||
|
(100, "├─ Page 2", 320),
|
||||||
|
(100, "└─ AssetManager", 360),
|
||||||
|
(150, " └─ assets/", 400),
|
||||||
|
]
|
||||||
|
|
||||||
|
for x, text, y in structure:
|
||||||
|
draw.text((x, y), text, fill='#333333', font=font)
|
||||||
|
|
||||||
|
# Draw connecting lines (simplified)
|
||||||
|
draw.line([(60, 90), (60, 380)], fill='#666666', width=2)
|
||||||
|
draw.line([(110, 130), (110, 280)], fill='#666666', width=2)
|
||||||
|
|
||||||
|
path = output_dir / "project_structure.png"
|
||||||
|
img.save(str(path))
|
||||||
|
print(f" Created: {path}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Error creating diagram: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Generate all documentation screenshots"""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("pyPhotoAlbum - Generate Documentation Screenshots")
|
||||||
|
print("="*60)
|
||||||
|
print("\nThis script creates visual examples for documentation.")
|
||||||
|
print("For actual application screenshots, run the app and")
|
||||||
|
print("use a screen capture tool.\n")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create output directory
|
||||||
|
output_dir = Path(__file__).parent / "screenshots"
|
||||||
|
output_dir.mkdir(exist_ok=True)
|
||||||
|
print(f"Output directory: {output_dir}")
|
||||||
|
|
||||||
|
# Generate examples
|
||||||
|
generate_layout_examples()
|
||||||
|
generate_feature_examples()
|
||||||
|
generate_workflow_example()
|
||||||
|
generate_template_examples()
|
||||||
|
generate_project_structure_diagram()
|
||||||
|
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("Screenshot generation complete!")
|
||||||
|
print("="*60)
|
||||||
|
print(f"\nGenerated files are in: {output_dir}")
|
||||||
|
print("\nThese mockups can be used in documentation to illustrate:")
|
||||||
|
print(" - Page layouts")
|
||||||
|
print(" - Template system")
|
||||||
|
print(" - Feature demonstrations")
|
||||||
|
print(" - Workflow examples")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\nError generating screenshots: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
437
examples/template_example.py
Normal file
437
examples/template_example.py
Normal file
@ -0,0 +1,437 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Template System Example for pyPhotoAlbum
|
||||||
|
|
||||||
|
This example demonstrates:
|
||||||
|
- Creating templates from existing pages
|
||||||
|
- Using built-in templates
|
||||||
|
- Applying templates to new pages
|
||||||
|
- Template scaling modes
|
||||||
|
- Saving and loading custom templates
|
||||||
|
|
||||||
|
Based on template_manager.py and TEMPLATES_README.md
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add parent directory to path to import pyPhotoAlbum
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from pyPhotoAlbum.project import Project, Page
|
||||||
|
from pyPhotoAlbum.page_layout import PageLayout
|
||||||
|
from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
|
||||||
|
from pyPhotoAlbum.template_manager import TemplateManager, Template
|
||||||
|
from pyPhotoAlbum.project_serializer import save_to_zip
|
||||||
|
|
||||||
|
|
||||||
|
def create_sample_image(path: str, color: str = 'blue', size: tuple = (400, 300)):
|
||||||
|
"""Create a sample image for testing"""
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
img = Image.new('RGB', size, color=color)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# Draw a border
|
||||||
|
border_width = 10
|
||||||
|
draw.rectangle(
|
||||||
|
[(border_width, border_width), (size[0]-border_width, size[1]-border_width)],
|
||||||
|
outline='white',
|
||||||
|
width=5
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add text
|
||||||
|
text = f"{color.upper()}"
|
||||||
|
try:
|
||||||
|
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 48)
|
||||||
|
except:
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
|
||||||
|
bbox = draw.textbbox((0, 0), text, font=font)
|
||||||
|
text_width = bbox[2] - bbox[0]
|
||||||
|
text_height = bbox[3] - bbox[1]
|
||||||
|
x = (size[0] - text_width) // 2
|
||||||
|
y = (size[1] - text_height) // 2
|
||||||
|
|
||||||
|
draw.text((x, y), text, fill='white', font=font)
|
||||||
|
img.save(path)
|
||||||
|
print(f" Created: {path}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Could not create image: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def example_1_list_templates():
|
||||||
|
"""
|
||||||
|
Example 1: List available templates
|
||||||
|
|
||||||
|
From template_manager.py
|
||||||
|
"""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("Example 1: Listing Available Templates")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
manager = TemplateManager()
|
||||||
|
|
||||||
|
print("\nAvailable templates:")
|
||||||
|
templates = manager.list_templates()
|
||||||
|
for i, template_name in enumerate(templates, 1):
|
||||||
|
print(f" {i}. {template_name}")
|
||||||
|
|
||||||
|
# Load and display info about each template
|
||||||
|
print("\nTemplate Details:")
|
||||||
|
for template_name in templates[:2]: # Show first 2 templates
|
||||||
|
try:
|
||||||
|
template = manager.load_template(template_name)
|
||||||
|
print(f"\n {template.name}:")
|
||||||
|
print(f" Description: {template.description}")
|
||||||
|
print(f" Page Size: {template.page_size_mm[0]}x{template.page_size_mm[1]} mm")
|
||||||
|
print(f" Elements: {len(template.elements)}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Error loading: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def example_2_create_page_from_template():
|
||||||
|
"""
|
||||||
|
Example 2: Create a new page from a built-in template
|
||||||
|
|
||||||
|
From template_manager.py - create_page_from_template method
|
||||||
|
"""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("Example 2: Creating Page from Template")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
manager = TemplateManager()
|
||||||
|
output_dir = Path(__file__).parent / "output"
|
||||||
|
output_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Create a project
|
||||||
|
temp_dir = tempfile.mkdtemp(prefix="template_example_")
|
||||||
|
project_folder = os.path.join(temp_dir, "template_project")
|
||||||
|
|
||||||
|
project = Project(name="Template Example", folder_path=project_folder)
|
||||||
|
project.page_size_mm = (210, 297) # A4
|
||||||
|
project.working_dpi = 300
|
||||||
|
|
||||||
|
print(f"\nCreated project: {project.name}")
|
||||||
|
print(f"Page size: {project.page_size_mm[0]}x{project.page_size_mm[1]} mm")
|
||||||
|
|
||||||
|
# Create a page from Grid_2x2 template
|
||||||
|
print("\nCreating page from 'Grid_2x2' template...")
|
||||||
|
page = manager.create_page_from_template(
|
||||||
|
template_name="Grid_2x2",
|
||||||
|
target_page_size=project.page_size_mm,
|
||||||
|
page_number=1
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f" Created page with {len(page.layout.elements)} placeholder elements")
|
||||||
|
|
||||||
|
# Show details of placeholders
|
||||||
|
for i, element in enumerate(page.layout.elements, 1):
|
||||||
|
if isinstance(element, PlaceholderData):
|
||||||
|
print(f" Placeholder {i}: position={element.position}, size={element.size}")
|
||||||
|
|
||||||
|
project.add_page(page)
|
||||||
|
|
||||||
|
# Now replace placeholders with actual images
|
||||||
|
print("\nReplacing placeholders with images...")
|
||||||
|
colors = ['red', 'green', 'blue', 'yellow']
|
||||||
|
|
||||||
|
for i, element in enumerate(page.layout.elements):
|
||||||
|
if isinstance(element, PlaceholderData) and i < len(colors):
|
||||||
|
# Create sample image
|
||||||
|
image_path = output_dir / f"grid_image_{colors[i]}.jpg"
|
||||||
|
create_sample_image(str(image_path), color=colors[i], size=(600, 600))
|
||||||
|
|
||||||
|
# Import to project
|
||||||
|
imported_path = project.asset_manager.import_asset(str(image_path))
|
||||||
|
|
||||||
|
# Replace placeholder with image
|
||||||
|
image = ImageData(
|
||||||
|
image_path=imported_path,
|
||||||
|
x=element.position[0],
|
||||||
|
y=element.position[1],
|
||||||
|
width=element.size[0],
|
||||||
|
height=element.size[1],
|
||||||
|
z_index=element.z_index
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove placeholder and add image
|
||||||
|
page.layout.remove_element(element)
|
||||||
|
page.layout.add_element(image)
|
||||||
|
print(f" Replaced placeholder {i+1} with {colors[i]} image")
|
||||||
|
|
||||||
|
# Save project
|
||||||
|
project_path = output_dir / "template_project.ppz"
|
||||||
|
print(f"\nSaving project to: {project_path}")
|
||||||
|
success, error = save_to_zip(project, str(project_path))
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print(" Project saved successfully!")
|
||||||
|
else:
|
||||||
|
print(f" Error: {error}")
|
||||||
|
|
||||||
|
|
||||||
|
def example_3_create_custom_template():
|
||||||
|
"""
|
||||||
|
Example 3: Create a custom template from a page
|
||||||
|
|
||||||
|
From template_manager.py - create_template_from_page method
|
||||||
|
"""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("Example 3: Creating Custom Template")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
manager = TemplateManager()
|
||||||
|
output_dir = Path(__file__).parent / "output"
|
||||||
|
output_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Create a page with a custom layout
|
||||||
|
print("\nDesigning custom page layout...")
|
||||||
|
layout = PageLayout(width=210, height=297)
|
||||||
|
|
||||||
|
# Large image at top
|
||||||
|
top_image = ImageData(
|
||||||
|
image_path="dummy.jpg", # Will be converted to placeholder
|
||||||
|
x=10.0,
|
||||||
|
y=10.0,
|
||||||
|
width=190.0,
|
||||||
|
height=140.0,
|
||||||
|
z_index=0
|
||||||
|
)
|
||||||
|
layout.add_element(top_image)
|
||||||
|
print(" Added large top image")
|
||||||
|
|
||||||
|
# Two smaller images at bottom
|
||||||
|
bottom_left = ImageData(
|
||||||
|
image_path="dummy.jpg",
|
||||||
|
x=10.0,
|
||||||
|
y=160.0,
|
||||||
|
width=90.0,
|
||||||
|
height=90.0,
|
||||||
|
z_index=0
|
||||||
|
)
|
||||||
|
layout.add_element(bottom_left)
|
||||||
|
|
||||||
|
bottom_right = ImageData(
|
||||||
|
image_path="dummy.jpg",
|
||||||
|
x=110.0,
|
||||||
|
y=160.0,
|
||||||
|
width=90.0,
|
||||||
|
height=90.0,
|
||||||
|
z_index=0
|
||||||
|
)
|
||||||
|
layout.add_element(bottom_right)
|
||||||
|
print(" Added two smaller bottom images")
|
||||||
|
|
||||||
|
# Title text box
|
||||||
|
title = TextBoxData(
|
||||||
|
text_content="Title",
|
||||||
|
font_settings={"family": "Arial", "size": 20, "color": (0, 0, 0)},
|
||||||
|
alignment="center",
|
||||||
|
x=10.0,
|
||||||
|
y=260.0,
|
||||||
|
width=190.0,
|
||||||
|
height=30.0,
|
||||||
|
z_index=1
|
||||||
|
)
|
||||||
|
layout.add_element(title)
|
||||||
|
print(" Added title text box")
|
||||||
|
|
||||||
|
# Create page
|
||||||
|
page = Page(layout=layout, page_number=1)
|
||||||
|
|
||||||
|
# Create template from page
|
||||||
|
print("\nCreating template from page...")
|
||||||
|
template = manager.create_template_from_page(
|
||||||
|
page=page,
|
||||||
|
name="Custom_Large_Plus_Two",
|
||||||
|
description="One large image at top, two smaller at bottom, with title"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f" Template created: {template.name}")
|
||||||
|
print(f" Description: {template.description}")
|
||||||
|
print(f" Elements: {len(template.elements)}")
|
||||||
|
|
||||||
|
# Save template
|
||||||
|
print("\nSaving custom template...")
|
||||||
|
try:
|
||||||
|
manager.save_template(template)
|
||||||
|
print(f" Template saved successfully!")
|
||||||
|
print(f" Location: {manager._get_templates_directory()}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Error saving template: {e}")
|
||||||
|
|
||||||
|
# Verify it's in the list
|
||||||
|
templates = manager.list_templates()
|
||||||
|
if template.name in templates:
|
||||||
|
print(f" Verified: Template appears in list")
|
||||||
|
|
||||||
|
|
||||||
|
def example_4_template_scaling_modes():
|
||||||
|
"""
|
||||||
|
Example 4: Demonstrate different template scaling modes
|
||||||
|
|
||||||
|
From template_manager.py - scale_template_elements method
|
||||||
|
"""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("Example 4: Template Scaling Modes")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
manager = TemplateManager()
|
||||||
|
|
||||||
|
# Create a simple template
|
||||||
|
print("\nCreating test template (100x100mm square)...")
|
||||||
|
template = Template(name="Test Square", page_size_mm=(100, 100))
|
||||||
|
|
||||||
|
# Add a centered square element
|
||||||
|
element = PlaceholderData(
|
||||||
|
x=25.0, y=25.0,
|
||||||
|
width=50.0, height=50.0
|
||||||
|
)
|
||||||
|
template.add_element(element)
|
||||||
|
|
||||||
|
print(f" Original element: position={element.position}, size={element.size}")
|
||||||
|
|
||||||
|
# Test different target sizes
|
||||||
|
target_sizes = [
|
||||||
|
(200, 200, "Same aspect ratio (2x)"),
|
||||||
|
(200, 100, "Wider (2x wide, 1x tall)"),
|
||||||
|
(100, 200, "Taller (1x wide, 2x tall)")
|
||||||
|
]
|
||||||
|
|
||||||
|
scaling_modes = ["proportional", "stretch", "center"]
|
||||||
|
|
||||||
|
for target_w, target_h, desc in target_sizes:
|
||||||
|
print(f"\nTarget size: {target_w}x{target_h}mm ({desc})")
|
||||||
|
|
||||||
|
for mode in scaling_modes:
|
||||||
|
scaled = manager.scale_template_elements(
|
||||||
|
template.elements.copy(),
|
||||||
|
source_size=template.page_size_mm,
|
||||||
|
target_size=(target_w, target_h),
|
||||||
|
scaling=mode
|
||||||
|
)
|
||||||
|
|
||||||
|
if scaled:
|
||||||
|
scaled_element = scaled[0]
|
||||||
|
print(f" {mode:12s}: position={scaled_element.position}, size={scaled_element.size}")
|
||||||
|
|
||||||
|
|
||||||
|
def example_5_apply_template_modes():
|
||||||
|
"""
|
||||||
|
Example 5: Apply template to existing page with different modes
|
||||||
|
|
||||||
|
From template_manager.py - apply_template_to_page method
|
||||||
|
"""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("Example 5: Applying Template to Existing Page")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
manager = TemplateManager()
|
||||||
|
output_dir = Path(__file__).parent / "output"
|
||||||
|
output_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Create a page with some existing content
|
||||||
|
print("\nCreating page with existing images...")
|
||||||
|
temp_dir = tempfile.mkdtemp(prefix="template_apply_")
|
||||||
|
project_folder = os.path.join(temp_dir, "apply_project")
|
||||||
|
|
||||||
|
project = Project(name="Apply Template Test", folder_path=project_folder)
|
||||||
|
project.page_size_mm = (210, 297)
|
||||||
|
|
||||||
|
layout = PageLayout(width=210, height=297)
|
||||||
|
|
||||||
|
# Add some images
|
||||||
|
colors = ['red', 'green', 'blue']
|
||||||
|
for i, color in enumerate(colors):
|
||||||
|
image_path = output_dir / f"apply_{color}.jpg"
|
||||||
|
create_sample_image(str(image_path), color=color, size=(600, 400))
|
||||||
|
imported_path = project.asset_manager.import_asset(str(image_path))
|
||||||
|
|
||||||
|
image = ImageData(
|
||||||
|
image_path=imported_path,
|
||||||
|
x=10.0 + i*20,
|
||||||
|
y=10.0 + i*30,
|
||||||
|
width=100.0,
|
||||||
|
height=100.0
|
||||||
|
)
|
||||||
|
layout.add_element(image)
|
||||||
|
|
||||||
|
page = Page(layout=layout, page_number=1)
|
||||||
|
project.add_page(page)
|
||||||
|
|
||||||
|
print(f" Created page with {len(page.layout.elements)} images")
|
||||||
|
|
||||||
|
# Apply Grid_2x2 template with "reflow" mode
|
||||||
|
print("\nApplying Grid_2x2 template with 'reflow' mode...")
|
||||||
|
print(" This will reposition existing images to fit template slots")
|
||||||
|
|
||||||
|
try:
|
||||||
|
template = manager.load_template("Grid_2x2")
|
||||||
|
manager.apply_template_to_page(
|
||||||
|
template=template,
|
||||||
|
target_page=page,
|
||||||
|
mode="reflow", # Keep existing images, reposition them
|
||||||
|
scaling="proportional"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f" Template applied! Page now has {len(page.layout.elements)} elements")
|
||||||
|
|
||||||
|
# Show new positions
|
||||||
|
for i, element in enumerate(page.layout.elements, 1):
|
||||||
|
print(f" Element {i}: position={element.position}, size={element.size}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Error applying template: {e}")
|
||||||
|
|
||||||
|
# Save result
|
||||||
|
project_path = output_dir / "template_applied.ppz"
|
||||||
|
print(f"\nSaving result to: {project_path}")
|
||||||
|
success, error = save_to_zip(project, str(project_path))
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print(" Saved successfully!")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run all template examples"""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("pyPhotoAlbum - Template System Examples")
|
||||||
|
print("="*60)
|
||||||
|
print("\nDemonstrating the template system using code from")
|
||||||
|
print("template_manager.py and TEMPLATES_README.md\n")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Example 1: List templates
|
||||||
|
example_1_list_templates()
|
||||||
|
|
||||||
|
# Example 2: Create page from template
|
||||||
|
example_2_create_page_from_template()
|
||||||
|
|
||||||
|
# Example 3: Create custom template
|
||||||
|
example_3_create_custom_template()
|
||||||
|
|
||||||
|
# Example 4: Scaling modes
|
||||||
|
example_4_template_scaling_modes()
|
||||||
|
|
||||||
|
# Example 5: Apply template
|
||||||
|
example_5_apply_template_modes()
|
||||||
|
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("All template examples completed!")
|
||||||
|
print("="*60)
|
||||||
|
print(f"\nOutput files are in: {Path(__file__).parent / 'output'}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\nError running examples: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
153
pyPhotoAlbum/README.md
Normal file
153
pyPhotoAlbum/README.md
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
# pyPhotoAlbum
|
||||||
|
|
||||||
|
A Python (PyQt6) application for designing photo albums and exporting them to PDF.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Core Features
|
||||||
|
- [x] Basic application structure with OpenGL rendering
|
||||||
|
- [x] Menu system (File, Edit, View)
|
||||||
|
- [x] Toolbar and status bar
|
||||||
|
- [x] Page layout controls (custom sizes, DPI settings, page management)
|
||||||
|
- [x] Template pages with grid layouts
|
||||||
|
- [x] Image drag-and-drop from file explorer
|
||||||
|
- [x] Image auto-scaling and center-crop fitting
|
||||||
|
- [x] Image rendering with OpenGL textures
|
||||||
|
- [x] Object selection, moving, and resizing
|
||||||
|
- [x] Mouse wheel zoom (10%-500%)
|
||||||
|
- [ ] Interactive cropping with constrained movement
|
||||||
|
- [ ] Text box support with rotation
|
||||||
|
- [ ] Undo/redo functionality
|
||||||
|
- [ ] PDF export
|
||||||
|
- [ ] XML project save/load
|
||||||
|
|
||||||
|
### Additional Features
|
||||||
|
- [ ] Grid cell merging for spanning photos
|
||||||
|
- [x] Double-page spread flag (rendering not yet implemented)
|
||||||
|
- [ ] Default minimum distance between images
|
||||||
|
- [x] Page numbering system
|
||||||
|
- [x] Add/Remove pages dynamically
|
||||||
|
- [ ] Background color/pattern options
|
||||||
|
- [ ] Theme presets
|
||||||
|
|
||||||
|
## Technical Stack
|
||||||
|
- Python 3.9+
|
||||||
|
- PyQt6 for GUI
|
||||||
|
- OpenGL for rendering
|
||||||
|
- ReportLab for PDF generation
|
||||||
|
- lxml for XML serialization
|
||||||
|
- Pillow for image processing
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### For Users
|
||||||
|
|
||||||
|
Install the package using pip:
|
||||||
|
```bash
|
||||||
|
pip install .
|
||||||
|
```
|
||||||
|
|
||||||
|
Or for an editable installation (development):
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
After installation, you can run the application with:
|
||||||
|
```bash
|
||||||
|
pyphotoalbum
|
||||||
|
```
|
||||||
|
|
||||||
|
### For Developers
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
```bash
|
||||||
|
git clone <your-repo-url>
|
||||||
|
cd pyPhotoAlbum
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create virtual environment:
|
||||||
|
```bash
|
||||||
|
python -m venv venv
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Activate virtual environment:
|
||||||
|
- Windows: `venv\Scripts\activate`
|
||||||
|
- Linux/macOS: `source venv/bin/activate`
|
||||||
|
|
||||||
|
4. Install in development mode with dev dependencies:
|
||||||
|
```bash
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running the Application
|
||||||
|
|
||||||
|
After installation, run:
|
||||||
|
```bash
|
||||||
|
pyphotoalbum
|
||||||
|
```
|
||||||
|
|
||||||
|
Or, for development, you can still run directly:
|
||||||
|
```bash
|
||||||
|
python pyPhotoAlbum/main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
Run all tests with coverage:
|
||||||
|
```bash
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
Run tests with verbose output:
|
||||||
|
```bash
|
||||||
|
pytest -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Run specific test file:
|
||||||
|
```bash
|
||||||
|
pytest tests/test_models.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Run tests with coverage report:
|
||||||
|
```bash
|
||||||
|
pytest --cov=pyPhotoAlbum --cov-report=html
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `htmlcov/index.html` in your browser to view the coverage report.
|
||||||
|
|
||||||
|
### Continuous Integration
|
||||||
|
|
||||||
|
The project uses Gitea Actions for CI/CD:
|
||||||
|
- **Tests**: Runs on Python 3.9, 3.10, and 3.11 on every push
|
||||||
|
- **Linting**: Checks code quality with flake8, black, and mypy
|
||||||
|
|
||||||
|
View workflow status in your Gitea repository's Actions tab.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
pyPhotoAlbum/
|
||||||
|
├── main.py # Main application entry point
|
||||||
|
├── requirements.txt # Python dependencies
|
||||||
|
└── README.md # Project documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Roadmap
|
||||||
|
|
||||||
|
1. **Phase 1: Core Functionality**
|
||||||
|
- Complete page layout controls
|
||||||
|
- Implement template system
|
||||||
|
- Add image handling capabilities
|
||||||
|
- Implement text box support
|
||||||
|
|
||||||
|
2. **Phase 2: Advanced Features**
|
||||||
|
- Add undo/redo functionality
|
||||||
|
- Implement PDF export
|
||||||
|
- Add XML project serialization
|
||||||
|
|
||||||
|
3. **Phase 3: Polish and Optimization**
|
||||||
|
- Optimize rendering performance
|
||||||
|
- Add additional UI polish
|
||||||
|
- Implement comprehensive testing
|
||||||
119
pyPhotoAlbum/TEMPLATES_README.md
Normal file
119
pyPhotoAlbum/TEMPLATES_README.md
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
# Template System Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The template system allows you to create reusable page layouts with placeholder blocks. Templates are saved as JSON files and can be applied to new or existing pages with flexible scaling options.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 1. Save Page as Template
|
||||||
|
- Converts all images on the current page to placeholder blocks
|
||||||
|
- Preserves positions, sizes, and layouts
|
||||||
|
- Keeps text boxes and existing placeholders
|
||||||
|
- Saves template as JSON file
|
||||||
|
|
||||||
|
### 2. Create Page from Template
|
||||||
|
- Creates a new page with the template layout
|
||||||
|
- Automatically scales to match project page size
|
||||||
|
- All elements are placeholders ready for images
|
||||||
|
|
||||||
|
### 3. Apply Template to Existing Page
|
||||||
|
- Two modes:
|
||||||
|
- **Replace**: Clears page and adds template placeholders
|
||||||
|
- **Reflow**: Repositions existing images to fit template slots
|
||||||
|
- Three scaling options:
|
||||||
|
- **Proportional**: Maintains aspect ratio (recommended)
|
||||||
|
- **Stretch**: Stretches to fit entire page
|
||||||
|
- **Center**: No scaling, centers template
|
||||||
|
|
||||||
|
## How to Use
|
||||||
|
|
||||||
|
### Creating a Template
|
||||||
|
|
||||||
|
1. Design a page with your desired layout (images, text, placeholders)
|
||||||
|
2. Go to **Layout** tab → **Templates** group
|
||||||
|
3. Click **Save as Template**
|
||||||
|
4. Enter a name and optional description
|
||||||
|
5. Template is saved to `~/.pyphotoalbum/templates/`
|
||||||
|
|
||||||
|
### Using a Template
|
||||||
|
|
||||||
|
**To create a new page:**
|
||||||
|
1. Click **New from Template**
|
||||||
|
2. Select a template from the list
|
||||||
|
3. New page is created with placeholder blocks
|
||||||
|
4. Drag and drop images onto placeholders
|
||||||
|
|
||||||
|
**To apply to current page:**
|
||||||
|
1. Click **Apply Template**
|
||||||
|
2. Select template and options:
|
||||||
|
- Mode: Replace or Reflow
|
||||||
|
- Scaling: Proportional, Stretch, or Center
|
||||||
|
3. Click Apply
|
||||||
|
|
||||||
|
## Template Storage
|
||||||
|
|
||||||
|
- **User templates**: `~/.pyphotoalbum/templates/` (your custom templates)
|
||||||
|
- **Built-in templates**: `pyPhotoAlbum/templates/` (shipped with app)
|
||||||
|
- `Grid_2x2`: 2x2 grid layout
|
||||||
|
- `Single_Large`: Single large image with title
|
||||||
|
|
||||||
|
## Template Format
|
||||||
|
|
||||||
|
Templates are JSON files with this structure:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Template Name",
|
||||||
|
"description": "Description",
|
||||||
|
"page_size_mm": [210, 297],
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"type": "placeholder",
|
||||||
|
"position": [x, y],
|
||||||
|
"size": [width, height],
|
||||||
|
"rotation": 0,
|
||||||
|
"z_index": 0,
|
||||||
|
"placeholder_type": "image",
|
||||||
|
"default_content": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- Templates scale automatically when page sizes differ
|
||||||
|
- Use **Proportional** scaling to prevent distortion
|
||||||
|
- **Reflow** mode is great for applying new layouts to existing content
|
||||||
|
- Create templates for common layouts you use frequently
|
||||||
|
- Built-in templates provide good starting points
|
||||||
|
|
||||||
|
## Workflow Example
|
||||||
|
|
||||||
|
1. Create a page with 4 images arranged in a grid
|
||||||
|
2. Save it as "My Grid" template
|
||||||
|
3. Later, create new page from "My Grid" template
|
||||||
|
4. Drag your photos onto the placeholder blocks
|
||||||
|
5. Repeat for multiple pages with consistent layout
|
||||||
|
|
||||||
|
## Advanced
|
||||||
|
|
||||||
|
### Scaling Behavior
|
||||||
|
|
||||||
|
When template and target page sizes differ:
|
||||||
|
|
||||||
|
- **Proportional**: `scale = min(width_ratio, height_ratio)`
|
||||||
|
- Maintains aspect ratio
|
||||||
|
- May leave empty space
|
||||||
|
- Best for preserving design
|
||||||
|
|
||||||
|
- **Stretch**: `scale_x = width_ratio, scale_y = height_ratio`
|
||||||
|
- Fills entire page
|
||||||
|
- May distort layout
|
||||||
|
- Good for flexible designs
|
||||||
|
|
||||||
|
- **Center**: No scaling
|
||||||
|
- Template positioned at center
|
||||||
|
- Original sizes preserved
|
||||||
|
- May overflow or leave space
|
||||||
12
pyPhotoAlbum/__init__.py
Normal file
12
pyPhotoAlbum/__init__.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
"""
|
||||||
|
pyPhotoAlbum - A Python application for designing photo albums and exporting them to PDF
|
||||||
|
|
||||||
|
This package provides a PyQt6-based GUI application for creating photo album layouts
|
||||||
|
with support for templates, image manipulation, and PDF export.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
__author__ = "pyPhotoAlbum Developer"
|
||||||
|
|
||||||
|
# Version info
|
||||||
|
VERSION = __version__
|
||||||
362
pyPhotoAlbum/alignment.py
Normal file
362
pyPhotoAlbum/alignment.py
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
"""
|
||||||
|
Alignment and distribution manager for pyPhotoAlbum
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Tuple
|
||||||
|
from pyPhotoAlbum.models import BaseLayoutElement
|
||||||
|
|
||||||
|
|
||||||
|
class AlignmentManager:
|
||||||
|
"""Manages alignment and distribution operations on multiple elements"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_bounds(elements: List[BaseLayoutElement]) -> Tuple[float, float, float, float]:
|
||||||
|
"""
|
||||||
|
Get the bounding box of multiple elements.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(min_x, min_y, max_x, max_y)
|
||||||
|
"""
|
||||||
|
if not elements:
|
||||||
|
return (0, 0, 0, 0)
|
||||||
|
|
||||||
|
min_x = min(elem.position[0] for elem in elements)
|
||||||
|
min_y = min(elem.position[1] for elem in elements)
|
||||||
|
max_x = max(elem.position[0] + elem.size[0] for elem in elements)
|
||||||
|
max_y = max(elem.position[1] + elem.size[1] for elem in elements)
|
||||||
|
|
||||||
|
return (min_x, min_y, max_x, max_y)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def align_left(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
|
||||||
|
"""
|
||||||
|
Align all elements to the leftmost element.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (element, old_position) tuples for undo
|
||||||
|
"""
|
||||||
|
if len(elements) < 2:
|
||||||
|
return []
|
||||||
|
|
||||||
|
min_x = min(elem.position[0] for elem in elements)
|
||||||
|
changes = []
|
||||||
|
|
||||||
|
for elem in elements:
|
||||||
|
old_pos = elem.position
|
||||||
|
elem.position = (min_x, elem.position[1])
|
||||||
|
changes.append((elem, old_pos))
|
||||||
|
|
||||||
|
return changes
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def align_right(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
|
||||||
|
"""
|
||||||
|
Align all elements to the rightmost element.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (element, old_position) tuples for undo
|
||||||
|
"""
|
||||||
|
if len(elements) < 2:
|
||||||
|
return []
|
||||||
|
|
||||||
|
max_right = max(elem.position[0] + elem.size[0] for elem in elements)
|
||||||
|
changes = []
|
||||||
|
|
||||||
|
for elem in elements:
|
||||||
|
old_pos = elem.position
|
||||||
|
new_x = max_right - elem.size[0]
|
||||||
|
elem.position = (new_x, elem.position[1])
|
||||||
|
changes.append((elem, old_pos))
|
||||||
|
|
||||||
|
return changes
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def align_top(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
|
||||||
|
"""
|
||||||
|
Align all elements to the topmost element.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (element, old_position) tuples for undo
|
||||||
|
"""
|
||||||
|
if len(elements) < 2:
|
||||||
|
return []
|
||||||
|
|
||||||
|
min_y = min(elem.position[1] for elem in elements)
|
||||||
|
changes = []
|
||||||
|
|
||||||
|
for elem in elements:
|
||||||
|
old_pos = elem.position
|
||||||
|
elem.position = (elem.position[0], min_y)
|
||||||
|
changes.append((elem, old_pos))
|
||||||
|
|
||||||
|
return changes
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def align_bottom(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
|
||||||
|
"""
|
||||||
|
Align all elements to the bottommost element.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (element, old_position) tuples for undo
|
||||||
|
"""
|
||||||
|
if len(elements) < 2:
|
||||||
|
return []
|
||||||
|
|
||||||
|
max_bottom = max(elem.position[1] + elem.size[1] for elem in elements)
|
||||||
|
changes = []
|
||||||
|
|
||||||
|
for elem in elements:
|
||||||
|
old_pos = elem.position
|
||||||
|
new_y = max_bottom - elem.size[1]
|
||||||
|
elem.position = (elem.position[0], new_y)
|
||||||
|
changes.append((elem, old_pos))
|
||||||
|
|
||||||
|
return changes
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def align_horizontal_center(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
|
||||||
|
"""
|
||||||
|
Align all elements to horizontal center.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (element, old_position) tuples for undo
|
||||||
|
"""
|
||||||
|
if len(elements) < 2:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Calculate average center
|
||||||
|
centers = [elem.position[0] + elem.size[0] / 2 for elem in elements]
|
||||||
|
avg_center = sum(centers) / len(centers)
|
||||||
|
|
||||||
|
changes = []
|
||||||
|
for elem in elements:
|
||||||
|
old_pos = elem.position
|
||||||
|
new_x = avg_center - elem.size[0] / 2
|
||||||
|
elem.position = (new_x, elem.position[1])
|
||||||
|
changes.append((elem, old_pos))
|
||||||
|
|
||||||
|
return changes
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def align_vertical_center(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
|
||||||
|
"""
|
||||||
|
Align all elements to vertical center.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (element, old_position) tuples for undo
|
||||||
|
"""
|
||||||
|
if len(elements) < 2:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Calculate average center
|
||||||
|
centers = [elem.position[1] + elem.size[1] / 2 for elem in elements]
|
||||||
|
avg_center = sum(centers) / len(centers)
|
||||||
|
|
||||||
|
changes = []
|
||||||
|
for elem in elements:
|
||||||
|
old_pos = elem.position
|
||||||
|
new_y = avg_center - elem.size[1] / 2
|
||||||
|
elem.position = (elem.position[0], new_y)
|
||||||
|
changes.append((elem, old_pos))
|
||||||
|
|
||||||
|
return changes
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def make_same_size(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
|
||||||
|
"""
|
||||||
|
Make all elements the same size as the first element.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (element, old_position, old_size) tuples for undo
|
||||||
|
"""
|
||||||
|
if len(elements) < 2:
|
||||||
|
return []
|
||||||
|
|
||||||
|
target_size = elements[0].size
|
||||||
|
changes = []
|
||||||
|
|
||||||
|
for elem in elements[1:]:
|
||||||
|
old_pos = elem.position
|
||||||
|
old_size = elem.size
|
||||||
|
elem.size = target_size
|
||||||
|
changes.append((elem, old_pos, old_size))
|
||||||
|
|
||||||
|
return changes
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def make_same_width(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
|
||||||
|
"""
|
||||||
|
Make all elements the same width as the first element.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (element, old_position, old_size) tuples for undo
|
||||||
|
"""
|
||||||
|
if len(elements) < 2:
|
||||||
|
return []
|
||||||
|
|
||||||
|
target_width = elements[0].size[0]
|
||||||
|
changes = []
|
||||||
|
|
||||||
|
for elem in elements[1:]:
|
||||||
|
old_pos = elem.position
|
||||||
|
old_size = elem.size
|
||||||
|
elem.size = (target_width, elem.size[1])
|
||||||
|
changes.append((elem, old_pos, old_size))
|
||||||
|
|
||||||
|
return changes
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def make_same_height(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]]:
|
||||||
|
"""
|
||||||
|
Make all elements the same height as the first element.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (element, old_position, old_size) tuples for undo
|
||||||
|
"""
|
||||||
|
if len(elements) < 2:
|
||||||
|
return []
|
||||||
|
|
||||||
|
target_height = elements[0].size[1]
|
||||||
|
changes = []
|
||||||
|
|
||||||
|
for elem in elements[1:]:
|
||||||
|
old_pos = elem.position
|
||||||
|
old_size = elem.size
|
||||||
|
elem.size = (elem.size[0], target_height)
|
||||||
|
changes.append((elem, old_pos, old_size))
|
||||||
|
|
||||||
|
return changes
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def distribute_horizontally(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
|
||||||
|
"""
|
||||||
|
Distribute elements evenly across horizontal span.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (element, old_position) tuples for undo
|
||||||
|
"""
|
||||||
|
if len(elements) < 3:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Sort by x position
|
||||||
|
sorted_elements = sorted(elements, key=lambda e: e.position[0])
|
||||||
|
|
||||||
|
# Get leftmost and rightmost positions
|
||||||
|
min_x = sorted_elements[0].position[0]
|
||||||
|
max_x = sorted_elements[-1].position[0]
|
||||||
|
|
||||||
|
# Calculate spacing between centers
|
||||||
|
total_span = max_x - min_x
|
||||||
|
spacing = total_span / (len(sorted_elements) - 1)
|
||||||
|
|
||||||
|
changes = []
|
||||||
|
for i, elem in enumerate(sorted_elements):
|
||||||
|
old_pos = elem.position
|
||||||
|
new_x = min_x + (i * spacing)
|
||||||
|
elem.position = (new_x, elem.position[1])
|
||||||
|
changes.append((elem, old_pos))
|
||||||
|
|
||||||
|
return changes
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def distribute_vertically(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
|
||||||
|
"""
|
||||||
|
Distribute elements evenly across vertical span.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (element, old_position) tuples for undo
|
||||||
|
"""
|
||||||
|
if len(elements) < 3:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Sort by y position
|
||||||
|
sorted_elements = sorted(elements, key=lambda e: e.position[1])
|
||||||
|
|
||||||
|
# Get topmost and bottommost positions
|
||||||
|
min_y = sorted_elements[0].position[1]
|
||||||
|
max_y = sorted_elements[-1].position[1]
|
||||||
|
|
||||||
|
# Calculate spacing between centers
|
||||||
|
total_span = max_y - min_y
|
||||||
|
spacing = total_span / (len(sorted_elements) - 1)
|
||||||
|
|
||||||
|
changes = []
|
||||||
|
for i, elem in enumerate(sorted_elements):
|
||||||
|
old_pos = elem.position
|
||||||
|
new_y = min_y + (i * spacing)
|
||||||
|
elem.position = (elem.position[0], new_y)
|
||||||
|
changes.append((elem, old_pos))
|
||||||
|
|
||||||
|
return changes
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def space_horizontally(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
|
||||||
|
"""
|
||||||
|
Distribute elements with equal spacing between them horizontally.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (element, old_position) tuples for undo
|
||||||
|
"""
|
||||||
|
if len(elements) < 3:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Sort by x position
|
||||||
|
sorted_elements = sorted(elements, key=lambda e: e.position[0])
|
||||||
|
|
||||||
|
# Get leftmost and rightmost boundaries
|
||||||
|
min_x = sorted_elements[0].position[0]
|
||||||
|
max_right = sorted_elements[-1].position[0] + sorted_elements[-1].size[0]
|
||||||
|
|
||||||
|
# Calculate total width of all elements
|
||||||
|
total_width = sum(elem.size[0] for elem in sorted_elements)
|
||||||
|
|
||||||
|
# Calculate available space and spacing
|
||||||
|
available_space = max_right - min_x - total_width
|
||||||
|
spacing = available_space / (len(sorted_elements) - 1)
|
||||||
|
|
||||||
|
changes = []
|
||||||
|
current_x = min_x
|
||||||
|
|
||||||
|
for elem in sorted_elements:
|
||||||
|
old_pos = elem.position
|
||||||
|
elem.position = (current_x, elem.position[1])
|
||||||
|
changes.append((elem, old_pos))
|
||||||
|
current_x += elem.size[0] + spacing
|
||||||
|
|
||||||
|
return changes
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def space_vertically(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]:
|
||||||
|
"""
|
||||||
|
Distribute elements with equal spacing between them vertically.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (element, old_position) tuples for undo
|
||||||
|
"""
|
||||||
|
if len(elements) < 3:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Sort by y position
|
||||||
|
sorted_elements = sorted(elements, key=lambda e: e.position[1])
|
||||||
|
|
||||||
|
# Get topmost and bottommost boundaries
|
||||||
|
min_y = sorted_elements[0].position[1]
|
||||||
|
max_bottom = sorted_elements[-1].position[1] + sorted_elements[-1].size[1]
|
||||||
|
|
||||||
|
# Calculate total height of all elements
|
||||||
|
total_height = sum(elem.size[1] for elem in sorted_elements)
|
||||||
|
|
||||||
|
# Calculate available space and spacing
|
||||||
|
available_space = max_bottom - min_y - total_height
|
||||||
|
spacing = available_space / (len(sorted_elements) - 1)
|
||||||
|
|
||||||
|
changes = []
|
||||||
|
current_y = min_y
|
||||||
|
|
||||||
|
for elem in sorted_elements:
|
||||||
|
old_pos = elem.position
|
||||||
|
elem.position = (elem.position[0], current_y)
|
||||||
|
changes.append((elem, old_pos))
|
||||||
|
current_y += elem.size[1] + spacing
|
||||||
|
|
||||||
|
return changes
|
||||||
153
pyPhotoAlbum/asset_manager.py
Normal file
153
pyPhotoAlbum/asset_manager.py
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
"""
|
||||||
|
Asset management system for pyPhotoAlbum with automatic reference counting
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from typing import Dict, Optional
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
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}
|
||||||
|
|
||||||
|
# Create assets folder if it doesn't exist
|
||||||
|
os.makedirs(self.assets_folder, exist_ok=True)
|
||||||
|
|
||||||
|
def import_asset(self, source_path: str) -> str:
|
||||||
|
"""
|
||||||
|
Import an asset into the project by copying it to the assets folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_path: Path to the source file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Relative path to the imported asset (e.g., "assets/photo_001.jpg")
|
||||||
|
"""
|
||||||
|
if not os.path.exists(source_path):
|
||||||
|
raise FileNotFoundError(f"Source file not found: {source_path}")
|
||||||
|
|
||||||
|
# Get filename and extension
|
||||||
|
filename = os.path.basename(source_path)
|
||||||
|
name, ext = os.path.splitext(filename)
|
||||||
|
|
||||||
|
# Find a unique filename if there's a collision
|
||||||
|
counter = 1
|
||||||
|
dest_filename = filename
|
||||||
|
dest_path = os.path.join(self.assets_folder, dest_filename)
|
||||||
|
|
||||||
|
while os.path.exists(dest_path):
|
||||||
|
dest_filename = f"{name}_{counter:03d}{ext}"
|
||||||
|
dest_path = os.path.join(self.assets_folder, dest_filename)
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
# Copy the file
|
||||||
|
shutil.copy2(source_path, dest_path)
|
||||||
|
|
||||||
|
# Get relative path from project folder (for storage/serialization)
|
||||||
|
relative_path = os.path.relpath(dest_path, self.project_folder)
|
||||||
|
|
||||||
|
# Initialize reference count
|
||||||
|
self.reference_counts[relative_path] = 1
|
||||||
|
|
||||||
|
print(f"AssetManager: Imported {source_path} → {dest_path} (stored as {relative_path}, refs=1)")
|
||||||
|
|
||||||
|
# Return absolute path for immediate use
|
||||||
|
return dest_path
|
||||||
|
|
||||||
|
def acquire_reference(self, asset_path: str):
|
||||||
|
"""
|
||||||
|
Increment the reference count for an asset.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
asset_path: Relative path to the asset
|
||||||
|
"""
|
||||||
|
if not asset_path:
|
||||||
|
return
|
||||||
|
|
||||||
|
if asset_path in self.reference_counts:
|
||||||
|
self.reference_counts[asset_path] += 1
|
||||||
|
print(f"AssetManager: Acquired reference to {asset_path} (refs={self.reference_counts[asset_path]})")
|
||||||
|
else:
|
||||||
|
# Asset might exist from a loaded project
|
||||||
|
full_path = os.path.join(self.project_folder, asset_path)
|
||||||
|
if os.path.exists(full_path):
|
||||||
|
self.reference_counts[asset_path] = 1
|
||||||
|
print(f"AssetManager: Acquired reference to existing asset {asset_path} (refs=1)")
|
||||||
|
else:
|
||||||
|
print(f"AssetManager: Warning - asset not found: {asset_path}")
|
||||||
|
|
||||||
|
def release_reference(self, asset_path: str):
|
||||||
|
"""
|
||||||
|
Decrement the reference count for an asset.
|
||||||
|
If count reaches zero, delete the asset file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
asset_path: Relative path to the asset
|
||||||
|
"""
|
||||||
|
if not asset_path:
|
||||||
|
return
|
||||||
|
|
||||||
|
if asset_path not in self.reference_counts:
|
||||||
|
print(f"AssetManager: Warning - attempting to release unknown asset: {asset_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.reference_counts[asset_path] -= 1
|
||||||
|
print(f"AssetManager: Released reference to {asset_path} (refs={self.reference_counts[asset_path]})")
|
||||||
|
|
||||||
|
if self.reference_counts[asset_path] <= 0:
|
||||||
|
# No more references - safe to delete
|
||||||
|
full_path = os.path.join(self.project_folder, asset_path)
|
||||||
|
try:
|
||||||
|
if os.path.exists(full_path):
|
||||||
|
os.remove(full_path)
|
||||||
|
print(f"AssetManager: Deleted unused asset {asset_path}")
|
||||||
|
del self.reference_counts[asset_path]
|
||||||
|
except Exception as e:
|
||||||
|
print(f"AssetManager: Error deleting asset {asset_path}: {e}")
|
||||||
|
|
||||||
|
def get_absolute_path(self, relative_path: str) -> str:
|
||||||
|
"""
|
||||||
|
Convert a relative asset path to an absolute path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
relative_path: Relative path from project folder
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Absolute path to the asset
|
||||||
|
"""
|
||||||
|
return os.path.join(self.project_folder, relative_path)
|
||||||
|
|
||||||
|
def get_reference_count(self, asset_path: str) -> int:
|
||||||
|
"""
|
||||||
|
Get the current reference count for an asset.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
asset_path: Relative path to the asset
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Reference count (0 if not tracked)
|
||||||
|
"""
|
||||||
|
return self.reference_counts.get(asset_path, 0)
|
||||||
|
|
||||||
|
def serialize(self) -> Dict:
|
||||||
|
"""Serialize asset manager state"""
|
||||||
|
return {
|
||||||
|
"reference_counts": self.reference_counts
|
||||||
|
}
|
||||||
|
|
||||||
|
def deserialize(self, data: Dict):
|
||||||
|
"""Deserialize asset manager state"""
|
||||||
|
self.reference_counts = data.get("reference_counts", {})
|
||||||
|
print(f"AssetManager: Loaded {len(self.reference_counts)} asset references")
|
||||||
612
pyPhotoAlbum/commands.py
Normal file
612
pyPhotoAlbum/commands.py
Normal file
@ -0,0 +1,612 @@
|
|||||||
|
"""
|
||||||
|
Command pattern implementation for undo/redo functionality
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_asset_path(image_path: str, asset_manager) -> str:
|
||||||
|
"""
|
||||||
|
Convert absolute path to relative for asset manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_path: Image path (absolute or relative)
|
||||||
|
asset_manager: AssetManager instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Relative path suitable for asset manager
|
||||||
|
"""
|
||||||
|
if not asset_manager or not image_path:
|
||||||
|
return image_path
|
||||||
|
|
||||||
|
import os
|
||||||
|
if os.path.isabs(image_path):
|
||||||
|
return os.path.relpath(image_path, asset_manager.project_folder)
|
||||||
|
return image_path
|
||||||
|
|
||||||
|
|
||||||
|
class Command(ABC):
|
||||||
|
"""Abstract base class for all commands"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def execute(self):
|
||||||
|
"""Execute the command"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def undo(self):
|
||||||
|
"""Undo the command"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def redo(self):
|
||||||
|
"""Redo the command (default implementation calls execute)"""
|
||||||
|
self.execute()
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def serialize(self) -> Dict[str, Any]:
|
||||||
|
"""Serialize command to dictionary for saving"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@abstractmethod
|
||||||
|
def deserialize(data: Dict[str, Any], project) -> 'Command':
|
||||||
|
"""Deserialize command from dictionary"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AddElementCommand(Command):
|
||||||
|
"""Command for adding an element to a page"""
|
||||||
|
|
||||||
|
def __init__(self, page_layout, element: BaseLayoutElement, asset_manager=None):
|
||||||
|
self.page_layout = page_layout
|
||||||
|
self.element = element
|
||||||
|
self.executed = False
|
||||||
|
self.asset_manager = asset_manager
|
||||||
|
|
||||||
|
# Acquire reference to asset when command is created
|
||||||
|
if self.asset_manager and isinstance(self.element, ImageData) and self.element.image_path:
|
||||||
|
rel_path = _normalize_asset_path(self.element.image_path, self.asset_manager)
|
||||||
|
self.asset_manager.acquire_reference(rel_path)
|
||||||
|
|
||||||
|
def execute(self):
|
||||||
|
"""Add the element to the page"""
|
||||||
|
if not self.executed:
|
||||||
|
self.page_layout.add_element(self.element)
|
||||||
|
self.executed = True
|
||||||
|
|
||||||
|
def undo(self):
|
||||||
|
"""Remove the element from the page"""
|
||||||
|
if self.executed:
|
||||||
|
self.page_layout.remove_element(self.element)
|
||||||
|
self.executed = False
|
||||||
|
|
||||||
|
def redo(self):
|
||||||
|
"""Re-add the element"""
|
||||||
|
self.execute()
|
||||||
|
|
||||||
|
def serialize(self) -> Dict[str, Any]:
|
||||||
|
"""Serialize to dictionary"""
|
||||||
|
return {
|
||||||
|
"type": "add_element",
|
||||||
|
"element": self.element.serialize(),
|
||||||
|
"executed": self.executed
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def deserialize(data: Dict[str, Any], project) -> 'AddElementCommand':
|
||||||
|
"""Deserialize from dictionary"""
|
||||||
|
# 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
|
||||||
|
cmd = AddElementCommand(None, element)
|
||||||
|
cmd.executed = data.get("executed", False)
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteElementCommand(Command):
|
||||||
|
"""Command for deleting an element from a page"""
|
||||||
|
|
||||||
|
def __init__(self, page_layout, element: BaseLayoutElement, asset_manager=None):
|
||||||
|
self.page_layout = page_layout
|
||||||
|
self.element = element
|
||||||
|
self.executed = False
|
||||||
|
self.asset_manager = asset_manager
|
||||||
|
|
||||||
|
# Acquire reference to asset to keep it alive while in undo history
|
||||||
|
if self.asset_manager and isinstance(self.element, ImageData) and self.element.image_path:
|
||||||
|
rel_path = _normalize_asset_path(self.element.image_path, self.asset_manager)
|
||||||
|
self.asset_manager.acquire_reference(rel_path)
|
||||||
|
|
||||||
|
def execute(self):
|
||||||
|
"""Remove the element from the page"""
|
||||||
|
if not self.executed:
|
||||||
|
self.page_layout.remove_element(self.element)
|
||||||
|
self.executed = True
|
||||||
|
|
||||||
|
def undo(self):
|
||||||
|
"""Re-add the element to the page"""
|
||||||
|
if self.executed:
|
||||||
|
self.page_layout.add_element(self.element)
|
||||||
|
self.executed = False
|
||||||
|
|
||||||
|
def redo(self):
|
||||||
|
"""Re-remove the element"""
|
||||||
|
self.execute()
|
||||||
|
|
||||||
|
def serialize(self) -> Dict[str, Any]:
|
||||||
|
"""Serialize to dictionary"""
|
||||||
|
return {
|
||||||
|
"type": "delete_element",
|
||||||
|
"element": self.element.serialize(),
|
||||||
|
"executed": self.executed
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def deserialize(data: Dict[str, Any], project) -> 'DeleteElementCommand':
|
||||||
|
"""Deserialize from dictionary"""
|
||||||
|
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)
|
||||||
|
|
||||||
|
cmd = DeleteElementCommand(None, element)
|
||||||
|
cmd.executed = data.get("executed", False)
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
class MoveElementCommand(Command):
|
||||||
|
"""Command for moving an element"""
|
||||||
|
|
||||||
|
def __init__(self, element: BaseLayoutElement, old_position: tuple, new_position: tuple):
|
||||||
|
self.element = element
|
||||||
|
self.old_position = old_position
|
||||||
|
self.new_position = new_position
|
||||||
|
|
||||||
|
def execute(self):
|
||||||
|
"""Move element to new position"""
|
||||||
|
self.element.position = self.new_position
|
||||||
|
|
||||||
|
def undo(self):
|
||||||
|
"""Move element back to old position"""
|
||||||
|
self.element.position = self.old_position
|
||||||
|
|
||||||
|
def redo(self):
|
||||||
|
"""Move element to new position again"""
|
||||||
|
self.execute()
|
||||||
|
|
||||||
|
def serialize(self) -> Dict[str, Any]:
|
||||||
|
"""Serialize to dictionary"""
|
||||||
|
return {
|
||||||
|
"type": "move_element",
|
||||||
|
"element": self.element.serialize(),
|
||||||
|
"old_position": self.old_position,
|
||||||
|
"new_position": self.new_position
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def deserialize(data: Dict[str, Any], project) -> 'MoveElementCommand':
|
||||||
|
"""Deserialize from dictionary"""
|
||||||
|
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"])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ResizeElementCommand(Command):
|
||||||
|
"""Command for resizing an element"""
|
||||||
|
|
||||||
|
def __init__(self, element: BaseLayoutElement, old_position: tuple, old_size: tuple,
|
||||||
|
new_position: tuple, new_size: tuple):
|
||||||
|
self.element = element
|
||||||
|
self.old_position = old_position
|
||||||
|
self.old_size = old_size
|
||||||
|
self.new_position = new_position
|
||||||
|
self.new_size = new_size
|
||||||
|
|
||||||
|
def execute(self):
|
||||||
|
"""Resize element to new size"""
|
||||||
|
self.element.position = self.new_position
|
||||||
|
self.element.size = self.new_size
|
||||||
|
|
||||||
|
def undo(self):
|
||||||
|
"""Resize element back to old size"""
|
||||||
|
self.element.position = self.old_position
|
||||||
|
self.element.size = self.old_size
|
||||||
|
|
||||||
|
def redo(self):
|
||||||
|
"""Resize element to new size again"""
|
||||||
|
self.execute()
|
||||||
|
|
||||||
|
def serialize(self) -> Dict[str, Any]:
|
||||||
|
"""Serialize to dictionary"""
|
||||||
|
return {
|
||||||
|
"type": "resize_element",
|
||||||
|
"element": self.element.serialize(),
|
||||||
|
"old_position": self.old_position,
|
||||||
|
"old_size": self.old_size,
|
||||||
|
"new_position": self.new_position,
|
||||||
|
"new_size": self.new_size
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def deserialize(data: Dict[str, Any], project) -> 'ResizeElementCommand':
|
||||||
|
"""Deserialize from dictionary"""
|
||||||
|
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 ResizeElementCommand(
|
||||||
|
element,
|
||||||
|
tuple(data["old_position"]),
|
||||||
|
tuple(data["old_size"]),
|
||||||
|
tuple(data["new_position"]),
|
||||||
|
tuple(data["new_size"])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RotateElementCommand(Command):
|
||||||
|
"""Command for rotating an element"""
|
||||||
|
|
||||||
|
def __init__(self, element: BaseLayoutElement, old_rotation: float, new_rotation: float):
|
||||||
|
self.element = element
|
||||||
|
self.old_rotation = old_rotation
|
||||||
|
self.new_rotation = new_rotation
|
||||||
|
|
||||||
|
def execute(self):
|
||||||
|
"""Rotate element to new angle"""
|
||||||
|
self.element.rotation = self.new_rotation
|
||||||
|
|
||||||
|
def undo(self):
|
||||||
|
"""Rotate element back to old angle"""
|
||||||
|
self.element.rotation = self.old_rotation
|
||||||
|
|
||||||
|
def redo(self):
|
||||||
|
"""Rotate element to new angle again"""
|
||||||
|
self.execute()
|
||||||
|
|
||||||
|
def serialize(self) -> Dict[str, Any]:
|
||||||
|
"""Serialize to dictionary"""
|
||||||
|
return {
|
||||||
|
"type": "rotate_element",
|
||||||
|
"element": self.element.serialize(),
|
||||||
|
"old_rotation": self.old_rotation,
|
||||||
|
"new_rotation": self.new_rotation
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def deserialize(data: Dict[str, Any], project) -> 'RotateElementCommand':
|
||||||
|
"""Deserialize from dictionary"""
|
||||||
|
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"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AlignElementsCommand(Command):
|
||||||
|
"""Command for aligning multiple elements"""
|
||||||
|
|
||||||
|
def __init__(self, changes: List[tuple]):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
changes: List of (element, old_position) tuples
|
||||||
|
"""
|
||||||
|
self.changes = changes
|
||||||
|
|
||||||
|
def execute(self):
|
||||||
|
"""Positions have already been set by AlignmentManager"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def undo(self):
|
||||||
|
"""Restore old positions"""
|
||||||
|
for element, old_position in self.changes:
|
||||||
|
element.position = old_position
|
||||||
|
|
||||||
|
def redo(self):
|
||||||
|
"""Re-apply alignment (positions are stored in current state)"""
|
||||||
|
# Store current positions and restore them
|
||||||
|
new_positions = [(elem, elem.position) for elem, _ in self.changes]
|
||||||
|
for element, old_position in self.changes:
|
||||||
|
element.position = old_position
|
||||||
|
# Then re-apply new positions
|
||||||
|
for element, new_position in new_positions:
|
||||||
|
element.position = new_position
|
||||||
|
|
||||||
|
def serialize(self) -> Dict[str, Any]:
|
||||||
|
"""Serialize to dictionary"""
|
||||||
|
return {
|
||||||
|
"type": "align_elements",
|
||||||
|
"changes": [
|
||||||
|
{
|
||||||
|
"element": elem.serialize(),
|
||||||
|
"old_position": old_pos
|
||||||
|
}
|
||||||
|
for elem, old_pos in self.changes
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def deserialize(data: Dict[str, Any], project) -> 'AlignElementsCommand':
|
||||||
|
"""Deserialize from dictionary"""
|
||||||
|
changes = []
|
||||||
|
for change_data in data.get("changes", []):
|
||||||
|
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:
|
||||||
|
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:
|
||||||
|
changes: List of (element, old_position, old_size) tuples
|
||||||
|
"""
|
||||||
|
self.changes = changes
|
||||||
|
self.new_states = [(elem, elem.position, elem.size) for elem, _, _ in changes]
|
||||||
|
|
||||||
|
def execute(self):
|
||||||
|
"""Sizes have already been set by AlignmentManager"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def undo(self):
|
||||||
|
"""Restore old positions and sizes"""
|
||||||
|
for element, old_position, old_size in self.changes:
|
||||||
|
element.position = old_position
|
||||||
|
element.size = old_size
|
||||||
|
|
||||||
|
def redo(self):
|
||||||
|
"""Re-apply new sizes"""
|
||||||
|
for element, new_position, new_size in self.new_states:
|
||||||
|
element.position = new_position
|
||||||
|
element.size = new_size
|
||||||
|
|
||||||
|
def serialize(self) -> Dict[str, Any]:
|
||||||
|
"""Serialize to dictionary"""
|
||||||
|
return {
|
||||||
|
"type": "resize_elements",
|
||||||
|
"changes": [
|
||||||
|
{
|
||||||
|
"element": elem.serialize(),
|
||||||
|
"old_position": old_pos,
|
||||||
|
"old_size": old_size
|
||||||
|
}
|
||||||
|
for elem, old_pos, old_size in self.changes
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def deserialize(data: Dict[str, Any], project) -> 'ResizeElementsCommand':
|
||||||
|
"""Deserialize from dictionary"""
|
||||||
|
changes = []
|
||||||
|
for change_data in data.get("changes", []):
|
||||||
|
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:
|
||||||
|
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 CommandHistory:
|
||||||
|
"""Manages undo/redo command history"""
|
||||||
|
|
||||||
|
def __init__(self, max_history: int = 100, asset_manager=None):
|
||||||
|
self.undo_stack: List[Command] = []
|
||||||
|
self.redo_stack: List[Command] = []
|
||||||
|
self.max_history = max_history
|
||||||
|
self.asset_manager = asset_manager
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
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)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def can_undo(self) -> bool:
|
||||||
|
"""Check if undo is available"""
|
||||||
|
return len(self.undo_stack) > 0
|
||||||
|
|
||||||
|
def can_redo(self) -> bool:
|
||||||
|
"""Check if redo is available"""
|
||||||
|
return len(self.redo_stack) > 0
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
"""Clear all history and release asset references"""
|
||||||
|
# Release all asset references
|
||||||
|
for cmd in self.undo_stack:
|
||||||
|
self._release_command_assets(cmd)
|
||||||
|
for cmd in self.redo_stack:
|
||||||
|
self._release_command_assets(cmd)
|
||||||
|
|
||||||
|
self.undo_stack.clear()
|
||||||
|
self.redo_stack.clear()
|
||||||
|
|
||||||
|
def serialize(self) -> Dict[str, Any]:
|
||||||
|
"""Serialize history to dictionary"""
|
||||||
|
return {
|
||||||
|
"undo_stack": [cmd.serialize() for cmd in self.undo_stack],
|
||||||
|
"redo_stack": [cmd.serialize() for cmd in self.redo_stack],
|
||||||
|
"max_history": self.max_history
|
||||||
|
}
|
||||||
|
|
||||||
|
def deserialize(self, data: Dict[str, Any], project):
|
||||||
|
"""Deserialize history from dictionary"""
|
||||||
|
self.max_history = data.get("max_history", 100)
|
||||||
|
|
||||||
|
# Deserialize undo stack
|
||||||
|
self.undo_stack = []
|
||||||
|
for cmd_data in data.get("undo_stack", []):
|
||||||
|
cmd = self._deserialize_command(cmd_data, project)
|
||||||
|
if cmd:
|
||||||
|
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:
|
||||||
|
self.redo_stack.append(cmd)
|
||||||
|
|
||||||
|
def _deserialize_command(self, data: Dict[str, Any], project) -> Optional[Command]:
|
||||||
|
"""Deserialize a single command"""
|
||||||
|
cmd_type = data.get("type")
|
||||||
|
|
||||||
|
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)
|
||||||
|
else:
|
||||||
|
print(f"Warning: Unknown command type: {cmd_type}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error deserializing command: {e}")
|
||||||
|
return None
|
||||||
202
pyPhotoAlbum/decorators.py
Normal file
202
pyPhotoAlbum/decorators.py
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
"""
|
||||||
|
Decorator system for pyPhotoAlbum ribbon UI
|
||||||
|
"""
|
||||||
|
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Optional, Callable
|
||||||
|
|
||||||
|
|
||||||
|
class RibbonAction:
|
||||||
|
"""
|
||||||
|
Decorator to mark methods as ribbon actions.
|
||||||
|
|
||||||
|
This decorator stores metadata about UI actions that should appear in the ribbon.
|
||||||
|
The metadata is used to auto-generate the ribbon configuration.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
@RibbonAction(
|
||||||
|
label="New",
|
||||||
|
tooltip="Create a new project",
|
||||||
|
tab="Home",
|
||||||
|
group="File",
|
||||||
|
icon="new.png",
|
||||||
|
shortcut="Ctrl+N"
|
||||||
|
)
|
||||||
|
def new_project(self):
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
label: str,
|
||||||
|
tooltip: str,
|
||||||
|
tab: str,
|
||||||
|
group: str,
|
||||||
|
icon: Optional[str] = None,
|
||||||
|
shortcut: Optional[str] = None,
|
||||||
|
requires_page: bool = False,
|
||||||
|
requires_selection: bool = False,
|
||||||
|
min_selection: int = 0
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the ribbon action decorator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
label: Button label text
|
||||||
|
tooltip: Tooltip text shown on hover
|
||||||
|
tab: Ribbon tab name (e.g., "Home", "Insert", "Layout")
|
||||||
|
group: Group name within the tab (e.g., "File", "Edit")
|
||||||
|
icon: Optional icon filename or path
|
||||||
|
shortcut: Optional keyboard shortcut (e.g., "Ctrl+N", "Ctrl+Shift+S")
|
||||||
|
requires_page: Whether this action requires an active page
|
||||||
|
requires_selection: Whether this action requires selected elements
|
||||||
|
min_selection: Minimum number of selected elements required
|
||||||
|
"""
|
||||||
|
self.label = label
|
||||||
|
self.tooltip = tooltip
|
||||||
|
self.tab = tab
|
||||||
|
self.group = group
|
||||||
|
self.icon = icon
|
||||||
|
self.shortcut = shortcut
|
||||||
|
self.requires_page = requires_page
|
||||||
|
self.requires_selection = requires_selection
|
||||||
|
self.min_selection = min_selection
|
||||||
|
|
||||||
|
def __call__(self, func: Callable) -> Callable:
|
||||||
|
"""
|
||||||
|
Decorate the function with ribbon action metadata.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
func: The function to decorate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The decorated function with metadata attached
|
||||||
|
"""
|
||||||
|
# Store metadata on function
|
||||||
|
func._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
|
||||||
|
}
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def ribbon_action(
|
||||||
|
label: str,
|
||||||
|
tooltip: str,
|
||||||
|
tab: str,
|
||||||
|
group: str,
|
||||||
|
icon: Optional[str] = None,
|
||||||
|
shortcut: Optional[str] = None,
|
||||||
|
requires_page: bool = False,
|
||||||
|
requires_selection: bool = False,
|
||||||
|
min_selection: int = 0
|
||||||
|
) -> Callable:
|
||||||
|
"""
|
||||||
|
Convenience function for the RibbonAction decorator.
|
||||||
|
|
||||||
|
This provides a lowercase function-style interface to the decorator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
label: Button label text
|
||||||
|
tooltip: Tooltip text shown on hover
|
||||||
|
tab: Ribbon tab name
|
||||||
|
group: Group name within the tab
|
||||||
|
icon: Optional icon filename or path
|
||||||
|
shortcut: Optional keyboard shortcut
|
||||||
|
requires_page: Whether this action requires an active page
|
||||||
|
requires_selection: Whether this action requires selected elements
|
||||||
|
min_selection: Minimum number of selected elements required
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RibbonAction decorator instance
|
||||||
|
"""
|
||||||
|
return RibbonAction(
|
||||||
|
label=label,
|
||||||
|
tooltip=tooltip,
|
||||||
|
tab=tab,
|
||||||
|
group=group,
|
||||||
|
icon=icon,
|
||||||
|
shortcut=shortcut,
|
||||||
|
requires_page=requires_page,
|
||||||
|
requires_selection=requires_selection,
|
||||||
|
min_selection=min_selection
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NumericalInput:
|
||||||
|
"""
|
||||||
|
Decorator to mark methods that require numerical width/height inputs.
|
||||||
|
|
||||||
|
This decorator stores metadata about numerical input fields that should
|
||||||
|
be presented in dialogs for methods that work with page dimensions.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
@numerical_input(
|
||||||
|
fields=[
|
||||||
|
('width', 'Width', 'mm', 10, 1000),
|
||||||
|
('height', 'Height', 'mm', 10, 1000)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
def set_page_size(self, width, height):
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, fields: list):
|
||||||
|
"""
|
||||||
|
Initialize the numerical input decorator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fields: List of tuples, each containing:
|
||||||
|
(param_name, label, unit, min_value, max_value)
|
||||||
|
"""
|
||||||
|
self.fields = fields
|
||||||
|
|
||||||
|
def __call__(self, func: Callable) -> Callable:
|
||||||
|
"""
|
||||||
|
Decorate the function with numerical input metadata.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
func: The function to decorate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The decorated function with metadata attached
|
||||||
|
"""
|
||||||
|
# Store metadata on function
|
||||||
|
func._numerical_input = {
|
||||||
|
'fields': self.fields
|
||||||
|
}
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def numerical_input(fields: list) -> Callable:
|
||||||
|
"""
|
||||||
|
Convenience function for the NumericalInput decorator.
|
||||||
|
|
||||||
|
This provides a lowercase function-style interface to the decorator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fields: List of tuples, each containing:
|
||||||
|
(param_name, label, unit, min_value, max_value)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NumericalInput decorator instance
|
||||||
|
"""
|
||||||
|
return NumericalInput(fields=fields)
|
||||||
1054
pyPhotoAlbum/gl_widget.py
Normal file
1054
pyPhotoAlbum/gl_widget.py
Normal file
File diff suppressed because it is too large
Load Diff
180
pyPhotoAlbum/main.py
Normal file
180
pyPhotoAlbum/main.py
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Refactored main application entry point for pyPhotoAlbum
|
||||||
|
|
||||||
|
This version uses the mixin architecture with auto-generated ribbon configuration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QApplication, QMainWindow, QVBoxLayout, QWidget,
|
||||||
|
QStatusBar
|
||||||
|
)
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
|
||||||
|
from pyPhotoAlbum.project import Project
|
||||||
|
from pyPhotoAlbum.template_manager import TemplateManager
|
||||||
|
from pyPhotoAlbum.ribbon_widget import RibbonWidget
|
||||||
|
from pyPhotoAlbum.ribbon_builder import build_ribbon_config, print_ribbon_summary
|
||||||
|
from pyPhotoAlbum.gl_widget import GLWidget
|
||||||
|
|
||||||
|
# Import mixins
|
||||||
|
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
|
||||||
|
from pyPhotoAlbum.mixins.operations import (
|
||||||
|
FileOperationsMixin,
|
||||||
|
EditOperationsMixin,
|
||||||
|
ElementOperationsMixin,
|
||||||
|
PageOperationsMixin,
|
||||||
|
TemplateOperationsMixin,
|
||||||
|
ViewOperationsMixin,
|
||||||
|
AlignmentOperationsMixin,
|
||||||
|
DistributionOperationsMixin,
|
||||||
|
SizeOperationsMixin,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MainWindow(
|
||||||
|
QMainWindow,
|
||||||
|
ApplicationStateMixin,
|
||||||
|
FileOperationsMixin,
|
||||||
|
EditOperationsMixin,
|
||||||
|
ElementOperationsMixin,
|
||||||
|
PageOperationsMixin,
|
||||||
|
TemplateOperationsMixin,
|
||||||
|
ViewOperationsMixin,
|
||||||
|
AlignmentOperationsMixin,
|
||||||
|
DistributionOperationsMixin,
|
||||||
|
SizeOperationsMixin,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
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 shared state first
|
||||||
|
self._init_state()
|
||||||
|
|
||||||
|
# Initialize UI
|
||||||
|
self._init_ui()
|
||||||
|
|
||||||
|
# Add a sample page for demonstration
|
||||||
|
self._add_sample_page()
|
||||||
|
|
||||||
|
def _init_state(self):
|
||||||
|
"""Initialize shared application state"""
|
||||||
|
# Initialize project
|
||||||
|
self._project = Project("My Photo Album")
|
||||||
|
|
||||||
|
# Initialize template manager
|
||||||
|
self._template_manager = TemplateManager()
|
||||||
|
|
||||||
|
def _init_ui(self):
|
||||||
|
"""Initialize user interface"""
|
||||||
|
# Basic window setup
|
||||||
|
self.setWindowTitle("pyPhotoAlbum")
|
||||||
|
self.resize(1200, 800)
|
||||||
|
|
||||||
|
# Create main widget with layout
|
||||||
|
main_widget = QWidget()
|
||||||
|
main_layout = QVBoxLayout()
|
||||||
|
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
main_layout.setSpacing(0)
|
||||||
|
main_widget.setLayout(main_layout)
|
||||||
|
|
||||||
|
# Build ribbon config from decorated methods
|
||||||
|
ribbon_config = build_ribbon_config(self.__class__)
|
||||||
|
|
||||||
|
# Print summary (for debugging)
|
||||||
|
print_ribbon_summary(ribbon_config)
|
||||||
|
|
||||||
|
# Create ribbon with auto-generated config
|
||||||
|
self.ribbon = RibbonWidget(self, ribbon_config)
|
||||||
|
main_layout.addWidget(self.ribbon, 0)
|
||||||
|
|
||||||
|
# Create OpenGL widget
|
||||||
|
self._gl_widget = GLWidget(self)
|
||||||
|
main_layout.addWidget(self._gl_widget, 1)
|
||||||
|
|
||||||
|
self.setCentralWidget(main_widget)
|
||||||
|
|
||||||
|
# Create status bar
|
||||||
|
self._status_bar = QStatusBar()
|
||||||
|
self.setStatusBar(self._status_bar)
|
||||||
|
|
||||||
|
# Register keyboard shortcuts
|
||||||
|
self._register_shortcuts()
|
||||||
|
|
||||||
|
def _register_shortcuts(self):
|
||||||
|
"""Register keyboard shortcuts from decorated methods"""
|
||||||
|
from PyQt6.QtGui import QShortcut, QKeySequence
|
||||||
|
from pyPhotoAlbum.ribbon_builder import get_keyboard_shortcuts
|
||||||
|
|
||||||
|
shortcuts = get_keyboard_shortcuts(self.__class__)
|
||||||
|
|
||||||
|
for shortcut_str, method_name in shortcuts.items():
|
||||||
|
if hasattr(self, method_name):
|
||||||
|
shortcut = QShortcut(QKeySequence(shortcut_str), self)
|
||||||
|
method = getattr(self, method_name)
|
||||||
|
shortcut.activated.connect(method)
|
||||||
|
print(f"Registered shortcut: {shortcut_str} -> {method_name}")
|
||||||
|
|
||||||
|
# Register additional Ctrl+Shift+Z shortcut for redo
|
||||||
|
if hasattr(self, 'redo'):
|
||||||
|
redo_shortcut = QShortcut(QKeySequence("Ctrl+Shift+Z"), self)
|
||||||
|
redo_shortcut.activated.connect(self.redo)
|
||||||
|
print("Registered shortcut: Ctrl+Shift+Z -> redo")
|
||||||
|
|
||||||
|
def _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 main():
|
||||||
|
"""Application entry point"""
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
|
||||||
|
# Enable high DPI scaling
|
||||||
|
try:
|
||||||
|
app.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True)
|
||||||
|
app.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps, True)
|
||||||
|
except AttributeError:
|
||||||
|
pass # Qt version doesn't support these attributes
|
||||||
|
|
||||||
|
window = MainWindow()
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
sys.exit(app.exec())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
7
pyPhotoAlbum/mixins/__init__.py
Normal file
7
pyPhotoAlbum/mixins/__init__.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
Mixin modules for pyPhotoAlbum
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
|
||||||
|
|
||||||
|
__all__ = ['ApplicationStateMixin']
|
||||||
174
pyPhotoAlbum/mixins/base.py
Normal file
174
pyPhotoAlbum/mixins/base.py
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
"""
|
||||||
|
Base mixin providing shared application state access
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
from PyQt6.QtWidgets import QStatusBar, QMessageBox
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationStateMixin:
|
||||||
|
"""
|
||||||
|
Base mixin providing access to shared application state.
|
||||||
|
|
||||||
|
This mixin provides properties and helper methods for accessing
|
||||||
|
core application objects that are shared across all operation mixins.
|
||||||
|
|
||||||
|
Required attributes (must be set by MainWindow):
|
||||||
|
_project: Project instance
|
||||||
|
_gl_widget: GLWidget instance
|
||||||
|
_status_bar: QStatusBar instance
|
||||||
|
_template_manager: TemplateManager instance
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def project(self):
|
||||||
|
"""Access to current project"""
|
||||||
|
if not hasattr(self, '_project'):
|
||||||
|
raise AttributeError("MainWindow must set _project attribute")
|
||||||
|
return self._project
|
||||||
|
|
||||||
|
@project.setter
|
||||||
|
def project(self, value):
|
||||||
|
"""Set the current project"""
|
||||||
|
self._project = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def gl_widget(self):
|
||||||
|
"""Access to GL rendering widget"""
|
||||||
|
if not hasattr(self, '_gl_widget'):
|
||||||
|
raise AttributeError("MainWindow must set _gl_widget attribute")
|
||||||
|
return self._gl_widget
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status_bar(self) -> QStatusBar:
|
||||||
|
"""Access to status bar"""
|
||||||
|
if not hasattr(self, '_status_bar'):
|
||||||
|
raise AttributeError("MainWindow must set _status_bar attribute")
|
||||||
|
return self._status_bar
|
||||||
|
|
||||||
|
@property
|
||||||
|
def template_manager(self):
|
||||||
|
"""Access to template manager"""
|
||||||
|
if not hasattr(self, '_template_manager'):
|
||||||
|
raise AttributeError("MainWindow must set _template_manager attribute")
|
||||||
|
return self._template_manager
|
||||||
|
|
||||||
|
# Common helper methods
|
||||||
|
|
||||||
|
def get_current_page(self):
|
||||||
|
"""
|
||||||
|
Get currently selected page.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Page instance or None if no page is selected
|
||||||
|
"""
|
||||||
|
if not self.project or not self.project.pages:
|
||||||
|
return None
|
||||||
|
|
||||||
|
index = self.gl_widget.current_page_index
|
||||||
|
if 0 <= index < len(self.project.pages):
|
||||||
|
return self.project.pages[index]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_current_page_index(self) -> int:
|
||||||
|
"""
|
||||||
|
Get current page index.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Current page index, or -1 if no page
|
||||||
|
"""
|
||||||
|
if not self.project or not self.project.pages:
|
||||||
|
return -1
|
||||||
|
return 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)
|
||||||
|
|
||||||
|
def show_warning(self, title: str, message: str):
|
||||||
|
"""
|
||||||
|
Show warning dialog.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Dialog title
|
||||||
|
message: Warning message
|
||||||
|
"""
|
||||||
|
QMessageBox.warning(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)
|
||||||
|
|
||||||
|
def require_page(self, show_warning: bool = True) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a page is available and optionally show warning.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
show_warning: Whether to show warning dialog if no page exists
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if page exists, False otherwise
|
||||||
|
"""
|
||||||
|
current_page = self.get_current_page()
|
||||||
|
|
||||||
|
if current_page is None:
|
||||||
|
if show_warning:
|
||||||
|
self.show_warning("No Page", "Please create a page first.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def require_selection(self, min_count: int = 1, show_warning: bool = True) -> bool:
|
||||||
|
"""
|
||||||
|
Check if required number of elements are selected.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
min_count: Minimum number of selected elements required
|
||||||
|
show_warning: Whether to show warning dialog if requirement not met
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if requirements met, False otherwise
|
||||||
|
"""
|
||||||
|
selected_count = len(self.gl_widget.selected_elements)
|
||||||
|
|
||||||
|
if selected_count < min_count:
|
||||||
|
if show_warning:
|
||||||
|
if min_count == 1:
|
||||||
|
self.show_info("No Selection", "Please select an element.")
|
||||||
|
else:
|
||||||
|
self.show_info(
|
||||||
|
"Selection Required",
|
||||||
|
f"Please select at least {min_count} elements."
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def update_view(self):
|
||||||
|
"""Trigger GL widget update to refresh the view"""
|
||||||
|
if self.gl_widget:
|
||||||
|
self.gl_widget.update()
|
||||||
25
pyPhotoAlbum/mixins/operations/__init__.py
Normal file
25
pyPhotoAlbum/mixins/operations/__init__.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
"""
|
||||||
|
Operation mixins for pyPhotoAlbum
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pyPhotoAlbum.mixins.operations.file_ops import FileOperationsMixin
|
||||||
|
from pyPhotoAlbum.mixins.operations.edit_ops import EditOperationsMixin
|
||||||
|
from pyPhotoAlbum.mixins.operations.element_ops import ElementOperationsMixin
|
||||||
|
from pyPhotoAlbum.mixins.operations.page_ops import PageOperationsMixin
|
||||||
|
from pyPhotoAlbum.mixins.operations.template_ops import TemplateOperationsMixin
|
||||||
|
from pyPhotoAlbum.mixins.operations.view_ops import ViewOperationsMixin
|
||||||
|
from pyPhotoAlbum.mixins.operations.alignment_ops import AlignmentOperationsMixin
|
||||||
|
from pyPhotoAlbum.mixins.operations.distribution_ops import DistributionOperationsMixin
|
||||||
|
from pyPhotoAlbum.mixins.operations.size_ops import SizeOperationsMixin
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'FileOperationsMixin',
|
||||||
|
'EditOperationsMixin',
|
||||||
|
'ElementOperationsMixin',
|
||||||
|
'PageOperationsMixin',
|
||||||
|
'TemplateOperationsMixin',
|
||||||
|
'ViewOperationsMixin',
|
||||||
|
'AlignmentOperationsMixin',
|
||||||
|
'DistributionOperationsMixin',
|
||||||
|
'SizeOperationsMixin',
|
||||||
|
]
|
||||||
141
pyPhotoAlbum/mixins/operations/alignment_ops.py
Normal file
141
pyPhotoAlbum/mixins/operations/alignment_ops.py
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
"""
|
||||||
|
Alignment operations mixin for pyPhotoAlbum
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pyPhotoAlbum.decorators import ribbon_action
|
||||||
|
from pyPhotoAlbum.alignment import AlignmentManager
|
||||||
|
from pyPhotoAlbum.commands import AlignElementsCommand
|
||||||
|
|
||||||
|
|
||||||
|
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 []
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Align Left",
|
||||||
|
tooltip="Align selected elements to the left",
|
||||||
|
tab="Arrange",
|
||||||
|
group="Align",
|
||||||
|
requires_selection=True,
|
||||||
|
min_selection=2
|
||||||
|
)
|
||||||
|
def align_left(self):
|
||||||
|
"""Align selected elements to the left"""
|
||||||
|
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)
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Align Right",
|
||||||
|
tooltip="Align selected elements to the right",
|
||||||
|
tab="Arrange",
|
||||||
|
group="Align",
|
||||||
|
requires_selection=True,
|
||||||
|
min_selection=2
|
||||||
|
)
|
||||||
|
def align_right(self):
|
||||||
|
"""Align selected elements to the right"""
|
||||||
|
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)
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Align Top",
|
||||||
|
tooltip="Align selected elements to the top",
|
||||||
|
tab="Arrange",
|
||||||
|
group="Align",
|
||||||
|
requires_selection=True,
|
||||||
|
min_selection=2
|
||||||
|
)
|
||||||
|
def align_top(self):
|
||||||
|
"""Align selected elements to the top"""
|
||||||
|
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)
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Align Bottom",
|
||||||
|
tooltip="Align selected elements to the bottom",
|
||||||
|
tab="Arrange",
|
||||||
|
group="Align",
|
||||||
|
requires_selection=True,
|
||||||
|
min_selection=2
|
||||||
|
)
|
||||||
|
def align_bottom(self):
|
||||||
|
"""Align selected elements to the bottom"""
|
||||||
|
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)
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Align H-Center",
|
||||||
|
tooltip="Align selected elements to horizontal center",
|
||||||
|
tab="Arrange",
|
||||||
|
group="Align",
|
||||||
|
requires_selection=True,
|
||||||
|
min_selection=2
|
||||||
|
)
|
||||||
|
def align_horizontal_center(self):
|
||||||
|
"""Align selected elements to horizontal center"""
|
||||||
|
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)
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Align V-Center",
|
||||||
|
tooltip="Align selected elements to vertical center",
|
||||||
|
tab="Arrange",
|
||||||
|
group="Align",
|
||||||
|
requires_selection=True,
|
||||||
|
min_selection=2
|
||||||
|
)
|
||||||
|
def align_vertical_center(self):
|
||||||
|
"""Align selected elements to vertical center"""
|
||||||
|
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)
|
||||||
99
pyPhotoAlbum/mixins/operations/distribution_ops.py
Normal file
99
pyPhotoAlbum/mixins/operations/distribution_ops.py
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
"""
|
||||||
|
Distribution operations mixin for pyPhotoAlbum
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pyPhotoAlbum.decorators import ribbon_action
|
||||||
|
from pyPhotoAlbum.alignment import AlignmentManager
|
||||||
|
from pyPhotoAlbum.commands import AlignElementsCommand
|
||||||
|
|
||||||
|
|
||||||
|
class DistributionOperationsMixin:
|
||||||
|
"""Mixin providing element distribution and spacing operations"""
|
||||||
|
|
||||||
|
def _get_selected_elements_list(self):
|
||||||
|
"""Get list of selected elements for distribution operations"""
|
||||||
|
return list(self.gl_widget.selected_elements) if self.gl_widget.selected_elements else []
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Distribute H",
|
||||||
|
tooltip="Distribute selected elements evenly horizontally",
|
||||||
|
tab="Arrange",
|
||||||
|
group="Distribute",
|
||||||
|
requires_selection=True,
|
||||||
|
min_selection=3
|
||||||
|
)
|
||||||
|
def distribute_horizontally(self):
|
||||||
|
"""Distribute selected elements evenly horizontally"""
|
||||||
|
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)
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Distribute V",
|
||||||
|
tooltip="Distribute selected elements evenly vertically",
|
||||||
|
tab="Arrange",
|
||||||
|
group="Distribute",
|
||||||
|
requires_selection=True,
|
||||||
|
min_selection=3
|
||||||
|
)
|
||||||
|
def distribute_vertically(self):
|
||||||
|
"""Distribute selected elements evenly vertically"""
|
||||||
|
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)
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Space H",
|
||||||
|
tooltip="Space selected elements equally horizontally",
|
||||||
|
tab="Arrange",
|
||||||
|
group="Distribute",
|
||||||
|
requires_selection=True,
|
||||||
|
min_selection=3
|
||||||
|
)
|
||||||
|
def space_horizontally(self):
|
||||||
|
"""Space selected elements equally horizontally"""
|
||||||
|
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)
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Space V",
|
||||||
|
tooltip="Space selected elements equally vertically",
|
||||||
|
tab="Arrange",
|
||||||
|
group="Distribute",
|
||||||
|
requires_selection=True,
|
||||||
|
min_selection=3
|
||||||
|
)
|
||||||
|
def space_vertically(self):
|
||||||
|
"""Space selected elements equally vertically"""
|
||||||
|
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)
|
||||||
157
pyPhotoAlbum/mixins/operations/edit_ops.py
Normal file
157
pyPhotoAlbum/mixins/operations/edit_ops.py
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
"""
|
||||||
|
Edit operations mixin for pyPhotoAlbum
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pyPhotoAlbum.decorators import ribbon_action
|
||||||
|
from pyPhotoAlbum.commands import DeleteElementCommand, RotateElementCommand
|
||||||
|
|
||||||
|
|
||||||
|
class EditOperationsMixin:
|
||||||
|
"""Mixin providing edit-related operations"""
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Undo",
|
||||||
|
tooltip="Undo last action (Ctrl+Z)",
|
||||||
|
tab="Home",
|
||||||
|
group="Edit",
|
||||||
|
shortcut="Ctrl+Z"
|
||||||
|
)
|
||||||
|
def undo(self):
|
||||||
|
"""Undo last action"""
|
||||||
|
if self.project.history.undo():
|
||||||
|
self.update_view()
|
||||||
|
self.show_status("Undo successful", 2000)
|
||||||
|
print("Undo successful")
|
||||||
|
else:
|
||||||
|
self.show_status("Nothing to undo", 2000)
|
||||||
|
print("Nothing to undo")
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Redo",
|
||||||
|
tooltip="Redo last action (Ctrl+Y or Ctrl+Shift+Z)",
|
||||||
|
tab="Home",
|
||||||
|
group="Edit",
|
||||||
|
shortcut="Ctrl+Y"
|
||||||
|
)
|
||||||
|
def redo(self):
|
||||||
|
"""Redo last action"""
|
||||||
|
if self.project.history.redo():
|
||||||
|
self.update_view()
|
||||||
|
self.show_status("Redo successful", 2000)
|
||||||
|
print("Redo successful")
|
||||||
|
else:
|
||||||
|
self.show_status("Nothing to redo", 2000)
|
||||||
|
print("Nothing to redo")
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Delete",
|
||||||
|
tooltip="Delete selected element (Delete key)",
|
||||||
|
tab="Home",
|
||||||
|
group="Edit",
|
||||||
|
requires_selection=True
|
||||||
|
)
|
||||||
|
def delete_selected_element(self):
|
||||||
|
"""Delete the currently selected element"""
|
||||||
|
if not self.require_selection(min_count=1):
|
||||||
|
return
|
||||||
|
|
||||||
|
current_page = self.get_current_page()
|
||||||
|
if not current_page:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Delete the first selected element (for backward compatibility)
|
||||||
|
# In the future, we could delete all selected elements
|
||||||
|
selected_element = next(iter(self.gl_widget.selected_elements))
|
||||||
|
|
||||||
|
try:
|
||||||
|
cmd = DeleteElementCommand(
|
||||||
|
current_page.layout,
|
||||||
|
selected_element,
|
||||||
|
asset_manager=self.project.asset_manager
|
||||||
|
)
|
||||||
|
self.project.history.execute(cmd)
|
||||||
|
|
||||||
|
# Clear selection
|
||||||
|
self.gl_widget.selected_elements.clear()
|
||||||
|
|
||||||
|
# Update display
|
||||||
|
self.update_view()
|
||||||
|
|
||||||
|
self.show_status("Element deleted (Ctrl+Z to undo)", 2000)
|
||||||
|
print("Deleted selected element")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.show_error("Error", f"Failed to delete element: {str(e)}")
|
||||||
|
print(f"Error deleting element: {e}")
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Rotate Left",
|
||||||
|
tooltip="Rotate selected element 90° counter-clockwise",
|
||||||
|
tab="Home",
|
||||||
|
group="Transform",
|
||||||
|
requires_selection=True
|
||||||
|
)
|
||||||
|
def rotate_left(self):
|
||||||
|
"""Rotate selected element 90 degrees counter-clockwise"""
|
||||||
|
if not self.require_selection(min_count=1):
|
||||||
|
return
|
||||||
|
|
||||||
|
selected_element = next(iter(self.gl_widget.selected_elements))
|
||||||
|
old_rotation = selected_element.rotation
|
||||||
|
new_rotation = (old_rotation - 90) % 360
|
||||||
|
|
||||||
|
cmd = RotateElementCommand(selected_element, old_rotation, new_rotation)
|
||||||
|
self.project.history.execute(cmd)
|
||||||
|
|
||||||
|
self.update_view()
|
||||||
|
self.show_status(f"Rotated to {new_rotation}° (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",
|
||||||
|
group="Transform",
|
||||||
|
requires_selection=True
|
||||||
|
)
|
||||||
|
def rotate_right(self):
|
||||||
|
"""Rotate selected element 90 degrees clockwise"""
|
||||||
|
if not self.require_selection(min_count=1):
|
||||||
|
return
|
||||||
|
|
||||||
|
selected_element = next(iter(self.gl_widget.selected_elements))
|
||||||
|
old_rotation = selected_element.rotation
|
||||||
|
new_rotation = (old_rotation + 90) % 360
|
||||||
|
|
||||||
|
cmd = RotateElementCommand(selected_element, old_rotation, new_rotation)
|
||||||
|
self.project.history.execute(cmd)
|
||||||
|
|
||||||
|
self.update_view()
|
||||||
|
self.show_status(f"Rotated to {new_rotation}° (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",
|
||||||
|
group="Transform",
|
||||||
|
requires_selection=True
|
||||||
|
)
|
||||||
|
def reset_rotation(self):
|
||||||
|
"""Reset selected element rotation to 0 degrees"""
|
||||||
|
if not self.require_selection(min_count=1):
|
||||||
|
return
|
||||||
|
|
||||||
|
selected_element = next(iter(self.gl_widget.selected_elements))
|
||||||
|
old_rotation = selected_element.rotation
|
||||||
|
|
||||||
|
if old_rotation == 0:
|
||||||
|
self.show_status("Element already at 0°", 2000)
|
||||||
|
return
|
||||||
|
|
||||||
|
cmd = RotateElementCommand(selected_element, old_rotation, 0)
|
||||||
|
self.project.history.execute(cmd)
|
||||||
|
|
||||||
|
self.update_view()
|
||||||
|
self.show_status("Reset rotation to 0° (Ctrl+Z to undo)", 2000)
|
||||||
|
print(f"Reset element rotation: {old_rotation}° → 0°")
|
||||||
166
pyPhotoAlbum/mixins/operations/element_ops.py
Normal file
166
pyPhotoAlbum/mixins/operations/element_ops.py
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class ElementOperationsMixin:
|
||||||
|
"""Mixin providing element creation and manipulation operations"""
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Image",
|
||||||
|
tooltip="Add an image to the current page",
|
||||||
|
tab="Insert",
|
||||||
|
group="Media",
|
||||||
|
requires_page=True
|
||||||
|
)
|
||||||
|
def add_image(self):
|
||||||
|
"""Add an image to the current page"""
|
||||||
|
if not self.require_page():
|
||||||
|
return
|
||||||
|
|
||||||
|
file_path, _ = QFileDialog.getOpenFileName(
|
||||||
|
self,
|
||||||
|
"Select Image",
|
||||||
|
"",
|
||||||
|
"Image Files (*.jpg *.jpeg *.png *.gif *.bmp *.tiff *.webp);;All Files (*)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
return
|
||||||
|
|
||||||
|
current_page = self.get_current_page()
|
||||||
|
if not current_page:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Import asset to project
|
||||||
|
asset_path = self.project.asset_manager.import_asset(file_path)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
# Create image element at center of page
|
||||||
|
page_width_mm = current_page.layout.size[0]
|
||||||
|
page_height_mm = current_page.layout.size[1]
|
||||||
|
|
||||||
|
# Center position
|
||||||
|
x = (page_width_mm - img_width) / 2
|
||||||
|
y = (page_height_mm - img_height) / 2
|
||||||
|
|
||||||
|
new_image = ImageData(
|
||||||
|
image_path=asset_path,
|
||||||
|
x=x,
|
||||||
|
y=y,
|
||||||
|
width=img_width,
|
||||||
|
height=img_height
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add element using command pattern for undo/redo
|
||||||
|
cmd = AddElementCommand(
|
||||||
|
current_page.layout,
|
||||||
|
new_image,
|
||||||
|
asset_manager=self.project.asset_manager
|
||||||
|
)
|
||||||
|
self.project.history.execute(cmd)
|
||||||
|
|
||||||
|
self.update_view()
|
||||||
|
self.show_status("Added image (Ctrl+Z to undo)", 2000)
|
||||||
|
print(f"Added image to page {self.get_current_page_index() + 1}: {asset_path}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.show_error("Error", f"Failed to add image: {str(e)}")
|
||||||
|
print(f"Error adding image: {e}")
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Text",
|
||||||
|
tooltip="Add a text box to the current page",
|
||||||
|
tab="Insert",
|
||||||
|
group="Media",
|
||||||
|
requires_page=True
|
||||||
|
)
|
||||||
|
def add_text(self):
|
||||||
|
"""Add text to the current page"""
|
||||||
|
if not self.require_page():
|
||||||
|
return
|
||||||
|
|
||||||
|
current_page = self.get_current_page()
|
||||||
|
if not current_page:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create text box element at center of page
|
||||||
|
page_width_mm = current_page.layout.size[0]
|
||||||
|
page_height_mm = current_page.layout.size[1]
|
||||||
|
|
||||||
|
text_width = 200
|
||||||
|
text_height = 50
|
||||||
|
|
||||||
|
# Center position
|
||||||
|
x = (page_width_mm - text_width) / 2
|
||||||
|
y = (page_height_mm - text_height) / 2
|
||||||
|
|
||||||
|
new_text = TextBoxData(
|
||||||
|
text_content="New Text",
|
||||||
|
x=x,
|
||||||
|
y=y,
|
||||||
|
width=text_width,
|
||||||
|
height=text_height
|
||||||
|
)
|
||||||
|
|
||||||
|
current_page.layout.add_element(new_text)
|
||||||
|
self.update_view()
|
||||||
|
|
||||||
|
print(f"Added text box to page {self.get_current_page_index() + 1}")
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Placeholder",
|
||||||
|
tooltip="Add a placeholder to the current page",
|
||||||
|
tab="Insert",
|
||||||
|
group="Media",
|
||||||
|
requires_page=True
|
||||||
|
)
|
||||||
|
def add_placeholder(self):
|
||||||
|
"""Add a placeholder to the current page"""
|
||||||
|
if not self.require_page():
|
||||||
|
return
|
||||||
|
|
||||||
|
current_page = self.get_current_page()
|
||||||
|
if not current_page:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create placeholder element at center of page
|
||||||
|
page_width_mm = current_page.layout.size[0]
|
||||||
|
page_height_mm = current_page.layout.size[1]
|
||||||
|
|
||||||
|
placeholder_width = 200
|
||||||
|
placeholder_height = 150
|
||||||
|
|
||||||
|
# Center position
|
||||||
|
x = (page_width_mm - placeholder_width) / 2
|
||||||
|
y = (page_height_mm - placeholder_height) / 2
|
||||||
|
|
||||||
|
new_placeholder = PlaceholderData(
|
||||||
|
placeholder_type="image",
|
||||||
|
x=x,
|
||||||
|
y=y,
|
||||||
|
width=placeholder_width,
|
||||||
|
height=placeholder_height
|
||||||
|
)
|
||||||
|
|
||||||
|
current_page.layout.add_element(new_placeholder)
|
||||||
|
self.update_view()
|
||||||
|
|
||||||
|
print(f"Added placeholder to page {self.get_current_page_index() + 1}")
|
||||||
393
pyPhotoAlbum/mixins/operations/file_ops.py
Normal file
393
pyPhotoAlbum/mixins/operations/file_ops.py
Normal file
@ -0,0 +1,393 @@
|
|||||||
|
"""
|
||||||
|
File operations mixin for pyPhotoAlbum
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PyQt6.QtWidgets import QFileDialog, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox, QRadioButton, QButtonGroup
|
||||||
|
from pyPhotoAlbum.decorators import ribbon_action, numerical_input
|
||||||
|
from pyPhotoAlbum.project import Project
|
||||||
|
|
||||||
|
|
||||||
|
class FileOperationsMixin:
|
||||||
|
"""Mixin providing file-related operations"""
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="New",
|
||||||
|
tooltip="Create a new project",
|
||||||
|
tab="Home",
|
||||||
|
group="File",
|
||||||
|
shortcut="Ctrl+N"
|
||||||
|
)
|
||||||
|
def new_project(self):
|
||||||
|
"""Create a new project"""
|
||||||
|
self.project = Project("New Project")
|
||||||
|
self.show_status("New project created")
|
||||||
|
print("New project created")
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Open",
|
||||||
|
tooltip="Open an existing project",
|
||||||
|
tab="Home",
|
||||||
|
group="File",
|
||||||
|
shortcut="Ctrl+O"
|
||||||
|
)
|
||||||
|
def open_project(self):
|
||||||
|
"""Open an existing project"""
|
||||||
|
from pyPhotoAlbum.project_serializer import load_from_zip
|
||||||
|
|
||||||
|
file_path, _ = QFileDialog.getOpenFileName(
|
||||||
|
self,
|
||||||
|
"Open Project",
|
||||||
|
"",
|
||||||
|
"pyPhotoAlbum Projects (*.ppz);;All Files (*)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if file_path:
|
||||||
|
print(f"Opening project: {file_path}")
|
||||||
|
|
||||||
|
# Load project from ZIP
|
||||||
|
project, error = load_from_zip(file_path)
|
||||||
|
|
||||||
|
if project:
|
||||||
|
self.project = project
|
||||||
|
self.current_page_index = 0 # Reset to first page
|
||||||
|
self.update_view()
|
||||||
|
self.show_status(f"Project opened: {file_path}")
|
||||||
|
print(f"Successfully loaded project: {project.name}")
|
||||||
|
else:
|
||||||
|
error_msg = f"Failed to open project: {error}"
|
||||||
|
self.show_status(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"""
|
||||||
|
from pyPhotoAlbum.project_serializer import save_to_zip
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
self.show_status(f"Project saved: {file_path}")
|
||||||
|
print(f"Successfully saved project to: {file_path}")
|
||||||
|
else:
|
||||||
|
error_msg = f"Failed to save project: {error}"
|
||||||
|
self.show_status(error_msg)
|
||||||
|
print(error_msg)
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Project Settings",
|
||||||
|
tooltip="Configure project-wide page size and defaults",
|
||||||
|
tab="Home",
|
||||||
|
group="File"
|
||||||
|
)
|
||||||
|
@numerical_input(
|
||||||
|
fields=[
|
||||||
|
('width', 'Width', 'mm', 10, 1000),
|
||||||
|
('height', 'Height', 'mm', 10, 1000)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
def project_settings(self):
|
||||||
|
"""Configure project-wide settings including default page size"""
|
||||||
|
# Create dialog
|
||||||
|
dialog = QDialog(self)
|
||||||
|
dialog.setWindowTitle("Project Settings")
|
||||||
|
dialog.setMinimumWidth(500)
|
||||||
|
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
|
# Page size group
|
||||||
|
size_group = QGroupBox("Default Page Size")
|
||||||
|
size_layout = QVBoxLayout()
|
||||||
|
|
||||||
|
# Width
|
||||||
|
width_layout = QHBoxLayout()
|
||||||
|
width_layout.addWidget(QLabel("Width:"))
|
||||||
|
width_spinbox = QDoubleSpinBox()
|
||||||
|
width_spinbox.setRange(10, 1000)
|
||||||
|
width_spinbox.setValue(self.project.page_size_mm[0])
|
||||||
|
width_spinbox.setSuffix(" mm")
|
||||||
|
width_layout.addWidget(width_spinbox)
|
||||||
|
size_layout.addLayout(width_layout)
|
||||||
|
|
||||||
|
# Height
|
||||||
|
height_layout = QHBoxLayout()
|
||||||
|
height_layout.addWidget(QLabel("Height:"))
|
||||||
|
height_spinbox = QDoubleSpinBox()
|
||||||
|
height_spinbox.setRange(10, 1000)
|
||||||
|
height_spinbox.setValue(self.project.page_size_mm[1])
|
||||||
|
height_spinbox.setSuffix(" mm")
|
||||||
|
height_layout.addWidget(height_spinbox)
|
||||||
|
size_layout.addLayout(height_layout)
|
||||||
|
|
||||||
|
size_group.setLayout(size_layout)
|
||||||
|
layout.addWidget(size_group)
|
||||||
|
|
||||||
|
# DPI settings group
|
||||||
|
dpi_group = QGroupBox("DPI Settings")
|
||||||
|
dpi_layout = QVBoxLayout()
|
||||||
|
|
||||||
|
# Working DPI
|
||||||
|
working_dpi_layout = QHBoxLayout()
|
||||||
|
working_dpi_layout.addWidget(QLabel("Working DPI:"))
|
||||||
|
working_dpi_spinbox = QSpinBox()
|
||||||
|
working_dpi_spinbox.setRange(72, 1200)
|
||||||
|
working_dpi_spinbox.setValue(self.project.working_dpi)
|
||||||
|
working_dpi_layout.addWidget(working_dpi_spinbox)
|
||||||
|
dpi_layout.addLayout(working_dpi_layout)
|
||||||
|
|
||||||
|
# Export DPI
|
||||||
|
export_dpi_layout = QHBoxLayout()
|
||||||
|
export_dpi_layout.addWidget(QLabel("Export DPI:"))
|
||||||
|
export_dpi_spinbox = QSpinBox()
|
||||||
|
export_dpi_spinbox.setRange(72, 1200)
|
||||||
|
export_dpi_spinbox.setValue(self.project.export_dpi)
|
||||||
|
export_dpi_layout.addWidget(export_dpi_spinbox)
|
||||||
|
dpi_layout.addLayout(export_dpi_layout)
|
||||||
|
|
||||||
|
dpi_group.setLayout(dpi_layout)
|
||||||
|
layout.addWidget(dpi_group)
|
||||||
|
|
||||||
|
# Content scaling options (only if pages exist and size is changing)
|
||||||
|
scaling_group = None
|
||||||
|
scaling_buttons = None
|
||||||
|
|
||||||
|
if self.project.pages:
|
||||||
|
scaling_group = QGroupBox("Apply to Existing Pages")
|
||||||
|
scaling_layout = QVBoxLayout()
|
||||||
|
|
||||||
|
info_label = QLabel("How should existing content be adjusted?\n(Pages with manual sizing will not be affected)")
|
||||||
|
info_label.setWordWrap(True)
|
||||||
|
scaling_layout.addWidget(info_label)
|
||||||
|
|
||||||
|
scaling_buttons = QButtonGroup()
|
||||||
|
|
||||||
|
proportional_radio = QRadioButton("Resize proportionally (fit to smallest axis)")
|
||||||
|
proportional_radio.setToolTip("Scale content uniformly to fit the new page size")
|
||||||
|
scaling_buttons.addButton(proportional_radio, 0)
|
||||||
|
scaling_layout.addWidget(proportional_radio)
|
||||||
|
|
||||||
|
stretch_radio = QRadioButton("Resize on both axes (stretch)")
|
||||||
|
stretch_radio.setToolTip("Scale width and height independently")
|
||||||
|
scaling_buttons.addButton(stretch_radio, 1)
|
||||||
|
scaling_layout.addWidget(stretch_radio)
|
||||||
|
|
||||||
|
reposition_radio = QRadioButton("Keep content size, reposition to center")
|
||||||
|
reposition_radio.setToolTip("Maintain element sizes but center them on new page")
|
||||||
|
scaling_buttons.addButton(reposition_radio, 2)
|
||||||
|
scaling_layout.addWidget(reposition_radio)
|
||||||
|
|
||||||
|
none_radio = QRadioButton("Don't adjust content (page size only)")
|
||||||
|
none_radio.setToolTip("Only change page size, leave content as-is")
|
||||||
|
none_radio.setChecked(True) # Default
|
||||||
|
scaling_buttons.addButton(none_radio, 3)
|
||||||
|
scaling_layout.addWidget(none_radio)
|
||||||
|
|
||||||
|
scaling_group.setLayout(scaling_layout)
|
||||||
|
layout.addWidget(scaling_group)
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
button_layout = QHBoxLayout()
|
||||||
|
cancel_btn = QPushButton("Cancel")
|
||||||
|
cancel_btn.clicked.connect(dialog.reject)
|
||||||
|
ok_btn = QPushButton("OK")
|
||||||
|
ok_btn.clicked.connect(dialog.accept)
|
||||||
|
ok_btn.setDefault(True)
|
||||||
|
|
||||||
|
button_layout.addStretch()
|
||||||
|
button_layout.addWidget(cancel_btn)
|
||||||
|
button_layout.addWidget(ok_btn)
|
||||||
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
|
dialog.setLayout(layout)
|
||||||
|
|
||||||
|
# Show dialog
|
||||||
|
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||||
|
# Get new values
|
||||||
|
new_width = width_spinbox.value()
|
||||||
|
new_height = height_spinbox.value()
|
||||||
|
new_working_dpi = working_dpi_spinbox.value()
|
||||||
|
new_export_dpi = export_dpi_spinbox.value()
|
||||||
|
|
||||||
|
# Determine scaling mode
|
||||||
|
scaling_mode = 'none'
|
||||||
|
if scaling_buttons:
|
||||||
|
selected_id = scaling_buttons.checkedId()
|
||||||
|
modes = {0: 'proportional', 1: 'stretch', 2: 'reposition', 3: 'none'}
|
||||||
|
scaling_mode = modes.get(selected_id, 'none')
|
||||||
|
|
||||||
|
# Apply settings
|
||||||
|
old_size = self.project.page_size_mm
|
||||||
|
self.project.page_size_mm = (new_width, new_height)
|
||||||
|
self.project.working_dpi = new_working_dpi
|
||||||
|
self.project.export_dpi = new_export_dpi
|
||||||
|
|
||||||
|
# Update existing pages (exclude manually sized ones)
|
||||||
|
if self.project.pages and old_size != (new_width, new_height):
|
||||||
|
self._apply_page_size_to_project(old_size, (new_width, new_height), scaling_mode)
|
||||||
|
|
||||||
|
self.update_view()
|
||||||
|
self.show_status(f"Project settings updated: {new_width}×{new_height} mm", 2000)
|
||||||
|
print(f"Project settings updated: {new_width}×{new_height} mm, scaling mode: {scaling_mode}")
|
||||||
|
|
||||||
|
def _apply_page_size_to_project(self, old_size, new_size, scaling_mode):
|
||||||
|
"""
|
||||||
|
Apply new page size to all non-manually-sized pages
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_size: Old page size (width, height) in mm
|
||||||
|
new_size: New page size (width, height) in mm
|
||||||
|
scaling_mode: 'proportional', 'stretch', 'reposition', or 'none'
|
||||||
|
"""
|
||||||
|
old_width, old_height = old_size
|
||||||
|
new_width, new_height = new_size
|
||||||
|
|
||||||
|
width_ratio = new_width / old_width if old_width > 0 else 1.0
|
||||||
|
height_ratio = new_height / old_height if old_height > 0 else 1.0
|
||||||
|
|
||||||
|
for page in self.project.pages:
|
||||||
|
# Skip manually sized pages
|
||||||
|
if page.manually_sized:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Update page size
|
||||||
|
old_page_width, old_page_height = page.layout.size
|
||||||
|
|
||||||
|
# For double spreads, maintain the 2x multiplier
|
||||||
|
if page.is_double_spread:
|
||||||
|
page.layout.size = (new_width * 2, new_height)
|
||||||
|
else:
|
||||||
|
page.layout.size = (new_width, new_height)
|
||||||
|
|
||||||
|
# Apply content scaling based on mode
|
||||||
|
if scaling_mode == 'proportional':
|
||||||
|
# Use smallest ratio to fit content
|
||||||
|
scale = min(width_ratio, height_ratio)
|
||||||
|
self._scale_page_elements(page, scale, scale)
|
||||||
|
elif scaling_mode == 'stretch':
|
||||||
|
# Scale independently on each axis
|
||||||
|
self._scale_page_elements(page, width_ratio, height_ratio)
|
||||||
|
elif scaling_mode == 'reposition':
|
||||||
|
# Keep size, center content
|
||||||
|
self._reposition_page_elements(page, old_size, new_size)
|
||||||
|
# 'none' - do nothing to elements
|
||||||
|
|
||||||
|
def _scale_page_elements(self, page, x_scale, y_scale):
|
||||||
|
"""
|
||||||
|
Scale all elements on a page
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page: Page object
|
||||||
|
x_scale: Horizontal scale factor
|
||||||
|
y_scale: Vertical scale factor
|
||||||
|
"""
|
||||||
|
for element in page.layout.elements:
|
||||||
|
# Scale position
|
||||||
|
x, y = element.position
|
||||||
|
element.position = (x * x_scale, y * y_scale)
|
||||||
|
|
||||||
|
# Scale size
|
||||||
|
width, height = element.size
|
||||||
|
element.size = (width * x_scale, height * y_scale)
|
||||||
|
|
||||||
|
def _reposition_page_elements(self, page, old_size, new_size):
|
||||||
|
"""
|
||||||
|
Reposition elements to center them on the new page size
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page: Page object
|
||||||
|
old_size: Old page size (width, height) in mm
|
||||||
|
new_size: New page size (width, height) in mm
|
||||||
|
"""
|
||||||
|
old_width, old_height = old_size
|
||||||
|
new_width, new_height = new_size
|
||||||
|
|
||||||
|
x_offset = (new_width - old_width) / 2.0
|
||||||
|
y_offset = (new_height - old_height) / 2.0
|
||||||
|
|
||||||
|
for element in page.layout.elements:
|
||||||
|
x, y = element.position
|
||||||
|
element.position = (x + x_offset, y + y_offset)
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Export PDF",
|
||||||
|
tooltip="Export project to PDF",
|
||||||
|
tab="Export",
|
||||||
|
group="Export"
|
||||||
|
)
|
||||||
|
def export_pdf(self):
|
||||||
|
"""Export project to PDF"""
|
||||||
|
from PyQt6.QtWidgets import QProgressDialog
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
from pyPhotoAlbum.pdf_exporter import PDFExporter
|
||||||
|
|
||||||
|
# Check if we have pages to export
|
||||||
|
if not self.project or not self.project.pages:
|
||||||
|
self.show_status("No pages to export")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Show file save dialog
|
||||||
|
file_path, _ = QFileDialog.getSaveFileName(
|
||||||
|
self,
|
||||||
|
"Export to PDF",
|
||||||
|
"",
|
||||||
|
"PDF Files (*.pdf);;All Files (*)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Ensure .pdf extension
|
||||||
|
if not file_path.lower().endswith('.pdf'):
|
||||||
|
file_path += '.pdf'
|
||||||
|
|
||||||
|
# Calculate total pages for progress
|
||||||
|
total_pages = sum(2 if page.is_double_spread else 1 for page in self.project.pages)
|
||||||
|
|
||||||
|
# Create progress dialog
|
||||||
|
progress = QProgressDialog("Exporting to PDF...", "Cancel", 0, total_pages, self)
|
||||||
|
progress.setWindowModality(Qt.WindowModality.WindowModal)
|
||||||
|
progress.setMinimumDuration(0)
|
||||||
|
progress.setValue(0)
|
||||||
|
|
||||||
|
# Progress callback
|
||||||
|
def update_progress(current, total, message):
|
||||||
|
progress.setLabelText(message)
|
||||||
|
progress.setValue(current)
|
||||||
|
if progress.wasCanceled():
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Export to PDF
|
||||||
|
exporter = PDFExporter(self.project)
|
||||||
|
success, warnings = exporter.export(file_path, update_progress)
|
||||||
|
|
||||||
|
progress.close()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
message = f"PDF exported successfully to {file_path}"
|
||||||
|
if warnings:
|
||||||
|
message += f"\n\nWarnings:\n" + "\n".join(warnings)
|
||||||
|
self.show_status(message)
|
||||||
|
print(message)
|
||||||
|
else:
|
||||||
|
error_message = f"PDF export failed"
|
||||||
|
if warnings:
|
||||||
|
error_message += f":\n" + "\n".join(warnings)
|
||||||
|
self.show_status(error_message)
|
||||||
|
print(error_message)
|
||||||
215
pyPhotoAlbum/mixins/operations/page_ops.py
Normal file
215
pyPhotoAlbum/mixins/operations/page_ops.py
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
"""
|
||||||
|
Page operations mixin for pyPhotoAlbum
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pyPhotoAlbum.decorators import ribbon_action
|
||||||
|
from pyPhotoAlbum.project import Page
|
||||||
|
from pyPhotoAlbum.page_layout import PageLayout
|
||||||
|
|
||||||
|
|
||||||
|
class PageOperationsMixin:
|
||||||
|
"""Mixin providing page management operations"""
|
||||||
|
|
||||||
|
# Note: Previous/Next page navigation removed - now using scrollable multi-page view
|
||||||
|
# User can scroll through all pages vertically
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Add Page",
|
||||||
|
tooltip="Add a new page to the project",
|
||||||
|
tab="Layout",
|
||||||
|
group="Page"
|
||||||
|
)
|
||||||
|
def add_page(self):
|
||||||
|
"""Add a new page to the project"""
|
||||||
|
new_page_number = len(self.project.pages) + 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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
|
||||||
|
# Use first page as reference
|
||||||
|
if not self.project.pages:
|
||||||
|
return
|
||||||
|
current_page = self.project.pages[0]
|
||||||
|
|
||||||
|
# Create dialog
|
||||||
|
dialog = QDialog(self)
|
||||||
|
dialog.setWindowTitle("Page Setup")
|
||||||
|
dialog.setMinimumWidth(400)
|
||||||
|
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
|
# 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.setValue(current_page.layout.size[0])
|
||||||
|
width_spinbox.setSuffix(" mm")
|
||||||
|
width_layout.addWidget(width_spinbox)
|
||||||
|
size_layout.addLayout(width_layout)
|
||||||
|
|
||||||
|
# Height
|
||||||
|
height_layout = QHBoxLayout()
|
||||||
|
height_layout.addWidget(QLabel("Height:"))
|
||||||
|
height_spinbox = QDoubleSpinBox()
|
||||||
|
height_spinbox.setRange(10, 1000)
|
||||||
|
height_spinbox.setValue(current_page.layout.size[1])
|
||||||
|
height_spinbox.setSuffix(" mm")
|
||||||
|
height_layout.addWidget(height_spinbox)
|
||||||
|
size_layout.addLayout(height_layout)
|
||||||
|
|
||||||
|
size_group.setLayout(size_layout)
|
||||||
|
layout.addWidget(size_group)
|
||||||
|
|
||||||
|
# DPI settings group
|
||||||
|
dpi_group = QGroupBox("DPI Settings")
|
||||||
|
dpi_layout = QVBoxLayout()
|
||||||
|
|
||||||
|
# Working DPI
|
||||||
|
working_dpi_layout = QHBoxLayout()
|
||||||
|
working_dpi_layout.addWidget(QLabel("Working DPI:"))
|
||||||
|
working_dpi_spinbox = QSpinBox()
|
||||||
|
working_dpi_spinbox.setRange(72, 1200)
|
||||||
|
working_dpi_spinbox.setValue(self.project.working_dpi)
|
||||||
|
working_dpi_layout.addWidget(working_dpi_spinbox)
|
||||||
|
dpi_layout.addLayout(working_dpi_layout)
|
||||||
|
|
||||||
|
# Export DPI
|
||||||
|
export_dpi_layout = QHBoxLayout()
|
||||||
|
export_dpi_layout.addWidget(QLabel("Export DPI:"))
|
||||||
|
export_dpi_spinbox = QSpinBox()
|
||||||
|
export_dpi_spinbox.setRange(72, 1200)
|
||||||
|
export_dpi_spinbox.setValue(self.project.export_dpi)
|
||||||
|
export_dpi_layout.addWidget(export_dpi_spinbox)
|
||||||
|
dpi_layout.addLayout(export_dpi_layout)
|
||||||
|
|
||||||
|
dpi_group.setLayout(dpi_layout)
|
||||||
|
layout.addWidget(dpi_group)
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
# Apply changes
|
||||||
|
width_mm = width_spinbox.value()
|
||||||
|
height_mm = height_spinbox.value()
|
||||||
|
|
||||||
|
# Check if size actually changed
|
||||||
|
old_size = current_page.layout.size
|
||||||
|
if old_size != (width_mm, height_mm):
|
||||||
|
# Mark page as manually sized
|
||||||
|
current_page.manually_sized = True
|
||||||
|
current_page.layout.size = (width_mm, height_mm)
|
||||||
|
print(f"Page {current_page.page_number} marked as manually sized")
|
||||||
|
|
||||||
|
self.project.working_dpi = working_dpi_spinbox.value()
|
||||||
|
self.project.export_dpi = export_dpi_spinbox.value()
|
||||||
|
|
||||||
|
self.update_view()
|
||||||
|
self.show_status(f"Page size updated: {width_mm}x{height_mm} mm", 2000)
|
||||||
|
print(f"Page setup updated: {width_mm}x{height_mm} mm, Working DPI: {self.project.working_dpi}, Export DPI: {self.project.export_dpi}")
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Toggle Spread",
|
||||||
|
tooltip="Toggle double page spread for last page",
|
||||||
|
tab="Layout",
|
||||||
|
group="Page"
|
||||||
|
)
|
||||||
|
def toggle_double_spread(self):
|
||||||
|
"""Toggle double spread for the last page"""
|
||||||
|
if not self.project.pages:
|
||||||
|
return
|
||||||
|
current_page = self.project.pages[-1]
|
||||||
|
|
||||||
|
# Toggle the state
|
||||||
|
is_double = not current_page.is_double_spread
|
||||||
|
current_page.is_double_spread = is_double
|
||||||
|
|
||||||
|
# Mark as manually sized when toggling spread
|
||||||
|
current_page.manually_sized = True
|
||||||
|
|
||||||
|
# Update the page layout width
|
||||||
|
current_width = current_page.layout.size[0]
|
||||||
|
current_height = current_page.layout.size[1]
|
||||||
|
|
||||||
|
# Get base width (might already be doubled)
|
||||||
|
if hasattr(current_page.layout, 'base_width'):
|
||||||
|
base_width = current_page.layout.base_width
|
||||||
|
else:
|
||||||
|
# Assume current width is single if not marked as facing
|
||||||
|
base_width = current_width / 2 if current_page.layout.is_facing_page else current_width
|
||||||
|
|
||||||
|
# Set new width based on double spread state
|
||||||
|
new_width = base_width * 2 if is_double else base_width
|
||||||
|
current_page.layout.base_width = base_width
|
||||||
|
current_page.layout.is_facing_page = is_double
|
||||||
|
current_page.layout.size = (new_width, current_height)
|
||||||
|
|
||||||
|
# Update display
|
||||||
|
self.update_view()
|
||||||
|
|
||||||
|
status = "enabled" if is_double else "disabled"
|
||||||
|
self.show_status(f"Double spread {status}: width = {new_width:.0f}mm", 2000)
|
||||||
|
print(f"Double spread {status}: width = {new_width}mm")
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Remove Page",
|
||||||
|
tooltip="Remove the last page",
|
||||||
|
tab="Layout",
|
||||||
|
group="Page"
|
||||||
|
)
|
||||||
|
def remove_page(self):
|
||||||
|
"""Remove the last page"""
|
||||||
|
if len(self.project.pages) <= 1:
|
||||||
|
self.show_warning("Cannot Remove", "Must have at least one page")
|
||||||
|
print("Cannot remove page - must have at least one page")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Remove last page
|
||||||
|
last_page = self.project.pages[-1]
|
||||||
|
self.project.remove_page(last_page)
|
||||||
|
|
||||||
|
# Renumber remaining pages
|
||||||
|
for i, page in enumerate(self.project.pages):
|
||||||
|
page.page_number = i + 1
|
||||||
|
|
||||||
|
# Update display
|
||||||
|
self.update_view()
|
||||||
|
|
||||||
|
print(f"Removed page, now have {len(self.project.pages)} pages")
|
||||||
78
pyPhotoAlbum/mixins/operations/size_ops.py
Normal file
78
pyPhotoAlbum/mixins/operations/size_ops.py
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
"""
|
||||||
|
Size operations mixin for pyPhotoAlbum
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pyPhotoAlbum.decorators import ribbon_action
|
||||||
|
from pyPhotoAlbum.alignment import AlignmentManager
|
||||||
|
from pyPhotoAlbum.commands import ResizeElementsCommand
|
||||||
|
|
||||||
|
|
||||||
|
class SizeOperationsMixin:
|
||||||
|
"""Mixin providing element sizing operations"""
|
||||||
|
|
||||||
|
def _get_selected_elements_list(self):
|
||||||
|
"""Get list of selected elements for size operations"""
|
||||||
|
return list(self.gl_widget.selected_elements) if self.gl_widget.selected_elements else []
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Same Size",
|
||||||
|
tooltip="Make all selected elements the same size",
|
||||||
|
tab="Arrange",
|
||||||
|
group="Size",
|
||||||
|
requires_selection=True,
|
||||||
|
min_selection=2
|
||||||
|
)
|
||||||
|
def make_same_size(self):
|
||||||
|
"""Make all selected elements the same size"""
|
||||||
|
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)
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Same Width",
|
||||||
|
tooltip="Make all selected elements the same width",
|
||||||
|
tab="Arrange",
|
||||||
|
group="Size",
|
||||||
|
requires_selection=True,
|
||||||
|
min_selection=2
|
||||||
|
)
|
||||||
|
def make_same_width(self):
|
||||||
|
"""Make all selected elements the same width"""
|
||||||
|
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)
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Same Height",
|
||||||
|
tooltip="Make all selected elements the same height",
|
||||||
|
tab="Arrange",
|
||||||
|
group="Size",
|
||||||
|
requires_selection=True,
|
||||||
|
min_selection=2
|
||||||
|
)
|
||||||
|
def make_same_height(self):
|
||||||
|
"""Make all selected elements the same height"""
|
||||||
|
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)
|
||||||
254
pyPhotoAlbum/mixins/operations/template_ops.py
Normal file
254
pyPhotoAlbum/mixins/operations/template_ops.py
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
"""
|
||||||
|
Template operations mixin for pyPhotoAlbum
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QInputDialog, QDialog, QVBoxLayout, QLabel, QComboBox,
|
||||||
|
QRadioButton, QButtonGroup, QPushButton, QHBoxLayout
|
||||||
|
)
|
||||||
|
from pyPhotoAlbum.decorators import ribbon_action
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateOperationsMixin:
|
||||||
|
"""Mixin providing template-related operations"""
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Save as Template",
|
||||||
|
tooltip="Save current page as a reusable template",
|
||||||
|
tab="Layout",
|
||||||
|
group="Templates",
|
||||||
|
requires_page=True
|
||||||
|
)
|
||||||
|
def save_page_as_template(self):
|
||||||
|
"""Save current page as a template"""
|
||||||
|
current_page = self.get_current_page()
|
||||||
|
if not current_page:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if page has any elements
|
||||||
|
if not current_page.layout.elements:
|
||||||
|
self.show_warning("Empty Page", "Cannot save an empty page as a template.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Ask for template name
|
||||||
|
name, ok = QInputDialog.getText(
|
||||||
|
self,
|
||||||
|
"Save Template",
|
||||||
|
"Enter template name:",
|
||||||
|
text=f"Template_{len(self.template_manager.list_templates()) + 1}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not ok or not name:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Ask for optional description
|
||||||
|
description, ok = QInputDialog.getText(
|
||||||
|
self,
|
||||||
|
"Template Description",
|
||||||
|
"Enter description (optional):"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not ok:
|
||||||
|
description = ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create template from page
|
||||||
|
template = self.template_manager.create_template_from_page(
|
||||||
|
current_page,
|
||||||
|
name,
|
||||||
|
description
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save template
|
||||||
|
self.template_manager.save_template(template)
|
||||||
|
|
||||||
|
self.show_info(
|
||||||
|
"Template Saved",
|
||||||
|
f"Template '{name}' has been saved successfully."
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Saved template: {name}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.show_error("Error", f"Failed to save template: {str(e)}")
|
||||||
|
print(f"Error saving template: {e}")
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="New from Template",
|
||||||
|
tooltip="Create a new page from a template",
|
||||||
|
tab="Layout",
|
||||||
|
group="Templates"
|
||||||
|
)
|
||||||
|
def new_page_from_template(self):
|
||||||
|
"""Create a new page from a template"""
|
||||||
|
# Get available templates
|
||||||
|
templates = self.template_manager.list_templates()
|
||||||
|
|
||||||
|
if not templates:
|
||||||
|
self.show_info(
|
||||||
|
"No Templates",
|
||||||
|
"No templates available. Create a template first by using 'Save as Template'."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Ask user to select template
|
||||||
|
template_name, ok = QInputDialog.getItem(
|
||||||
|
self,
|
||||||
|
"Select Template",
|
||||||
|
"Choose a template:",
|
||||||
|
templates,
|
||||||
|
0,
|
||||||
|
False
|
||||||
|
)
|
||||||
|
|
||||||
|
if not ok:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Load template
|
||||||
|
template = self.template_manager.load_template(template_name)
|
||||||
|
|
||||||
|
# Create new page from template
|
||||||
|
new_page_number = len(self.project.pages) + 1
|
||||||
|
new_page = self.template_manager.create_page_from_template(
|
||||||
|
template,
|
||||||
|
page_number=new_page_number,
|
||||||
|
target_size_mm=self.project.page_size_mm
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add to project
|
||||||
|
self.project.add_page(new_page)
|
||||||
|
|
||||||
|
# Switch to new page
|
||||||
|
self.gl_widget.current_page_index = len(self.project.pages) - 1
|
||||||
|
self.update_view()
|
||||||
|
|
||||||
|
self.show_status(f"Created page {new_page_number} from template '{template_name}'", 3000)
|
||||||
|
print(f"Created page from template: {template_name}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.show_error("Error", f"Failed to create page from template: {str(e)}")
|
||||||
|
print(f"Error creating page from template: {e}")
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Apply Template",
|
||||||
|
tooltip="Apply a template layout to current page",
|
||||||
|
tab="Layout",
|
||||||
|
group="Templates",
|
||||||
|
requires_page=True
|
||||||
|
)
|
||||||
|
def apply_template_to_page(self):
|
||||||
|
"""Apply a template to the current page"""
|
||||||
|
current_page = self.get_current_page()
|
||||||
|
if not current_page:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get available templates
|
||||||
|
templates = self.template_manager.list_templates()
|
||||||
|
|
||||||
|
if not templates:
|
||||||
|
self.show_info(
|
||||||
|
"No Templates",
|
||||||
|
"No templates available. Create a template first by using 'Save as Template'."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create dialog for template application options
|
||||||
|
dialog = QDialog(self)
|
||||||
|
dialog.setWindowTitle("Apply Template")
|
||||||
|
dialog.setMinimumWidth(400)
|
||||||
|
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
|
# Template selection
|
||||||
|
layout.addWidget(QLabel("Select Template:"))
|
||||||
|
template_combo = QComboBox()
|
||||||
|
template_combo.addItems(templates)
|
||||||
|
layout.addWidget(template_combo)
|
||||||
|
|
||||||
|
layout.addSpacing(10)
|
||||||
|
|
||||||
|
# Mode selection
|
||||||
|
layout.addWidget(QLabel("Mode:"))
|
||||||
|
mode_group = QButtonGroup(dialog)
|
||||||
|
|
||||||
|
replace_radio = QRadioButton("Replace with placeholders")
|
||||||
|
replace_radio.setChecked(True)
|
||||||
|
replace_radio.setToolTip("Clear page and add template placeholders")
|
||||||
|
mode_group.addButton(replace_radio, 0)
|
||||||
|
layout.addWidget(replace_radio)
|
||||||
|
|
||||||
|
reflow_radio = QRadioButton("Reflow existing content")
|
||||||
|
reflow_radio.setToolTip("Keep existing images and reposition to template slots")
|
||||||
|
mode_group.addButton(reflow_radio, 1)
|
||||||
|
layout.addWidget(reflow_radio)
|
||||||
|
|
||||||
|
layout.addSpacing(10)
|
||||||
|
|
||||||
|
# Scaling selection
|
||||||
|
layout.addWidget(QLabel("Scaling:"))
|
||||||
|
scale_group = QButtonGroup(dialog)
|
||||||
|
|
||||||
|
proportional_radio = QRadioButton("Proportional (maintain aspect ratio)")
|
||||||
|
proportional_radio.setChecked(True)
|
||||||
|
scale_group.addButton(proportional_radio, 0)
|
||||||
|
layout.addWidget(proportional_radio)
|
||||||
|
|
||||||
|
stretch_radio = QRadioButton("Stretch to fit")
|
||||||
|
scale_group.addButton(stretch_radio, 1)
|
||||||
|
layout.addWidget(stretch_radio)
|
||||||
|
|
||||||
|
center_radio = QRadioButton("Center (no scaling)")
|
||||||
|
scale_group.addButton(center_radio, 2)
|
||||||
|
layout.addWidget(center_radio)
|
||||||
|
|
||||||
|
layout.addSpacing(20)
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
button_layout = QHBoxLayout()
|
||||||
|
cancel_btn = QPushButton("Cancel")
|
||||||
|
cancel_btn.clicked.connect(dialog.reject)
|
||||||
|
apply_btn = QPushButton("Apply")
|
||||||
|
apply_btn.clicked.connect(dialog.accept)
|
||||||
|
apply_btn.setDefault(True)
|
||||||
|
|
||||||
|
button_layout.addStretch()
|
||||||
|
button_layout.addWidget(cancel_btn)
|
||||||
|
button_layout.addWidget(apply_btn)
|
||||||
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
|
dialog.setLayout(layout)
|
||||||
|
|
||||||
|
# Show dialog
|
||||||
|
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get selections
|
||||||
|
template_name = template_combo.currentText()
|
||||||
|
mode_id = mode_group.checkedId()
|
||||||
|
scale_id = scale_group.checkedId()
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update display
|
||||||
|
self.update_view()
|
||||||
|
|
||||||
|
self.show_status(f"Applied template '{template_name}' to current page", 3000)
|
||||||
|
print(f"Applied template '{template_name}' with mode={mode}, scale_mode={scale_mode}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.show_error("Error", f"Failed to apply template: {str(e)}")
|
||||||
|
print(f"Error applying template: {e}")
|
||||||
289
pyPhotoAlbum/mixins/operations/view_ops.py
Normal file
289
pyPhotoAlbum/mixins/operations/view_ops.py
Normal file
@ -0,0 +1,289 @@
|
|||||||
|
"""
|
||||||
|
View operations mixin for pyPhotoAlbum
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pyPhotoAlbum.decorators import ribbon_action
|
||||||
|
|
||||||
|
|
||||||
|
class ViewOperationsMixin:
|
||||||
|
"""Mixin providing view-related operations"""
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Zoom In",
|
||||||
|
tooltip="Zoom in",
|
||||||
|
tab="View",
|
||||||
|
group="Zoom",
|
||||||
|
shortcut="Ctrl++"
|
||||||
|
)
|
||||||
|
def zoom_in(self):
|
||||||
|
"""Zoom in"""
|
||||||
|
self.gl_widget.zoom_level *= 1.2
|
||||||
|
if self.gl_widget.zoom_level > 5.0:
|
||||||
|
self.gl_widget.zoom_level = 5.0
|
||||||
|
self.update_view()
|
||||||
|
self.show_status(f"Zoom: {int(self.gl_widget.zoom_level * 100)}%", 2000)
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Zoom Out",
|
||||||
|
tooltip="Zoom out",
|
||||||
|
tab="View",
|
||||||
|
group="Zoom",
|
||||||
|
shortcut="Ctrl+-"
|
||||||
|
)
|
||||||
|
def zoom_out(self):
|
||||||
|
"""Zoom out"""
|
||||||
|
self.gl_widget.zoom_level /= 1.2
|
||||||
|
if self.gl_widget.zoom_level < 0.1:
|
||||||
|
self.gl_widget.zoom_level = 0.1
|
||||||
|
self.update_view()
|
||||||
|
self.show_status(f"Zoom: {int(self.gl_widget.zoom_level * 100)}%", 2000)
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Fit to Window",
|
||||||
|
tooltip="Fit page to window",
|
||||||
|
tab="View",
|
||||||
|
group="Zoom",
|
||||||
|
shortcut="Ctrl+0"
|
||||||
|
)
|
||||||
|
def zoom_fit(self):
|
||||||
|
"""Fit page to window"""
|
||||||
|
if not self.project.pages:
|
||||||
|
return
|
||||||
|
|
||||||
|
current_page = self.project.pages[self.gl_widget.current_page_index]
|
||||||
|
page_width_mm = current_page.layout.size[0]
|
||||||
|
page_height_mm = current_page.layout.size[1]
|
||||||
|
|
||||||
|
# Convert to pixels
|
||||||
|
dpi = self.project.working_dpi
|
||||||
|
page_width_px = page_width_mm * dpi / 25.4
|
||||||
|
page_height_px = page_height_mm * dpi / 25.4
|
||||||
|
|
||||||
|
# Get widget size
|
||||||
|
widget_width = self.gl_widget.width() - 100 # Margins
|
||||||
|
widget_height = self.gl_widget.height() - 100
|
||||||
|
|
||||||
|
# Calculate zoom to fit
|
||||||
|
zoom_w = widget_width / page_width_px
|
||||||
|
zoom_h = widget_height / page_height_px
|
||||||
|
|
||||||
|
self.gl_widget.zoom_level = min(zoom_w, zoom_h)
|
||||||
|
self.gl_widget.zoom_level = max(0.1, min(5.0, self.gl_widget.zoom_level))
|
||||||
|
self.update_view()
|
||||||
|
self.show_status(f"Zoom: {int(self.gl_widget.zoom_level * 100)}%", 2000)
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Toggle Grid Snap",
|
||||||
|
tooltip="Toggle snapping to grid",
|
||||||
|
tab="View",
|
||||||
|
group="Snapping"
|
||||||
|
)
|
||||||
|
def toggle_grid_snap(self):
|
||||||
|
"""Toggle grid snapping"""
|
||||||
|
current_page = self.get_current_page()
|
||||||
|
if not current_page:
|
||||||
|
return
|
||||||
|
|
||||||
|
snap_sys = current_page.layout.snapping_system
|
||||||
|
snap_sys.snap_to_grid = not snap_sys.snap_to_grid
|
||||||
|
|
||||||
|
status = "enabled" if snap_sys.snap_to_grid else "disabled"
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
def toggle_edge_snap(self):
|
||||||
|
"""Toggle edge snapping"""
|
||||||
|
current_page = self.get_current_page()
|
||||||
|
if not current_page:
|
||||||
|
return
|
||||||
|
|
||||||
|
snap_sys = current_page.layout.snapping_system
|
||||||
|
snap_sys.snap_to_edges = not snap_sys.snap_to_edges
|
||||||
|
|
||||||
|
status = "enabled" if snap_sys.snap_to_edges else "disabled"
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
def toggle_guide_snap(self):
|
||||||
|
"""Toggle guide snapping"""
|
||||||
|
current_page = self.get_current_page()
|
||||||
|
if not current_page:
|
||||||
|
return
|
||||||
|
|
||||||
|
snap_sys = current_page.layout.snapping_system
|
||||||
|
snap_sys.snap_to_guides = not snap_sys.snap_to_guides
|
||||||
|
|
||||||
|
status = "enabled" if snap_sys.snap_to_guides else "disabled"
|
||||||
|
self.update_view()
|
||||||
|
self.show_status(f"Guide snapping {status}", 2000)
|
||||||
|
print(f"Guide snapping {status}")
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Toggle Snap Lines",
|
||||||
|
tooltip="Toggle visibility of snap lines",
|
||||||
|
tab="View",
|
||||||
|
group="Snapping"
|
||||||
|
)
|
||||||
|
def toggle_snap_lines(self):
|
||||||
|
"""Toggle snap lines visibility"""
|
||||||
|
current_page = self.get_current_page()
|
||||||
|
if not current_page:
|
||||||
|
return
|
||||||
|
|
||||||
|
current_page.layout.show_snap_lines = not current_page.layout.show_snap_lines
|
||||||
|
|
||||||
|
status = "visible" if current_page.layout.show_snap_lines else "hidden"
|
||||||
|
self.update_view()
|
||||||
|
self.show_status(f"Snap lines {status}", 2000)
|
||||||
|
print(f"Snap lines {status}")
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Add H Guide",
|
||||||
|
tooltip="Add horizontal guide at page center",
|
||||||
|
tab="View",
|
||||||
|
group="Guides"
|
||||||
|
)
|
||||||
|
def add_horizontal_guide(self):
|
||||||
|
"""Add a horizontal guide at page center"""
|
||||||
|
current_page = self.get_current_page()
|
||||||
|
if not current_page:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Add guide at vertical center (in mm)
|
||||||
|
center_y = current_page.layout.size[1] / 2.0
|
||||||
|
current_page.layout.snapping_system.add_guide(center_y, 'horizontal')
|
||||||
|
|
||||||
|
self.update_view()
|
||||||
|
self.show_status(f"Added horizontal guide at {center_y:.1f} mm", 2000)
|
||||||
|
print(f"Added horizontal guide at {center_y:.1f} mm")
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Add V Guide",
|
||||||
|
tooltip="Add vertical guide at page center",
|
||||||
|
tab="View",
|
||||||
|
group="Guides"
|
||||||
|
)
|
||||||
|
def add_vertical_guide(self):
|
||||||
|
"""Add a vertical guide at page center"""
|
||||||
|
current_page = self.get_current_page()
|
||||||
|
if not current_page:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Add guide at horizontal center (in mm)
|
||||||
|
center_x = current_page.layout.size[0] / 2.0
|
||||||
|
current_page.layout.snapping_system.add_guide(center_x, 'vertical')
|
||||||
|
|
||||||
|
self.update_view()
|
||||||
|
self.show_status(f"Added vertical guide at {center_x:.1f} mm", 2000)
|
||||||
|
print(f"Added vertical guide at {center_x:.1f} mm")
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Clear Guides",
|
||||||
|
tooltip="Clear all guides from current page",
|
||||||
|
tab="View",
|
||||||
|
group="Guides"
|
||||||
|
)
|
||||||
|
def clear_guides(self):
|
||||||
|
"""Clear all guides from current page"""
|
||||||
|
current_page = self.get_current_page()
|
||||||
|
if not current_page:
|
||||||
|
return
|
||||||
|
|
||||||
|
guide_count = len(current_page.layout.snapping_system.guides)
|
||||||
|
current_page.layout.snapping_system.clear_guides()
|
||||||
|
|
||||||
|
self.update_view()
|
||||||
|
self.show_status(f"Cleared {guide_count} guides", 2000)
|
||||||
|
print(f"Cleared {guide_count} guides")
|
||||||
|
|
||||||
|
@ribbon_action(
|
||||||
|
label="Set Grid Size...",
|
||||||
|
tooltip="Configure grid spacing for snapping",
|
||||||
|
tab="View",
|
||||||
|
group="Snapping"
|
||||||
|
)
|
||||||
|
def set_grid_size(self):
|
||||||
|
"""Open dialog to set grid size"""
|
||||||
|
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QDoubleSpinBox, QPushButton
|
||||||
|
|
||||||
|
current_page = self.get_current_page()
|
||||||
|
if not current_page:
|
||||||
|
return
|
||||||
|
|
||||||
|
snap_sys = current_page.layout.snapping_system
|
||||||
|
|
||||||
|
# Create dialog
|
||||||
|
dialog = QDialog(self)
|
||||||
|
dialog.setWindowTitle("Grid Settings")
|
||||||
|
dialog.setMinimumWidth(300)
|
||||||
|
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
|
# Grid size setting
|
||||||
|
size_layout = QHBoxLayout()
|
||||||
|
size_layout.addWidget(QLabel("Grid Size:"))
|
||||||
|
|
||||||
|
size_spinbox = QDoubleSpinBox()
|
||||||
|
size_spinbox.setRange(1.0, 100.0)
|
||||||
|
size_spinbox.setValue(snap_sys.grid_size_mm)
|
||||||
|
size_spinbox.setSuffix(" mm")
|
||||||
|
size_spinbox.setDecimals(1)
|
||||||
|
size_spinbox.setSingleStep(1.0)
|
||||||
|
size_layout.addWidget(size_spinbox)
|
||||||
|
|
||||||
|
layout.addLayout(size_layout)
|
||||||
|
|
||||||
|
# Snap threshold setting
|
||||||
|
threshold_layout = QHBoxLayout()
|
||||||
|
threshold_layout.addWidget(QLabel("Snap Threshold:"))
|
||||||
|
|
||||||
|
threshold_spinbox = QDoubleSpinBox()
|
||||||
|
threshold_spinbox.setRange(0.5, 20.0)
|
||||||
|
threshold_spinbox.setValue(snap_sys.snap_threshold_mm)
|
||||||
|
threshold_spinbox.setSuffix(" mm")
|
||||||
|
threshold_spinbox.setDecimals(1)
|
||||||
|
threshold_spinbox.setSingleStep(0.5)
|
||||||
|
threshold_layout.addWidget(threshold_spinbox)
|
||||||
|
|
||||||
|
layout.addLayout(threshold_layout)
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
button_layout = QHBoxLayout()
|
||||||
|
cancel_btn = QPushButton("Cancel")
|
||||||
|
cancel_btn.clicked.connect(dialog.reject)
|
||||||
|
ok_btn = QPushButton("OK")
|
||||||
|
ok_btn.clicked.connect(dialog.accept)
|
||||||
|
ok_btn.setDefault(True)
|
||||||
|
|
||||||
|
button_layout.addStretch()
|
||||||
|
button_layout.addWidget(cancel_btn)
|
||||||
|
button_layout.addWidget(ok_btn)
|
||||||
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
|
dialog.setLayout(layout)
|
||||||
|
|
||||||
|
# Show dialog and apply if accepted
|
||||||
|
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||||
|
new_grid_size = size_spinbox.value()
|
||||||
|
new_threshold = threshold_spinbox.value()
|
||||||
|
|
||||||
|
snap_sys.grid_size_mm = new_grid_size
|
||||||
|
snap_sys.snap_threshold_mm = new_threshold
|
||||||
|
|
||||||
|
self.update_view()
|
||||||
|
self.show_status(f"Grid size: {new_grid_size:.1f}mm, Threshold: {new_threshold:.1f}mm", 2000)
|
||||||
|
print(f"Updated grid settings - Size: {new_grid_size:.1f}mm, Threshold: {new_threshold:.1f}mm")
|
||||||
415
pyPhotoAlbum/models.py
Normal file
415
pyPhotoAlbum/models.py
Normal file
@ -0,0 +1,415 @@
|
|||||||
|
"""
|
||||||
|
Data model classes for pyPhotoAlbum
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Tuple, Optional, Dict, Any
|
||||||
|
import json
|
||||||
|
|
||||||
|
class BaseLayoutElement(ABC):
|
||||||
|
"""Abstract base class for all layout elements"""
|
||||||
|
|
||||||
|
def __init__(self, x: float = 0, y: float = 0, width: float = 100, height: float = 100, rotation: float = 0, z_index: int = 0):
|
||||||
|
self.position = (x, y)
|
||||||
|
self.size = (width, height)
|
||||||
|
self.rotation = rotation
|
||||||
|
self.z_index = z_index
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def render(self):
|
||||||
|
"""Render the element using OpenGL"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def serialize(self) -> Dict[str, Any]:
|
||||||
|
"""Serialize the element to a dictionary"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def deserialize(self, data: Dict[str, Any]):
|
||||||
|
"""Deserialize from a dictionary"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ImageData(BaseLayoutElement):
|
||||||
|
"""Class to store image data and properties"""
|
||||||
|
|
||||||
|
def __init__(self, image_path: str = "", crop_info: Optional[Tuple] = None, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.image_path = image_path
|
||||||
|
self.crop_info = crop_info or (0, 0, 1, 1) # Default: no crop
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
"""Render the image using OpenGL"""
|
||||||
|
from OpenGL.GL import (glBegin, glEnd, glVertex2f, glColor3f, glColor4f, GL_QUADS, GL_LINE_LOOP,
|
||||||
|
glEnable, glDisable, GL_TEXTURE_2D, glBindTexture, glTexCoord2f,
|
||||||
|
glGenTextures, glTexImage2D, GL_RGBA, GL_UNSIGNED_BYTE,
|
||||||
|
glTexParameteri, GL_TEXTURE_MIN_FILTER, GL_TEXTURE_MAG_FILTER, GL_LINEAR,
|
||||||
|
glDeleteTextures)
|
||||||
|
from PIL import Image
|
||||||
|
import os
|
||||||
|
|
||||||
|
x, y = self.position
|
||||||
|
w, h = self.size
|
||||||
|
|
||||||
|
# Try to load and render the actual image
|
||||||
|
texture_id = None
|
||||||
|
|
||||||
|
# Handle both absolute and relative paths
|
||||||
|
image_full_path = self.image_path
|
||||||
|
if self.image_path and not os.path.isabs(self.image_path):
|
||||||
|
# Relative path - need to resolve relative to project folder
|
||||||
|
# Try common locations
|
||||||
|
possible_paths = [
|
||||||
|
self.image_path, # Try as-is first
|
||||||
|
os.path.join(os.getcwd(), self.image_path), # Relative to CWD
|
||||||
|
os.path.join(os.path.dirname(os.getcwd()), self.image_path), # Parent of CWD
|
||||||
|
]
|
||||||
|
|
||||||
|
# Also try to find projects folder
|
||||||
|
cwd_parts = os.getcwd().split(os.sep)
|
||||||
|
for i in range(len(cwd_parts), 0, -1):
|
||||||
|
base = os.sep.join(cwd_parts[:i])
|
||||||
|
test_path = os.path.join(base, self.image_path)
|
||||||
|
possible_paths.append(test_path)
|
||||||
|
|
||||||
|
for path in possible_paths:
|
||||||
|
if os.path.exists(path):
|
||||||
|
image_full_path = path
|
||||||
|
print(f"ImageData: Resolved {self.image_path} → {path}")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
print(f"ImageData: Could not resolve path: {self.image_path}")
|
||||||
|
print(f" Tried paths: {possible_paths[:3]}") # Print first 3 to avoid clutter
|
||||||
|
|
||||||
|
if image_full_path and os.path.exists(image_full_path):
|
||||||
|
# Check if we already have a cached texture
|
||||||
|
if not hasattr(self, '_texture_id') or not hasattr(self, '_texture_path') or self._texture_path != image_full_path:
|
||||||
|
try:
|
||||||
|
# Load image
|
||||||
|
img = Image.open(image_full_path)
|
||||||
|
img = img.convert('RGBA')
|
||||||
|
|
||||||
|
# Scale down large images for performance (max 2048px on longest side)
|
||||||
|
max_size = 2048
|
||||||
|
if img.width > max_size or img.height > max_size:
|
||||||
|
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)
|
||||||
|
|
||||||
|
img_data = img.tobytes()
|
||||||
|
|
||||||
|
# Delete old texture if it exists
|
||||||
|
if hasattr(self, '_texture_id') and self._texture_id:
|
||||||
|
glDeleteTextures([self._texture_id])
|
||||||
|
|
||||||
|
# Create texture
|
||||||
|
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, img.width, img.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, img_data)
|
||||||
|
|
||||||
|
# Cache texture ID and path
|
||||||
|
self._texture_id = texture_id
|
||||||
|
self._texture_path = image_full_path
|
||||||
|
self._img_width = img.width
|
||||||
|
self._img_height = img.height
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error loading image {image_full_path}: {e}")
|
||||||
|
print(f" Original path: {self.image_path}")
|
||||||
|
self._texture_id = None
|
||||||
|
|
||||||
|
# Use cached texture
|
||||||
|
if hasattr(self, '_texture_id') and self._texture_id:
|
||||||
|
texture_id = self._texture_id
|
||||||
|
|
||||||
|
# Get crop info
|
||||||
|
crop_x_min, crop_y_min, crop_x_max, crop_y_max = self.crop_info
|
||||||
|
|
||||||
|
# Calculate aspect ratios for center crop
|
||||||
|
img_aspect = self._img_width / self._img_height
|
||||||
|
target_aspect = w / h
|
||||||
|
|
||||||
|
# Calculate texture coordinates for center crop
|
||||||
|
if img_aspect > target_aspect:
|
||||||
|
# Image is wider - 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 - 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)
|
||||||
|
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
|
||||||
|
|
||||||
|
# Enable texturing and draw with crop
|
||||||
|
glEnable(GL_TEXTURE_2D)
|
||||||
|
glBindTexture(GL_TEXTURE_2D, texture_id)
|
||||||
|
glColor4f(1.0, 1.0, 1.0, 1.0) # White color to show texture as-is
|
||||||
|
|
||||||
|
glBegin(GL_QUADS)
|
||||||
|
glTexCoord2f(tx_min, ty_min); glVertex2f(x, y)
|
||||||
|
glTexCoord2f(tx_max, ty_min); glVertex2f(x + w, y)
|
||||||
|
glTexCoord2f(tx_max, ty_max); glVertex2f(x + w, y + h)
|
||||||
|
glTexCoord2f(tx_min, ty_max); glVertex2f(x, y + h)
|
||||||
|
glEnd()
|
||||||
|
|
||||||
|
glDisable(GL_TEXTURE_2D)
|
||||||
|
|
||||||
|
# If no image or loading failed, draw placeholder
|
||||||
|
if not texture_id:
|
||||||
|
glColor3f(0.7, 0.85, 1.0) # Light blue
|
||||||
|
glBegin(GL_QUADS)
|
||||||
|
glVertex2f(x, y)
|
||||||
|
glVertex2f(x + w, y)
|
||||||
|
glVertex2f(x + w, y + h)
|
||||||
|
glVertex2f(x, y + h)
|
||||||
|
glEnd()
|
||||||
|
|
||||||
|
# Draw border
|
||||||
|
glColor3f(0.0, 0.0, 0.0) # Black border
|
||||||
|
glBegin(GL_LINE_LOOP)
|
||||||
|
glVertex2f(x, y)
|
||||||
|
glVertex2f(x + w, y)
|
||||||
|
glVertex2f(x + w, y + h)
|
||||||
|
glVertex2f(x, y + h)
|
||||||
|
glEnd()
|
||||||
|
|
||||||
|
def serialize(self) -> Dict[str, Any]:
|
||||||
|
"""Serialize image data to dictionary"""
|
||||||
|
return {
|
||||||
|
"type": "image",
|
||||||
|
"position": self.position,
|
||||||
|
"size": self.size,
|
||||||
|
"rotation": self.rotation,
|
||||||
|
"z_index": self.z_index,
|
||||||
|
"image_path": self.image_path,
|
||||||
|
"crop_info": self.crop_info
|
||||||
|
}
|
||||||
|
|
||||||
|
def deserialize(self, data: Dict[str, Any]):
|
||||||
|
"""Deserialize from dictionary"""
|
||||||
|
self.position = tuple(data.get("position", (0, 0)))
|
||||||
|
self.size = tuple(data.get("size", (100, 100)))
|
||||||
|
self.rotation = data.get("rotation", 0)
|
||||||
|
self.z_index = data.get("z_index", 0)
|
||||||
|
self.image_path = data.get("image_path", "")
|
||||||
|
self.crop_info = tuple(data.get("crop_info", (0, 0, 1, 1)))
|
||||||
|
|
||||||
|
class PlaceholderData(BaseLayoutElement):
|
||||||
|
"""Class to store placeholder data"""
|
||||||
|
|
||||||
|
def __init__(self, placeholder_type: str = "image", default_content: str = "", **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.placeholder_type = placeholder_type
|
||||||
|
self.default_content = default_content
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
"""Render the placeholder using OpenGL"""
|
||||||
|
from OpenGL.GL import glBegin, glEnd, glVertex2f, glColor3f, GL_QUADS, GL_LINE_LOOP, glLineStipple, glEnable, glDisable, GL_LINE_STIPPLE
|
||||||
|
|
||||||
|
x, y = self.position
|
||||||
|
w, h = self.size
|
||||||
|
|
||||||
|
# Draw a light gray rectangle as placeholder background
|
||||||
|
glColor3f(0.9, 0.9, 0.9) # Light gray
|
||||||
|
glBegin(GL_QUADS)
|
||||||
|
glVertex2f(x, y)
|
||||||
|
glVertex2f(x + w, y)
|
||||||
|
glVertex2f(x + w, y + h)
|
||||||
|
glVertex2f(x, y + h)
|
||||||
|
glEnd()
|
||||||
|
|
||||||
|
# Draw dashed border for placeholder
|
||||||
|
glEnable(GL_LINE_STIPPLE)
|
||||||
|
glLineStipple(1, 0x00FF) # Dashed pattern
|
||||||
|
glColor3f(0.5, 0.5, 0.5) # Gray border
|
||||||
|
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)
|
||||||
|
|
||||||
|
def serialize(self) -> Dict[str, Any]:
|
||||||
|
"""Serialize placeholder data to dictionary"""
|
||||||
|
return {
|
||||||
|
"type": "placeholder",
|
||||||
|
"position": self.position,
|
||||||
|
"size": self.size,
|
||||||
|
"rotation": self.rotation,
|
||||||
|
"z_index": self.z_index,
|
||||||
|
"placeholder_type": self.placeholder_type,
|
||||||
|
"default_content": self.default_content
|
||||||
|
}
|
||||||
|
|
||||||
|
def deserialize(self, data: Dict[str, Any]):
|
||||||
|
"""Deserialize from dictionary"""
|
||||||
|
self.position = tuple(data.get("position", (0, 0)))
|
||||||
|
self.size = tuple(data.get("size", (100, 100)))
|
||||||
|
self.rotation = data.get("rotation", 0)
|
||||||
|
self.z_index = data.get("z_index", 0)
|
||||||
|
self.placeholder_type = data.get("placeholder_type", "image")
|
||||||
|
self.default_content = data.get("default_content", "")
|
||||||
|
|
||||||
|
class TextBoxData(BaseLayoutElement):
|
||||||
|
"""Class to store text box data"""
|
||||||
|
|
||||||
|
def __init__(self, text_content: str = "", font_settings: Optional[Dict] = None, alignment: str = "left", **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.text_content = text_content
|
||||||
|
self.font_settings = font_settings or {"family": "Arial", "size": 12, "color": (0, 0, 0)}
|
||||||
|
self.alignment = alignment
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
"""Render the text box using OpenGL"""
|
||||||
|
from OpenGL.GL import (glBegin, glEnd, glVertex2f, glColor3f, glColor4f, GL_QUADS, GL_LINE_LOOP,
|
||||||
|
glEnable, glDisable, GL_BLEND, glBlendFunc, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
|
||||||
|
|
||||||
|
x, y = self.position
|
||||||
|
w, h = self.size
|
||||||
|
|
||||||
|
# Enable alpha blending for transparency
|
||||||
|
glEnable(GL_BLEND)
|
||||||
|
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
|
||||||
|
|
||||||
|
# Draw a semi-transparent yellow rectangle as text box background
|
||||||
|
glColor4f(1.0, 1.0, 0.7, 0.3) # Light yellow with 30% opacity
|
||||||
|
glBegin(GL_QUADS)
|
||||||
|
glVertex2f(x, y)
|
||||||
|
glVertex2f(x + w, y)
|
||||||
|
glVertex2f(x + w, y + h)
|
||||||
|
glVertex2f(x, y + h)
|
||||||
|
glEnd()
|
||||||
|
|
||||||
|
glDisable(GL_BLEND)
|
||||||
|
|
||||||
|
# Draw border
|
||||||
|
glColor3f(0.0, 0.0, 0.0) # Black border
|
||||||
|
glBegin(GL_LINE_LOOP)
|
||||||
|
glVertex2f(x, y)
|
||||||
|
glVertex2f(x + w, y)
|
||||||
|
glVertex2f(x + w, y + h)
|
||||||
|
glVertex2f(x, y + h)
|
||||||
|
glEnd()
|
||||||
|
|
||||||
|
# Note: Text content is rendered using QPainter overlay in GLWidget.paintGL()
|
||||||
|
|
||||||
|
def serialize(self) -> Dict[str, Any]:
|
||||||
|
"""Serialize text box data to dictionary"""
|
||||||
|
return {
|
||||||
|
"type": "textbox",
|
||||||
|
"position": self.position,
|
||||||
|
"size": self.size,
|
||||||
|
"rotation": self.rotation,
|
||||||
|
"z_index": self.z_index,
|
||||||
|
"text_content": self.text_content,
|
||||||
|
"font_settings": self.font_settings,
|
||||||
|
"alignment": self.alignment
|
||||||
|
}
|
||||||
|
|
||||||
|
def deserialize(self, data: Dict[str, Any]):
|
||||||
|
"""Deserialize from dictionary"""
|
||||||
|
self.position = tuple(data.get("position", (0, 0)))
|
||||||
|
self.size = tuple(data.get("size", (100, 100)))
|
||||||
|
self.rotation = data.get("rotation", 0)
|
||||||
|
self.z_index = data.get("z_index", 0)
|
||||||
|
self.text_content = data.get("text_content", "")
|
||||||
|
self.font_settings = data.get("font_settings", {"family": "Arial", "size": 12, "color": (0, 0, 0)})
|
||||||
|
self.alignment = data.get("alignment", "left")
|
||||||
|
|
||||||
|
class GhostPageData(BaseLayoutElement):
|
||||||
|
"""Class to represent a ghost page placeholder for alignment in double-page spreads"""
|
||||||
|
|
||||||
|
def __init__(self, page_size: Tuple[float, float] = (210, 297), **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.page_size = page_size # Size in mm
|
||||||
|
self.is_ghost = True
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
"""Render the ghost page with 'Add Page' button in page-local coordinates"""
|
||||||
|
from OpenGL.GL import (glBegin, glEnd, glVertex2f, glColor3f, glColor4f, GL_QUADS, GL_LINE_LOOP,
|
||||||
|
glEnable, glDisable, GL_BLEND, glBlendFunc, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA,
|
||||||
|
glLineStipple, GL_LINE_STIPPLE)
|
||||||
|
|
||||||
|
# Render at page origin (0,0) in page-local coordinates
|
||||||
|
# PageRenderer will handle transformation to screen coordinates
|
||||||
|
x, y = 0, 0
|
||||||
|
|
||||||
|
# Calculate dimensions from page_size (in mm) - assume 300 DPI for now
|
||||||
|
# This will be overridden by proper size calculation in PageRenderer
|
||||||
|
dpi = 300 # Default DPI for rendering
|
||||||
|
w = self.page_size[0] * dpi / 25.4
|
||||||
|
h = self.page_size[1] * dpi / 25.4
|
||||||
|
|
||||||
|
# Enable alpha blending for transparency
|
||||||
|
glEnable(GL_BLEND)
|
||||||
|
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
|
||||||
|
|
||||||
|
# Draw a light grey semi-transparent rectangle as ghost page background
|
||||||
|
glColor4f(0.8, 0.8, 0.8, 0.5) # Light grey with 50% opacity
|
||||||
|
glBegin(GL_QUADS)
|
||||||
|
glVertex2f(x, y)
|
||||||
|
glVertex2f(x + w, y)
|
||||||
|
glVertex2f(x + w, y + h)
|
||||||
|
glVertex2f(x, y + h)
|
||||||
|
glEnd()
|
||||||
|
|
||||||
|
glDisable(GL_BLEND)
|
||||||
|
|
||||||
|
# Draw dashed border
|
||||||
|
glEnable(GL_LINE_STIPPLE)
|
||||||
|
glLineStipple(2, 0x0F0F) # Dashed pattern
|
||||||
|
glColor3f(0.5, 0.5, 0.5) # Grey border
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Note: "Click to Add Page" text is rendered using QPainter overlay in GLWidget
|
||||||
|
# The entire page is clickable
|
||||||
|
|
||||||
|
def get_page_rect(self) -> Tuple[float, float, float, float]:
|
||||||
|
"""Get the bounding box of the entire ghost page in page-local coordinates (x, y, width, height)"""
|
||||||
|
# Return in page-local coordinates (matching render method)
|
||||||
|
x, y = 0, 0
|
||||||
|
dpi = 300 # Default DPI
|
||||||
|
w = self.page_size[0] * dpi / 25.4
|
||||||
|
h = self.page_size[1] * dpi / 25.4
|
||||||
|
return (x, y, w, h)
|
||||||
|
|
||||||
|
def serialize(self) -> Dict[str, Any]:
|
||||||
|
"""Serialize ghost page data to dictionary"""
|
||||||
|
return {
|
||||||
|
"type": "ghostpage",
|
||||||
|
"position": self.position,
|
||||||
|
"size": self.size,
|
||||||
|
"page_size": self.page_size
|
||||||
|
}
|
||||||
|
|
||||||
|
def deserialize(self, data: Dict[str, Any]):
|
||||||
|
"""Deserialize from dictionary"""
|
||||||
|
self.position = tuple(data.get("position", (0, 0)))
|
||||||
|
self.size = tuple(data.get("size", (100, 100)))
|
||||||
|
self.page_size = tuple(data.get("page_size", (210, 297)))
|
||||||
259
pyPhotoAlbum/page_layout.py
Normal file
259
pyPhotoAlbum/page_layout.py
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
"""
|
||||||
|
Page layout and template system for pyPhotoAlbum
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Dict, Any, Optional, Tuple
|
||||||
|
from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData
|
||||||
|
from pyPhotoAlbum.snapping import SnappingSystem
|
||||||
|
|
||||||
|
class PageLayout:
|
||||||
|
"""Class to manage page layout and templates"""
|
||||||
|
|
||||||
|
def __init__(self, width: float = 210, height: float = 297, is_facing_page: bool = False):
|
||||||
|
"""
|
||||||
|
Initialize page layout.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
width: Width in mm (doubled automatically if is_facing_page=True)
|
||||||
|
height: Height in mm
|
||||||
|
is_facing_page: If True, width is doubled for facing page spread
|
||||||
|
"""
|
||||||
|
self.base_width = width # Store the base single-page width
|
||||||
|
self.is_facing_page = is_facing_page
|
||||||
|
self.size = (width * 2 if is_facing_page else width, height)
|
||||||
|
self.elements: List[BaseLayoutElement] = []
|
||||||
|
self.grid_layout: Optional[GridLayout] = None
|
||||||
|
self.background_color = (1.0, 1.0, 1.0) # White background
|
||||||
|
self.snapping_system = SnappingSystem()
|
||||||
|
self.show_snap_lines = True # Show snap lines while dragging
|
||||||
|
|
||||||
|
def add_element(self, element: BaseLayoutElement):
|
||||||
|
"""Add a layout element to the page"""
|
||||||
|
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'):
|
||||||
|
"""Set a grid layout for the page"""
|
||||||
|
self.grid_layout = grid
|
||||||
|
|
||||||
|
def render(self, dpi: int = 300):
|
||||||
|
"""
|
||||||
|
Render all elements on the page in page-local coordinates.
|
||||||
|
|
||||||
|
Note: This method assumes OpenGL transformations have already been set up
|
||||||
|
by PageRenderer.begin_render(). All coordinates here are in page-local space.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dpi: Working DPI for converting mm to pixels
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
glBegin(GL_QUADS)
|
||||||
|
glVertex2f(page_x + shadow_offset, page_y + height_px)
|
||||||
|
glVertex2f(page_x + width_px + shadow_offset, page_y + height_px)
|
||||||
|
glVertex2f(page_x + width_px + shadow_offset, page_y + height_px + shadow_offset)
|
||||||
|
glVertex2f(page_x + shadow_offset, page_y + height_px + shadow_offset)
|
||||||
|
glEnd()
|
||||||
|
|
||||||
|
glBegin(GL_QUADS)
|
||||||
|
glVertex2f(page_x + width_px, page_y + shadow_offset)
|
||||||
|
glVertex2f(page_x + width_px + shadow_offset, page_y + shadow_offset)
|
||||||
|
glVertex2f(page_x + width_px + shadow_offset, page_y + height_px)
|
||||||
|
glVertex2f(page_x + width_px, page_y + height_px)
|
||||||
|
glEnd()
|
||||||
|
|
||||||
|
# Draw page background (slightly off-white to distinguish from canvas)
|
||||||
|
glColor3f(0.98, 0.98, 0.98)
|
||||||
|
glBegin(GL_QUADS)
|
||||||
|
glVertex2f(page_x, page_y)
|
||||||
|
glVertex2f(page_x + width_px, page_y)
|
||||||
|
glVertex2f(page_x + width_px, page_y + height_px)
|
||||||
|
glVertex2f(page_x, page_y + height_px)
|
||||||
|
glEnd()
|
||||||
|
|
||||||
|
# Render elements in z-order - they're already in page-local coordinates
|
||||||
|
for element in sorted(self.elements, key=lambda x: x.z_index):
|
||||||
|
element.render()
|
||||||
|
|
||||||
|
# Draw page border LAST (on top of everything)
|
||||||
|
glColor3f(0.7, 0.7, 0.7)
|
||||||
|
glLineWidth(2.0)
|
||||||
|
glBegin(GL_LINE_LOOP)
|
||||||
|
glVertex2f(page_x, page_y)
|
||||||
|
glVertex2f(page_x + width_px, page_y)
|
||||||
|
glVertex2f(page_x + width_px, page_y + height_px)
|
||||||
|
glVertex2f(page_x, page_y + height_px)
|
||||||
|
glEnd()
|
||||||
|
glLineWidth(1.0)
|
||||||
|
|
||||||
|
# Draw center line for facing pages
|
||||||
|
if self.is_facing_page:
|
||||||
|
center_x = page_x + (width_px / 2)
|
||||||
|
glColor3f(0.5, 0.5, 0.5) # Gray line
|
||||||
|
glLineWidth(1.5)
|
||||||
|
glBegin(GL_LINES)
|
||||||
|
glVertex2f(center_x, page_y)
|
||||||
|
glVertex2f(center_x, page_y + height_px)
|
||||||
|
glEnd()
|
||||||
|
glLineWidth(1.0)
|
||||||
|
|
||||||
|
# Always render snap lines (grid shows when snap_to_grid is on, guides show when show_snap_lines is on)
|
||||||
|
self._render_snap_lines(dpi, page_x, page_y)
|
||||||
|
|
||||||
|
# Re-enable depth testing
|
||||||
|
glEnable(GL_DEPTH_TEST)
|
||||||
|
|
||||||
|
def _render_snap_lines(self, dpi: int, page_x: float, page_y: float):
|
||||||
|
"""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)
|
||||||
|
|
||||||
|
snap_lines = self.snapping_system.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) - always visible when snap_to_grid is enabled
|
||||||
|
if self.snapping_system.snap_to_grid and snap_lines['grid']:
|
||||||
|
glColor4f(0.6, 0.6, 0.6, 0.4) # Gray with 40% opacity
|
||||||
|
glLineWidth(1.0)
|
||||||
|
for orientation, position in snap_lines['grid']:
|
||||||
|
glBegin(GL_LINES)
|
||||||
|
if orientation == 'vertical':
|
||||||
|
glVertex2f(page_x + position, page_y)
|
||||||
|
glVertex2f(page_x + position, page_y + self.size[1] * dpi / 25.4)
|
||||||
|
else: # horizontal
|
||||||
|
glVertex2f(page_x, page_y + position)
|
||||||
|
glVertex2f(page_x + self.size[0] * dpi / 25.4, page_y + position)
|
||||||
|
glEnd()
|
||||||
|
|
||||||
|
# Draw guides (cyan, more visible with transparency) - only show when show_snap_lines is on
|
||||||
|
if self.show_snap_lines and snap_lines['guides']:
|
||||||
|
glColor4f(0.0, 0.7, 0.9, 0.8) # Cyan with 80% opacity
|
||||||
|
glLineWidth(1.5)
|
||||||
|
for orientation, position in snap_lines['guides']:
|
||||||
|
glBegin(GL_LINES)
|
||||||
|
if orientation == 'vertical':
|
||||||
|
glVertex2f(page_x + position, page_y)
|
||||||
|
glVertex2f(page_x + position, page_y + self.size[1] * dpi / 25.4)
|
||||||
|
else: # horizontal
|
||||||
|
glVertex2f(page_x, page_y + position)
|
||||||
|
glVertex2f(page_x + self.size[0] * dpi / 25.4, page_y + position)
|
||||||
|
glEnd()
|
||||||
|
|
||||||
|
glLineWidth(1.0)
|
||||||
|
glDisable(GL_BLEND)
|
||||||
|
|
||||||
|
def serialize(self) -> Dict[str, Any]:
|
||||||
|
"""Serialize page layout to dictionary"""
|
||||||
|
return {
|
||||||
|
"size": self.size,
|
||||||
|
"base_width": self.base_width,
|
||||||
|
"is_facing_page": self.is_facing_page,
|
||||||
|
"background_color": self.background_color,
|
||||||
|
"elements": [elem.serialize() for elem in self.elements],
|
||||||
|
"grid_layout": self.grid_layout.serialize() if self.grid_layout else None,
|
||||||
|
"snapping_system": self.snapping_system.serialize(),
|
||||||
|
"show_snap_lines": self.show_snap_lines
|
||||||
|
}
|
||||||
|
|
||||||
|
def deserialize(self, data: Dict[str, Any]):
|
||||||
|
"""Deserialize from dictionary"""
|
||||||
|
self.size = tuple(data.get("size", (210, 297)))
|
||||||
|
self.base_width = data.get("base_width", self.size[0])
|
||||||
|
self.is_facing_page = data.get("is_facing_page", False)
|
||||||
|
self.background_color = tuple(data.get("background_color", (1.0, 1.0, 1.0)))
|
||||||
|
self.elements = []
|
||||||
|
|
||||||
|
# Deserialize elements
|
||||||
|
for elem_data in data.get("elements", []):
|
||||||
|
elem_type = elem_data.get("type")
|
||||||
|
if elem_type == "image":
|
||||||
|
elem = ImageData()
|
||||||
|
elif elem_type == "placeholder":
|
||||||
|
elem = PlaceholderData()
|
||||||
|
elif elem_type == "textbox":
|
||||||
|
elem = TextBoxData()
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
elem.deserialize(elem_data)
|
||||||
|
self.elements.append(elem)
|
||||||
|
|
||||||
|
# Deserialize grid layout
|
||||||
|
grid_data = data.get("grid_layout")
|
||||||
|
if grid_data:
|
||||||
|
self.grid_layout = GridLayout()
|
||||||
|
self.grid_layout.deserialize(grid_data)
|
||||||
|
|
||||||
|
# Deserialize snapping system
|
||||||
|
snap_data = data.get("snapping_system")
|
||||||
|
if snap_data:
|
||||||
|
self.snapping_system.deserialize(snap_data)
|
||||||
|
|
||||||
|
self.show_snap_lines = data.get("show_snap_lines", True)
|
||||||
|
|
||||||
|
class GridLayout:
|
||||||
|
"""Class to manage grid layouts"""
|
||||||
|
|
||||||
|
def __init__(self, rows: int = 1, columns: int = 1, spacing: float = 10.0):
|
||||||
|
self.rows = rows
|
||||||
|
self.columns = columns
|
||||||
|
self.spacing = spacing
|
||||||
|
self.merged_cells: List[Tuple[int, int]] = [] # List of (row, col) for merged cells
|
||||||
|
|
||||||
|
def merge_cells(self, row: int, col: int):
|
||||||
|
"""Merge cells in the grid"""
|
||||||
|
self.merged_cells.append((row, col))
|
||||||
|
|
||||||
|
def get_cell_position(self, row: int, col: int, page_width: float = 800, page_height: float = 600) -> Tuple[float, float]:
|
||||||
|
"""Get the position of a grid cell"""
|
||||||
|
cell_width = (page_width - (self.spacing * (self.columns + 1))) / self.columns
|
||||||
|
cell_height = (page_height - (self.spacing * (self.rows + 1))) / self.rows
|
||||||
|
|
||||||
|
x = self.spacing + (col * (cell_width + self.spacing))
|
||||||
|
y = self.spacing + (row * (cell_height + self.spacing))
|
||||||
|
|
||||||
|
return (x, y)
|
||||||
|
|
||||||
|
def get_cell_size(self, page_width: float = 800, page_height: float = 600) -> Tuple[float, float]:
|
||||||
|
"""Get the size of a grid cell"""
|
||||||
|
cell_width = (page_width - (self.spacing * (self.columns + 1))) / self.columns
|
||||||
|
cell_height = (page_height - (self.spacing * (self.rows + 1))) / self.rows
|
||||||
|
|
||||||
|
return (cell_width, cell_height)
|
||||||
|
|
||||||
|
def serialize(self) -> Dict[str, Any]:
|
||||||
|
"""Serialize grid layout to dictionary"""
|
||||||
|
return {
|
||||||
|
"rows": self.rows,
|
||||||
|
"columns": self.columns,
|
||||||
|
"spacing": self.spacing,
|
||||||
|
"merged_cells": self.merged_cells
|
||||||
|
}
|
||||||
|
|
||||||
|
def deserialize(self, data: Dict[str, Any]):
|
||||||
|
"""Deserialize from dictionary"""
|
||||||
|
self.rows = data.get("rows", 1)
|
||||||
|
self.columns = data.get("columns", 1)
|
||||||
|
self.spacing = data.get("spacing", 10.0)
|
||||||
|
self.merged_cells = data.get("merged_cells", [])
|
||||||
158
pyPhotoAlbum/page_renderer.py
Normal file
158
pyPhotoAlbum/page_renderer.py
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
"""
|
||||||
|
Page renderer helper for pyPhotoAlbum
|
||||||
|
|
||||||
|
This module provides a unified coordinate system for rendering pages and their elements.
|
||||||
|
All coordinate transformations are centralized here to ensure consistency.
|
||||||
|
|
||||||
|
Coordinate Systems:
|
||||||
|
- Page-local: Coordinates in millimeters relative to the page's top-left corner
|
||||||
|
- Pixel: Coordinates in pixels at working DPI
|
||||||
|
- Screen: Coordinates on screen after applying zoom and pan
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Tuple, Optional
|
||||||
|
from OpenGL.GL import glPushMatrix, glPopMatrix, glScalef, glTranslatef
|
||||||
|
|
||||||
|
|
||||||
|
class PageRenderer:
|
||||||
|
"""
|
||||||
|
Handles rendering and coordinate transformations for a single page.
|
||||||
|
|
||||||
|
This class encapsulates all coordinate transformations needed to render
|
||||||
|
a page and its elements consistently.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
page_width_mm: float,
|
||||||
|
page_height_mm: float,
|
||||||
|
screen_x: float,
|
||||||
|
screen_y: float,
|
||||||
|
dpi: int,
|
||||||
|
zoom: float):
|
||||||
|
"""
|
||||||
|
Initialize a page renderer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page_width_mm: Page width in millimeters
|
||||||
|
page_height_mm: Page height in millimeters
|
||||||
|
screen_x: X position on screen where page should be rendered
|
||||||
|
screen_y: Y position on screen where page should be rendered
|
||||||
|
dpi: Working DPI for converting mm to pixels
|
||||||
|
zoom: Current zoom level
|
||||||
|
"""
|
||||||
|
self.page_width_mm = page_width_mm
|
||||||
|
self.page_height_mm = page_height_mm
|
||||||
|
self.screen_x = screen_x
|
||||||
|
self.screen_y = screen_y
|
||||||
|
self.dpi = dpi
|
||||||
|
self.zoom = zoom
|
||||||
|
|
||||||
|
# Calculate page dimensions in pixels
|
||||||
|
self.page_width_px = page_width_mm * dpi / 25.4
|
||||||
|
self.page_height_px = page_height_mm * dpi / 25.4
|
||||||
|
|
||||||
|
# Calculate screen dimensions (with zoom applied)
|
||||||
|
self.screen_width = self.page_width_px * zoom
|
||||||
|
self.screen_height = self.page_height_px * zoom
|
||||||
|
|
||||||
|
def page_to_screen(self, page_x: float, page_y: float) -> Tuple[float, float]:
|
||||||
|
"""
|
||||||
|
Convert page-local coordinates (in pixels) to screen coordinates.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page_x: X coordinate in page-local space (pixels)
|
||||||
|
page_y: Y coordinate in page-local space (pixels)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (screen_x, screen_y)
|
||||||
|
"""
|
||||||
|
screen_x = self.screen_x + page_x * self.zoom
|
||||||
|
screen_y = self.screen_y + page_y * self.zoom
|
||||||
|
return (screen_x, screen_y)
|
||||||
|
|
||||||
|
def screen_to_page(self, screen_x: float, screen_y: float) -> Tuple[float, float]:
|
||||||
|
"""
|
||||||
|
Convert screen coordinates to page-local coordinates (in pixels).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
screen_x: X coordinate in screen space
|
||||||
|
screen_y: Y coordinate in screen space
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (page_x, page_y) in pixels, or None if outside page bounds
|
||||||
|
"""
|
||||||
|
page_x = (screen_x - self.screen_x) / self.zoom
|
||||||
|
page_y = (screen_y - self.screen_y) / self.zoom
|
||||||
|
return (page_x, page_y)
|
||||||
|
|
||||||
|
def is_point_in_page(self, screen_x: float, screen_y: float) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a screen coordinate is within the page bounds.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
screen_x: X coordinate in screen space
|
||||||
|
screen_y: Y coordinate in screen space
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the point is within the page bounds
|
||||||
|
"""
|
||||||
|
return (self.screen_x <= screen_x <= self.screen_x + self.screen_width and
|
||||||
|
self.screen_y <= screen_y <= self.screen_y + self.screen_height)
|
||||||
|
|
||||||
|
def get_sub_page_at(self, screen_x: float, is_facing_page: bool) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
For facing page spreads, determine if mouse is on left or right page.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
screen_x: X coordinate in screen space
|
||||||
|
is_facing_page: Whether this is a facing page spread
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
'left' or 'right' for facing pages, None for single pages
|
||||||
|
"""
|
||||||
|
if not is_facing_page:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Calculate the center line of the spread
|
||||||
|
center_x = self.screen_x + self.screen_width / 2
|
||||||
|
|
||||||
|
if screen_x < center_x:
|
||||||
|
return 'left'
|
||||||
|
else:
|
||||||
|
return 'right'
|
||||||
|
|
||||||
|
def begin_render(self):
|
||||||
|
"""
|
||||||
|
Set up OpenGL transformations for rendering this page.
|
||||||
|
Call this before rendering page content.
|
||||||
|
"""
|
||||||
|
glPushMatrix()
|
||||||
|
# Apply zoom
|
||||||
|
glScalef(self.zoom, self.zoom, 1.0)
|
||||||
|
# Translate to page position (in zoomed coordinates)
|
||||||
|
glTranslatef(self.screen_x / self.zoom, self.screen_y / self.zoom, 0.0)
|
||||||
|
|
||||||
|
def end_render(self):
|
||||||
|
"""
|
||||||
|
Clean up OpenGL transformations after rendering this page.
|
||||||
|
Call this after rendering page content.
|
||||||
|
"""
|
||||||
|
glPopMatrix()
|
||||||
|
|
||||||
|
def get_page_bounds_screen(self) -> Tuple[float, float, float, float]:
|
||||||
|
"""
|
||||||
|
Get the page bounds in screen coordinates.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (x, y, width, height) in screen space
|
||||||
|
"""
|
||||||
|
return (self.screen_x, self.screen_y, self.screen_width, self.screen_height)
|
||||||
|
|
||||||
|
def get_page_bounds_page(self) -> Tuple[float, float, float, float]:
|
||||||
|
"""
|
||||||
|
Get the page bounds in page-local coordinates.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (x, y, width, height) in page-local space (pixels)
|
||||||
|
"""
|
||||||
|
return (0, 0, self.page_width_px, self.page_height_px)
|
||||||
490
pyPhotoAlbum/pdf_exporter.py
Normal file
490
pyPhotoAlbum/pdf_exporter.py
Normal file
@ -0,0 +1,490 @@
|
|||||||
|
"""
|
||||||
|
PDF export functionality for pyPhotoAlbum
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import List, Tuple, Optional
|
||||||
|
from reportlab.lib.pagesizes import A4
|
||||||
|
from reportlab.pdfgen import canvas
|
||||||
|
from reportlab.lib.utils import ImageReader
|
||||||
|
from PIL import Image
|
||||||
|
import math
|
||||||
|
|
||||||
|
|
||||||
|
class PDFExporter:
|
||||||
|
"""Handles PDF export of photo album projects"""
|
||||||
|
|
||||||
|
# Conversion constants
|
||||||
|
MM_TO_POINTS = 2.834645669 # 1mm = 2.834645669 points
|
||||||
|
SPLIT_THRESHOLD_RATIO = 0.002 # 1:500 threshold for tiny elements
|
||||||
|
|
||||||
|
def __init__(self, project, export_dpi: int = 300):
|
||||||
|
"""
|
||||||
|
Initialize PDF exporter with a project.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project: The Project instance to export
|
||||||
|
export_dpi: Target DPI for images in the PDF (default 300 for print quality)
|
||||||
|
Use 300 for high-quality print, 150 for screen/draft
|
||||||
|
"""
|
||||||
|
self.project = project
|
||||||
|
self.export_dpi = export_dpi
|
||||||
|
self.warnings = []
|
||||||
|
self.current_pdf_page = 1
|
||||||
|
|
||||||
|
def export(self, output_path: str, progress_callback=None) -> Tuple[bool, List[str]]:
|
||||||
|
"""
|
||||||
|
Export the project to PDF.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_path: Path where PDF should be saved
|
||||||
|
progress_callback: Optional callback(current, total, message) for progress updates
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success: bool, warnings: List[str])
|
||||||
|
"""
|
||||||
|
self.warnings = []
|
||||||
|
self.current_pdf_page = 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Calculate total pages for progress
|
||||||
|
total_pages = sum(2 if page.is_double_spread else 1 for page in self.project.pages)
|
||||||
|
|
||||||
|
# Get page dimensions from project (in mm)
|
||||||
|
page_width_mm, page_height_mm = self.project.page_size_mm
|
||||||
|
|
||||||
|
# Convert to PDF points
|
||||||
|
page_width_pt = page_width_mm * self.MM_TO_POINTS
|
||||||
|
page_height_pt = page_height_mm * self.MM_TO_POINTS
|
||||||
|
|
||||||
|
# Create PDF canvas
|
||||||
|
c = canvas.Canvas(output_path, pagesize=(page_width_pt, page_height_pt))
|
||||||
|
|
||||||
|
# Process each page
|
||||||
|
pages_processed = 0
|
||||||
|
for page in self.project.pages:
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(pages_processed, total_pages,
|
||||||
|
f"Exporting page {page.page_number}...")
|
||||||
|
|
||||||
|
if page.is_double_spread:
|
||||||
|
# Ensure spread starts on even page (left page of facing pair)
|
||||||
|
if self.current_pdf_page % 2 == 1:
|
||||||
|
# Insert blank page
|
||||||
|
c.showPage() # Finish current page
|
||||||
|
self.current_pdf_page += 1
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(pages_processed, total_pages,
|
||||||
|
f"Inserting blank page for alignment...")
|
||||||
|
|
||||||
|
# Export spread as two pages
|
||||||
|
self._export_spread(c, page, page_width_pt, page_height_pt)
|
||||||
|
pages_processed += 2
|
||||||
|
else:
|
||||||
|
# Export single page
|
||||||
|
self._export_single_page(c, page, page_width_pt, page_height_pt)
|
||||||
|
pages_processed += 1
|
||||||
|
|
||||||
|
# Save PDF
|
||||||
|
c.save()
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(total_pages, total_pages, "Export complete!")
|
||||||
|
|
||||||
|
return True, self.warnings
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.warnings.append(f"Export failed: {str(e)}")
|
||||||
|
return False, self.warnings
|
||||||
|
|
||||||
|
def _export_single_page(self, c: canvas.Canvas, page, page_width_pt: float,
|
||||||
|
page_height_pt: float):
|
||||||
|
"""Export a single page to PDF"""
|
||||||
|
# Render all elements
|
||||||
|
for element in sorted(page.layout.elements, key=lambda x: x.z_index):
|
||||||
|
self._render_element(c, element, 0, page_width_pt, page_height_pt, page.page_number)
|
||||||
|
|
||||||
|
c.showPage() # Finish this page
|
||||||
|
self.current_pdf_page += 1
|
||||||
|
|
||||||
|
def _export_spread(self, c: canvas.Canvas, page, page_width_pt: float,
|
||||||
|
page_height_pt: float):
|
||||||
|
"""Export a double-page spread as two PDF pages"""
|
||||||
|
# Get center line position in mm
|
||||||
|
page_width_mm = self.project.page_size_mm[0]
|
||||||
|
center_mm = page_width_mm # Center of the spread (which is 2x width)
|
||||||
|
|
||||||
|
# Convert center line to pixels for comparison
|
||||||
|
dpi = self.project.working_dpi
|
||||||
|
center_px = center_mm * dpi / 25.4
|
||||||
|
|
||||||
|
# Calculate threshold for tiny elements (1:500) in pixels
|
||||||
|
threshold_px = page_width_mm * self.SPLIT_THRESHOLD_RATIO * dpi / 25.4
|
||||||
|
|
||||||
|
# Process elements for left page
|
||||||
|
for element in sorted(page.layout.elements, key=lambda x: x.z_index):
|
||||||
|
element_x_px, element_y_px = element.position
|
||||||
|
element_width_px, element_height_px = element.size
|
||||||
|
|
||||||
|
# Check if element is on left page, right page, or spanning (compare in pixels)
|
||||||
|
if element_x_px + element_width_px <= center_px + threshold_px:
|
||||||
|
# Entirely on left page
|
||||||
|
self._render_element(c, element, 0, page_width_pt, page_height_pt, page.page_number)
|
||||||
|
elif element_x_px >= center_px - threshold_px:
|
||||||
|
# Skip for now, will render on right page
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Spanning element - render left portion
|
||||||
|
self._render_split_element(c, element, 0, center_mm, page_width_pt,
|
||||||
|
page_height_pt, page.page_number, 'left')
|
||||||
|
|
||||||
|
c.showPage() # Finish left page
|
||||||
|
self.current_pdf_page += 1
|
||||||
|
|
||||||
|
# Process elements for right page
|
||||||
|
for element in sorted(page.layout.elements, key=lambda x: x.z_index):
|
||||||
|
element_x_px, element_y_px = element.position
|
||||||
|
element_width_px, element_height_px = element.size
|
||||||
|
|
||||||
|
# Check if element is on right page or spanning (compare in pixels)
|
||||||
|
if element_x_px >= center_px - threshold_px and element_x_px + element_width_px > center_px:
|
||||||
|
# Entirely on right page or mostly on right
|
||||||
|
self._render_element(c, element, center_mm, page_width_pt, page_height_pt,
|
||||||
|
page.page_number + 1)
|
||||||
|
elif element_x_px < center_px and element_x_px + element_width_px > center_px + threshold_px:
|
||||||
|
# Spanning element - render right portion
|
||||||
|
self._render_split_element(c, element, center_mm, center_mm, page_width_pt,
|
||||||
|
page_height_pt, page.page_number + 1, 'right')
|
||||||
|
|
||||||
|
c.showPage() # Finish right page
|
||||||
|
self.current_pdf_page += 1
|
||||||
|
|
||||||
|
def _render_element(self, c: canvas.Canvas, element, x_offset_mm: float,
|
||||||
|
page_width_pt: float, page_height_pt: float, page_number: int):
|
||||||
|
"""
|
||||||
|
Render a single element on the PDF canvas.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
c: ReportLab canvas
|
||||||
|
element: The layout element to render
|
||||||
|
x_offset_mm: X offset in mm (for right page of spread)
|
||||||
|
page_width_pt: Page width in points
|
||||||
|
page_height_pt: Page height in points
|
||||||
|
page_number: Current page number (for error messages)
|
||||||
|
"""
|
||||||
|
from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
|
||||||
|
|
||||||
|
# Skip placeholders
|
||||||
|
if isinstance(element, PlaceholderData):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get element position and size (in PIXELS from OpenGL coordinates)
|
||||||
|
element_x_px, element_y_px = element.position
|
||||||
|
element_width_px, element_height_px = element.size
|
||||||
|
|
||||||
|
# Convert from pixels to mm using the working DPI
|
||||||
|
dpi = self.project.working_dpi
|
||||||
|
element_x_mm = element_x_px * 25.4 / dpi
|
||||||
|
element_y_mm = element_y_px * 25.4 / dpi
|
||||||
|
element_width_mm = element_width_px * 25.4 / dpi
|
||||||
|
element_height_mm = element_height_px * 25.4 / dpi
|
||||||
|
|
||||||
|
# Adjust x position for offset (now in mm)
|
||||||
|
adjusted_x_mm = element_x_mm - x_offset_mm
|
||||||
|
|
||||||
|
# Convert to PDF points and flip Y coordinate (PDF origin is bottom-left)
|
||||||
|
x_pt = adjusted_x_mm * self.MM_TO_POINTS
|
||||||
|
y_pt = page_height_pt - (element_y_mm * self.MM_TO_POINTS) - (element_height_mm * self.MM_TO_POINTS)
|
||||||
|
width_pt = element_width_mm * self.MM_TO_POINTS
|
||||||
|
height_pt = element_height_mm * self.MM_TO_POINTS
|
||||||
|
|
||||||
|
if isinstance(element, ImageData):
|
||||||
|
self._render_image(c, element, x_pt, y_pt, width_pt, height_pt, page_number)
|
||||||
|
elif isinstance(element, TextBoxData):
|
||||||
|
self._render_textbox(c, element, x_pt, y_pt, width_pt, height_pt)
|
||||||
|
|
||||||
|
def _render_split_element(self, c: canvas.Canvas, element, x_offset_mm: float,
|
||||||
|
split_line_mm: float, page_width_pt: float, page_height_pt: float,
|
||||||
|
page_number: int, side: str):
|
||||||
|
"""
|
||||||
|
Render a split element (only the portion on one side of the split line).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
c: ReportLab canvas
|
||||||
|
element: The layout element to render
|
||||||
|
x_offset_mm: X offset in mm (0 for left, page_width for right)
|
||||||
|
split_line_mm: Position of split line in mm
|
||||||
|
page_width_pt: Page width in points
|
||||||
|
page_height_pt: Page height in points
|
||||||
|
page_number: Current page number
|
||||||
|
side: 'left' or 'right'
|
||||||
|
"""
|
||||||
|
from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
|
||||||
|
|
||||||
|
# Skip placeholders
|
||||||
|
if isinstance(element, PlaceholderData):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get element position and size in pixels
|
||||||
|
element_x_px, element_y_px = element.position
|
||||||
|
element_width_px, element_height_px = element.size
|
||||||
|
|
||||||
|
# Convert to mm
|
||||||
|
dpi = self.project.working_dpi
|
||||||
|
element_x_mm = element_x_px * 25.4 / dpi
|
||||||
|
element_y_mm = element_y_px * 25.4 / dpi
|
||||||
|
element_width_mm = element_width_px * 25.4 / dpi
|
||||||
|
element_height_mm = element_height_px * 25.4 / dpi
|
||||||
|
|
||||||
|
if isinstance(element, ImageData):
|
||||||
|
# Calculate which portion of the image to render
|
||||||
|
if side == 'left':
|
||||||
|
# Render from element start to split line
|
||||||
|
crop_width_mm = split_line_mm - element_x_mm
|
||||||
|
crop_x_start = 0
|
||||||
|
render_x_mm = element_x_mm
|
||||||
|
else: # right
|
||||||
|
# Render from split line to element end
|
||||||
|
crop_width_mm = (element_x_mm + element_width_mm) - split_line_mm
|
||||||
|
crop_x_start = split_line_mm - element_x_mm
|
||||||
|
render_x_mm = split_line_mm # Start at split line in spread coordinates
|
||||||
|
|
||||||
|
# Adjust render position for offset
|
||||||
|
adjusted_x_mm = render_x_mm - x_offset_mm
|
||||||
|
|
||||||
|
# Convert to points
|
||||||
|
x_pt = adjusted_x_mm * self.MM_TO_POINTS
|
||||||
|
y_pt = page_height_pt - (element_y_mm * self.MM_TO_POINTS) - (element_height_mm * self.MM_TO_POINTS)
|
||||||
|
width_pt = crop_width_mm * self.MM_TO_POINTS
|
||||||
|
height_pt = element_height_mm * self.MM_TO_POINTS
|
||||||
|
|
||||||
|
# Calculate original element dimensions in points (before splitting)
|
||||||
|
original_width_pt = element_width_mm * self.MM_TO_POINTS
|
||||||
|
original_height_pt = element_height_mm * self.MM_TO_POINTS
|
||||||
|
|
||||||
|
# Render cropped image with original dimensions for correct aspect ratio
|
||||||
|
self._render_image(c, element, x_pt, y_pt, width_pt, height_pt, page_number,
|
||||||
|
crop_left=crop_x_start / element_width_mm,
|
||||||
|
crop_right=(crop_x_start + crop_width_mm) / element_width_mm,
|
||||||
|
original_width_pt=original_width_pt,
|
||||||
|
original_height_pt=original_height_pt)
|
||||||
|
|
||||||
|
elif isinstance(element, TextBoxData):
|
||||||
|
# For text boxes spanning the split, we'll render the whole text on the side
|
||||||
|
# where most of it appears (simpler than trying to split text)
|
||||||
|
element_center_mm = element_x_mm + element_width_mm / 2
|
||||||
|
if (side == 'left' and element_center_mm < split_line_mm) or \
|
||||||
|
(side == 'right' and element_center_mm >= split_line_mm):
|
||||||
|
self._render_element(c, element, x_offset_mm, page_width_pt, page_height_pt, page_number)
|
||||||
|
|
||||||
|
def _render_image(self, c: canvas.Canvas, image_element: 'ImageData', x_pt: float,
|
||||||
|
y_pt: float, width_pt: float, height_pt: float, page_number: int,
|
||||||
|
crop_left: float = 0.0, crop_right: float = 1.0,
|
||||||
|
original_width_pt: Optional[float] = None, original_height_pt: Optional[float] = None):
|
||||||
|
"""
|
||||||
|
Render an image element on the PDF canvas.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
c: ReportLab canvas
|
||||||
|
image_element: ImageData instance
|
||||||
|
x_pt, y_pt, width_pt, height_pt: Position and size in points (after cropping for split images)
|
||||||
|
page_number: Current page number (for warnings)
|
||||||
|
crop_left: Left crop position (0.0 to 1.0)
|
||||||
|
crop_right: Right crop position (0.0 to 1.0)
|
||||||
|
original_width_pt: Original element width in points (before splitting, for aspect ratio)
|
||||||
|
original_height_pt: Original element height in points (before splitting, for aspect ratio)
|
||||||
|
"""
|
||||||
|
# Check if image exists
|
||||||
|
if not image_element.image_path or not os.path.exists(image_element.image_path):
|
||||||
|
warning = f"Page {page_number}: Image not found: {image_element.image_path}"
|
||||||
|
print(f"WARNING: {warning}")
|
||||||
|
self.warnings.append(warning)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Load image
|
||||||
|
img = Image.open(image_element.image_path)
|
||||||
|
img = img.convert('RGBA')
|
||||||
|
|
||||||
|
# Apply element's crop_info (from the element's own cropping)
|
||||||
|
crop_x_min, crop_y_min, crop_x_max, crop_y_max = image_element.crop_info
|
||||||
|
|
||||||
|
# Combine with split cropping if applicable
|
||||||
|
final_crop_x_min = crop_x_min + (crop_x_max - crop_x_min) * crop_left
|
||||||
|
final_crop_x_max = crop_x_min + (crop_x_max - crop_x_min) * crop_right
|
||||||
|
|
||||||
|
# Calculate pixel crop coordinates
|
||||||
|
img_width, img_height = img.size
|
||||||
|
|
||||||
|
# Apply center crop first (matching the render logic in models.py)
|
||||||
|
img_aspect = img_width / img_height
|
||||||
|
# Use original dimensions for aspect ratio if provided (for split images)
|
||||||
|
# This prevents stretching when splitting an image across pages
|
||||||
|
if original_width_pt is not None and original_height_pt is not None:
|
||||||
|
target_aspect = original_width_pt / original_height_pt
|
||||||
|
else:
|
||||||
|
target_aspect = width_pt / height_pt
|
||||||
|
|
||||||
|
if img_aspect > target_aspect:
|
||||||
|
# Image is wider - 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 - 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 element crop_info range
|
||||||
|
tx_range = tx_max_base - tx_min_base
|
||||||
|
ty_range = ty_max_base - ty_min_base
|
||||||
|
|
||||||
|
tx_min = tx_min_base + final_crop_x_min * tx_range
|
||||||
|
tx_max = tx_min_base + final_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
|
||||||
|
|
||||||
|
# Convert to pixel coordinates
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Crop the image
|
||||||
|
cropped_img = img.crop((crop_left_px, crop_top_px, crop_right_px, crop_bottom_px))
|
||||||
|
|
||||||
|
# Downsample image to target resolution based on export DPI
|
||||||
|
# This prevents embedding huge images and reduces PDF file size
|
||||||
|
# Calculate target dimensions in pixels based on physical size and export DPI
|
||||||
|
target_width_px = int((width_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
|
||||||
|
target_height_px = int((height_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
|
||||||
|
|
||||||
|
# Only downsample if current image is larger than target
|
||||||
|
# Don't upscale small images as that would reduce quality
|
||||||
|
current_width, current_height = cropped_img.size
|
||||||
|
if current_width > target_width_px or current_height > target_height_px:
|
||||||
|
# Use LANCZOS resampling for high quality downsampling
|
||||||
|
cropped_img = cropped_img.resize((target_width_px, target_height_px),
|
||||||
|
Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
# Apply rotation if needed
|
||||||
|
if image_element.rotation != 0:
|
||||||
|
# Rotate around center
|
||||||
|
cropped_img = cropped_img.rotate(-image_element.rotation, expand=True,
|
||||||
|
fillcolor=(255, 255, 255, 0))
|
||||||
|
|
||||||
|
# Save state for transformations
|
||||||
|
c.saveState()
|
||||||
|
|
||||||
|
# Apply rotation to canvas if needed
|
||||||
|
if image_element.rotation != 0:
|
||||||
|
# Move to element center
|
||||||
|
center_x = x_pt + width_pt / 2
|
||||||
|
center_y = y_pt + height_pt / 2
|
||||||
|
c.translate(center_x, center_y)
|
||||||
|
c.rotate(image_element.rotation)
|
||||||
|
c.translate(-width_pt / 2, -height_pt / 2)
|
||||||
|
# Draw at origin after transformation
|
||||||
|
c.drawImage(ImageReader(cropped_img), 0, 0, width_pt, height_pt,
|
||||||
|
mask='auto', preserveAspectRatio=False)
|
||||||
|
else:
|
||||||
|
# Draw without rotation
|
||||||
|
c.drawImage(ImageReader(cropped_img), x_pt, y_pt, width_pt, height_pt,
|
||||||
|
mask='auto', preserveAspectRatio=False)
|
||||||
|
|
||||||
|
c.restoreState()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
warning = f"Page {page_number}: Error rendering image {image_element.image_path}: {str(e)}"
|
||||||
|
print(f"WARNING: {warning}")
|
||||||
|
self.warnings.append(warning)
|
||||||
|
|
||||||
|
def _render_textbox(self, c: canvas.Canvas, text_element: 'TextBoxData',
|
||||||
|
x_pt: float, y_pt: float, width_pt: float, height_pt: float):
|
||||||
|
"""
|
||||||
|
Render a text box element on the PDF canvas with transparent background.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
c: ReportLab canvas
|
||||||
|
text_element: TextBoxData instance
|
||||||
|
x_pt, y_pt, width_pt, height_pt: Position and size in points
|
||||||
|
"""
|
||||||
|
if not text_element.text_content:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get font settings
|
||||||
|
font_family = text_element.font_settings.get('family', 'Helvetica')
|
||||||
|
font_size = text_element.font_settings.get('size', 12)
|
||||||
|
font_color = text_element.font_settings.get('color', (0, 0, 0))
|
||||||
|
|
||||||
|
# Map common font names to ReportLab standard fonts
|
||||||
|
font_map = {
|
||||||
|
'Arial': 'Helvetica',
|
||||||
|
'Times New Roman': 'Times-Roman',
|
||||||
|
'Courier New': 'Courier',
|
||||||
|
}
|
||||||
|
font_family = font_map.get(font_family, font_family)
|
||||||
|
|
||||||
|
# Save state for transformations
|
||||||
|
c.saveState()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Set font
|
||||||
|
c.setFont(font_family, font_size)
|
||||||
|
|
||||||
|
# Set color to black (normalize from 0-255 to 0-1 if needed)
|
||||||
|
if all(isinstance(x, int) and x > 1 for x in font_color):
|
||||||
|
color = tuple(x / 255.0 for x in font_color)
|
||||||
|
else:
|
||||||
|
color = font_color
|
||||||
|
c.setFillColorRGB(*color)
|
||||||
|
|
||||||
|
# No background is drawn - transparent background in PDF
|
||||||
|
|
||||||
|
# Apply rotation if needed
|
||||||
|
if text_element.rotation != 0:
|
||||||
|
# Move to element center
|
||||||
|
center_x = x_pt + width_pt / 2
|
||||||
|
center_y = y_pt + height_pt / 2
|
||||||
|
c.translate(center_x, center_y)
|
||||||
|
c.rotate(text_element.rotation)
|
||||||
|
# Draw text relative to rotation center
|
||||||
|
text_y = -height_pt / 2 + font_size # Adjust for text baseline
|
||||||
|
|
||||||
|
if text_element.alignment == 'center':
|
||||||
|
text_x = -c.stringWidth(text_element.text_content, font_family, font_size) / 2
|
||||||
|
elif text_element.alignment == 'right':
|
||||||
|
text_x = width_pt / 2 - c.stringWidth(text_element.text_content, font_family, font_size)
|
||||||
|
else: # left
|
||||||
|
text_x = -width_pt / 2
|
||||||
|
|
||||||
|
c.drawString(text_x, text_y, text_element.text_content)
|
||||||
|
else:
|
||||||
|
# No rotation - draw normally with alignment
|
||||||
|
text_y = y_pt + font_size # Adjust for text baseline
|
||||||
|
|
||||||
|
if text_element.alignment == 'center':
|
||||||
|
text_x = x_pt + (width_pt - c.stringWidth(text_element.text_content,
|
||||||
|
font_family, font_size)) / 2
|
||||||
|
elif text_element.alignment == 'right':
|
||||||
|
text_x = x_pt + width_pt - c.stringWidth(text_element.text_content,
|
||||||
|
font_family, font_size)
|
||||||
|
else: # left
|
||||||
|
text_x = x_pt
|
||||||
|
|
||||||
|
c.drawString(text_x, text_y, text_element.text_content)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
warning = f"Error rendering text box: {str(e)}"
|
||||||
|
print(f"WARNING: {warning}")
|
||||||
|
self.warnings.append(warning)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
c.restoreState()
|
||||||
236
pyPhotoAlbum/project.py
Normal file
236
pyPhotoAlbum/project.py
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
"""
|
||||||
|
Project and page management for pyPhotoAlbum
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import List, Dict, Any, Optional, Tuple
|
||||||
|
from pyPhotoAlbum.page_layout import PageLayout
|
||||||
|
from pyPhotoAlbum.commands import CommandHistory
|
||||||
|
from pyPhotoAlbum.asset_manager import AssetManager
|
||||||
|
|
||||||
|
class Page:
|
||||||
|
"""Class representing a single page in the photo album"""
|
||||||
|
|
||||||
|
def __init__(self, layout: Optional[PageLayout] = None, page_number: int = 1, is_double_spread: bool = False):
|
||||||
|
"""
|
||||||
|
Initialize a page.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
layout: PageLayout instance (created automatically if None)
|
||||||
|
page_number: The page number (for spreads, this is the left page number)
|
||||||
|
is_double_spread: If True, this is a facing page spread (2x width)
|
||||||
|
"""
|
||||||
|
self.page_number = page_number
|
||||||
|
self.is_cover = False
|
||||||
|
self.is_double_spread = is_double_spread
|
||||||
|
self.manually_sized = False # Track if user manually changed page size
|
||||||
|
|
||||||
|
# Create layout with appropriate width
|
||||||
|
if layout is None:
|
||||||
|
self.layout = PageLayout(is_facing_page=is_double_spread)
|
||||||
|
else:
|
||||||
|
self.layout = layout
|
||||||
|
# Ensure layout matches the is_double_spread setting
|
||||||
|
if is_double_spread != self.layout.is_facing_page:
|
||||||
|
# Need to update the layout for the new facing page state
|
||||||
|
self.layout.is_facing_page = is_double_spread
|
||||||
|
height = self.layout.size[1]
|
||||||
|
# Use the base_width if available, otherwise derive it
|
||||||
|
if hasattr(self.layout, 'base_width'):
|
||||||
|
base_width = self.layout.base_width
|
||||||
|
else:
|
||||||
|
# If base_width not set, assume current width is correct
|
||||||
|
# and derive base_width from current state
|
||||||
|
base_width = self.layout.size[0] / 2 if not is_double_spread else self.layout.size[0]
|
||||||
|
self.layout.base_width = base_width
|
||||||
|
|
||||||
|
# Set the new width based on facing page state
|
||||||
|
self.layout.size = (base_width * 2 if is_double_spread else base_width, height)
|
||||||
|
|
||||||
|
def get_page_numbers(self) -> List[int]:
|
||||||
|
"""
|
||||||
|
Get the page numbers this page represents.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of page numbers (2 numbers for spreads, 1 for single pages)
|
||||||
|
"""
|
||||||
|
if self.is_double_spread:
|
||||||
|
return [self.page_number, self.page_number + 1]
|
||||||
|
else:
|
||||||
|
return [self.page_number]
|
||||||
|
|
||||||
|
def get_page_count(self) -> int:
|
||||||
|
"""
|
||||||
|
Get the number of physical pages this represents.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
2 for spreads, 1 for single pages
|
||||||
|
"""
|
||||||
|
return 2 if self.is_double_spread else 1
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
"""Render the entire page"""
|
||||||
|
print(f"Rendering page {self.page_number}")
|
||||||
|
self.layout.render()
|
||||||
|
|
||||||
|
def serialize(self) -> Dict[str, Any]:
|
||||||
|
"""Serialize page to dictionary"""
|
||||||
|
return {
|
||||||
|
"page_number": self.page_number,
|
||||||
|
"is_cover": self.is_cover,
|
||||||
|
"is_double_spread": self.is_double_spread,
|
||||||
|
"manually_sized": self.manually_sized,
|
||||||
|
"layout": self.layout.serialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
def deserialize(self, data: Dict[str, Any]):
|
||||||
|
"""Deserialize from dictionary"""
|
||||||
|
self.page_number = data.get("page_number", 1)
|
||||||
|
self.is_cover = data.get("is_cover", False)
|
||||||
|
self.is_double_spread = data.get("is_double_spread", False)
|
||||||
|
self.manually_sized = data.get("manually_sized", False)
|
||||||
|
|
||||||
|
layout_data = data.get("layout", {})
|
||||||
|
self.layout = PageLayout()
|
||||||
|
self.layout.deserialize(layout_data)
|
||||||
|
|
||||||
|
class Project:
|
||||||
|
"""Class representing the entire photo album project"""
|
||||||
|
|
||||||
|
def __init__(self, name: str = "Untitled Project", folder_path: Optional[str] = None):
|
||||||
|
self.name = name
|
||||||
|
self.folder_path = folder_path or os.path.join("./projects", name.replace(" ", "_"))
|
||||||
|
self.pages: List[Page] = []
|
||||||
|
self.default_min_distance = 10.0 # Default minimum distance between images
|
||||||
|
self.cover_size = (800, 600) # Default cover size in pixels
|
||||||
|
self.page_size = (800, 600) # Default page size in pixels
|
||||||
|
self.page_size_mm = (140, 140) # Default page size in mm (14cm x 14cm)
|
||||||
|
self.working_dpi = 300 # Default working DPI
|
||||||
|
self.export_dpi = 300 # Default export DPI
|
||||||
|
self.page_spacing_mm = 10.0 # Default spacing between pages (1cm)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
def add_page(self, page: Page):
|
||||||
|
"""Add a page to the project"""
|
||||||
|
self.pages.append(page)
|
||||||
|
|
||||||
|
def remove_page(self, page: Page):
|
||||||
|
"""Remove a page from the project"""
|
||||||
|
self.pages.remove(page)
|
||||||
|
|
||||||
|
def calculate_page_layout_with_ghosts(self) -> List[Tuple[str, Any, int]]:
|
||||||
|
"""
|
||||||
|
Calculate page layout including ghost pages for alignment.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of tuples (page_type, page_or_ghost, logical_position)
|
||||||
|
where page_type is 'page' or 'ghost',
|
||||||
|
page_or_ghost is the Page object or None for ghost,
|
||||||
|
logical_position is the position in the album (1=right, 2=left, etc.)
|
||||||
|
"""
|
||||||
|
from pyPhotoAlbum.models import GhostPageData
|
||||||
|
|
||||||
|
layout = []
|
||||||
|
current_position = 1 # Start at position 1 (right page)
|
||||||
|
|
||||||
|
for page in self.pages:
|
||||||
|
# Check if we need a ghost page for alignment
|
||||||
|
# Ghost pages are needed when a single page would appear on the left
|
||||||
|
# but should be on the right (odd positions)
|
||||||
|
if not page.is_double_spread and current_position % 2 == 0:
|
||||||
|
# Current position is even (left page), but we have a single page
|
||||||
|
# This is fine - single page goes on left
|
||||||
|
pass
|
||||||
|
elif not page.is_double_spread and current_position % 2 == 1:
|
||||||
|
# Current position is odd (right page), single page is fine
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Actually, let me reconsider the logic:
|
||||||
|
# In a photobook:
|
||||||
|
# - Position 1 is the right page (when opened, first content page)
|
||||||
|
# - Position 2 is the left page of the next spread
|
||||||
|
# - Position 3 is the right page of the next spread
|
||||||
|
# - etc.
|
||||||
|
#
|
||||||
|
# Double spreads occupy TWO positions (both left and right of a spread)
|
||||||
|
# They must start on an even position (left side) so they span across both pages
|
||||||
|
|
||||||
|
# Check if this is a double spread starting at an odd position
|
||||||
|
if page.is_double_spread and current_position % 2 == 1:
|
||||||
|
# Need to insert a ghost page to push the double spread to next position
|
||||||
|
layout.append(('ghost', None, current_position))
|
||||||
|
current_position += 1
|
||||||
|
|
||||||
|
# Add the actual page
|
||||||
|
layout.append(('page', page, current_position))
|
||||||
|
|
||||||
|
# Update position based on page type
|
||||||
|
if page.is_double_spread:
|
||||||
|
current_position += 2 # Double spread takes 2 positions
|
||||||
|
else:
|
||||||
|
current_position += 1 # Single page takes 1 position
|
||||||
|
|
||||||
|
return layout
|
||||||
|
|
||||||
|
def render_all_pages(self):
|
||||||
|
"""Render all pages in the project"""
|
||||||
|
for page in self.pages:
|
||||||
|
page.render()
|
||||||
|
|
||||||
|
def serialize(self) -> Dict[str, Any]:
|
||||||
|
"""Serialize entire project to dictionary"""
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"folder_path": self.folder_path,
|
||||||
|
"default_min_distance": self.default_min_distance,
|
||||||
|
"cover_size": self.cover_size,
|
||||||
|
"page_size": self.page_size,
|
||||||
|
"page_size_mm": self.page_size_mm,
|
||||||
|
"working_dpi": self.working_dpi,
|
||||||
|
"export_dpi": self.export_dpi,
|
||||||
|
"page_spacing_mm": self.page_spacing_mm,
|
||||||
|
"pages": [page.serialize() for page in self.pages],
|
||||||
|
"history": self.history.serialize(),
|
||||||
|
"asset_manager": self.asset_manager.serialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
def deserialize(self, data: Dict[str, Any]):
|
||||||
|
"""Deserialize from dictionary"""
|
||||||
|
self.name = data.get("name", "Untitled Project")
|
||||||
|
self.folder_path = data.get("folder_path", os.path.join("./projects", self.name.replace(" ", "_")))
|
||||||
|
self.default_min_distance = data.get("default_min_distance", 10.0)
|
||||||
|
self.cover_size = tuple(data.get("cover_size", (800, 600)))
|
||||||
|
self.page_size = tuple(data.get("page_size", (800, 600)))
|
||||||
|
self.page_size_mm = tuple(data.get("page_size_mm", (210, 297)))
|
||||||
|
self.working_dpi = data.get("working_dpi", 300)
|
||||||
|
self.export_dpi = data.get("export_dpi", 300)
|
||||||
|
self.page_spacing_mm = data.get("page_spacing_mm", 10.0)
|
||||||
|
self.pages = []
|
||||||
|
|
||||||
|
# Deserialize asset manager
|
||||||
|
self.asset_manager = AssetManager(self.folder_path)
|
||||||
|
asset_data = data.get("asset_manager")
|
||||||
|
if asset_data:
|
||||||
|
self.asset_manager.deserialize(asset_data)
|
||||||
|
|
||||||
|
# Deserialize pages
|
||||||
|
for page_data in data.get("pages", []):
|
||||||
|
page = Page()
|
||||||
|
page.deserialize(page_data)
|
||||||
|
self.pages.append(page)
|
||||||
|
|
||||||
|
# Deserialize command history with asset manager
|
||||||
|
history_data = data.get("history")
|
||||||
|
if history_data:
|
||||||
|
self.history = CommandHistory(max_history=100)
|
||||||
|
self.history.asset_manager = self.asset_manager
|
||||||
|
self.history.deserialize(history_data, self)
|
||||||
|
else:
|
||||||
|
self.history = CommandHistory(max_history=100)
|
||||||
|
self.history.asset_manager = self.asset_manager
|
||||||
154
pyPhotoAlbum/project_serializer.py
Normal file
154
pyPhotoAlbum/project_serializer.py
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
"""
|
||||||
|
Project serialization to/from ZIP files for pyPhotoAlbum
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import zipfile
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
from pathlib import Path
|
||||||
|
from pyPhotoAlbum.project import Project
|
||||||
|
|
||||||
|
|
||||||
|
# Version for serialization format - increment when making breaking changes
|
||||||
|
SERIALIZATION_VERSION = "1.0"
|
||||||
|
|
||||||
|
|
||||||
|
def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""
|
||||||
|
Save a project to a ZIP file, including all assets.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project: The Project instance to save
|
||||||
|
zip_path: Path where the ZIP file should be created
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success: bool, error_message: Optional[str])
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Ensure .ppz extension
|
||||||
|
if not zip_path.lower().endswith('.ppz'):
|
||||||
|
zip_path += '.ppz'
|
||||||
|
|
||||||
|
# Serialize project to dictionary
|
||||||
|
project_data = project.serialize()
|
||||||
|
|
||||||
|
# Add version information
|
||||||
|
project_data['serialization_version'] = SERIALIZATION_VERSION
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
# Add all files from the assets folder
|
||||||
|
assets_folder = project.asset_manager.assets_folder
|
||||||
|
if os.path.exists(assets_folder):
|
||||||
|
for root, dirs, files in os.walk(assets_folder):
|
||||||
|
for file in files:
|
||||||
|
file_path = os.path.join(root, file)
|
||||||
|
# Store with relative path from project folder
|
||||||
|
arcname = os.path.relpath(file_path, project.folder_path)
|
||||||
|
zipf.write(file_path, arcname)
|
||||||
|
|
||||||
|
print(f"Project saved to {zip_path}")
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Error saving project: {str(e)}"
|
||||||
|
print(error_msg)
|
||||||
|
return False, error_msg
|
||||||
|
|
||||||
|
|
||||||
|
def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Tuple[Optional[Project], Optional[str]]:
|
||||||
|
"""
|
||||||
|
Load a project from a ZIP file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
zip_path: Path to the ZIP file to load
|
||||||
|
extract_to: Optional directory to extract to. If None, uses a directory
|
||||||
|
based on the ZIP filename in ./projects/
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (project: Optional[Project], error_message: Optional[str])
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not os.path.exists(zip_path):
|
||||||
|
return None, f"ZIP file not found: {zip_path}"
|
||||||
|
|
||||||
|
# Determine extraction directory
|
||||||
|
if extract_to is None:
|
||||||
|
# Extract to ./projects/{zipname}/
|
||||||
|
zip_basename = os.path.splitext(os.path.basename(zip_path))[0]
|
||||||
|
extract_to = os.path.join("./projects", zip_basename)
|
||||||
|
|
||||||
|
# Create extraction directory
|
||||||
|
os.makedirs(extract_to, exist_ok=True)
|
||||||
|
|
||||||
|
# Extract ZIP contents
|
||||||
|
with zipfile.ZipFile(zip_path, 'r') as zipf:
|
||||||
|
zipf.extractall(extract_to)
|
||||||
|
|
||||||
|
# Load project.json
|
||||||
|
project_json_path = os.path.join(extract_to, 'project.json')
|
||||||
|
if not os.path.exists(project_json_path):
|
||||||
|
return None, "Invalid project file: project.json not found"
|
||||||
|
|
||||||
|
with open(project_json_path, 'r') as f:
|
||||||
|
project_data = json.load(f)
|
||||||
|
|
||||||
|
# Check version compatibility
|
||||||
|
version = project_data.get('serialization_version', '1.0')
|
||||||
|
if version != SERIALIZATION_VERSION:
|
||||||
|
print(f"Warning: Loading project with version {version}, current version is {SERIALIZATION_VERSION}")
|
||||||
|
|
||||||
|
# Create new project
|
||||||
|
project_name = project_data.get('name', 'Untitled Project')
|
||||||
|
project = Project(name=project_name, folder_path=extract_to)
|
||||||
|
|
||||||
|
# Deserialize project data
|
||||||
|
project.deserialize(project_data)
|
||||||
|
|
||||||
|
# Update folder path to extraction location
|
||||||
|
project.folder_path = extract_to
|
||||||
|
project.asset_manager.project_folder = extract_to
|
||||||
|
project.asset_manager.assets_folder = os.path.join(extract_to, "assets")
|
||||||
|
|
||||||
|
print(f"Project loaded from {zip_path} to {extract_to}")
|
||||||
|
return project, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Error loading project: {str(e)}"
|
||||||
|
print(error_msg)
|
||||||
|
return None, error_msg
|
||||||
|
|
||||||
|
|
||||||
|
def get_project_info(zip_path: str) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
Get basic information about a project without fully loading it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
zip_path: Path to the ZIP file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with project info, or None if error
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(zip_path, 'r') as zipf:
|
||||||
|
# Read project.json
|
||||||
|
project_json = zipf.read('project.json').decode('utf-8')
|
||||||
|
project_data = json.loads(project_json)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'name': project_data.get('name', 'Unknown'),
|
||||||
|
'version': project_data.get('serialization_version', 'Unknown'),
|
||||||
|
'page_count': len(project_data.get('pages', [])),
|
||||||
|
'page_size_mm': project_data.get('page_size_mm', (0, 0)),
|
||||||
|
'working_dpi': project_data.get('working_dpi', 300),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error reading project info: {e}")
|
||||||
|
return None
|
||||||
6
pyPhotoAlbum/requirements.txt
Normal file
6
pyPhotoAlbum/requirements.txt
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
PyQt6>=6.0.0
|
||||||
|
PyOpenGL>=3.1.0
|
||||||
|
numpy>=1.20.0
|
||||||
|
Pillow>=8.0.0
|
||||||
|
reportlab>=3.5.0
|
||||||
|
lxml>=4.6.0
|
||||||
241
pyPhotoAlbum/ribbon_builder.py
Normal file
241
pyPhotoAlbum/ribbon_builder.py
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
"""
|
||||||
|
Ribbon configuration builder for pyPhotoAlbum
|
||||||
|
|
||||||
|
This module scans classes for methods decorated with @ribbon_action
|
||||||
|
and automatically builds the ribbon configuration structure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, List, Any, Type
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
|
||||||
|
def build_ribbon_config(window_class: Type) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Extract decorated methods and build ribbon configuration.
|
||||||
|
|
||||||
|
This function scans all methods in the window class and its mixins
|
||||||
|
for methods decorated with @ribbon_action, then builds a nested
|
||||||
|
configuration structure suitable for the RibbonWidget.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
window_class: The MainWindow class with decorated methods
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing the ribbon configuration with structure:
|
||||||
|
{
|
||||||
|
"TabName": {
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"name": "GroupName",
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"label": "Button Label",
|
||||||
|
"action": "method_name",
|
||||||
|
"tooltip": "Tooltip text",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# Structure to collect actions by tab and group
|
||||||
|
tabs = defaultdict(lambda: defaultdict(list))
|
||||||
|
|
||||||
|
# Scan all methods in the class and its bases (mixins)
|
||||||
|
for attr_name in dir(window_class):
|
||||||
|
try:
|
||||||
|
attr = getattr(window_class, attr_name)
|
||||||
|
|
||||||
|
# Check if this attribute has ribbon action metadata
|
||||||
|
if hasattr(attr, '_ribbon_action'):
|
||||||
|
action_data = attr._ribbon_action
|
||||||
|
|
||||||
|
# Extract tab and group information
|
||||||
|
tab_name = action_data['tab']
|
||||||
|
group_name = action_data['group']
|
||||||
|
|
||||||
|
# Add action to the appropriate tab and group
|
||||||
|
tabs[tab_name][group_name].append({
|
||||||
|
'label': action_data['label'],
|
||||||
|
'action': action_data['action'],
|
||||||
|
'tooltip': action_data['tooltip'],
|
||||||
|
'icon': action_data.get('icon'),
|
||||||
|
'shortcut': action_data.get('shortcut'),
|
||||||
|
})
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
# Skip attributes that can't be inspected
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Convert to the expected ribbon config format
|
||||||
|
ribbon_config = {}
|
||||||
|
|
||||||
|
# Define tab order (tabs will appear in this order)
|
||||||
|
tab_order = ['Home', 'Insert', 'Layout', 'Arrange', 'View', 'Export']
|
||||||
|
|
||||||
|
# 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'],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get the group order for this tab, or use alphabetical
|
||||||
|
if tab_name in group_orders:
|
||||||
|
group_order = group_orders[tab_name]
|
||||||
|
# Add any groups not in the defined order
|
||||||
|
all_groups = list(groups_dict.keys())
|
||||||
|
group_order.extend([g for g in all_groups if g not in group_order])
|
||||||
|
else:
|
||||||
|
group_order = sorted(groups_dict.keys())
|
||||||
|
|
||||||
|
for group_name in group_order:
|
||||||
|
if group_name in groups_dict:
|
||||||
|
actions = groups_dict[group_name]
|
||||||
|
groups_list.append({
|
||||||
|
'name': group_name,
|
||||||
|
'actions': actions
|
||||||
|
})
|
||||||
|
|
||||||
|
ribbon_config[tab_name] = {
|
||||||
|
'groups': groups_list
|
||||||
|
}
|
||||||
|
|
||||||
|
return ribbon_config
|
||||||
|
|
||||||
|
|
||||||
|
def get_keyboard_shortcuts(window_class: Type) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
Extract keyboard shortcuts from decorated methods.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
window_class: The MainWindow class with decorated methods
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping shortcut strings to method names
|
||||||
|
Example: {'Ctrl+N': 'new_project', 'Ctrl+S': 'save_project'}
|
||||||
|
"""
|
||||||
|
shortcuts = {}
|
||||||
|
|
||||||
|
for attr_name in dir(window_class):
|
||||||
|
try:
|
||||||
|
attr = getattr(window_class, attr_name)
|
||||||
|
|
||||||
|
if hasattr(attr, '_ribbon_action'):
|
||||||
|
action_data = attr._ribbon_action
|
||||||
|
shortcut = action_data.get('shortcut')
|
||||||
|
|
||||||
|
if shortcut:
|
||||||
|
shortcuts[shortcut] = action_data['action']
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
return shortcuts
|
||||||
|
|
||||||
|
|
||||||
|
def validate_ribbon_config(config: Dict[str, Any]) -> List[str]:
|
||||||
|
"""
|
||||||
|
Validate the ribbon configuration structure.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: The ribbon configuration dictionary
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of validation error messages (empty if valid)
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
errors.append("Config must be a dictionary")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
for tab_name, tab_data in config.items():
|
||||||
|
if not isinstance(tab_data, dict):
|
||||||
|
errors.append(f"Tab '{tab_name}' data must be a dictionary")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if 'groups' not in tab_data:
|
||||||
|
errors.append(f"Tab '{tab_name}' missing 'groups' key")
|
||||||
|
continue
|
||||||
|
|
||||||
|
groups = tab_data['groups']
|
||||||
|
if not isinstance(groups, list):
|
||||||
|
errors.append(f"Tab '{tab_name}' groups must be a list")
|
||||||
|
continue
|
||||||
|
|
||||||
|
for i, group in enumerate(groups):
|
||||||
|
if not isinstance(group, dict):
|
||||||
|
errors.append(f"Tab '{tab_name}' group {i} must be a dictionary")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if 'name' not in group:
|
||||||
|
errors.append(f"Tab '{tab_name}' group {i} missing 'name'")
|
||||||
|
|
||||||
|
if 'actions' not in group:
|
||||||
|
errors.append(f"Tab '{tab_name}' group {i} missing 'actions'")
|
||||||
|
continue
|
||||||
|
|
||||||
|
actions = group['actions']
|
||||||
|
if not isinstance(actions, list):
|
||||||
|
errors.append(f"Tab '{tab_name}' group {i} actions must be a list")
|
||||||
|
continue
|
||||||
|
|
||||||
|
for j, action in enumerate(actions):
|
||||||
|
if not isinstance(action, dict):
|
||||||
|
errors.append(f"Tab '{tab_name}' group {i} action {j} must be a dictionary")
|
||||||
|
continue
|
||||||
|
|
||||||
|
required_keys = ['label', 'action', 'tooltip']
|
||||||
|
for key in required_keys:
|
||||||
|
if key not in action:
|
||||||
|
errors.append(
|
||||||
|
f"Tab '{tab_name}' group {i} action {j} missing '{key}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def print_ribbon_summary(config: Dict[str, Any]):
|
||||||
|
"""
|
||||||
|
Print a summary of the ribbon configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: The ribbon configuration dictionary
|
||||||
|
"""
|
||||||
|
print("\n=== Ribbon Configuration Summary ===\n")
|
||||||
|
|
||||||
|
total_tabs = len(config)
|
||||||
|
total_groups = sum(len(tab_data['groups']) for tab_data in config.values())
|
||||||
|
total_actions = sum(
|
||||||
|
len(group['actions'])
|
||||||
|
for tab_data in config.values()
|
||||||
|
for group in tab_data['groups']
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Total Tabs: {total_tabs}")
|
||||||
|
print(f"Total Groups: {total_groups}")
|
||||||
|
print(f"Total Actions: {total_actions}\n")
|
||||||
|
|
||||||
|
for tab_name, tab_data in config.items():
|
||||||
|
print(f"📑 {tab_name}")
|
||||||
|
for group in tab_data['groups']:
|
||||||
|
print(f" 📦 {group['name']} ({len(group['actions'])} actions)")
|
||||||
|
for action in group['actions']:
|
||||||
|
shortcut = f" ({action['shortcut']})" if action.get('shortcut') else ""
|
||||||
|
print(f" • {action['label']}{shortcut}")
|
||||||
|
print()
|
||||||
112
pyPhotoAlbum/ribbon_widget.py
Normal file
112
pyPhotoAlbum/ribbon_widget.py
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
"""
|
||||||
|
Ribbon widget for pyPhotoAlbum
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PyQt6.QtWidgets import QWidget, QTabWidget, QHBoxLayout, QVBoxLayout, QPushButton, QLabel, QFrame
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
|
||||||
|
|
||||||
|
class RibbonWidget(QWidget):
|
||||||
|
"""A ribbon-style toolbar using QTabWidget"""
|
||||||
|
|
||||||
|
def __init__(self, main_window, ribbon_config=None, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.main_window = main_window
|
||||||
|
|
||||||
|
# Use provided config or fall back to importing the old one
|
||||||
|
if ribbon_config is None:
|
||||||
|
from ribbon_config import RIBBON_CONFIG
|
||||||
|
self.ribbon_config = RIBBON_CONFIG
|
||||||
|
else:
|
||||||
|
self.ribbon_config = ribbon_config
|
||||||
|
|
||||||
|
# Main layout
|
||||||
|
main_layout = QVBoxLayout()
|
||||||
|
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
main_layout.setSpacing(0)
|
||||||
|
self.setLayout(main_layout)
|
||||||
|
|
||||||
|
# Create tab widget
|
||||||
|
self.tab_widget = QTabWidget()
|
||||||
|
self.tab_widget.setDocumentMode(True)
|
||||||
|
main_layout.addWidget(self.tab_widget)
|
||||||
|
|
||||||
|
# Build ribbon from config
|
||||||
|
self._build_ribbon()
|
||||||
|
|
||||||
|
def _build_ribbon(self):
|
||||||
|
"""Build the ribbon UI from configuration"""
|
||||||
|
for tab_name, tab_config in self.ribbon_config.items():
|
||||||
|
tab_widget = self._create_tab(tab_config)
|
||||||
|
self.tab_widget.addTab(tab_widget, tab_name)
|
||||||
|
|
||||||
|
def _create_tab(self, tab_config):
|
||||||
|
"""Create a tab widget with groups and actions"""
|
||||||
|
tab_widget = QWidget()
|
||||||
|
tab_layout = QHBoxLayout()
|
||||||
|
tab_layout.setContentsMargins(5, 5, 5, 5)
|
||||||
|
tab_layout.setSpacing(10)
|
||||||
|
tab_widget.setLayout(tab_layout)
|
||||||
|
|
||||||
|
# Create groups
|
||||||
|
for group_config in tab_config.get("groups", []):
|
||||||
|
group_widget = self._create_group(group_config)
|
||||||
|
tab_layout.addWidget(group_widget)
|
||||||
|
|
||||||
|
# Add stretch to push groups to the left
|
||||||
|
tab_layout.addStretch()
|
||||||
|
|
||||||
|
return tab_widget
|
||||||
|
|
||||||
|
def _create_group(self, group_config):
|
||||||
|
"""Create a group of actions"""
|
||||||
|
group_widget = QFrame()
|
||||||
|
group_layout = QVBoxLayout()
|
||||||
|
group_layout.setContentsMargins(5, 5, 5, 5)
|
||||||
|
group_layout.setSpacing(5)
|
||||||
|
group_widget.setLayout(group_layout)
|
||||||
|
|
||||||
|
# Create actions layout
|
||||||
|
actions_layout = QHBoxLayout()
|
||||||
|
actions_layout.setSpacing(5)
|
||||||
|
|
||||||
|
for action_config in group_config.get("actions", []):
|
||||||
|
button = self._create_action_button(action_config)
|
||||||
|
actions_layout.addWidget(button)
|
||||||
|
|
||||||
|
group_layout.addLayout(actions_layout)
|
||||||
|
|
||||||
|
# Add group label
|
||||||
|
group_label = QLabel(group_config.get("name", ""))
|
||||||
|
group_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
group_label.setStyleSheet("font-size: 10px; color: gray;")
|
||||||
|
group_layout.addWidget(group_label)
|
||||||
|
|
||||||
|
# Add separator frame
|
||||||
|
group_widget.setFrameShape(QFrame.Shape.Box)
|
||||||
|
group_widget.setFrameShadow(QFrame.Shadow.Sunken)
|
||||||
|
group_widget.setLineWidth(1)
|
||||||
|
|
||||||
|
return group_widget
|
||||||
|
|
||||||
|
def _create_action_button(self, action_config):
|
||||||
|
"""Create a button for an action"""
|
||||||
|
button = QPushButton(action_config.get("label", ""))
|
||||||
|
button.setToolTip(action_config.get("tooltip", ""))
|
||||||
|
button.setMinimumSize(60, 40)
|
||||||
|
|
||||||
|
# Connect to action
|
||||||
|
action_name = action_config.get("action")
|
||||||
|
if action_name:
|
||||||
|
button.clicked.connect(lambda: self._execute_action(action_name))
|
||||||
|
|
||||||
|
return button
|
||||||
|
|
||||||
|
def _execute_action(self, action_name):
|
||||||
|
"""Execute an action by calling the corresponding method on main window"""
|
||||||
|
if hasattr(self.main_window, action_name):
|
||||||
|
method = getattr(self.main_window, action_name)
|
||||||
|
if callable(method):
|
||||||
|
method()
|
||||||
|
else:
|
||||||
|
print(f"Warning: Action '{action_name}' not implemented in main window")
|
||||||
407
pyPhotoAlbum/snapping.py
Normal file
407
pyPhotoAlbum/snapping.py
Normal file
@ -0,0 +1,407 @@
|
|||||||
|
"""
|
||||||
|
Snapping system for pyPhotoAlbum
|
||||||
|
Provides grid snapping, edge snapping, and custom guide snapping
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Tuple, Optional
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Guide:
|
||||||
|
"""Represents a snapping guide (vertical or horizontal line)"""
|
||||||
|
position: float # Position in mm
|
||||||
|
orientation: str # 'vertical' or 'horizontal'
|
||||||
|
|
||||||
|
def serialize(self) -> dict:
|
||||||
|
"""Serialize guide to dictionary"""
|
||||||
|
return {
|
||||||
|
"position": self.position,
|
||||||
|
"orientation": self.orientation
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def deserialize(data: dict) -> 'Guide':
|
||||||
|
"""Deserialize guide from dictionary"""
|
||||||
|
return Guide(
|
||||||
|
position=data.get("position", 0),
|
||||||
|
orientation=data.get("orientation", "vertical")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SnappingSystem:
|
||||||
|
"""Manages snapping behavior for layout elements"""
|
||||||
|
|
||||||
|
def __init__(self, snap_threshold_mm: float = 5.0):
|
||||||
|
"""
|
||||||
|
Initialize snapping system
|
||||||
|
|
||||||
|
Args:
|
||||||
|
snap_threshold_mm: Distance in mm within which snapping occurs
|
||||||
|
"""
|
||||||
|
self.snap_threshold_mm = snap_threshold_mm
|
||||||
|
self.grid_size_mm = 10.0 # Grid spacing in mm
|
||||||
|
self.snap_to_grid = False
|
||||||
|
self.snap_to_edges = True
|
||||||
|
self.snap_to_guides = True
|
||||||
|
self.guides: List[Guide] = []
|
||||||
|
|
||||||
|
def add_guide(self, position: float, orientation: str):
|
||||||
|
"""Add a new guide"""
|
||||||
|
guide = Guide(position=position, orientation=orientation)
|
||||||
|
self.guides.append(guide)
|
||||||
|
return guide
|
||||||
|
|
||||||
|
def remove_guide(self, guide: Guide):
|
||||||
|
"""Remove a guide"""
|
||||||
|
if guide in self.guides:
|
||||||
|
self.guides.remove(guide)
|
||||||
|
|
||||||
|
def clear_guides(self):
|
||||||
|
"""Remove all guides"""
|
||||||
|
self.guides.clear()
|
||||||
|
|
||||||
|
def snap_position(self,
|
||||||
|
position: Tuple[float, float],
|
||||||
|
size: Tuple[float, float],
|
||||||
|
page_size: Tuple[float, float],
|
||||||
|
dpi: int = 300) -> Tuple[float, float]:
|
||||||
|
"""
|
||||||
|
Apply snapping to a position (per-axis)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
position: Current position (x, y) in pixels
|
||||||
|
size: Element size (width, height) in pixels
|
||||||
|
page_size: Page size (width, height) in mm
|
||||||
|
dpi: DPI for conversion
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Snapped position (x, y) in pixels
|
||||||
|
"""
|
||||||
|
x, y = position
|
||||||
|
width, height = size
|
||||||
|
page_width_mm, page_height_mm = page_size
|
||||||
|
|
||||||
|
# Convert threshold from mm to pixels
|
||||||
|
snap_threshold_px = self.snap_threshold_mm * dpi / 25.4
|
||||||
|
|
||||||
|
# Snap X axis
|
||||||
|
snapped_x = self._snap_axis(
|
||||||
|
x, width, page_width_mm, dpi, snap_threshold_px, 'vertical'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Snap Y axis
|
||||||
|
snapped_y = self._snap_axis(
|
||||||
|
y, height, page_height_mm, dpi, snap_threshold_px, 'horizontal'
|
||||||
|
)
|
||||||
|
|
||||||
|
return (snapped_x, snapped_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) -> 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
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (snapped_position, snapped_size) in pixels
|
||||||
|
"""
|
||||||
|
x, y = position
|
||||||
|
width, height = size
|
||||||
|
page_width_mm, page_height_mm = page_size
|
||||||
|
|
||||||
|
# Convert threshold from mm to pixels
|
||||||
|
snap_threshold_px = self.snap_threshold_mm * 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']:
|
||||||
|
# Top edge moving
|
||||||
|
new_y = y + dy
|
||||||
|
new_height = height - dy
|
||||||
|
|
||||||
|
if resize_handle in ['sw', 's', 'se']:
|
||||||
|
# Bottom edge moving
|
||||||
|
new_height = height + dy
|
||||||
|
|
||||||
|
if resize_handle in ['nw', 'w', 'sw']:
|
||||||
|
# Left edge moving
|
||||||
|
new_x = x + dx
|
||||||
|
new_width = width - dx
|
||||||
|
|
||||||
|
if resize_handle in ['ne', 'e', 'se']:
|
||||||
|
# Right edge moving
|
||||||
|
new_width = width + dx
|
||||||
|
|
||||||
|
# Now apply snapping to the edges that are being moved
|
||||||
|
|
||||||
|
# Snap left edge (for nw, w, sw handles)
|
||||||
|
if resize_handle in ['nw', 'w', 'sw']:
|
||||||
|
snapped_left = self._snap_axis(
|
||||||
|
new_x, new_width, page_width_mm, dpi, snap_threshold_px, 'vertical'
|
||||||
|
)
|
||||||
|
# Adjust width to compensate for position change
|
||||||
|
width_adjustment = new_x - snapped_left
|
||||||
|
new_x = snapped_left
|
||||||
|
new_width += width_adjustment
|
||||||
|
|
||||||
|
# Snap right edge (for ne, e, se handles)
|
||||||
|
if 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'
|
||||||
|
)
|
||||||
|
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']:
|
||||||
|
snapped_top = self._snap_axis(
|
||||||
|
new_y, new_height, page_height_mm, dpi, snap_threshold_px, 'horizontal'
|
||||||
|
)
|
||||||
|
# Adjust height to compensate for position change
|
||||||
|
height_adjustment = new_y - snapped_top
|
||||||
|
new_y = snapped_top
|
||||||
|
new_height += height_adjustment
|
||||||
|
|
||||||
|
# Snap bottom edge (for sw, s, se handles)
|
||||||
|
if 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'
|
||||||
|
)
|
||||||
|
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) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
Snap an edge position to available targets (grid, edges, guides)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
edge_position: Current edge position in pixels
|
||||||
|
page_size_mm: Page size along axis in mm
|
||||||
|
dpi: DPI for conversion
|
||||||
|
snap_threshold_px: Snap threshold in pixels
|
||||||
|
orientation: 'vertical' for x-axis, 'horizontal' for y-axis
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Snapped edge position in pixels, or None if no snap
|
||||||
|
"""
|
||||||
|
snap_candidates = []
|
||||||
|
|
||||||
|
# 1. Page edge snapping
|
||||||
|
if self.snap_to_edges:
|
||||||
|
# Snap to start edge (0)
|
||||||
|
snap_candidates.append((0, abs(edge_position - 0)))
|
||||||
|
|
||||||
|
# Snap to end edge
|
||||||
|
page_size_px = page_size_mm * dpi / 25.4
|
||||||
|
snap_candidates.append((page_size_px, abs(edge_position - page_size_px)))
|
||||||
|
|
||||||
|
# 2. Grid snapping
|
||||||
|
if self.snap_to_grid:
|
||||||
|
grid_size_px = self.grid_size_mm * dpi / 25.4
|
||||||
|
|
||||||
|
# Snap to nearest grid line
|
||||||
|
nearest_grid = round(edge_position / grid_size_px) * grid_size_px
|
||||||
|
snap_candidates.append((nearest_grid, abs(edge_position - nearest_grid)))
|
||||||
|
|
||||||
|
# 3. Guide snapping
|
||||||
|
if self.snap_to_guides:
|
||||||
|
for guide in self.guides:
|
||||||
|
if guide.orientation == orientation:
|
||||||
|
guide_pos_px = guide.position * dpi / 25.4
|
||||||
|
snap_candidates.append((guide_pos_px, abs(edge_position - guide_pos_px)))
|
||||||
|
|
||||||
|
# Find the best snap candidate within threshold
|
||||||
|
best_snap = None
|
||||||
|
best_distance = snap_threshold_px
|
||||||
|
|
||||||
|
for snap_pos, distance in snap_candidates:
|
||||||
|
if distance < best_distance:
|
||||||
|
best_snap = snap_pos
|
||||||
|
best_distance = distance
|
||||||
|
|
||||||
|
return best_snap
|
||||||
|
|
||||||
|
def _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': []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Grid lines
|
||||||
|
if self.snap_to_grid:
|
||||||
|
grid_size_px = self.grid_size_mm * dpi / 25.4
|
||||||
|
|
||||||
|
# Vertical grid lines
|
||||||
|
x = 0
|
||||||
|
while x <= page_width_px:
|
||||||
|
result['grid'].append(('vertical', x))
|
||||||
|
x += grid_size_px
|
||||||
|
|
||||||
|
# Horizontal grid lines
|
||||||
|
y = 0
|
||||||
|
while y <= page_height_px:
|
||||||
|
result['grid'].append(('horizontal', y))
|
||||||
|
y += grid_size_px
|
||||||
|
|
||||||
|
# Edge lines
|
||||||
|
if self.snap_to_edges:
|
||||||
|
result['edges'].extend([
|
||||||
|
('vertical', 0),
|
||||||
|
('vertical', page_width_px),
|
||||||
|
('horizontal', 0),
|
||||||
|
('horizontal', page_height_px)
|
||||||
|
])
|
||||||
|
|
||||||
|
# Guide lines
|
||||||
|
if self.snap_to_guides:
|
||||||
|
for guide in self.guides:
|
||||||
|
guide_pos_px = guide.position * dpi / 25.4
|
||||||
|
result['guides'].append((guide.orientation, guide_pos_px))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def serialize(self) -> dict:
|
||||||
|
"""Serialize snapping system to dictionary"""
|
||||||
|
return {
|
||||||
|
"snap_threshold_mm": self.snap_threshold_mm,
|
||||||
|
"grid_size_mm": self.grid_size_mm,
|
||||||
|
"snap_to_grid": self.snap_to_grid,
|
||||||
|
"snap_to_edges": self.snap_to_edges,
|
||||||
|
"snap_to_guides": self.snap_to_guides,
|
||||||
|
"guides": [guide.serialize() for guide in self.guides]
|
||||||
|
}
|
||||||
|
|
||||||
|
def deserialize(self, data: dict):
|
||||||
|
"""Deserialize from dictionary"""
|
||||||
|
self.snap_threshold_mm = data.get("snap_threshold_mm", 5.0)
|
||||||
|
self.grid_size_mm = data.get("grid_size_mm", 10.0)
|
||||||
|
self.snap_to_grid = data.get("snap_to_grid", False)
|
||||||
|
self.snap_to_edges = data.get("snap_to_edges", True)
|
||||||
|
self.snap_to_guides = data.get("snap_to_guides", True)
|
||||||
|
|
||||||
|
self.guides = []
|
||||||
|
for guide_data in data.get("guides", []):
|
||||||
|
self.guides.append(Guide.deserialize(guide_data))
|
||||||
396
pyPhotoAlbum/template_manager.py
Normal file
396
pyPhotoAlbum/template_manager.py
Normal file
@ -0,0 +1,396 @@
|
|||||||
|
"""
|
||||||
|
Template management system for pyPhotoAlbum
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Any, Tuple, Optional
|
||||||
|
from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData
|
||||||
|
from pyPhotoAlbum.page_layout import PageLayout
|
||||||
|
from pyPhotoAlbum.project import Page
|
||||||
|
|
||||||
|
|
||||||
|
class Template:
|
||||||
|
"""Class representing a page layout template"""
|
||||||
|
|
||||||
|
def __init__(self, name: str = "Untitled Template", description: str = "", page_size_mm: Tuple[float, float] = (210, 297)):
|
||||||
|
self.name = name
|
||||||
|
self.description = description
|
||||||
|
self.page_size_mm = page_size_mm
|
||||||
|
self.elements: List[BaseLayoutElement] = []
|
||||||
|
|
||||||
|
def add_element(self, element: BaseLayoutElement):
|
||||||
|
"""Add an element to the template"""
|
||||||
|
self.elements.append(element)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Serialize template to dictionary"""
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"description": self.description,
|
||||||
|
"page_size_mm": self.page_size_mm,
|
||||||
|
"elements": [elem.serialize() for elem in self.elements]
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> 'Template':
|
||||||
|
"""Deserialize template from dictionary"""
|
||||||
|
template = cls(
|
||||||
|
name=data.get("name", "Untitled Template"),
|
||||||
|
description=data.get("description", ""),
|
||||||
|
page_size_mm=tuple(data.get("page_size_mm", (210, 297)))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Deserialize elements
|
||||||
|
for elem_data in data.get("elements", []):
|
||||||
|
elem_type = elem_data.get("type")
|
||||||
|
if elem_type == "placeholder":
|
||||||
|
elem = PlaceholderData()
|
||||||
|
elif elem_type == "textbox":
|
||||||
|
elem = TextBoxData()
|
||||||
|
else:
|
||||||
|
continue # Skip image elements in templates
|
||||||
|
|
||||||
|
elem.deserialize(elem_data)
|
||||||
|
template.add_element(elem)
|
||||||
|
|
||||||
|
return template
|
||||||
|
|
||||||
|
def save_to_file(self, file_path: str):
|
||||||
|
"""Save template to JSON file"""
|
||||||
|
with open(file_path, 'w') as f:
|
||||||
|
json.dump(self.to_dict(), f, indent=2)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load_from_file(cls, file_path: str) -> 'Template':
|
||||||
|
"""Load template from JSON file"""
|
||||||
|
with open(file_path, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return cls.from_dict(data)
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateManager:
|
||||||
|
"""Manager for template operations"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.templates_dir = self._get_templates_directory()
|
||||||
|
self._ensure_templates_directory()
|
||||||
|
|
||||||
|
def _get_templates_directory(self) -> Path:
|
||||||
|
"""Get the templates directory path"""
|
||||||
|
# User templates directory
|
||||||
|
home = Path.home()
|
||||||
|
templates_dir = home / ".pyphotoalbum" / "templates"
|
||||||
|
return templates_dir
|
||||||
|
|
||||||
|
def _get_builtin_templates_directory(self) -> Path:
|
||||||
|
"""Get the built-in templates directory path"""
|
||||||
|
# Built-in templates in the application directory
|
||||||
|
app_dir = Path(__file__).parent
|
||||||
|
return app_dir / "templates"
|
||||||
|
|
||||||
|
def _ensure_templates_directory(self):
|
||||||
|
"""Create templates directory if it doesn't exist"""
|
||||||
|
self.templates_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Also ensure built-in templates directory exists
|
||||||
|
builtin_dir = self._get_builtin_templates_directory()
|
||||||
|
builtin_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def list_templates(self) -> List[str]:
|
||||||
|
"""List all available template names (user + built-in)"""
|
||||||
|
templates = []
|
||||||
|
|
||||||
|
# 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"""
|
||||||
|
# Check if it's a built-in template
|
||||||
|
if name.startswith("[Built-in] "):
|
||||||
|
actual_name = name.replace("[Built-in] ", "")
|
||||||
|
template_path = self._get_builtin_templates_directory() / f"{actual_name}.json"
|
||||||
|
else:
|
||||||
|
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):
|
||||||
|
"""Save a template"""
|
||||||
|
template_path = self.templates_dir / f"{template.name}.json"
|
||||||
|
template.save_to_file(str(template_path))
|
||||||
|
|
||||||
|
def delete_template(self, name: str):
|
||||||
|
"""Delete a template (only user templates)"""
|
||||||
|
if name.startswith("[Built-in] "):
|
||||||
|
raise PermissionError("Cannot delete built-in templates")
|
||||||
|
|
||||||
|
template_path = self.templates_dir / f"{name}.json"
|
||||||
|
if template_path.exists():
|
||||||
|
template_path.unlink()
|
||||||
|
|
||||||
|
def create_template_from_page(self, page: Page, name: str, description: str = "") -> Template:
|
||||||
|
"""
|
||||||
|
Create a template from an existing page.
|
||||||
|
Converts all ImageData elements to PlaceholderData.
|
||||||
|
"""
|
||||||
|
template = Template(
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
page_size_mm=page.layout.size
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert elements
|
||||||
|
for element in page.layout.elements:
|
||||||
|
if isinstance(element, ImageData):
|
||||||
|
# Convert image to placeholder
|
||||||
|
placeholder = PlaceholderData(
|
||||||
|
placeholder_type="image",
|
||||||
|
x=element.position[0],
|
||||||
|
y=element.position[1],
|
||||||
|
width=element.size[0],
|
||||||
|
height=element.size[1],
|
||||||
|
rotation=element.rotation,
|
||||||
|
z_index=element.z_index
|
||||||
|
)
|
||||||
|
template.add_element(placeholder)
|
||||||
|
elif isinstance(element, TextBoxData):
|
||||||
|
# Keep text boxes as-is
|
||||||
|
text_box = TextBoxData(
|
||||||
|
text_content=element.text_content,
|
||||||
|
font_settings=element.font_settings,
|
||||||
|
alignment=element.alignment,
|
||||||
|
x=element.position[0],
|
||||||
|
y=element.position[1],
|
||||||
|
width=element.size[0],
|
||||||
|
height=element.size[1],
|
||||||
|
rotation=element.rotation,
|
||||||
|
z_index=element.z_index
|
||||||
|
)
|
||||||
|
template.add_element(text_box)
|
||||||
|
elif isinstance(element, PlaceholderData):
|
||||||
|
# Keep placeholders as-is
|
||||||
|
placeholder = PlaceholderData(
|
||||||
|
placeholder_type=element.placeholder_type,
|
||||||
|
default_content=element.default_content,
|
||||||
|
x=element.position[0],
|
||||||
|
y=element.position[1],
|
||||||
|
width=element.size[0],
|
||||||
|
height=element.size[1],
|
||||||
|
rotation=element.rotation,
|
||||||
|
z_index=element.z_index
|
||||||
|
)
|
||||||
|
template.add_element(placeholder)
|
||||||
|
|
||||||
|
return template
|
||||||
|
|
||||||
|
def scale_template_elements(
|
||||||
|
self,
|
||||||
|
elements: List[BaseLayoutElement],
|
||||||
|
from_size: Tuple[float, float],
|
||||||
|
to_size: Tuple[float, float],
|
||||||
|
scale_mode: str = "proportional"
|
||||||
|
) -> List[BaseLayoutElement]:
|
||||||
|
"""
|
||||||
|
Scale template elements to fit target page size.
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of scaled elements
|
||||||
|
"""
|
||||||
|
from_width, from_height = from_size
|
||||||
|
to_width, to_height = to_size
|
||||||
|
|
||||||
|
if scale_mode == "center":
|
||||||
|
# No scaling, just center elements
|
||||||
|
offset_x = (to_width - from_width) / 2
|
||||||
|
offset_y = (to_height - from_height) / 2
|
||||||
|
scale_x = 1.0
|
||||||
|
scale_y = 1.0
|
||||||
|
elif scale_mode == "proportional":
|
||||||
|
# Maintain aspect ratio
|
||||||
|
scale = min(to_width / from_width, to_height / from_height)
|
||||||
|
scale_x = scale
|
||||||
|
scale_y = scale
|
||||||
|
# Center the scaled content
|
||||||
|
offset_x = (to_width - from_width * scale) / 2
|
||||||
|
offset_y = (to_height - from_height * scale) / 2
|
||||||
|
else: # "stretch"
|
||||||
|
# Stretch to fit
|
||||||
|
scale_x = to_width / from_width
|
||||||
|
scale_y = to_height / from_height
|
||||||
|
offset_x = 0
|
||||||
|
offset_y = 0
|
||||||
|
|
||||||
|
scaled_elements = []
|
||||||
|
for element in elements:
|
||||||
|
# Create a new element of the same type
|
||||||
|
if isinstance(element, PlaceholderData):
|
||||||
|
new_elem = PlaceholderData(
|
||||||
|
placeholder_type=element.placeholder_type,
|
||||||
|
default_content=element.default_content
|
||||||
|
)
|
||||||
|
elif isinstance(element, TextBoxData):
|
||||||
|
new_elem = TextBoxData(
|
||||||
|
text_content=element.text_content,
|
||||||
|
font_settings=element.font_settings.copy() if element.font_settings else None,
|
||||||
|
alignment=element.alignment
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
continue # Skip other types
|
||||||
|
|
||||||
|
# Scale position and size
|
||||||
|
old_x, old_y = element.position
|
||||||
|
old_w, old_h = element.size
|
||||||
|
|
||||||
|
new_elem.position = (
|
||||||
|
old_x * scale_x + offset_x,
|
||||||
|
old_y * scale_y + offset_y
|
||||||
|
)
|
||||||
|
new_elem.size = (
|
||||||
|
old_w * scale_x,
|
||||||
|
old_h * scale_y
|
||||||
|
)
|
||||||
|
new_elem.rotation = element.rotation
|
||||||
|
new_elem.z_index = element.z_index
|
||||||
|
|
||||||
|
scaled_elements.append(new_elem)
|
||||||
|
|
||||||
|
return scaled_elements
|
||||||
|
|
||||||
|
def apply_template_to_page(
|
||||||
|
self,
|
||||||
|
template: Template,
|
||||||
|
page: Page,
|
||||||
|
mode: str = "replace",
|
||||||
|
scale_mode: str = "proportional"
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Apply template to an existing page.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template: Template to apply
|
||||||
|
page: Target page
|
||||||
|
mode: "replace" to clear page and add placeholders,
|
||||||
|
"reflow" to keep existing content and reposition
|
||||||
|
scale_mode: "proportional", "stretch", or "center"
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
)
|
||||||
|
|
||||||
|
template_placeholders = [e for e in scaled_elements if isinstance(e, PlaceholderData)]
|
||||||
|
template_text = [e for e in scaled_elements if isinstance(e, TextBoxData)]
|
||||||
|
|
||||||
|
# Clear page
|
||||||
|
page.layout.elements.clear()
|
||||||
|
|
||||||
|
# Reflow images into placeholder slots
|
||||||
|
for i, placeholder in enumerate(template_placeholders):
|
||||||
|
if i < len(existing_images):
|
||||||
|
# Use existing image, update position/size
|
||||||
|
img = existing_images[i]
|
||||||
|
img.position = placeholder.position
|
||||||
|
img.size = placeholder.size
|
||||||
|
img.z_index = placeholder.z_index
|
||||||
|
page.layout.add_element(img)
|
||||||
|
else:
|
||||||
|
# Add placeholder if no more images
|
||||||
|
page.layout.add_element(placeholder)
|
||||||
|
|
||||||
|
# Add remaining images (if any) at their original positions
|
||||||
|
for img in existing_images[len(template_placeholders):]:
|
||||||
|
page.layout.add_element(img)
|
||||||
|
|
||||||
|
# Add template text boxes
|
||||||
|
for text_elem in template_text:
|
||||||
|
page.layout.add_element(text_elem)
|
||||||
|
|
||||||
|
def create_page_from_template(
|
||||||
|
self,
|
||||||
|
template: Template,
|
||||||
|
page_number: int = 1,
|
||||||
|
target_size_mm: Optional[Tuple[float, float]] = None,
|
||||||
|
scale_mode: str = "proportional"
|
||||||
|
) -> Page:
|
||||||
|
"""
|
||||||
|
Create a new page from a template.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template: Template to use
|
||||||
|
page_number: Page number for the new page
|
||||||
|
target_size_mm: Target page size (if different from template)
|
||||||
|
scale_mode: Scaling mode if target_size_mm is provided
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
New Page instance with template layout
|
||||||
|
"""
|
||||||
|
# Determine page size
|
||||||
|
if target_size_mm is None:
|
||||||
|
page_size = template.page_size_mm
|
||||||
|
elements = [e for e in template.elements] # Copy elements as-is
|
||||||
|
else:
|
||||||
|
page_size = target_size_mm
|
||||||
|
# Scale template elements
|
||||||
|
elements = self.scale_template_elements(
|
||||||
|
template.elements,
|
||||||
|
template.page_size_mm,
|
||||||
|
target_size_mm,
|
||||||
|
scale_mode
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create new page layout
|
||||||
|
layout = PageLayout(width=page_size[0], height=page_size[1])
|
||||||
|
|
||||||
|
# Add elements
|
||||||
|
for element in elements:
|
||||||
|
layout.add_element(element)
|
||||||
|
|
||||||
|
# Create and return page
|
||||||
|
page = Page(layout=layout, page_number=page_number)
|
||||||
|
return page
|
||||||
70
pyPhotoAlbum/templates/Grid_2x2.json
Normal file
70
pyPhotoAlbum/templates/Grid_2x2.json
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"name": "Grid_2x2",
|
||||||
|
"description": "Simple 2x2 grid layout with equal-sized image placeholders",
|
||||||
|
"page_size_mm": [
|
||||||
|
210,
|
||||||
|
297
|
||||||
|
],
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"type": "placeholder",
|
||||||
|
"position": [
|
||||||
|
5,
|
||||||
|
5
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
100,
|
||||||
|
143.5
|
||||||
|
],
|
||||||
|
"rotation": 0,
|
||||||
|
"z_index": 0,
|
||||||
|
"placeholder_type": "image",
|
||||||
|
"default_content": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "placeholder",
|
||||||
|
"position": [
|
||||||
|
105,
|
||||||
|
5
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
100,
|
||||||
|
143.5
|
||||||
|
],
|
||||||
|
"rotation": 0,
|
||||||
|
"z_index": 0,
|
||||||
|
"placeholder_type": "image",
|
||||||
|
"default_content": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "placeholder",
|
||||||
|
"position": [
|
||||||
|
5,
|
||||||
|
148.5
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
100,
|
||||||
|
143.5
|
||||||
|
],
|
||||||
|
"rotation": 0,
|
||||||
|
"z_index": 0,
|
||||||
|
"placeholder_type": "image",
|
||||||
|
"default_content": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "placeholder",
|
||||||
|
"position": [
|
||||||
|
105,
|
||||||
|
148.5
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
100,
|
||||||
|
143.5
|
||||||
|
],
|
||||||
|
"rotation": 0,
|
||||||
|
"z_index": 0,
|
||||||
|
"placeholder_type": "image",
|
||||||
|
"default_content": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
49
pyPhotoAlbum/templates/Single_Large.json
Normal file
49
pyPhotoAlbum/templates/Single_Large.json
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"name": "Single_Large",
|
||||||
|
"description": "Single large image placeholder with title text",
|
||||||
|
"page_size_mm": [
|
||||||
|
210,
|
||||||
|
297
|
||||||
|
],
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"type": "textbox",
|
||||||
|
"position": [
|
||||||
|
10,
|
||||||
|
10
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
190,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"rotation": 0,
|
||||||
|
"z_index": 1,
|
||||||
|
"text_content": "Title",
|
||||||
|
"font_settings": {
|
||||||
|
"family": "Arial",
|
||||||
|
"size": 24,
|
||||||
|
"color": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"alignment": "center"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "placeholder",
|
||||||
|
"position": [
|
||||||
|
10,
|
||||||
|
50
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
190,
|
||||||
|
230
|
||||||
|
],
|
||||||
|
"rotation": 0,
|
||||||
|
"z_index": 0,
|
||||||
|
"placeholder_type": "image",
|
||||||
|
"default_content": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
105
pyproject.toml
Normal file
105
pyproject.toml
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "pyphotoalbum"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A Python application for designing photo albums and exporting them to PDF"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
license = {text = "MIT"}
|
||||||
|
authors = [
|
||||||
|
{name = "pyPhotoAlbum Developer", email = "dev@pyphotoalbum.local"}
|
||||||
|
]
|
||||||
|
keywords = ["photo", "album", "pdf", "pyqt6", "design"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 3 - Alpha",
|
||||||
|
"Intended Audience :: End Users/Desktop",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Operating System :: OS Independent",
|
||||||
|
]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"PyQt6>=6.0.0",
|
||||||
|
"PyOpenGL>=3.1.0",
|
||||||
|
"numpy>=1.20.0",
|
||||||
|
"Pillow>=8.0.0",
|
||||||
|
"reportlab>=3.5.0",
|
||||||
|
"lxml>=4.6.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=7.0.0",
|
||||||
|
"pytest-qt>=4.2.0",
|
||||||
|
"pytest-cov>=4.0.0",
|
||||||
|
"pytest-mock>=3.10.0",
|
||||||
|
"flake8>=5.0.0",
|
||||||
|
"black>=22.0.0",
|
||||||
|
"mypy>=0.990",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
pyphotoalbum = "pyPhotoAlbum.main:main"
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://github.com/yourusername/pyPhotoAlbum"
|
||||||
|
Repository = "https://github.com/yourusername/pyPhotoAlbum"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["."]
|
||||||
|
include = ["pyPhotoAlbum*"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
pyPhotoAlbum = ["templates/*.json"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
python_files = ["test_*.py"]
|
||||||
|
python_classes = ["Test*"]
|
||||||
|
python_functions = ["test_*"]
|
||||||
|
addopts = "-v --cov=pyPhotoAlbum --cov-report=html --cov-report=term-missing"
|
||||||
|
filterwarnings = [
|
||||||
|
"ignore::DeprecationWarning",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
omit = ["tests/*", "venv/*", "*/site-packages/*"]
|
||||||
|
source = ["pyPhotoAlbum"]
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
exclude_lines = [
|
||||||
|
"pragma: no cover",
|
||||||
|
"def __repr__",
|
||||||
|
"raise AssertionError",
|
||||||
|
"raise NotImplementedError",
|
||||||
|
"if __name__ == .__main__.:",
|
||||||
|
"if TYPE_CHECKING:",
|
||||||
|
"class .*\\bProtocol\\):",
|
||||||
|
"@(abc\\.)?abstractmethod",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 120
|
||||||
|
target-version = ['py39', 'py310', 'py311']
|
||||||
|
include = '\.pyi?$'
|
||||||
|
exclude = '''
|
||||||
|
/(
|
||||||
|
\.git
|
||||||
|
| \.venv
|
||||||
|
| venv
|
||||||
|
| build
|
||||||
|
| dist
|
||||||
|
)/
|
||||||
|
'''
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.9"
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_configs = true
|
||||||
|
ignore_missing_imports = true
|
||||||
3
tests/__init__.py
Normal file
3
tests/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for pyPhotoAlbum
|
||||||
|
"""
|
||||||
105
tests/conftest.py
Normal file
105
tests/conftest.py
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
"""
|
||||||
|
Pytest configuration and fixtures for pyPhotoAlbum tests
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from PIL import Image
|
||||||
|
from pyPhotoAlbum.models import ImageData, PlaceholderData, TextBoxData
|
||||||
|
from pyPhotoAlbum.page_layout import PageLayout, GridLayout
|
||||||
|
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:
|
||||||
|
# Create a simple test image
|
||||||
|
img = Image.new('RGB', (100, 100), color='red')
|
||||||
|
img.save(f.name)
|
||||||
|
yield f.name
|
||||||
|
# Cleanup
|
||||||
|
try:
|
||||||
|
os.unlink(f.name)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_dir():
|
||||||
|
"""Create a temporary directory for tests"""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
yield tmpdir
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_page_layout():
|
||||||
|
"""Create a sample PageLayout instance"""
|
||||||
|
layout = PageLayout()
|
||||||
|
return layout
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_grid_layout():
|
||||||
|
"""Create a sample GridLayout instance"""
|
||||||
|
return GridLayout(rows=2, columns=2, spacing=10.0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_page(sample_page_layout):
|
||||||
|
"""Create a sample Page instance"""
|
||||||
|
return Page(layout=sample_page_layout, page_number=1)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_project():
|
||||||
|
"""Create a sample Project instance"""
|
||||||
|
return Project(name="Test Project")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def populated_page_layout(sample_image_data, sample_placeholder_data, sample_textbox_data):
|
||||||
|
"""Create a page layout populated with various elements"""
|
||||||
|
layout = PageLayout()
|
||||||
|
layout.add_element(sample_image_data)
|
||||||
|
layout.add_element(sample_placeholder_data)
|
||||||
|
layout.add_element(sample_textbox_data)
|
||||||
|
return layout
|
||||||
424
tests/test_models.py
Normal file
424
tests/test_models.py
Normal file
@ -0,0 +1,424 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for pyPhotoAlbum models
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pyPhotoAlbum.models import ImageData, PlaceholderData, TextBoxData, BaseLayoutElement
|
||||||
|
|
||||||
|
|
||||||
|
class TestBaseLayoutElement:
|
||||||
|
"""Tests for BaseLayoutElement abstract class"""
|
||||||
|
|
||||||
|
def test_cannot_instantiate_abstract_class(self):
|
||||||
|
"""Test that BaseLayoutElement cannot be instantiated directly"""
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
BaseLayoutElement()
|
||||||
|
|
||||||
|
|
||||||
|
class TestImageData:
|
||||||
|
"""Tests for ImageData class"""
|
||||||
|
|
||||||
|
def test_initialization_default(self):
|
||||||
|
"""Test ImageData initialization with default values"""
|
||||||
|
img = ImageData()
|
||||||
|
assert img.image_path == ""
|
||||||
|
assert img.position == (0, 0)
|
||||||
|
assert img.size == (100, 100)
|
||||||
|
assert img.rotation == 0
|
||||||
|
assert img.z_index == 0
|
||||||
|
assert img.crop_info == (0, 0, 1, 1)
|
||||||
|
|
||||||
|
def test_initialization_with_parameters(self, temp_image_file):
|
||||||
|
"""Test ImageData initialization with custom parameters"""
|
||||||
|
img = ImageData(
|
||||||
|
image_path=temp_image_file,
|
||||||
|
x=10.0,
|
||||||
|
y=20.0,
|
||||||
|
width=200.0,
|
||||||
|
height=150.0,
|
||||||
|
rotation=45.0,
|
||||||
|
z_index=5
|
||||||
|
)
|
||||||
|
assert img.image_path == temp_image_file
|
||||||
|
assert img.position == (10.0, 20.0)
|
||||||
|
assert img.size == (200.0, 150.0)
|
||||||
|
assert img.rotation == 45.0
|
||||||
|
assert img.z_index == 5
|
||||||
|
|
||||||
|
def test_initialization_with_crop_info(self):
|
||||||
|
"""Test ImageData initialization with custom crop info"""
|
||||||
|
crop = (0.1, 0.2, 0.8, 0.9)
|
||||||
|
img = ImageData(image_path="test.jpg", crop_info=crop)
|
||||||
|
assert img.crop_info == crop
|
||||||
|
|
||||||
|
def test_serialization(self, temp_image_file):
|
||||||
|
"""Test ImageData serialization to dictionary"""
|
||||||
|
img = ImageData(
|
||||||
|
image_path=temp_image_file,
|
||||||
|
x=15.0,
|
||||||
|
y=25.0,
|
||||||
|
width=180.0,
|
||||||
|
height=120.0,
|
||||||
|
rotation=30.0,
|
||||||
|
z_index=3
|
||||||
|
)
|
||||||
|
data = img.serialize()
|
||||||
|
|
||||||
|
assert data["type"] == "image"
|
||||||
|
assert data["image_path"] == temp_image_file
|
||||||
|
assert data["position"] == (15.0, 25.0)
|
||||||
|
assert data["size"] == (180.0, 120.0)
|
||||||
|
assert data["rotation"] == 30.0
|
||||||
|
assert data["z_index"] == 3
|
||||||
|
assert data["crop_info"] == (0, 0, 1, 1)
|
||||||
|
|
||||||
|
def test_deserialization(self):
|
||||||
|
"""Test ImageData deserialization from dictionary"""
|
||||||
|
img = ImageData()
|
||||||
|
data = {
|
||||||
|
"position": (30.0, 40.0),
|
||||||
|
"size": (220.0, 180.0),
|
||||||
|
"rotation": 90.0,
|
||||||
|
"z_index": 7,
|
||||||
|
"image_path": "new_image.jpg",
|
||||||
|
"crop_info": (0.2, 0.3, 0.7, 0.8)
|
||||||
|
}
|
||||||
|
img.deserialize(data)
|
||||||
|
|
||||||
|
assert img.position == (30.0, 40.0)
|
||||||
|
assert img.size == (220.0, 180.0)
|
||||||
|
assert img.rotation == 90.0
|
||||||
|
assert img.z_index == 7
|
||||||
|
assert img.image_path == "new_image.jpg"
|
||||||
|
assert img.crop_info == (0.2, 0.3, 0.7, 0.8)
|
||||||
|
|
||||||
|
def test_deserialization_with_defaults(self):
|
||||||
|
"""Test ImageData deserialization with missing fields uses defaults"""
|
||||||
|
img = ImageData()
|
||||||
|
data = {"image_path": "test.jpg"}
|
||||||
|
img.deserialize(data)
|
||||||
|
|
||||||
|
assert img.position == (0, 0)
|
||||||
|
assert img.size == (100, 100)
|
||||||
|
assert img.rotation == 0
|
||||||
|
assert img.z_index == 0
|
||||||
|
assert img.crop_info == (0, 0, 1, 1)
|
||||||
|
|
||||||
|
def test_serialize_deserialize_roundtrip(self, temp_image_file):
|
||||||
|
"""Test that serialize and deserialize are inverse operations"""
|
||||||
|
original = ImageData(
|
||||||
|
image_path=temp_image_file,
|
||||||
|
x=50.0,
|
||||||
|
y=60.0,
|
||||||
|
width=300.0,
|
||||||
|
height=200.0,
|
||||||
|
rotation=15.0,
|
||||||
|
z_index=2,
|
||||||
|
crop_info=(0.1, 0.1, 0.9, 0.9)
|
||||||
|
)
|
||||||
|
data = original.serialize()
|
||||||
|
restored = ImageData()
|
||||||
|
restored.deserialize(data)
|
||||||
|
|
||||||
|
assert restored.image_path == original.image_path
|
||||||
|
assert restored.position == original.position
|
||||||
|
assert restored.size == original.size
|
||||||
|
assert restored.rotation == original.rotation
|
||||||
|
assert restored.z_index == original.z_index
|
||||||
|
assert restored.crop_info == original.crop_info
|
||||||
|
|
||||||
|
def test_position_modification(self):
|
||||||
|
"""Test modifying position after initialization"""
|
||||||
|
img = ImageData()
|
||||||
|
img.position = (100.0, 200.0)
|
||||||
|
assert img.position == (100.0, 200.0)
|
||||||
|
|
||||||
|
def test_size_modification(self):
|
||||||
|
"""Test modifying size after initialization"""
|
||||||
|
img = ImageData()
|
||||||
|
img.size = (400.0, 300.0)
|
||||||
|
assert img.size == (400.0, 300.0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlaceholderData:
|
||||||
|
"""Tests for PlaceholderData class"""
|
||||||
|
|
||||||
|
def test_initialization_default(self):
|
||||||
|
"""Test PlaceholderData initialization with default values"""
|
||||||
|
placeholder = PlaceholderData()
|
||||||
|
assert placeholder.placeholder_type == "image"
|
||||||
|
assert placeholder.default_content == ""
|
||||||
|
assert placeholder.position == (0, 0)
|
||||||
|
assert placeholder.size == (100, 100)
|
||||||
|
assert placeholder.rotation == 0
|
||||||
|
assert placeholder.z_index == 0
|
||||||
|
|
||||||
|
def test_initialization_with_parameters(self):
|
||||||
|
"""Test PlaceholderData initialization with custom parameters"""
|
||||||
|
placeholder = PlaceholderData(
|
||||||
|
placeholder_type="text",
|
||||||
|
default_content="Sample",
|
||||||
|
x=20.0,
|
||||||
|
y=30.0,
|
||||||
|
width=150.0,
|
||||||
|
height=100.0,
|
||||||
|
rotation=10.0,
|
||||||
|
z_index=4
|
||||||
|
)
|
||||||
|
assert placeholder.placeholder_type == "text"
|
||||||
|
assert placeholder.default_content == "Sample"
|
||||||
|
assert placeholder.position == (20.0, 30.0)
|
||||||
|
assert placeholder.size == (150.0, 100.0)
|
||||||
|
assert placeholder.rotation == 10.0
|
||||||
|
assert placeholder.z_index == 4
|
||||||
|
|
||||||
|
def test_serialization(self):
|
||||||
|
"""Test PlaceholderData serialization to dictionary"""
|
||||||
|
placeholder = PlaceholderData(
|
||||||
|
placeholder_type="image",
|
||||||
|
default_content="placeholder.jpg",
|
||||||
|
x=40.0,
|
||||||
|
y=50.0,
|
||||||
|
width=200.0,
|
||||||
|
height=150.0,
|
||||||
|
rotation=20.0,
|
||||||
|
z_index=2
|
||||||
|
)
|
||||||
|
data = placeholder.serialize()
|
||||||
|
|
||||||
|
assert data["type"] == "placeholder"
|
||||||
|
assert data["placeholder_type"] == "image"
|
||||||
|
assert data["default_content"] == "placeholder.jpg"
|
||||||
|
assert data["position"] == (40.0, 50.0)
|
||||||
|
assert data["size"] == (200.0, 150.0)
|
||||||
|
assert data["rotation"] == 20.0
|
||||||
|
assert data["z_index"] == 2
|
||||||
|
|
||||||
|
def test_deserialization(self):
|
||||||
|
"""Test PlaceholderData deserialization from dictionary"""
|
||||||
|
placeholder = PlaceholderData()
|
||||||
|
data = {
|
||||||
|
"position": (60.0, 70.0),
|
||||||
|
"size": (250.0, 180.0),
|
||||||
|
"rotation": 45.0,
|
||||||
|
"z_index": 6,
|
||||||
|
"placeholder_type": "text",
|
||||||
|
"default_content": "Default Text"
|
||||||
|
}
|
||||||
|
placeholder.deserialize(data)
|
||||||
|
|
||||||
|
assert placeholder.position == (60.0, 70.0)
|
||||||
|
assert placeholder.size == (250.0, 180.0)
|
||||||
|
assert placeholder.rotation == 45.0
|
||||||
|
assert placeholder.z_index == 6
|
||||||
|
assert placeholder.placeholder_type == "text"
|
||||||
|
assert placeholder.default_content == "Default Text"
|
||||||
|
|
||||||
|
def test_deserialization_with_defaults(self):
|
||||||
|
"""Test PlaceholderData deserialization with missing fields uses defaults"""
|
||||||
|
placeholder = PlaceholderData()
|
||||||
|
data = {"placeholder_type": "image"}
|
||||||
|
placeholder.deserialize(data)
|
||||||
|
|
||||||
|
assert placeholder.position == (0, 0)
|
||||||
|
assert placeholder.size == (100, 100)
|
||||||
|
assert placeholder.rotation == 0
|
||||||
|
assert placeholder.z_index == 0
|
||||||
|
assert placeholder.default_content == ""
|
||||||
|
|
||||||
|
def test_serialize_deserialize_roundtrip(self):
|
||||||
|
"""Test that serialize and deserialize are inverse operations"""
|
||||||
|
original = PlaceholderData(
|
||||||
|
placeholder_type="image",
|
||||||
|
default_content="test.jpg",
|
||||||
|
x=80.0,
|
||||||
|
y=90.0,
|
||||||
|
width=300.0,
|
||||||
|
height=250.0,
|
||||||
|
rotation=60.0,
|
||||||
|
z_index=8
|
||||||
|
)
|
||||||
|
data = original.serialize()
|
||||||
|
restored = PlaceholderData()
|
||||||
|
restored.deserialize(data)
|
||||||
|
|
||||||
|
assert restored.placeholder_type == original.placeholder_type
|
||||||
|
assert restored.default_content == original.default_content
|
||||||
|
assert restored.position == original.position
|
||||||
|
assert restored.size == original.size
|
||||||
|
assert restored.rotation == original.rotation
|
||||||
|
assert restored.z_index == original.z_index
|
||||||
|
|
||||||
|
|
||||||
|
class TestTextBoxData:
|
||||||
|
"""Tests for TextBoxData class"""
|
||||||
|
|
||||||
|
def test_initialization_default(self):
|
||||||
|
"""Test TextBoxData initialization with default values"""
|
||||||
|
textbox = TextBoxData()
|
||||||
|
assert textbox.text_content == ""
|
||||||
|
assert textbox.font_settings == {"family": "Arial", "size": 12, "color": (0, 0, 0)}
|
||||||
|
assert textbox.alignment == "left"
|
||||||
|
assert textbox.position == (0, 0)
|
||||||
|
assert textbox.size == (100, 100)
|
||||||
|
assert textbox.rotation == 0
|
||||||
|
assert textbox.z_index == 0
|
||||||
|
|
||||||
|
def test_initialization_with_parameters(self):
|
||||||
|
"""Test TextBoxData initialization with custom parameters"""
|
||||||
|
font_settings = {"family": "Times", "size": 14, "color": (255, 0, 0)}
|
||||||
|
textbox = TextBoxData(
|
||||||
|
text_content="Hello World",
|
||||||
|
font_settings=font_settings,
|
||||||
|
alignment="center",
|
||||||
|
x=25.0,
|
||||||
|
y=35.0,
|
||||||
|
width=180.0,
|
||||||
|
height=60.0,
|
||||||
|
rotation=5.0,
|
||||||
|
z_index=3
|
||||||
|
)
|
||||||
|
assert textbox.text_content == "Hello World"
|
||||||
|
assert textbox.font_settings == font_settings
|
||||||
|
assert textbox.alignment == "center"
|
||||||
|
assert textbox.position == (25.0, 35.0)
|
||||||
|
assert textbox.size == (180.0, 60.0)
|
||||||
|
assert textbox.rotation == 5.0
|
||||||
|
assert textbox.z_index == 3
|
||||||
|
|
||||||
|
def test_serialization(self):
|
||||||
|
"""Test TextBoxData serialization to dictionary"""
|
||||||
|
font_settings = {"family": "Helvetica", "size": 16, "color": (0, 0, 255)}
|
||||||
|
textbox = TextBoxData(
|
||||||
|
text_content="Test Text",
|
||||||
|
font_settings=font_settings,
|
||||||
|
alignment="right",
|
||||||
|
x=45.0,
|
||||||
|
y=55.0,
|
||||||
|
width=220.0,
|
||||||
|
height=80.0,
|
||||||
|
rotation=15.0,
|
||||||
|
z_index=5
|
||||||
|
)
|
||||||
|
data = textbox.serialize()
|
||||||
|
|
||||||
|
assert data["type"] == "textbox"
|
||||||
|
assert data["text_content"] == "Test Text"
|
||||||
|
assert data["font_settings"] == font_settings
|
||||||
|
assert data["alignment"] == "right"
|
||||||
|
assert data["position"] == (45.0, 55.0)
|
||||||
|
assert data["size"] == (220.0, 80.0)
|
||||||
|
assert data["rotation"] == 15.0
|
||||||
|
assert data["z_index"] == 5
|
||||||
|
|
||||||
|
def test_deserialization(self):
|
||||||
|
"""Test TextBoxData deserialization from dictionary"""
|
||||||
|
textbox = TextBoxData()
|
||||||
|
font_settings = {"family": "Courier", "size": 18, "color": (128, 128, 128)}
|
||||||
|
data = {
|
||||||
|
"position": (65.0, 75.0),
|
||||||
|
"size": (260.0, 100.0),
|
||||||
|
"rotation": 30.0,
|
||||||
|
"z_index": 7,
|
||||||
|
"text_content": "Deserialized Text",
|
||||||
|
"font_settings": font_settings,
|
||||||
|
"alignment": "justify"
|
||||||
|
}
|
||||||
|
textbox.deserialize(data)
|
||||||
|
|
||||||
|
assert textbox.position == (65.0, 75.0)
|
||||||
|
assert textbox.size == (260.0, 100.0)
|
||||||
|
assert textbox.rotation == 30.0
|
||||||
|
assert textbox.z_index == 7
|
||||||
|
assert textbox.text_content == "Deserialized Text"
|
||||||
|
assert textbox.font_settings == font_settings
|
||||||
|
assert textbox.alignment == "justify"
|
||||||
|
|
||||||
|
def test_deserialization_with_defaults(self):
|
||||||
|
"""Test TextBoxData deserialization with missing fields uses defaults"""
|
||||||
|
textbox = TextBoxData()
|
||||||
|
data = {"text_content": "Minimal"}
|
||||||
|
textbox.deserialize(data)
|
||||||
|
|
||||||
|
assert textbox.position == (0, 0)
|
||||||
|
assert textbox.size == (100, 100)
|
||||||
|
assert textbox.rotation == 0
|
||||||
|
assert textbox.z_index == 0
|
||||||
|
assert textbox.font_settings == {"family": "Arial", "size": 12, "color": (0, 0, 0)}
|
||||||
|
assert textbox.alignment == "left"
|
||||||
|
|
||||||
|
def test_serialize_deserialize_roundtrip(self):
|
||||||
|
"""Test that serialize and deserialize are inverse operations"""
|
||||||
|
font_settings = {"family": "Georgia", "size": 20, "color": (255, 255, 0)}
|
||||||
|
original = TextBoxData(
|
||||||
|
text_content="Round Trip Test",
|
||||||
|
font_settings=font_settings,
|
||||||
|
alignment="center",
|
||||||
|
x=85.0,
|
||||||
|
y=95.0,
|
||||||
|
width=320.0,
|
||||||
|
height=120.0,
|
||||||
|
rotation=25.0,
|
||||||
|
z_index=9
|
||||||
|
)
|
||||||
|
data = original.serialize()
|
||||||
|
restored = TextBoxData()
|
||||||
|
restored.deserialize(data)
|
||||||
|
|
||||||
|
assert restored.text_content == original.text_content
|
||||||
|
assert restored.font_settings == original.font_settings
|
||||||
|
assert restored.alignment == original.alignment
|
||||||
|
assert restored.position == original.position
|
||||||
|
assert restored.size == original.size
|
||||||
|
assert restored.rotation == original.rotation
|
||||||
|
assert restored.z_index == original.z_index
|
||||||
|
|
||||||
|
def test_text_content_modification(self):
|
||||||
|
"""Test modifying text content after initialization"""
|
||||||
|
textbox = TextBoxData()
|
||||||
|
textbox.text_content = "Modified Text"
|
||||||
|
assert textbox.text_content == "Modified Text"
|
||||||
|
|
||||||
|
def test_font_settings_modification(self):
|
||||||
|
"""Test modifying font settings after initialization"""
|
||||||
|
textbox = TextBoxData()
|
||||||
|
new_font = {"family": "Verdana", "size": 24, "color": (100, 200, 50)}
|
||||||
|
textbox.font_settings = new_font
|
||||||
|
assert textbox.font_settings == new_font
|
||||||
|
|
||||||
|
def test_alignment_modification(self):
|
||||||
|
"""Test modifying alignment after initialization"""
|
||||||
|
textbox = TextBoxData()
|
||||||
|
textbox.alignment = "right"
|
||||||
|
assert textbox.alignment == "right"
|
||||||
|
|
||||||
|
|
||||||
|
class TestElementComparison:
|
||||||
|
"""Tests comparing different element types"""
|
||||||
|
|
||||||
|
def test_different_element_types_serialize_differently(self):
|
||||||
|
"""Test that different element types have different serialization"""
|
||||||
|
img = ImageData(x=10, y=10)
|
||||||
|
placeholder = PlaceholderData(x=10, y=10)
|
||||||
|
textbox = TextBoxData(x=10, y=10)
|
||||||
|
|
||||||
|
img_data = img.serialize()
|
||||||
|
placeholder_data = placeholder.serialize()
|
||||||
|
textbox_data = textbox.serialize()
|
||||||
|
|
||||||
|
assert img_data["type"] == "image"
|
||||||
|
assert placeholder_data["type"] == "placeholder"
|
||||||
|
assert textbox_data["type"] == "textbox"
|
||||||
|
|
||||||
|
def test_z_index_comparison(self):
|
||||||
|
"""Test that z_index can be used for layering"""
|
||||||
|
img1 = ImageData(z_index=1)
|
||||||
|
img2 = ImageData(z_index=5)
|
||||||
|
img3 = ImageData(z_index=3)
|
||||||
|
|
||||||
|
elements = [img1, img2, img3]
|
||||||
|
sorted_elements = sorted(elements, key=lambda e: e.z_index)
|
||||||
|
|
||||||
|
assert sorted_elements[0].z_index == 1
|
||||||
|
assert sorted_elements[1].z_index == 3
|
||||||
|
assert sorted_elements[2].z_index == 5
|
||||||
463
tests/test_page_renderer.py
Normal file
463
tests/test_page_renderer.py
Normal file
@ -0,0 +1,463 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for PageRenderer coordinate transformations
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pyPhotoAlbum.page_renderer import PageRenderer
|
||||||
|
|
||||||
|
|
||||||
|
class TestPageRendererCoordinates:
|
||||||
|
"""Test coordinate transformation methods"""
|
||||||
|
|
||||||
|
def test_page_to_screen_no_zoom_no_pan(self):
|
||||||
|
"""Test page_to_screen conversion with zoom=1.0 and no pan"""
|
||||||
|
renderer = PageRenderer(
|
||||||
|
page_width_mm=210.0,
|
||||||
|
page_height_mm=297.0,
|
||||||
|
screen_x=100.0,
|
||||||
|
screen_y=200.0,
|
||||||
|
dpi=96,
|
||||||
|
zoom=1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Element at page origin should map to screen_x, screen_y
|
||||||
|
screen_x, screen_y = renderer.page_to_screen(0, 0)
|
||||||
|
assert screen_x == 100.0
|
||||||
|
assert screen_y == 200.0
|
||||||
|
|
||||||
|
# Element at (50, 75) should be offset by that amount
|
||||||
|
screen_x, screen_y = renderer.page_to_screen(50, 75)
|
||||||
|
assert screen_x == 150.0
|
||||||
|
assert screen_y == 275.0
|
||||||
|
|
||||||
|
def test_page_to_screen_with_zoom(self):
|
||||||
|
"""Test page_to_screen conversion with zoom applied"""
|
||||||
|
renderer = PageRenderer(
|
||||||
|
page_width_mm=210.0,
|
||||||
|
page_height_mm=297.0,
|
||||||
|
screen_x=100.0,
|
||||||
|
screen_y=200.0,
|
||||||
|
dpi=96,
|
||||||
|
zoom=2.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# With zoom=2.0, distances should be doubled
|
||||||
|
screen_x, screen_y = renderer.page_to_screen(50, 75)
|
||||||
|
assert screen_x == 200.0 # 100 + 50*2
|
||||||
|
assert screen_y == 350.0 # 200 + 75*2
|
||||||
|
|
||||||
|
def test_page_to_screen_with_fractional_zoom(self):
|
||||||
|
"""Test page_to_screen conversion with fractional zoom"""
|
||||||
|
renderer = PageRenderer(
|
||||||
|
page_width_mm=210.0,
|
||||||
|
page_height_mm=297.0,
|
||||||
|
screen_x=100.0,
|
||||||
|
screen_y=200.0,
|
||||||
|
dpi=96,
|
||||||
|
zoom=0.5
|
||||||
|
)
|
||||||
|
|
||||||
|
# With zoom=0.5, distances should be halved
|
||||||
|
screen_x, screen_y = renderer.page_to_screen(100, 150)
|
||||||
|
assert screen_x == 150.0 # 100 + 100*0.5
|
||||||
|
assert screen_y == 275.0 # 200 + 150*0.5
|
||||||
|
|
||||||
|
def test_screen_to_page_no_zoom_no_pan(self):
|
||||||
|
"""Test screen_to_page conversion with zoom=1.0 and no pan"""
|
||||||
|
renderer = PageRenderer(
|
||||||
|
page_width_mm=210.0,
|
||||||
|
page_height_mm=297.0,
|
||||||
|
screen_x=100.0,
|
||||||
|
screen_y=200.0,
|
||||||
|
dpi=96,
|
||||||
|
zoom=1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Screen position at screen_x, screen_y should map to page origin
|
||||||
|
page_x, page_y = renderer.screen_to_page(100.0, 200.0)
|
||||||
|
assert page_x == 0.0
|
||||||
|
assert page_y == 0.0
|
||||||
|
|
||||||
|
# Screen position offset should map to same offset in page coords
|
||||||
|
page_x, page_y = renderer.screen_to_page(150.0, 275.0)
|
||||||
|
assert page_x == 50.0
|
||||||
|
assert page_y == 75.0
|
||||||
|
|
||||||
|
def test_screen_to_page_with_zoom(self):
|
||||||
|
"""Test screen_to_page conversion with zoom applied"""
|
||||||
|
renderer = PageRenderer(
|
||||||
|
page_width_mm=210.0,
|
||||||
|
page_height_mm=297.0,
|
||||||
|
screen_x=100.0,
|
||||||
|
screen_y=200.0,
|
||||||
|
dpi=96,
|
||||||
|
zoom=2.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# With zoom=2.0, screen distances should be divided by 2 to get page coords
|
||||||
|
page_x, page_y = renderer.screen_to_page(200.0, 350.0)
|
||||||
|
assert page_x == 50.0 # (200-100)/2
|
||||||
|
assert page_y == 75.0 # (350-200)/2
|
||||||
|
|
||||||
|
def test_roundtrip_conversion_no_zoom(self):
|
||||||
|
"""Test that page->screen->page conversion is accurate with no zoom"""
|
||||||
|
renderer = PageRenderer(
|
||||||
|
page_width_mm=210.0,
|
||||||
|
page_height_mm=297.0,
|
||||||
|
screen_x=100.0,
|
||||||
|
screen_y=200.0,
|
||||||
|
dpi=96,
|
||||||
|
zoom=1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start with page coordinates
|
||||||
|
orig_page_x, orig_page_y = 123.45, 678.90
|
||||||
|
|
||||||
|
# Convert to screen and back
|
||||||
|
screen_x, screen_y = renderer.page_to_screen(orig_page_x, orig_page_y)
|
||||||
|
page_x, page_y = renderer.screen_to_page(screen_x, screen_y)
|
||||||
|
|
||||||
|
# Should get back the original values
|
||||||
|
assert abs(page_x - orig_page_x) < 0.001
|
||||||
|
assert abs(page_y - orig_page_y) < 0.001
|
||||||
|
|
||||||
|
def test_roundtrip_conversion_with_zoom(self):
|
||||||
|
"""Test that page->screen->page conversion is accurate with zoom"""
|
||||||
|
renderer = PageRenderer(
|
||||||
|
page_width_mm=210.0,
|
||||||
|
page_height_mm=297.0,
|
||||||
|
screen_x=100.0,
|
||||||
|
screen_y=200.0,
|
||||||
|
dpi=96,
|
||||||
|
zoom=1.5
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start with page coordinates
|
||||||
|
orig_page_x, orig_page_y = 123.45, 678.90
|
||||||
|
|
||||||
|
# Convert to screen and back
|
||||||
|
screen_x, screen_y = renderer.page_to_screen(orig_page_x, orig_page_y)
|
||||||
|
page_x, page_y = renderer.screen_to_page(screen_x, screen_y)
|
||||||
|
|
||||||
|
# Should get back the original values (with floating point tolerance)
|
||||||
|
assert abs(page_x - orig_page_x) < 0.001
|
||||||
|
assert abs(page_y - orig_page_y) < 0.001
|
||||||
|
|
||||||
|
def test_roundtrip_conversion_extreme_zoom(self):
|
||||||
|
"""Test coordinate conversion with extreme zoom levels"""
|
||||||
|
for zoom in [0.1, 0.5, 1.0, 2.0, 5.0]:
|
||||||
|
renderer = PageRenderer(
|
||||||
|
page_width_mm=210.0,
|
||||||
|
page_height_mm=297.0,
|
||||||
|
screen_x=50.0,
|
||||||
|
screen_y=100.0,
|
||||||
|
dpi=96,
|
||||||
|
zoom=zoom
|
||||||
|
)
|
||||||
|
|
||||||
|
orig_page_x, orig_page_y = 250.0, 400.0
|
||||||
|
screen_x, screen_y = renderer.page_to_screen(orig_page_x, orig_page_y)
|
||||||
|
page_x, page_y = renderer.screen_to_page(screen_x, screen_y)
|
||||||
|
|
||||||
|
assert abs(page_x - orig_page_x) < 0.001
|
||||||
|
assert abs(page_y - orig_page_y) < 0.001
|
||||||
|
|
||||||
|
|
||||||
|
class TestPageRendererBounds:
|
||||||
|
"""Test page bounds and point detection"""
|
||||||
|
|
||||||
|
def test_is_point_in_page_inside(self):
|
||||||
|
"""Test is_point_in_page for points inside the page"""
|
||||||
|
renderer = PageRenderer(
|
||||||
|
page_width_mm=210.0, # A4 width
|
||||||
|
page_height_mm=297.0, # A4 height
|
||||||
|
screen_x=100.0,
|
||||||
|
screen_y=200.0,
|
||||||
|
dpi=96,
|
||||||
|
zoom=1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate page dimensions in pixels
|
||||||
|
page_width_px = 210.0 * 96 / 25.4 # ~794 pixels
|
||||||
|
page_height_px = 297.0 * 96 / 25.4 # ~1123 pixels
|
||||||
|
|
||||||
|
# Point in center should be inside
|
||||||
|
center_x = 100.0 + page_width_px / 2
|
||||||
|
center_y = 200.0 + page_height_px / 2
|
||||||
|
assert renderer.is_point_in_page(center_x, center_y)
|
||||||
|
|
||||||
|
# Point at origin should be inside
|
||||||
|
assert renderer.is_point_in_page(100.0, 200.0)
|
||||||
|
|
||||||
|
# Point at bottom-right corner should be inside
|
||||||
|
assert renderer.is_point_in_page(
|
||||||
|
100.0 + page_width_px,
|
||||||
|
200.0 + page_height_px
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_is_point_in_page_outside(self):
|
||||||
|
"""Test is_point_in_page for points outside the page"""
|
||||||
|
renderer = PageRenderer(
|
||||||
|
page_width_mm=210.0,
|
||||||
|
page_height_mm=297.0,
|
||||||
|
screen_x=100.0,
|
||||||
|
screen_y=200.0,
|
||||||
|
dpi=96,
|
||||||
|
zoom=1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Point before page start
|
||||||
|
assert not renderer.is_point_in_page(50.0, 150.0)
|
||||||
|
|
||||||
|
# Point way beyond page
|
||||||
|
assert not renderer.is_point_in_page(2000.0, 2000.0)
|
||||||
|
|
||||||
|
# Point to the left of page
|
||||||
|
assert not renderer.is_point_in_page(50.0, 500.0)
|
||||||
|
|
||||||
|
# Point above page
|
||||||
|
assert not renderer.is_point_in_page(500.0, 150.0)
|
||||||
|
|
||||||
|
def test_is_point_in_page_with_zoom(self):
|
||||||
|
"""Test is_point_in_page with different zoom levels"""
|
||||||
|
for zoom in [0.5, 1.0, 2.0]:
|
||||||
|
renderer = PageRenderer(
|
||||||
|
page_width_mm=210.0,
|
||||||
|
page_height_mm=297.0,
|
||||||
|
screen_x=100.0,
|
||||||
|
screen_y=200.0,
|
||||||
|
dpi=96,
|
||||||
|
zoom=zoom
|
||||||
|
)
|
||||||
|
|
||||||
|
# Center of page should always be inside regardless of zoom
|
||||||
|
page_width_px = 210.0 * 96 / 25.4
|
||||||
|
page_height_px = 297.0 * 96 / 25.4
|
||||||
|
center_x = 100.0 + (page_width_px * zoom) / 2
|
||||||
|
center_y = 200.0 + (page_height_px * zoom) / 2
|
||||||
|
assert renderer.is_point_in_page(center_x, center_y)
|
||||||
|
|
||||||
|
def test_get_page_bounds_screen(self):
|
||||||
|
"""Test get_page_bounds_screen returns correct screen coordinates"""
|
||||||
|
renderer = PageRenderer(
|
||||||
|
page_width_mm=210.0,
|
||||||
|
page_height_mm=297.0,
|
||||||
|
screen_x=100.0,
|
||||||
|
screen_y=200.0,
|
||||||
|
dpi=96,
|
||||||
|
zoom=1.5
|
||||||
|
)
|
||||||
|
|
||||||
|
x, y, w, h = renderer.get_page_bounds_screen()
|
||||||
|
|
||||||
|
assert x == 100.0
|
||||||
|
assert y == 200.0
|
||||||
|
|
||||||
|
# Width and height should be scaled by zoom
|
||||||
|
page_width_px = 210.0 * 96 / 25.4
|
||||||
|
page_height_px = 297.0 * 96 / 25.4
|
||||||
|
assert abs(w - page_width_px * 1.5) < 0.1
|
||||||
|
assert abs(h - page_height_px * 1.5) < 0.1
|
||||||
|
|
||||||
|
def test_get_page_bounds_page(self):
|
||||||
|
"""Test get_page_bounds_page returns correct page-local coordinates"""
|
||||||
|
renderer = PageRenderer(
|
||||||
|
page_width_mm=210.0,
|
||||||
|
page_height_mm=297.0,
|
||||||
|
screen_x=100.0,
|
||||||
|
screen_y=200.0,
|
||||||
|
dpi=96,
|
||||||
|
zoom=1.5
|
||||||
|
)
|
||||||
|
|
||||||
|
x, y, w, h = renderer.get_page_bounds_page()
|
||||||
|
|
||||||
|
# Origin should be at 0,0 in page-local coordinates
|
||||||
|
assert x == 0.0
|
||||||
|
assert y == 0.0
|
||||||
|
|
||||||
|
# Width and height should NOT be affected by zoom (page-local coords)
|
||||||
|
page_width_px = 210.0 * 96 / 25.4
|
||||||
|
page_height_px = 297.0 * 96 / 25.4
|
||||||
|
assert abs(w - page_width_px) < 0.1
|
||||||
|
assert abs(h - page_height_px) < 0.1
|
||||||
|
|
||||||
|
|
||||||
|
class TestPageRendererSubPages:
|
||||||
|
"""Test sub-page detection for facing pages"""
|
||||||
|
|
||||||
|
def test_get_sub_page_at_single_page(self):
|
||||||
|
"""Test that get_sub_page_at returns None for single pages"""
|
||||||
|
renderer = PageRenderer(
|
||||||
|
page_width_mm=210.0,
|
||||||
|
page_height_mm=297.0,
|
||||||
|
screen_x=100.0,
|
||||||
|
screen_y=200.0,
|
||||||
|
dpi=96,
|
||||||
|
zoom=1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# For non-facing pages, should return None
|
||||||
|
result = renderer.get_sub_page_at(500.0, is_facing_page=False)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_get_sub_page_at_facing_page_left(self):
|
||||||
|
"""Test get_sub_page_at for left side of facing page"""
|
||||||
|
renderer = PageRenderer(
|
||||||
|
page_width_mm=420.0, # Double width for facing page
|
||||||
|
page_height_mm=297.0,
|
||||||
|
screen_x=100.0,
|
||||||
|
screen_y=200.0,
|
||||||
|
dpi=96,
|
||||||
|
zoom=1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate center line
|
||||||
|
page_width_px = 420.0 * 96 / 25.4
|
||||||
|
center_x = 100.0 + page_width_px / 2
|
||||||
|
|
||||||
|
# Point before center should be 'left'
|
||||||
|
result = renderer.get_sub_page_at(center_x - 10, is_facing_page=True)
|
||||||
|
assert result == 'left'
|
||||||
|
|
||||||
|
def test_get_sub_page_at_facing_page_right(self):
|
||||||
|
"""Test get_sub_page_at for right side of facing page"""
|
||||||
|
renderer = PageRenderer(
|
||||||
|
page_width_mm=420.0, # Double width for facing page
|
||||||
|
page_height_mm=297.0,
|
||||||
|
screen_x=100.0,
|
||||||
|
screen_y=200.0,
|
||||||
|
dpi=96,
|
||||||
|
zoom=1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate center line
|
||||||
|
page_width_px = 420.0 * 96 / 25.4
|
||||||
|
center_x = 100.0 + page_width_px / 2
|
||||||
|
|
||||||
|
# Point after center should be 'right'
|
||||||
|
result = renderer.get_sub_page_at(center_x + 10, is_facing_page=True)
|
||||||
|
assert result == 'right'
|
||||||
|
|
||||||
|
|
||||||
|
class TestPageRendererDimensions:
|
||||||
|
"""Test page dimension calculations"""
|
||||||
|
|
||||||
|
def test_page_dimensions_calculated_correctly(self):
|
||||||
|
"""Test that page dimensions are calculated correctly from mm to pixels"""
|
||||||
|
renderer = PageRenderer(
|
||||||
|
page_width_mm=210.0, # A4 width
|
||||||
|
page_height_mm=297.0, # A4 height
|
||||||
|
screen_x=0.0,
|
||||||
|
screen_y=0.0,
|
||||||
|
dpi=96,
|
||||||
|
zoom=1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# A4 at 96 DPI
|
||||||
|
expected_width = 210.0 * 96 / 25.4 # ~794 pixels
|
||||||
|
expected_height = 297.0 * 96 / 25.4 # ~1123 pixels
|
||||||
|
|
||||||
|
assert abs(renderer.page_width_px - expected_width) < 0.1
|
||||||
|
assert abs(renderer.page_height_px - expected_height) < 0.1
|
||||||
|
|
||||||
|
def test_screen_dimensions_with_zoom(self):
|
||||||
|
"""Test that screen dimensions account for zoom"""
|
||||||
|
renderer = PageRenderer(
|
||||||
|
page_width_mm=210.0,
|
||||||
|
page_height_mm=297.0,
|
||||||
|
screen_x=0.0,
|
||||||
|
screen_y=0.0,
|
||||||
|
dpi=96,
|
||||||
|
zoom=2.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Screen dimensions should be doubled due to zoom
|
||||||
|
expected_width = (210.0 * 96 / 25.4) * 2.0
|
||||||
|
expected_height = (297.0 * 96 / 25.4) * 2.0
|
||||||
|
|
||||||
|
assert abs(renderer.screen_width - expected_width) < 0.1
|
||||||
|
assert abs(renderer.screen_height - expected_height) < 0.1
|
||||||
|
|
||||||
|
def test_different_dpi_values(self):
|
||||||
|
"""Test page dimensions with different DPI values"""
|
||||||
|
dpi_values = [72, 96, 150, 300]
|
||||||
|
|
||||||
|
for dpi in dpi_values:
|
||||||
|
renderer = PageRenderer(
|
||||||
|
page_width_mm=210.0,
|
||||||
|
page_height_mm=297.0,
|
||||||
|
screen_x=0.0,
|
||||||
|
screen_y=0.0,
|
||||||
|
dpi=dpi,
|
||||||
|
zoom=1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_width = 210.0 * dpi / 25.4
|
||||||
|
expected_height = 297.0 * dpi / 25.4
|
||||||
|
|
||||||
|
assert abs(renderer.page_width_px - expected_width) < 0.1
|
||||||
|
assert abs(renderer.page_height_px - expected_height) < 0.1
|
||||||
|
|
||||||
|
|
||||||
|
class TestPageRendererEdgeCases:
|
||||||
|
"""Test edge cases and boundary conditions"""
|
||||||
|
|
||||||
|
def test_zero_coordinates(self):
|
||||||
|
"""Test handling of zero coordinates"""
|
||||||
|
renderer = PageRenderer(
|
||||||
|
page_width_mm=210.0,
|
||||||
|
page_height_mm=297.0,
|
||||||
|
screen_x=100.0,
|
||||||
|
screen_y=200.0,
|
||||||
|
dpi=96,
|
||||||
|
zoom=1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
screen_x, screen_y = renderer.page_to_screen(0, 0)
|
||||||
|
assert screen_x == 100.0
|
||||||
|
assert screen_y == 200.0
|
||||||
|
|
||||||
|
page_x, page_y = renderer.screen_to_page(100.0, 200.0)
|
||||||
|
assert page_x == 0.0
|
||||||
|
assert page_y == 0.0
|
||||||
|
|
||||||
|
def test_negative_page_coordinates(self):
|
||||||
|
"""Test handling of negative page coordinates"""
|
||||||
|
renderer = PageRenderer(
|
||||||
|
page_width_mm=210.0,
|
||||||
|
page_height_mm=297.0,
|
||||||
|
screen_x=100.0,
|
||||||
|
screen_y=200.0,
|
||||||
|
dpi=96,
|
||||||
|
zoom=1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Negative page coordinates should still convert correctly
|
||||||
|
screen_x, screen_y = renderer.page_to_screen(-50, -75)
|
||||||
|
assert screen_x == 50.0
|
||||||
|
assert screen_y == 125.0
|
||||||
|
|
||||||
|
# And back again
|
||||||
|
page_x, page_y = renderer.screen_to_page(50.0, 125.0)
|
||||||
|
assert page_x == -50.0
|
||||||
|
assert page_y == -75.0
|
||||||
|
|
||||||
|
def test_very_large_coordinates(self):
|
||||||
|
"""Test handling of very large coordinates"""
|
||||||
|
renderer = PageRenderer(
|
||||||
|
page_width_mm=210.0,
|
||||||
|
page_height_mm=297.0,
|
||||||
|
screen_x=100.0,
|
||||||
|
screen_y=200.0,
|
||||||
|
dpi=96,
|
||||||
|
zoom=1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
large_x, large_y = 10000.0, 20000.0
|
||||||
|
|
||||||
|
screen_x, screen_y = renderer.page_to_screen(large_x, large_y)
|
||||||
|
page_x, page_y = renderer.screen_to_page(screen_x, screen_y)
|
||||||
|
|
||||||
|
assert abs(page_x - large_x) < 0.001
|
||||||
|
assert abs(page_y - large_y) < 0.001
|
||||||
708
tests/test_pdf_export.py
Normal file
708
tests/test_pdf_export.py
Normal file
@ -0,0 +1,708 @@
|
|||||||
|
"""
|
||||||
|
Tests for PDF export functionality
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pyPhotoAlbum.project import Project, Page
|
||||||
|
from pyPhotoAlbum.page_layout import PageLayout
|
||||||
|
from pyPhotoAlbum.models import ImageData, TextBoxData
|
||||||
|
from pyPhotoAlbum.pdf_exporter import PDFExporter
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_exporter_basic():
|
||||||
|
"""Test basic PDF export with single page"""
|
||||||
|
# Create a simple project
|
||||||
|
project = Project("Test Project")
|
||||||
|
project.page_size_mm = (210, 297) # A4
|
||||||
|
|
||||||
|
# Add a single page
|
||||||
|
page = Page(page_number=1, is_double_spread=False)
|
||||||
|
project.add_page(page)
|
||||||
|
|
||||||
|
# Export to temporary file
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
exporter = PDFExporter(project)
|
||||||
|
success, warnings = exporter.export(tmp_path)
|
||||||
|
|
||||||
|
assert success, f"Export failed: {warnings}"
|
||||||
|
assert os.path.exists(tmp_path), "PDF file was not created"
|
||||||
|
assert os.path.getsize(tmp_path) > 0, "PDF file is empty"
|
||||||
|
|
||||||
|
print(f"✓ Basic PDF export successful: {tmp_path}")
|
||||||
|
if warnings:
|
||||||
|
print(f" Warnings: {warnings}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if os.path.exists(tmp_path):
|
||||||
|
os.remove(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_exporter_double_spread():
|
||||||
|
"""Test PDF export with double-page spread"""
|
||||||
|
project = Project("Test Spread Project")
|
||||||
|
project.page_size_mm = (210, 297) # A4
|
||||||
|
|
||||||
|
# Add a double-page spread
|
||||||
|
spread_page = Page(page_number=1, is_double_spread=True)
|
||||||
|
project.add_page(spread_page)
|
||||||
|
|
||||||
|
# Export to temporary file
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
exporter = PDFExporter(project)
|
||||||
|
success, warnings = exporter.export(tmp_path)
|
||||||
|
|
||||||
|
assert success, f"Export failed: {warnings}"
|
||||||
|
assert os.path.exists(tmp_path), "PDF file was not created"
|
||||||
|
|
||||||
|
print(f"✓ Double-spread PDF export successful: {tmp_path}")
|
||||||
|
if warnings:
|
||||||
|
print(f" Warnings: {warnings}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if os.path.exists(tmp_path):
|
||||||
|
os.remove(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_exporter_with_text():
|
||||||
|
"""Test PDF export with text boxes"""
|
||||||
|
project = Project("Test Text Project")
|
||||||
|
project.page_size_mm = (210, 297)
|
||||||
|
|
||||||
|
# Create page with text box
|
||||||
|
page = Page(page_number=1, is_double_spread=False)
|
||||||
|
|
||||||
|
# Add a text box
|
||||||
|
text_box = TextBoxData(
|
||||||
|
text_content="Hello, World!",
|
||||||
|
font_settings={"family": "Helvetica", "size": 24, "color": (0, 0, 0)},
|
||||||
|
alignment="center",
|
||||||
|
x=50, y=50, width=100, height=30
|
||||||
|
)
|
||||||
|
page.layout.add_element(text_box)
|
||||||
|
|
||||||
|
project.add_page(page)
|
||||||
|
|
||||||
|
# Export to temporary file
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
exporter = PDFExporter(project)
|
||||||
|
success, warnings = exporter.export(tmp_path)
|
||||||
|
|
||||||
|
assert success, f"Export failed: {warnings}"
|
||||||
|
assert os.path.exists(tmp_path), "PDF file was not created"
|
||||||
|
|
||||||
|
print(f"✓ Text box PDF export successful: {tmp_path}")
|
||||||
|
if warnings:
|
||||||
|
print(f" Warnings: {warnings}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if os.path.exists(tmp_path):
|
||||||
|
os.remove(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_exporter_facing_pages_alignment():
|
||||||
|
"""Test that double spreads align to facing pages"""
|
||||||
|
project = Project("Test Facing Pages")
|
||||||
|
project.page_size_mm = (210, 297)
|
||||||
|
|
||||||
|
# Add single page (page 1)
|
||||||
|
page1 = Page(page_number=1, is_double_spread=False)
|
||||||
|
project.add_page(page1)
|
||||||
|
|
||||||
|
# Add double spread (should start on page 2, which requires blank insert)
|
||||||
|
# Since page 1 is odd, a blank page should be inserted, making the spread pages 2-3
|
||||||
|
spread = Page(page_number=2, is_double_spread=True)
|
||||||
|
project.add_page(spread)
|
||||||
|
|
||||||
|
# Export to temporary file
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
exporter = PDFExporter(project)
|
||||||
|
success, warnings = exporter.export(tmp_path)
|
||||||
|
|
||||||
|
assert success, f"Export failed: {warnings}"
|
||||||
|
assert os.path.exists(tmp_path), "PDF file was not created"
|
||||||
|
|
||||||
|
print(f"✓ Facing pages alignment successful: {tmp_path}")
|
||||||
|
print(f" Expected: Page 1 (single), blank page, Pages 2-3 (spread)")
|
||||||
|
if warnings:
|
||||||
|
print(f" Warnings: {warnings}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if os.path.exists(tmp_path):
|
||||||
|
os.remove(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_exporter_missing_image():
|
||||||
|
"""Test PDF export with missing image (should warn but not fail)"""
|
||||||
|
project = Project("Test Missing Image")
|
||||||
|
project.page_size_mm = (210, 297)
|
||||||
|
|
||||||
|
# Create page with image that doesn't exist
|
||||||
|
page = Page(page_number=1, is_double_spread=False)
|
||||||
|
|
||||||
|
# Add image with non-existent path
|
||||||
|
image = ImageData(
|
||||||
|
image_path="/nonexistent/path/to/image.jpg",
|
||||||
|
x=50, y=50, width=100, height=100
|
||||||
|
)
|
||||||
|
page.layout.add_element(image)
|
||||||
|
|
||||||
|
project.add_page(page)
|
||||||
|
|
||||||
|
# Export to temporary file
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
exporter = PDFExporter(project)
|
||||||
|
success, warnings = exporter.export(tmp_path)
|
||||||
|
|
||||||
|
assert success, "Export should succeed even with missing images"
|
||||||
|
assert len(warnings) > 0, "Should have warnings for missing image"
|
||||||
|
assert "not found" in warnings[0].lower(), "Warning should mention missing image"
|
||||||
|
|
||||||
|
print(f"✓ Missing image handling successful: {tmp_path}")
|
||||||
|
print(f" Warnings: {warnings}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if os.path.exists(tmp_path):
|
||||||
|
os.remove(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_exporter_spanning_image():
|
||||||
|
"""Test PDF export with image spanning across center line of double spread"""
|
||||||
|
import tempfile
|
||||||
|
from PIL import Image as PILImage
|
||||||
|
|
||||||
|
project = Project("Test Spanning Image")
|
||||||
|
project.page_size_mm = (210, 297) # A4
|
||||||
|
project.working_dpi = 96 # Standard DPI
|
||||||
|
|
||||||
|
# Create a test image (solid color for easy verification)
|
||||||
|
test_img = PILImage.new('RGB', (400, 200), color='red')
|
||||||
|
|
||||||
|
# Save test image to temporary file
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp:
|
||||||
|
img_path = img_tmp.name
|
||||||
|
test_img.save(img_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create a double-page spread
|
||||||
|
spread_page = Page(page_number=1, is_double_spread=True)
|
||||||
|
|
||||||
|
# Calculate center position in pixels (for a 210mm page width at 96 DPI)
|
||||||
|
# Spread width is 2 * 210mm = 420mm
|
||||||
|
spread_width_px = 420 * 96 / 25.4 # ~1587 pixels
|
||||||
|
center_px = spread_width_px / 2 # ~794 pixels
|
||||||
|
|
||||||
|
# Add an image that spans across the center
|
||||||
|
# Position it so it overlaps the center line
|
||||||
|
image_width_px = 400
|
||||||
|
image_x_px = center_px - 200 # Start 200px before center, end 200px after
|
||||||
|
|
||||||
|
spanning_image = ImageData(
|
||||||
|
image_path=img_path,
|
||||||
|
x=image_x_px,
|
||||||
|
y=100,
|
||||||
|
width=image_width_px,
|
||||||
|
height=200
|
||||||
|
)
|
||||||
|
spread_page.layout.add_element(spanning_image)
|
||||||
|
|
||||||
|
project.add_page(spread_page)
|
||||||
|
|
||||||
|
# Export to temporary PDF
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp:
|
||||||
|
pdf_path = pdf_tmp.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
exporter = PDFExporter(project)
|
||||||
|
success, warnings = exporter.export(pdf_path)
|
||||||
|
|
||||||
|
assert success, f"Export failed: {warnings}"
|
||||||
|
assert os.path.exists(pdf_path), "PDF file was not created"
|
||||||
|
|
||||||
|
print(f"✓ Spanning image export successful: {pdf_path}")
|
||||||
|
print(f" Image spans from {image_x_px:.1f}px to {image_x_px + image_width_px:.1f}px")
|
||||||
|
print(f" Center line at {center_px:.1f}px")
|
||||||
|
if warnings:
|
||||||
|
print(f" Warnings: {warnings}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if os.path.exists(pdf_path):
|
||||||
|
os.remove(pdf_path)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if os.path.exists(img_path):
|
||||||
|
os.remove(img_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_exporter_multiple_spanning_elements():
|
||||||
|
"""Test PDF export with multiple images spanning the center line"""
|
||||||
|
import tempfile
|
||||||
|
from PIL import Image as PILImage
|
||||||
|
|
||||||
|
project = Project("Test Multiple Spanning")
|
||||||
|
project.page_size_mm = (210, 297) # A4
|
||||||
|
project.working_dpi = 96
|
||||||
|
|
||||||
|
# Create test images
|
||||||
|
test_img1 = PILImage.new('RGB', (300, 150), color='blue')
|
||||||
|
test_img2 = PILImage.new('RGB', (250, 200), color='green')
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp1:
|
||||||
|
img_path1 = img_tmp1.name
|
||||||
|
test_img1.save(img_path1)
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp2:
|
||||||
|
img_path2 = img_tmp2.name
|
||||||
|
test_img2.save(img_path2)
|
||||||
|
|
||||||
|
try:
|
||||||
|
spread_page = Page(page_number=1, is_double_spread=True)
|
||||||
|
|
||||||
|
# Calculate positions
|
||||||
|
spread_width_px = 420 * 96 / 25.4
|
||||||
|
center_px = spread_width_px / 2
|
||||||
|
|
||||||
|
# First spanning image
|
||||||
|
image1 = ImageData(
|
||||||
|
image_path=img_path1,
|
||||||
|
x=center_px - 150, # Centered on split line
|
||||||
|
y=50,
|
||||||
|
width=300,
|
||||||
|
height=150
|
||||||
|
)
|
||||||
|
|
||||||
|
# Second spanning image (different position)
|
||||||
|
image2 = ImageData(
|
||||||
|
image_path=img_path2,
|
||||||
|
x=center_px - 100,
|
||||||
|
y=250,
|
||||||
|
width=250,
|
||||||
|
height=200
|
||||||
|
)
|
||||||
|
|
||||||
|
spread_page.layout.add_element(image1)
|
||||||
|
spread_page.layout.add_element(image2)
|
||||||
|
|
||||||
|
project.add_page(spread_page)
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp:
|
||||||
|
pdf_path = pdf_tmp.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
exporter = PDFExporter(project)
|
||||||
|
success, warnings = exporter.export(pdf_path)
|
||||||
|
|
||||||
|
assert success, f"Export failed: {warnings}"
|
||||||
|
assert os.path.exists(pdf_path), "PDF file was not created"
|
||||||
|
|
||||||
|
print(f"✓ Multiple spanning images export successful: {pdf_path}")
|
||||||
|
if warnings:
|
||||||
|
print(f" Warnings: {warnings}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if os.path.exists(pdf_path):
|
||||||
|
os.remove(pdf_path)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if os.path.exists(img_path1):
|
||||||
|
os.remove(img_path1)
|
||||||
|
if os.path.exists(img_path2):
|
||||||
|
os.remove(img_path2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_exporter_edge_case_barely_spanning():
|
||||||
|
"""Test image that barely crosses the threshold"""
|
||||||
|
import tempfile
|
||||||
|
from PIL import Image as PILImage
|
||||||
|
|
||||||
|
project = Project("Test Edge Case")
|
||||||
|
project.page_size_mm = (210, 297)
|
||||||
|
project.working_dpi = 96
|
||||||
|
|
||||||
|
test_img = PILImage.new('RGB', (100, 100), color='yellow')
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp:
|
||||||
|
img_path = img_tmp.name
|
||||||
|
test_img.save(img_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
spread_page = Page(page_number=1, is_double_spread=True)
|
||||||
|
|
||||||
|
spread_width_px = 420 * 96 / 25.4
|
||||||
|
center_px = spread_width_px / 2
|
||||||
|
|
||||||
|
# Image that just barely crosses the center line
|
||||||
|
image = ImageData(
|
||||||
|
image_path=img_path,
|
||||||
|
x=center_px - 5, # Just 5px overlap
|
||||||
|
y=100,
|
||||||
|
width=100,
|
||||||
|
height=100
|
||||||
|
)
|
||||||
|
|
||||||
|
spread_page.layout.add_element(image)
|
||||||
|
project.add_page(spread_page)
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp:
|
||||||
|
pdf_path = pdf_tmp.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
exporter = PDFExporter(project)
|
||||||
|
success, warnings = exporter.export(pdf_path)
|
||||||
|
|
||||||
|
assert success, f"Export failed: {warnings}"
|
||||||
|
|
||||||
|
print(f"✓ Edge case (barely spanning) export successful: {pdf_path}")
|
||||||
|
if warnings:
|
||||||
|
print(f" Warnings: {warnings}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if os.path.exists(pdf_path):
|
||||||
|
os.remove(pdf_path)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if os.path.exists(img_path):
|
||||||
|
os.remove(img_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_exporter_text_spanning():
|
||||||
|
"""Test text box spanning the center line"""
|
||||||
|
project = Project("Test Spanning Text")
|
||||||
|
project.page_size_mm = (210, 297)
|
||||||
|
project.working_dpi = 96
|
||||||
|
|
||||||
|
spread_page = Page(page_number=1, is_double_spread=True)
|
||||||
|
|
||||||
|
spread_width_px = 420 * 96 / 25.4
|
||||||
|
center_px = spread_width_px / 2
|
||||||
|
|
||||||
|
# Text box spanning the center
|
||||||
|
text_box = TextBoxData(
|
||||||
|
text_content="Spanning Text",
|
||||||
|
font_settings={"family": "Helvetica", "size": 24, "color": (0, 0, 0)},
|
||||||
|
alignment="center",
|
||||||
|
x=center_px - 100,
|
||||||
|
y=100,
|
||||||
|
width=200,
|
||||||
|
height=50
|
||||||
|
)
|
||||||
|
|
||||||
|
spread_page.layout.add_element(text_box)
|
||||||
|
project.add_page(spread_page)
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp:
|
||||||
|
pdf_path = pdf_tmp.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
exporter = PDFExporter(project)
|
||||||
|
success, warnings = exporter.export(pdf_path)
|
||||||
|
|
||||||
|
assert success, f"Export failed: {warnings}"
|
||||||
|
|
||||||
|
print(f"✓ Spanning text box export successful: {pdf_path}")
|
||||||
|
if warnings:
|
||||||
|
print(f" Warnings: {warnings}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if os.path.exists(pdf_path):
|
||||||
|
os.remove(pdf_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_exporter_spanning_image_aspect_ratio():
|
||||||
|
"""Test that spanning images maintain correct aspect ratio and can be recombined"""
|
||||||
|
import tempfile
|
||||||
|
from PIL import Image as PILImage, ImageDraw
|
||||||
|
|
||||||
|
project = Project("Test Aspect Ratio")
|
||||||
|
project.page_size_mm = (210, 297) # A4
|
||||||
|
project.working_dpi = 96
|
||||||
|
|
||||||
|
# Create a distinctive test image: red left half, blue right half, with a vertical line in center
|
||||||
|
test_width, test_height = 800, 400
|
||||||
|
test_img = PILImage.new('RGB', (test_width, test_height))
|
||||||
|
draw = ImageDraw.Draw(test_img)
|
||||||
|
|
||||||
|
# Fill left half red
|
||||||
|
draw.rectangle([0, 0, test_width // 2, test_height], fill=(255, 0, 0))
|
||||||
|
|
||||||
|
# Fill right half blue
|
||||||
|
draw.rectangle([test_width // 2, 0, test_width, test_height], fill=(0, 0, 255))
|
||||||
|
|
||||||
|
# Draw a black vertical line in the middle
|
||||||
|
draw.line([test_width // 2, 0, test_width // 2, test_height], fill=(0, 0, 0), width=5)
|
||||||
|
|
||||||
|
# Draw horizontal reference lines for visual verification
|
||||||
|
for y in range(0, test_height, 50):
|
||||||
|
draw.line([0, y, test_width, y], fill=(255, 255, 255), width=2)
|
||||||
|
|
||||||
|
# Save test image to temporary file
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp:
|
||||||
|
img_path = img_tmp.name
|
||||||
|
test_img.save(img_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create a double-page spread
|
||||||
|
spread_page = Page(page_number=1, is_double_spread=True)
|
||||||
|
|
||||||
|
# Calculate positions
|
||||||
|
spread_width_px = 420 * 96 / 25.4 # ~1587 pixels
|
||||||
|
center_px = spread_width_px / 2 # ~794 pixels
|
||||||
|
|
||||||
|
# Create an image element that spans the center with a specific aspect ratio
|
||||||
|
# Make it 600px wide and 300px tall (2:1 aspect ratio)
|
||||||
|
image_width_px = 600
|
||||||
|
image_height_px = 300
|
||||||
|
image_x_px = center_px - 300 # Centered on the split line
|
||||||
|
|
||||||
|
spanning_image = ImageData(
|
||||||
|
image_path=img_path,
|
||||||
|
x=image_x_px,
|
||||||
|
y=100,
|
||||||
|
width=image_width_px,
|
||||||
|
height=image_height_px
|
||||||
|
)
|
||||||
|
spread_page.layout.add_element(spanning_image)
|
||||||
|
|
||||||
|
project.add_page(spread_page)
|
||||||
|
|
||||||
|
# Export to temporary PDF
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp:
|
||||||
|
pdf_path = pdf_tmp.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
exporter = PDFExporter(project)
|
||||||
|
success, warnings = exporter.export(pdf_path)
|
||||||
|
|
||||||
|
assert success, f"Export failed: {warnings}"
|
||||||
|
assert os.path.exists(pdf_path), "PDF file was not created"
|
||||||
|
|
||||||
|
# Verify the PDF was created and has expected properties
|
||||||
|
# We can't easily extract and verify pixel-perfect image reconstruction without
|
||||||
|
# additional dependencies, but we can verify the export succeeded
|
||||||
|
file_size = os.path.getsize(pdf_path)
|
||||||
|
assert file_size > 1000, "PDF file seems too small"
|
||||||
|
|
||||||
|
print(f"✓ Spanning image aspect ratio test successful: {pdf_path}")
|
||||||
|
print(f" Original image: {test_width}x{test_height} (aspect {test_width/test_height:.2f}:1)")
|
||||||
|
print(f" Element size: {image_width_px}x{image_height_px} (aspect {image_width_px/image_height_px:.2f}:1)")
|
||||||
|
print(f" Split at: {center_px:.1f}px")
|
||||||
|
print(f" Left portion: {center_px - image_x_px:.1f}px wide")
|
||||||
|
print(f" Right portion: {image_width_px - (center_px - image_x_px):.1f}px wide")
|
||||||
|
print(f" PDF size: {file_size} bytes")
|
||||||
|
|
||||||
|
if warnings:
|
||||||
|
print(f" Warnings: {warnings}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if os.path.exists(pdf_path):
|
||||||
|
os.remove(pdf_path)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if os.path.exists(img_path):
|
||||||
|
os.remove(img_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_exporter_varying_aspect_ratios():
|
||||||
|
"""Test spanning images with various aspect ratios"""
|
||||||
|
import tempfile
|
||||||
|
from PIL import Image as PILImage, ImageDraw
|
||||||
|
|
||||||
|
project = Project("Test Varying Aspects")
|
||||||
|
project.page_size_mm = (210, 297)
|
||||||
|
project.working_dpi = 96
|
||||||
|
|
||||||
|
# Test different aspect ratios
|
||||||
|
test_configs = [
|
||||||
|
("Square", 400, 400), # 1:1
|
||||||
|
("Landscape", 800, 400), # 2:1
|
||||||
|
("Portrait", 400, 800), # 1:2
|
||||||
|
("Wide", 1200, 400), # 3:1
|
||||||
|
]
|
||||||
|
|
||||||
|
spread_width_px = 420 * 96 / 25.4
|
||||||
|
center_px = spread_width_px / 2
|
||||||
|
|
||||||
|
for idx, (name, img_w, img_h) in enumerate(test_configs):
|
||||||
|
# Create test image
|
||||||
|
test_img = PILImage.new('RGB', (img_w, img_h))
|
||||||
|
draw = ImageDraw.Draw(test_img)
|
||||||
|
|
||||||
|
# Different colors for each test
|
||||||
|
colors = [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 0)]
|
||||||
|
draw.rectangle([0, 0, img_w // 2, img_h], fill=colors[idx])
|
||||||
|
draw.rectangle([img_w // 2, 0, img_w, img_h], fill=(255-colors[idx][0], 255-colors[idx][1], 255-colors[idx][2]))
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp:
|
||||||
|
img_path = img_tmp.name
|
||||||
|
test_img.save(img_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
spread_page = Page(page_number=idx + 1, is_double_spread=True)
|
||||||
|
|
||||||
|
# Position spanning element
|
||||||
|
element_width_px = 500
|
||||||
|
element_height_px = int(500 * img_h / img_w) # Maintain aspect ratio
|
||||||
|
|
||||||
|
spanning_image = ImageData(
|
||||||
|
image_path=img_path,
|
||||||
|
x=center_px - 250,
|
||||||
|
y=100 + idx * 200,
|
||||||
|
width=element_width_px,
|
||||||
|
height=element_height_px
|
||||||
|
)
|
||||||
|
spread_page.layout.add_element(spanning_image)
|
||||||
|
|
||||||
|
project.add_page(spread_page)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if os.path.exists(img_path):
|
||||||
|
os.remove(img_path)
|
||||||
|
|
||||||
|
# Export all pages
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp:
|
||||||
|
pdf_path = pdf_tmp.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
exporter = PDFExporter(project)
|
||||||
|
success, warnings = exporter.export(pdf_path)
|
||||||
|
|
||||||
|
assert success, f"Export failed: {warnings}"
|
||||||
|
assert os.path.exists(pdf_path), "PDF file was not created"
|
||||||
|
|
||||||
|
print(f"✓ Varying aspect ratios test successful: {pdf_path}")
|
||||||
|
print(f" Tested {len(test_configs)} different aspect ratios")
|
||||||
|
if warnings:
|
||||||
|
print(f" Warnings: {warnings}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if os.path.exists(pdf_path):
|
||||||
|
os.remove(pdf_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_exporter_image_downsampling():
|
||||||
|
"""Test that export DPI controls image downsampling and reduces file size"""
|
||||||
|
import tempfile
|
||||||
|
from PIL import Image as PILImage
|
||||||
|
|
||||||
|
project = Project("Test Downsampling")
|
||||||
|
project.page_size_mm = (210, 297) # A4
|
||||||
|
project.working_dpi = 96
|
||||||
|
|
||||||
|
# Create a large test image (4000x3000 - typical high-res camera)
|
||||||
|
large_img = PILImage.new('RGB', (4000, 3000))
|
||||||
|
# Add some pattern so it doesn't compress too much
|
||||||
|
import random
|
||||||
|
pixels = large_img.load()
|
||||||
|
for i in range(0, 4000, 10):
|
||||||
|
for j in range(0, 3000, 10):
|
||||||
|
pixels[i, j] = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp:
|
||||||
|
img_path = img_tmp.name
|
||||||
|
large_img.save(img_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create a page with the large image
|
||||||
|
page = Page(page_number=1, is_double_spread=False)
|
||||||
|
|
||||||
|
# Add image at reasonable size (100mm x 75mm)
|
||||||
|
image = ImageData(
|
||||||
|
image_path=img_path,
|
||||||
|
x=50,
|
||||||
|
y=50,
|
||||||
|
width=int(100 * 96 / 25.4), # ~378 px
|
||||||
|
height=int(75 * 96 / 25.4) # ~283 px
|
||||||
|
)
|
||||||
|
page.layout.add_element(image)
|
||||||
|
project.add_page(page)
|
||||||
|
|
||||||
|
# Export with high DPI (300 - print quality)
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp1:
|
||||||
|
pdf_path_300dpi = pdf_tmp1.name
|
||||||
|
|
||||||
|
# Export with low DPI (150 - screen quality)
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp2:
|
||||||
|
pdf_path_150dpi = pdf_tmp2.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Export at 300 DPI
|
||||||
|
exporter_300 = PDFExporter(project, export_dpi=300)
|
||||||
|
success1, warnings1 = exporter_300.export(pdf_path_300dpi)
|
||||||
|
assert success1, f"300 DPI export failed: {warnings1}"
|
||||||
|
|
||||||
|
# Export at 150 DPI
|
||||||
|
exporter_150 = PDFExporter(project, export_dpi=150)
|
||||||
|
success2, warnings2 = exporter_150.export(pdf_path_150dpi)
|
||||||
|
assert success2, f"150 DPI export failed: {warnings2}"
|
||||||
|
|
||||||
|
# Check file sizes
|
||||||
|
size_300dpi = os.path.getsize(pdf_path_300dpi)
|
||||||
|
size_150dpi = os.path.getsize(pdf_path_150dpi)
|
||||||
|
|
||||||
|
print(f"✓ Image downsampling test successful:")
|
||||||
|
print(f" Original image: 4000x3000 pixels")
|
||||||
|
print(f" Element size: 100mm x 75mm")
|
||||||
|
print(f" PDF at 300 DPI: {size_300dpi:,} bytes")
|
||||||
|
print(f" PDF at 150 DPI: {size_150dpi:,} bytes")
|
||||||
|
print(f" Size reduction: {(1 - size_150dpi/size_300dpi)*100:.1f}%")
|
||||||
|
|
||||||
|
# 150 DPI should be smaller than 300 DPI
|
||||||
|
assert size_150dpi < size_300dpi, \
|
||||||
|
f"150 DPI file ({size_150dpi}) should be smaller than 300 DPI file ({size_300dpi})"
|
||||||
|
|
||||||
|
# 150 DPI should be significantly smaller (at least 50% reduction)
|
||||||
|
reduction_ratio = size_150dpi / size_300dpi
|
||||||
|
assert reduction_ratio < 0.7, \
|
||||||
|
f"150 DPI should be at least 30% smaller, got {(1-reduction_ratio)*100:.1f}%"
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if os.path.exists(pdf_path_300dpi):
|
||||||
|
os.remove(pdf_path_300dpi)
|
||||||
|
if os.path.exists(pdf_path_150dpi):
|
||||||
|
os.remove(pdf_path_150dpi)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if os.path.exists(img_path):
|
||||||
|
os.remove(img_path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Running PDF export tests...\n")
|
||||||
|
|
||||||
|
try:
|
||||||
|
test_pdf_exporter_basic()
|
||||||
|
test_pdf_exporter_double_spread()
|
||||||
|
test_pdf_exporter_with_text()
|
||||||
|
test_pdf_exporter_facing_pages_alignment()
|
||||||
|
test_pdf_exporter_missing_image()
|
||||||
|
test_pdf_exporter_spanning_image()
|
||||||
|
test_pdf_exporter_multiple_spanning_elements()
|
||||||
|
test_pdf_exporter_edge_case_barely_spanning()
|
||||||
|
test_pdf_exporter_text_spanning()
|
||||||
|
test_pdf_exporter_spanning_image_aspect_ratio()
|
||||||
|
test_pdf_exporter_varying_aspect_ratios()
|
||||||
|
test_pdf_exporter_image_downsampling()
|
||||||
|
|
||||||
|
print("\n✓ All tests passed!")
|
||||||
|
|
||||||
|
except AssertionError as e:
|
||||||
|
print(f"\n✗ Test failed: {e}")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n✗ Unexpected error: {e}")
|
||||||
|
raise
|
||||||
254
tests/test_project.py
Normal file
254
tests/test_project.py
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for pyPhotoAlbum project module
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pyPhotoAlbum.project import Project, Page
|
||||||
|
from pyPhotoAlbum.page_layout import PageLayout
|
||||||
|
from pyPhotoAlbum.models import ImageData
|
||||||
|
|
||||||
|
|
||||||
|
class TestPage:
|
||||||
|
"""Tests for Page class"""
|
||||||
|
|
||||||
|
def test_initialization_default(self):
|
||||||
|
"""Test Page initialization with default values"""
|
||||||
|
layout = PageLayout()
|
||||||
|
page = Page(layout=layout, page_number=1)
|
||||||
|
|
||||||
|
assert page.layout is layout
|
||||||
|
assert page.page_number == 1
|
||||||
|
|
||||||
|
def test_initialization_with_parameters(self):
|
||||||
|
"""Test Page initialization with custom parameters"""
|
||||||
|
layout = PageLayout()
|
||||||
|
page = Page(layout=layout, page_number=5)
|
||||||
|
|
||||||
|
assert page.layout is layout
|
||||||
|
assert page.page_number == 5
|
||||||
|
|
||||||
|
def test_page_number_modification(self):
|
||||||
|
"""Test modifying page number after initialization"""
|
||||||
|
layout = PageLayout()
|
||||||
|
page = Page(layout=layout, page_number=1)
|
||||||
|
page.page_number = 10
|
||||||
|
|
||||||
|
assert page.page_number == 10
|
||||||
|
|
||||||
|
|
||||||
|
class TestProject:
|
||||||
|
"""Tests for Project class"""
|
||||||
|
|
||||||
|
def test_initialization_default(self):
|
||||||
|
"""Test Project initialization with default values"""
|
||||||
|
project = Project()
|
||||||
|
|
||||||
|
assert project.name == "Untitled Project"
|
||||||
|
assert len(project.pages) == 0
|
||||||
|
assert project.working_dpi == 300
|
||||||
|
assert project.page_size_mm == (140, 140) # Default 14cm x 14cm square
|
||||||
|
|
||||||
|
def test_initialization_with_name(self):
|
||||||
|
"""Test Project initialization with custom name"""
|
||||||
|
project = Project(name="My Album")
|
||||||
|
|
||||||
|
assert project.name == "My Album"
|
||||||
|
|
||||||
|
def test_add_page(self):
|
||||||
|
"""Test adding a page to the project"""
|
||||||
|
project = Project()
|
||||||
|
layout = PageLayout()
|
||||||
|
page = Page(layout=layout, page_number=1)
|
||||||
|
|
||||||
|
project.add_page(page)
|
||||||
|
|
||||||
|
assert len(project.pages) == 1
|
||||||
|
assert project.pages[0] is page
|
||||||
|
|
||||||
|
def test_add_multiple_pages(self):
|
||||||
|
"""Test adding multiple pages to the project"""
|
||||||
|
project = Project()
|
||||||
|
|
||||||
|
page1 = Page(layout=PageLayout(), page_number=1)
|
||||||
|
page2 = Page(layout=PageLayout(), page_number=2)
|
||||||
|
page3 = Page(layout=PageLayout(), page_number=3)
|
||||||
|
|
||||||
|
project.add_page(page1)
|
||||||
|
project.add_page(page2)
|
||||||
|
project.add_page(page3)
|
||||||
|
|
||||||
|
assert len(project.pages) == 3
|
||||||
|
assert project.pages[0] is page1
|
||||||
|
assert project.pages[1] is page2
|
||||||
|
assert project.pages[2] is page3
|
||||||
|
|
||||||
|
def test_remove_page(self):
|
||||||
|
"""Test removing a page from the project"""
|
||||||
|
project = Project()
|
||||||
|
|
||||||
|
page1 = Page(layout=PageLayout(), page_number=1)
|
||||||
|
page2 = Page(layout=PageLayout(), page_number=2)
|
||||||
|
|
||||||
|
project.add_page(page1)
|
||||||
|
project.add_page(page2)
|
||||||
|
|
||||||
|
project.remove_page(page1)
|
||||||
|
|
||||||
|
assert len(project.pages) == 1
|
||||||
|
assert project.pages[0] is page2
|
||||||
|
|
||||||
|
def test_remove_page_not_in_list(self):
|
||||||
|
"""Test removing a page that's not in the project"""
|
||||||
|
project = Project()
|
||||||
|
page1 = Page(layout=PageLayout(), page_number=1)
|
||||||
|
page2 = Page(layout=PageLayout(), page_number=2)
|
||||||
|
project.add_page(page1)
|
||||||
|
|
||||||
|
# Try to remove a page that was never added
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
project.remove_page(page2)
|
||||||
|
|
||||||
|
def test_working_dpi_modification(self):
|
||||||
|
"""Test modifying working DPI"""
|
||||||
|
project = Project()
|
||||||
|
project.working_dpi = 300
|
||||||
|
|
||||||
|
assert project.working_dpi == 300
|
||||||
|
|
||||||
|
def test_page_size_modification(self):
|
||||||
|
"""Test modifying page size"""
|
||||||
|
project = Project()
|
||||||
|
project.page_size_mm = (300, 400)
|
||||||
|
|
||||||
|
assert project.page_size_mm == (300, 400)
|
||||||
|
|
||||||
|
def test_project_name_modification(self):
|
||||||
|
"""Test modifying project name"""
|
||||||
|
project = Project(name="Initial Name")
|
||||||
|
project.name = "New Name"
|
||||||
|
|
||||||
|
assert project.name == "New Name"
|
||||||
|
|
||||||
|
def test_asset_manager_exists(self):
|
||||||
|
"""Test that project has an asset manager"""
|
||||||
|
project = Project()
|
||||||
|
|
||||||
|
assert hasattr(project, 'asset_manager')
|
||||||
|
assert project.asset_manager is not None
|
||||||
|
|
||||||
|
def test_history_exists(self):
|
||||||
|
"""Test that project has a history manager"""
|
||||||
|
project = Project()
|
||||||
|
|
||||||
|
assert hasattr(project, 'history')
|
||||||
|
assert project.history is not None
|
||||||
|
|
||||||
|
def test_pages_list_is_mutable(self):
|
||||||
|
"""Test that pages list can be directly modified"""
|
||||||
|
project = Project()
|
||||||
|
page = Page(layout=PageLayout(), page_number=1)
|
||||||
|
|
||||||
|
project.pages.append(page)
|
||||||
|
|
||||||
|
assert len(project.pages) == 1
|
||||||
|
assert project.pages[0] is page
|
||||||
|
|
||||||
|
def test_empty_project_has_no_pages(self):
|
||||||
|
"""Test that a new project has no pages"""
|
||||||
|
project = Project()
|
||||||
|
|
||||||
|
assert len(project.pages) == 0
|
||||||
|
assert project.pages == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestProjectWithPages:
|
||||||
|
"""Integration tests for Project with Page operations"""
|
||||||
|
|
||||||
|
def test_project_with_populated_pages(self, sample_image_data):
|
||||||
|
"""Test project with pages containing elements"""
|
||||||
|
project = Project(name="Photo Album")
|
||||||
|
|
||||||
|
# Create pages with elements
|
||||||
|
for i in range(3):
|
||||||
|
layout = PageLayout()
|
||||||
|
img = ImageData(
|
||||||
|
image_path=f"image_{i}.jpg",
|
||||||
|
x=10 + i*10,
|
||||||
|
y=20 + i*10,
|
||||||
|
width=100,
|
||||||
|
height=100
|
||||||
|
)
|
||||||
|
layout.add_element(img)
|
||||||
|
page = Page(layout=layout, page_number=i+1)
|
||||||
|
project.add_page(page)
|
||||||
|
|
||||||
|
assert len(project.pages) == 3
|
||||||
|
|
||||||
|
# Check each page has elements
|
||||||
|
for i, page in enumerate(project.pages):
|
||||||
|
assert len(page.layout.elements) == 1
|
||||||
|
assert page.page_number == i + 1
|
||||||
|
|
||||||
|
def test_reorder_pages(self):
|
||||||
|
"""Test reordering pages in project"""
|
||||||
|
project = Project()
|
||||||
|
|
||||||
|
page1 = Page(layout=PageLayout(), page_number=1)
|
||||||
|
page2 = Page(layout=PageLayout(), page_number=2)
|
||||||
|
page3 = Page(layout=PageLayout(), page_number=3)
|
||||||
|
|
||||||
|
project.add_page(page1)
|
||||||
|
project.add_page(page2)
|
||||||
|
project.add_page(page3)
|
||||||
|
|
||||||
|
# Swap page 1 and page 3
|
||||||
|
project.pages[0], project.pages[2] = project.pages[2], project.pages[0]
|
||||||
|
|
||||||
|
assert project.pages[0] is page3
|
||||||
|
assert project.pages[1] is page2
|
||||||
|
assert project.pages[2] is page1
|
||||||
|
|
||||||
|
def test_clear_all_pages(self):
|
||||||
|
"""Test clearing all pages from project"""
|
||||||
|
project = Project()
|
||||||
|
|
||||||
|
for i in range(5):
|
||||||
|
page = Page(layout=PageLayout(), page_number=i+1)
|
||||||
|
project.add_page(page)
|
||||||
|
|
||||||
|
# Clear all pages
|
||||||
|
project.pages.clear()
|
||||||
|
|
||||||
|
assert len(project.pages) == 0
|
||||||
|
|
||||||
|
def test_get_page_by_index(self):
|
||||||
|
"""Test accessing pages by index"""
|
||||||
|
project = Project()
|
||||||
|
|
||||||
|
page1 = Page(layout=PageLayout(), page_number=1)
|
||||||
|
page2 = Page(layout=PageLayout(), page_number=2)
|
||||||
|
|
||||||
|
project.add_page(page1)
|
||||||
|
project.add_page(page2)
|
||||||
|
|
||||||
|
assert project.pages[0] is page1
|
||||||
|
assert project.pages[1] is page2
|
||||||
|
|
||||||
|
def test_insert_page_at_position(self):
|
||||||
|
"""Test inserting a page at a specific position"""
|
||||||
|
project = Project()
|
||||||
|
|
||||||
|
page1 = Page(layout=PageLayout(), page_number=1)
|
||||||
|
page2 = Page(layout=PageLayout(), page_number=2)
|
||||||
|
page_new = Page(layout=PageLayout(), page_number=99)
|
||||||
|
|
||||||
|
project.add_page(page1)
|
||||||
|
project.add_page(page2)
|
||||||
|
|
||||||
|
# Insert new page in the middle
|
||||||
|
project.pages.insert(1, page_new)
|
||||||
|
|
||||||
|
assert len(project.pages) == 3
|
||||||
|
assert project.pages[0] is page1
|
||||||
|
assert project.pages[1] is page_new
|
||||||
|
assert project.pages[2] is page2
|
||||||
424
tests/test_project_serialization.py
Normal file
424
tests/test_project_serialization.py
Normal file
@ -0,0 +1,424 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for project serialization (save/load to ZIP)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import zipfile
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from pyPhotoAlbum.project import Project, Page
|
||||||
|
from pyPhotoAlbum.page_layout import PageLayout
|
||||||
|
from pyPhotoAlbum.models import ImageData, TextBoxData
|
||||||
|
from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip, get_project_info
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_dir():
|
||||||
|
"""Create a temporary directory for testing"""
|
||||||
|
temp_path = tempfile.mkdtemp()
|
||||||
|
yield temp_path
|
||||||
|
# Cleanup
|
||||||
|
if os.path.exists(temp_path):
|
||||||
|
shutil.rmtree(temp_path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_project(temp_dir):
|
||||||
|
"""Create a sample project for testing"""
|
||||||
|
project = Project(name="Test Project", folder_path=os.path.join(temp_dir, "test_project"))
|
||||||
|
project.page_size_mm = (210, 297)
|
||||||
|
project.working_dpi = 300
|
||||||
|
project.export_dpi = 300
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_image(temp_dir):
|
||||||
|
"""Create a sample image file for testing"""
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
# Create a simple test image
|
||||||
|
img = Image.new('RGB', (100, 100), color='red')
|
||||||
|
image_path = os.path.join(temp_dir, "test_image.jpg")
|
||||||
|
img.save(image_path)
|
||||||
|
return image_path
|
||||||
|
|
||||||
|
|
||||||
|
class TestBasicSerialization:
|
||||||
|
"""Tests for basic save/load functionality"""
|
||||||
|
|
||||||
|
def test_save_empty_project(self, sample_project, temp_dir):
|
||||||
|
"""Test saving an empty project to ZIP"""
|
||||||
|
zip_path = os.path.join(temp_dir, "empty_project.ppz")
|
||||||
|
|
||||||
|
success, error = save_to_zip(sample_project, zip_path)
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
assert error is None
|
||||||
|
assert os.path.exists(zip_path)
|
||||||
|
assert zip_path.endswith('.ppz')
|
||||||
|
|
||||||
|
def test_save_adds_ppz_extension(self, sample_project, temp_dir):
|
||||||
|
"""Test that .ppz extension is added automatically"""
|
||||||
|
zip_path = os.path.join(temp_dir, "project")
|
||||||
|
|
||||||
|
success, error = save_to_zip(sample_project, zip_path)
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
expected_path = zip_path + '.ppz'
|
||||||
|
assert os.path.exists(expected_path)
|
||||||
|
|
||||||
|
def test_load_empty_project(self, sample_project, temp_dir):
|
||||||
|
"""Test loading an empty project from ZIP"""
|
||||||
|
zip_path = os.path.join(temp_dir, "empty_project.ppz")
|
||||||
|
save_to_zip(sample_project, zip_path)
|
||||||
|
|
||||||
|
loaded_project, error = load_from_zip(zip_path)
|
||||||
|
|
||||||
|
assert loaded_project is not None
|
||||||
|
assert error is None
|
||||||
|
assert loaded_project.name == "Test Project"
|
||||||
|
assert loaded_project.page_size_mm == (210, 297)
|
||||||
|
assert loaded_project.working_dpi == 300
|
||||||
|
assert len(loaded_project.pages) == 0
|
||||||
|
|
||||||
|
def test_load_nonexistent_file(self, temp_dir):
|
||||||
|
"""Test loading from a non-existent file"""
|
||||||
|
zip_path = os.path.join(temp_dir, "nonexistent.ppz")
|
||||||
|
|
||||||
|
loaded_project, error = load_from_zip(zip_path)
|
||||||
|
|
||||||
|
assert loaded_project is None
|
||||||
|
assert error is not None
|
||||||
|
assert "not found" in error.lower()
|
||||||
|
|
||||||
|
def test_save_project_with_pages(self, sample_project, temp_dir):
|
||||||
|
"""Test saving a project with multiple pages"""
|
||||||
|
# Add pages
|
||||||
|
for i in range(3):
|
||||||
|
layout = PageLayout()
|
||||||
|
page = Page(layout=layout, page_number=i+1)
|
||||||
|
sample_project.add_page(page)
|
||||||
|
|
||||||
|
zip_path = os.path.join(temp_dir, "project_with_pages.ppz")
|
||||||
|
success, error = save_to_zip(sample_project, zip_path)
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
assert os.path.exists(zip_path)
|
||||||
|
|
||||||
|
def test_load_project_with_pages(self, sample_project, temp_dir):
|
||||||
|
"""Test loading a project with multiple pages"""
|
||||||
|
# Add pages
|
||||||
|
for i in range(3):
|
||||||
|
layout = PageLayout()
|
||||||
|
page = Page(layout=layout, page_number=i+1)
|
||||||
|
sample_project.add_page(page)
|
||||||
|
|
||||||
|
# Save and load
|
||||||
|
zip_path = os.path.join(temp_dir, "project_with_pages.ppz")
|
||||||
|
save_to_zip(sample_project, zip_path)
|
||||||
|
loaded_project, error = load_from_zip(zip_path)
|
||||||
|
|
||||||
|
assert loaded_project is not None
|
||||||
|
assert len(loaded_project.pages) == 3
|
||||||
|
assert loaded_project.pages[0].page_number == 1
|
||||||
|
assert loaded_project.pages[2].page_number == 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestZipStructure:
|
||||||
|
"""Tests for ZIP file structure and content"""
|
||||||
|
|
||||||
|
def test_zip_contains_project_json(self, sample_project, temp_dir):
|
||||||
|
"""Test that ZIP contains project.json"""
|
||||||
|
zip_path = os.path.join(temp_dir, "test.ppz")
|
||||||
|
save_to_zip(sample_project, zip_path)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(zip_path, 'r') as zipf:
|
||||||
|
assert 'project.json' in zipf.namelist()
|
||||||
|
|
||||||
|
def test_project_json_is_valid(self, sample_project, temp_dir):
|
||||||
|
"""Test that project.json contains valid JSON"""
|
||||||
|
zip_path = os.path.join(temp_dir, "test.ppz")
|
||||||
|
save_to_zip(sample_project, zip_path)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(zip_path, 'r') as zipf:
|
||||||
|
project_json = zipf.read('project.json').decode('utf-8')
|
||||||
|
data = json.loads(project_json)
|
||||||
|
|
||||||
|
assert 'name' in data
|
||||||
|
assert 'serialization_version' in data
|
||||||
|
assert data['name'] == "Test Project"
|
||||||
|
|
||||||
|
def test_version_in_serialized_data(self, sample_project, temp_dir):
|
||||||
|
"""Test that version information is included"""
|
||||||
|
zip_path = os.path.join(temp_dir, "test.ppz")
|
||||||
|
save_to_zip(sample_project, zip_path)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(zip_path, 'r') as zipf:
|
||||||
|
project_json = zipf.read('project.json').decode('utf-8')
|
||||||
|
data = json.loads(project_json)
|
||||||
|
|
||||||
|
assert 'serialization_version' in data
|
||||||
|
assert data['serialization_version'] == "1.0"
|
||||||
|
|
||||||
|
|
||||||
|
class TestAssetManagement:
|
||||||
|
"""Tests for asset bundling and management"""
|
||||||
|
|
||||||
|
def test_save_project_with_image(self, sample_project, sample_image, temp_dir):
|
||||||
|
"""Test saving a project with an image"""
|
||||||
|
# Import image to project
|
||||||
|
imported_path = sample_project.asset_manager.import_asset(sample_image)
|
||||||
|
|
||||||
|
# Create page with image
|
||||||
|
layout = PageLayout()
|
||||||
|
img_data = ImageData(image_path=imported_path, x=10, y=10, width=100, height=100)
|
||||||
|
layout.add_element(img_data)
|
||||||
|
page = Page(layout=layout, page_number=1)
|
||||||
|
sample_project.add_page(page)
|
||||||
|
|
||||||
|
# Save
|
||||||
|
zip_path = os.path.join(temp_dir, "project_with_image.ppz")
|
||||||
|
success, error = save_to_zip(sample_project, zip_path)
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
assert os.path.exists(zip_path)
|
||||||
|
|
||||||
|
def test_assets_folder_in_zip(self, sample_project, sample_image, temp_dir):
|
||||||
|
"""Test that assets folder is included in ZIP"""
|
||||||
|
# Import image
|
||||||
|
imported_path = sample_project.asset_manager.import_asset(sample_image)
|
||||||
|
|
||||||
|
# Create page with image
|
||||||
|
layout = PageLayout()
|
||||||
|
img_data = ImageData(image_path=imported_path, x=10, y=10, width=100, height=100)
|
||||||
|
layout.add_element(img_data)
|
||||||
|
page = Page(layout=layout, page_number=1)
|
||||||
|
sample_project.add_page(page)
|
||||||
|
|
||||||
|
# Save
|
||||||
|
zip_path = os.path.join(temp_dir, "project_with_image.ppz")
|
||||||
|
save_to_zip(sample_project, zip_path)
|
||||||
|
|
||||||
|
# Check ZIP contents
|
||||||
|
with zipfile.ZipFile(zip_path, 'r') as zipf:
|
||||||
|
names = zipf.namelist()
|
||||||
|
# Should contain assets folder
|
||||||
|
asset_files = [n for n in names if n.startswith('assets/')]
|
||||||
|
assert len(asset_files) > 0
|
||||||
|
|
||||||
|
def test_load_project_with_image(self, sample_project, sample_image, temp_dir):
|
||||||
|
"""Test loading a project with images"""
|
||||||
|
# Import image
|
||||||
|
imported_path = sample_project.asset_manager.import_asset(sample_image)
|
||||||
|
|
||||||
|
# Create page with image
|
||||||
|
layout = PageLayout()
|
||||||
|
img_data = ImageData(image_path=imported_path, x=10, y=10, width=100, height=100)
|
||||||
|
layout.add_element(img_data)
|
||||||
|
page = Page(layout=layout, page_number=1)
|
||||||
|
sample_project.add_page(page)
|
||||||
|
|
||||||
|
# Save and load
|
||||||
|
zip_path = os.path.join(temp_dir, "project_with_image.ppz")
|
||||||
|
save_to_zip(sample_project, zip_path)
|
||||||
|
loaded_project, error = load_from_zip(zip_path)
|
||||||
|
|
||||||
|
assert loaded_project is not None
|
||||||
|
assert len(loaded_project.pages) == 1
|
||||||
|
assert len(loaded_project.pages[0].layout.elements) == 1
|
||||||
|
|
||||||
|
# Verify image element
|
||||||
|
img_element = loaded_project.pages[0].layout.elements[0]
|
||||||
|
assert isinstance(img_element, ImageData)
|
||||||
|
assert img_element.image_path != ""
|
||||||
|
|
||||||
|
def test_asset_reference_counts_preserved(self, sample_project, sample_image, temp_dir):
|
||||||
|
"""Test that asset reference counts are preserved"""
|
||||||
|
# Import image
|
||||||
|
imported_path = sample_project.asset_manager.import_asset(sample_image)
|
||||||
|
|
||||||
|
# Use image twice
|
||||||
|
layout1 = PageLayout()
|
||||||
|
img1 = ImageData(image_path=imported_path, x=10, y=10, width=100, height=100)
|
||||||
|
layout1.add_element(img1)
|
||||||
|
page1 = Page(layout=layout1, page_number=1)
|
||||||
|
sample_project.add_page(page1)
|
||||||
|
|
||||||
|
layout2 = PageLayout()
|
||||||
|
img2 = ImageData(image_path=imported_path, x=20, y=20, width=100, height=100)
|
||||||
|
layout2.add_element(img2)
|
||||||
|
page2 = Page(layout=layout2, page_number=2)
|
||||||
|
sample_project.add_page(page2)
|
||||||
|
|
||||||
|
# Get relative path for reference count check
|
||||||
|
rel_path = os.path.relpath(imported_path, sample_project.folder_path)
|
||||||
|
original_ref_count = sample_project.asset_manager.get_reference_count(rel_path)
|
||||||
|
|
||||||
|
# Save and load
|
||||||
|
zip_path = os.path.join(temp_dir, "project_refs.ppz")
|
||||||
|
save_to_zip(sample_project, zip_path)
|
||||||
|
loaded_project, error = load_from_zip(zip_path)
|
||||||
|
|
||||||
|
assert loaded_project is not None
|
||||||
|
# Reference counts should be preserved
|
||||||
|
# Note: The actual reference counting behavior depends on deserialize implementation
|
||||||
|
|
||||||
|
|
||||||
|
class TestPortability:
|
||||||
|
"""Tests for project portability across different locations"""
|
||||||
|
|
||||||
|
def test_load_to_different_directory(self, sample_project, sample_image, temp_dir):
|
||||||
|
"""Test loading project to a different directory"""
|
||||||
|
# Import image and create page
|
||||||
|
imported_path = sample_project.asset_manager.import_asset(sample_image)
|
||||||
|
layout = PageLayout()
|
||||||
|
img_data = ImageData(image_path=imported_path, x=10, y=10, width=100, height=100)
|
||||||
|
layout.add_element(img_data)
|
||||||
|
page = Page(layout=layout, page_number=1)
|
||||||
|
sample_project.add_page(page)
|
||||||
|
|
||||||
|
# Save
|
||||||
|
zip_path = os.path.join(temp_dir, "portable_project.ppz")
|
||||||
|
save_to_zip(sample_project, zip_path)
|
||||||
|
|
||||||
|
# Load to a different location
|
||||||
|
new_location = os.path.join(temp_dir, "different_location")
|
||||||
|
loaded_project, error = load_from_zip(zip_path, extract_to=new_location)
|
||||||
|
|
||||||
|
assert loaded_project is not None
|
||||||
|
assert loaded_project.folder_path == new_location
|
||||||
|
assert os.path.exists(new_location)
|
||||||
|
|
||||||
|
# Verify assets were extracted
|
||||||
|
assets_folder = os.path.join(new_location, "assets")
|
||||||
|
assert os.path.exists(assets_folder)
|
||||||
|
|
||||||
|
def test_relative_paths_work_after_move(self, sample_project, sample_image, temp_dir):
|
||||||
|
"""Test that relative paths still work after loading to different location"""
|
||||||
|
# Import image
|
||||||
|
imported_path = sample_project.asset_manager.import_asset(sample_image)
|
||||||
|
layout = PageLayout()
|
||||||
|
img_data = ImageData(image_path=imported_path, x=10, y=10, width=100, height=100)
|
||||||
|
layout.add_element(img_data)
|
||||||
|
page = Page(layout=layout, page_number=1)
|
||||||
|
sample_project.add_page(page)
|
||||||
|
|
||||||
|
# Save
|
||||||
|
zip_path = os.path.join(temp_dir, "portable_project.ppz")
|
||||||
|
save_to_zip(sample_project, zip_path)
|
||||||
|
|
||||||
|
# Load to different location
|
||||||
|
new_location = os.path.join(temp_dir, "new_location")
|
||||||
|
loaded_project, error = load_from_zip(zip_path, extract_to=new_location)
|
||||||
|
|
||||||
|
# Verify image path is accessible from new location
|
||||||
|
img_element = loaded_project.pages[0].layout.elements[0]
|
||||||
|
image_path = img_element.image_path
|
||||||
|
|
||||||
|
# Image path should exist
|
||||||
|
# Note: May be absolute or relative depending on implementation
|
||||||
|
if not os.path.isabs(image_path):
|
||||||
|
full_path = os.path.join(loaded_project.folder_path, image_path)
|
||||||
|
assert os.path.exists(full_path)
|
||||||
|
else:
|
||||||
|
assert os.path.exists(image_path)
|
||||||
|
|
||||||
|
|
||||||
|
class TestProjectInfo:
|
||||||
|
"""Tests for get_project_info utility function"""
|
||||||
|
|
||||||
|
def test_get_project_info(self, sample_project, temp_dir):
|
||||||
|
"""Test getting project info without loading"""
|
||||||
|
# Add some pages
|
||||||
|
for i in range(5):
|
||||||
|
layout = PageLayout()
|
||||||
|
page = Page(layout=layout, page_number=i+1)
|
||||||
|
sample_project.add_page(page)
|
||||||
|
|
||||||
|
# Save
|
||||||
|
zip_path = os.path.join(temp_dir, "info_test.ppz")
|
||||||
|
save_to_zip(sample_project, zip_path)
|
||||||
|
|
||||||
|
# Get info
|
||||||
|
info = get_project_info(zip_path)
|
||||||
|
|
||||||
|
assert info is not None
|
||||||
|
assert info['name'] == "Test Project"
|
||||||
|
assert info['page_count'] == 5
|
||||||
|
assert info['version'] == "1.0"
|
||||||
|
assert info['working_dpi'] == 300
|
||||||
|
|
||||||
|
def test_get_info_invalid_zip(self, temp_dir):
|
||||||
|
"""Test getting info from invalid ZIP"""
|
||||||
|
zip_path = os.path.join(temp_dir, "invalid.ppz")
|
||||||
|
|
||||||
|
info = get_project_info(zip_path)
|
||||||
|
|
||||||
|
assert info is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestEdgeCases:
|
||||||
|
"""Tests for edge cases and error handling"""
|
||||||
|
|
||||||
|
def test_save_to_invalid_path(self, sample_project):
|
||||||
|
"""Test saving to an invalid path"""
|
||||||
|
invalid_path = "/nonexistent/directory/project.ppz"
|
||||||
|
|
||||||
|
success, error = save_to_zip(sample_project, invalid_path)
|
||||||
|
|
||||||
|
assert success is False
|
||||||
|
assert error is not None
|
||||||
|
|
||||||
|
def test_load_corrupted_zip(self, temp_dir):
|
||||||
|
"""Test loading a corrupted ZIP file"""
|
||||||
|
# Create a fake corrupted file
|
||||||
|
corrupted_path = os.path.join(temp_dir, "corrupted.ppz")
|
||||||
|
with open(corrupted_path, 'w') as f:
|
||||||
|
f.write("This is not a ZIP file")
|
||||||
|
|
||||||
|
loaded_project, error = load_from_zip(corrupted_path)
|
||||||
|
|
||||||
|
assert loaded_project is None
|
||||||
|
assert error is not None
|
||||||
|
|
||||||
|
def test_load_zip_without_project_json(self, temp_dir):
|
||||||
|
"""Test loading a ZIP without project.json"""
|
||||||
|
zip_path = os.path.join(temp_dir, "no_json.ppz")
|
||||||
|
|
||||||
|
# Create ZIP without project.json
|
||||||
|
with zipfile.ZipFile(zip_path, 'w') as zipf:
|
||||||
|
zipf.writestr('dummy.txt', 'dummy content')
|
||||||
|
|
||||||
|
loaded_project, error = load_from_zip(zip_path)
|
||||||
|
|
||||||
|
assert loaded_project is None
|
||||||
|
assert error is not None
|
||||||
|
assert "project.json not found" in error
|
||||||
|
|
||||||
|
def test_project_with_text_elements(self, sample_project, temp_dir):
|
||||||
|
"""Test saving/loading project with text elements"""
|
||||||
|
# Create page with text
|
||||||
|
layout = PageLayout()
|
||||||
|
text = TextBoxData(
|
||||||
|
text_content="Hello World",
|
||||||
|
x=10, y=10, width=200, height=50
|
||||||
|
)
|
||||||
|
layout.add_element(text)
|
||||||
|
page = Page(layout=layout, page_number=1)
|
||||||
|
sample_project.add_page(page)
|
||||||
|
|
||||||
|
# Save and load
|
||||||
|
zip_path = os.path.join(temp_dir, "with_text.ppz")
|
||||||
|
save_to_zip(sample_project, zip_path)
|
||||||
|
loaded_project, error = load_from_zip(zip_path)
|
||||||
|
|
||||||
|
assert loaded_project is not None
|
||||||
|
assert len(loaded_project.pages) == 1
|
||||||
|
|
||||||
|
text_element = loaded_project.pages[0].layout.elements[0]
|
||||||
|
assert isinstance(text_element, TextBoxData)
|
||||||
|
assert text_element.text_content == "Hello World"
|
||||||
Loading…
x
Reference in New Issue
Block a user