pyPhotoAlbum/README.md
2025-11-11 16:02:02 +00:00

716 lines
18 KiB
Markdown

# 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
### Quick Install (Linux)
```bash
# Clone the repository
git clone https://gitea.tourolle.paris/dtourolle/pyPhotoAlbum.git
cd pyPhotoAlbum
# Run automated installer
./install.sh
```
**For GNOME users:** See [GNOME_QUICKSTART.md](GNOME_QUICKSTART.md) for desktop integration.
**For detailed instructions:** See [INSTALLATION.md](INSTALLATION.md)
### Requirements
- Python 3.9 or higher
- PyQt6
- PyOpenGL
- Pillow
- ReportLab
- lxml
### Distribution Packages
**Fedora (RPM):**
```bash
rpmbuild -ba pyphotoalbum.spec
sudo dnf install ~/rpmbuild/RPMS/noarch/pyphotoalbum-*.rpm
```
**Arch/CachyOS (PKGBUILD):**
```bash
makepkg -si
```
See [INSTALLATION.md](INSTALLATION.md) for complete instructions.
### Install for Development
```bash
# Clone repository
git clone https://gitea.tourolle.paris/dtourolle/pyPhotoAlbum.git
cd pyPhotoAlbum
# Create virtual environment
python -m venv venv
source venv/bin/activate
# Install with development dependencies
pip install -e ".[dev]"
```
## Quick Start
### Running the Application
```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
### GLWidget Mixin Architecture
The main OpenGL widget uses a **mixin-based architecture** for maintainability and testability. The monolithic 1,368-line `gl_widget.py` has been refactored into 9 focused mixins averaging 89 lines each:
```python
class GLWidget(
ViewportMixin, # Zoom & pan state
RenderingMixin, # OpenGL rendering
AssetDropMixin, # Drag-and-drop
PageNavigationMixin, # Page detection
ImagePanMixin, # Image cropping
ElementManipulationMixin, # Resize & rotate
ElementSelectionMixin, # Hit detection
MouseInteractionMixin, # Event routing
UndoableInteractionMixin, # Undo/redo
QOpenGLWidget # Qt base class
):
"""Clean orchestration with minimal boilerplate"""
```
**Benefits:**
- Each mixin has a single, clear responsibility
- 89 comprehensive unit tests with 69-97% coverage per mixin
- Easy to test in isolation with mock dependencies
- Clear separation of concerns
- Maintainable codebase (average 89 lines per mixin)
See [REFACTORING_COMPLETE.md](REFACTORING_COMPLETE.md) for details on the refactoring process.
### Core Components
#### Models (`models.py`)
Base classes for layout elements:
```python
# Image element with crop support
image = ImageData(
image_path="photo.jpg",
x=10, y=20,
width=200, height=150,
rotation=0,
z_index=0,
crop_info=(0, 0, 1, 1) # (x_min, y_min, x_max, y_max)
)
# Text box element
textbox = TextBoxData(
text_content="My Caption",
font_settings={"family": "Arial", "size": 14, "color": (0, 0, 0)},
alignment="center",
x=10, y=180,
width=200, height=30
)
# Placeholder for templates
placeholder = PlaceholderData(
placeholder_type="image",
x=10, y=10,
width=100, height=100
)
```
#### Project Structure (`project.py`)
```python
# Project contains multiple pages
project = Project(name="Album", folder_path="/path/to/project")
# Each page has a layout with elements
page = Page(layout=PageLayout(), page_number=1)
page.layout.add_element(image)
project.add_page(page)
```
#### Asset Management (`asset_manager.py`)
Automatic asset handling with reference counting:
```python
# Import an image into the project
asset_path = project.asset_manager.import_asset("photo.jpg")
# Returns: "assets/photo_001.jpg" (relative path)
# Assets are automatically copied to project folder
# Reference counting tracks usage across pages
# Cleanup happens automatically when elements are deleted
```
#### Command System (`commands.py`)
Undo/redo support for all operations:
```python
# Commands are automatically created for operations
from pyPhotoAlbum.commands import AddElementCommand, MoveElementCommand
# Add element (undoable)
cmd = AddElementCommand(page.layout, image, project.asset_manager)
project.history.execute(cmd)
# Move element (undoable)
cmd = MoveElementCommand(image, old_pos=(10, 10), new_pos=(20, 20))
project.history.execute(cmd)
# Undo/redo
project.history.undo()
project.history.redo()
```
### Layout System
#### PageLayout (`page_layout.py`)
Manages elements on a page:
```python
layout = PageLayout(width=210, height=297) # A4 in mm
# Add multiple elements
layout.add_element(image1)
layout.add_element(image2)
layout.add_element(textbox)
# Elements are rendered in z_index order
# Serialize/deserialize for saving
data = layout.serialize()
layout2 = PageLayout()
layout2.deserialize(data)
```
#### Alignment Tools (`alignment.py`)
Precise element positioning:
```python
from pyPhotoAlbum.alignment import AlignmentManager
# Align multiple elements to the left
changes = AlignmentManager.align_left(selected_elements)
for element, new_position in changes:
element.position = new_position
# Distribute elements evenly
changes = AlignmentManager.distribute_horizontally(selected_elements)
# Make elements the same size
changes = AlignmentManager.make_same_size(selected_elements)
for element, new_position, new_size in changes:
element.position = new_position
element.size = new_size
```
### Template System
#### Creating Templates
```python
from pyPhotoAlbum.template_manager import TemplateManager, Template
manager = TemplateManager()
# Create template from existing page
template = manager.create_template_from_page(
page=current_page,
name="My Grid Layout",
description="2x2 photo grid"
)
# Save template
manager.save_template(template)
```
#### Using Templates
```python
# List available templates
templates = manager.list_templates()
# Returns: ["Grid_2x2", "Single_Large", "My Grid Layout", ...]
# Create new page from template
new_page = manager.create_page_from_template(
template_name="Grid_2x2",
target_page_size=(210, 297),
page_number=5
)
# Apply template to existing page
manager.apply_template_to_page(
template=template,
target_page=existing_page,
mode="replace", # or "reflow"
scaling="proportional" # or "stretch" or "center"
)
```
### Project Serialization
#### Save/Load Projects
Projects are saved as ZIP archives (.ppz) containing:
- `project.json` - Project metadata and structure
- `assets/` - All referenced images
```python
from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip
# Save project
success, error = save_to_zip(project, "album.ppz")
if not success:
print(f"Error saving: {error}")
# Load project
loaded_project, error = load_from_zip("album.ppz")
if loaded_project:
print(f"Loaded: {loaded_project.name}")
else:
print(f"Error loading: {error}")
# Get project info without loading
from pyPhotoAlbum.project_serializer import get_project_info
info = get_project_info("album.ppz")
print(f"Name: {info['name']}, Pages: {info['page_count']}")
```
#### Project Structure
```python
{
"name": "My Album",
"serialization_version": "1.0",
"page_size_mm": [210, 297],
"working_dpi": 300,
"export_dpi": 300,
"pages": [
{
"page_number": 1,
"is_double_spread": false,
"layout": {
"width": 210,
"height": 297,
"elements": [...]
}
}
]
}
```
### PDF Export
```python
from pyPhotoAlbum.pdf_exporter import PDFExporter
# Create exporter
exporter = PDFExporter(project, export_dpi=300)
# Export with progress callback
def progress_callback(current, total):
print(f"Exporting page {current}/{total}")
success, errors = exporter.export(
output_path="album.pdf",
progress_callback=progress_callback
)
if success:
print("PDF exported successfully")
else:
print(f"Errors: {errors}")
```
## Testing
### Run Tests
```bash
# Run all tests
pytest
# Run with coverage
pytest --cov=pyPhotoAlbum --cov-report=html
# Run specific test file
pytest tests/test_models.py
# Run with verbose output
pytest -v
```
### Test Structure
```
tests/
├── __init__.py
├── conftest.py # Shared fixtures
├── test_models.py # Model serialization tests
├── test_project.py # Project and page tests
├── test_project_serialization.py # Save/load tests
├── test_page_renderer.py # Rendering tests
├── test_pdf_export.py # PDF export tests
├── test_gl_widget_fixtures.py # Shared GL widget test fixtures
├── test_viewport_mixin.py # Viewport mixin tests
├── test_element_selection_mixin.py # Selection mixin tests
├── test_element_manipulation_mixin.py # Manipulation mixin tests
├── test_image_pan_mixin.py # Image pan mixin tests
├── test_page_navigation_mixin.py # Page navigation mixin tests
└── test_asset_drop_mixin.py # Asset drop mixin tests
```
### Example Test Cases
From `tests/test_models.py`:
```python
def test_image_serialization():
"""Test ImageData serialization"""
img = ImageData(
image_path="test.jpg",
x=15.0, y=25.0,
width=180.0, height=120.0,
rotation=30.0,
z_index=3
)
# Serialize
data = img.serialize()
assert data["type"] == "image"
assert data["position"] == (15.0, 25.0)
# Deserialize
img2 = ImageData()
img2.deserialize(data)
assert img2.position == img.position
```
From `tests/test_project_serialization.py`:
```python
def test_save_and_load_project(tmp_path):
"""Test complete save/load cycle"""
# Create project with pages
project = Project(name="Test")
page = Page(layout=PageLayout(), page_number=1)
project.add_page(page)
# Save
zip_path = tmp_path / "project.ppz"
success, error = save_to_zip(project, str(zip_path))
assert success is True
# Load
loaded, error = load_from_zip(str(zip_path))
assert loaded.name == "Test"
assert len(loaded.pages) == 1
```
## Project Structure
```
pyPhotoAlbum/
├── __init__.py
├── main.py # Application entry point
├── models.py # Data models (ImageData, TextBoxData, etc.)
├── project.py # Project and Page classes
├── page_layout.py # Page layout management
├── page_renderer.py # OpenGL rendering
├── gl_widget.py # Main OpenGL widget (mixin orchestration)
├── project_serializer.py # Save/load functionality
├── asset_manager.py # Asset handling
├── commands.py # Undo/redo system
├── template_manager.py # Template system
├── pdf_exporter.py # PDF export
├── alignment.py # Alignment tools
├── snapping.py # Snapping system
├── decorators.py # UI decorators
├── ribbon_widget.py # Ribbon interface
├── ribbon_builder.py # Ribbon configuration
├── mixins/ # Mixin architecture
│ ├── __init__.py
│ ├── base.py # Base mixin class
│ ├── viewport.py # Zoom and pan management
│ ├── rendering.py # OpenGL rendering pipeline
│ ├── asset_drop.py # Drag-and-drop functionality
│ ├── page_navigation.py # Page detection and ghost pages
│ ├── image_pan.py # Image cropping within frames
│ ├── element_manipulation.py # Resize and rotate
│ ├── element_selection.py # Hit detection and selection
│ ├── mouse_interaction.py # Mouse event coordination
│ ├── interaction_undo.py # Undo/redo integration
│ └── operations/ # Operation mixins
│ ├── element_ops.py
│ ├── page_ops.py
│ ├── file_ops.py
│ ├── view_ops.py
│ ├── edit_ops.py
│ ├── template_ops.py
│ ├── alignment_ops.py
│ ├── distribution_ops.py
│ └── size_ops.py
└── templates/ # Built-in templates
├── Grid_2x2.json
└── Single_Large.json
tests/ # Unit tests (312 tests, 29% coverage)
examples/ # Usage examples
```
## Development
### Code Style
The project uses:
- **Black** for code formatting (line length: 120)
- **Flake8** for linting
- **MyPy** for type checking
```bash
# Format code
black pyPhotoAlbum tests
# Run linter
flake8 pyPhotoAlbum tests
# Type checking
mypy pyPhotoAlbum
```
### Continuous Integration
GitHub Actions / Gitea Actions workflows:
- Run tests on Python 3.9, 3.10, 3.11
- Check code quality with linters
- Generate coverage reports
### Contributing
1. Fork the repository
2. Create a feature branch
3. Write tests for new features
4. Ensure all tests pass
5. Submit a pull request
## Usage Examples
See the `examples/` directory for complete working examples:
- `basic_usage.py` - Creating projects and adding images
- `template_example.py` - Working with templates
- `generate_screenshots.py` - Creating documentation screenshots
Run examples:
```bash
cd examples
python basic_usage.py
```
## API Reference
### Key Classes
#### BaseLayoutElement (Abstract)
Base class for all layout elements.
**Methods:**
- `render()` - Render element using OpenGL
- `serialize() -> Dict` - Convert to dictionary
- `deserialize(data: Dict)` - Load from dictionary
**Attributes:**
- `position: Tuple[float, float]` - (x, y) in mm
- `size: Tuple[float, float]` - (width, height) in mm
- `rotation: float` - Rotation angle in degrees
- `z_index: int` - Layer order
#### ImageData
Image element with crop support.
**Constructor:**
```python
ImageData(
image_path: str = "",
crop_info: Tuple = (0, 0, 1, 1),
x: float = 0,
y: float = 0,
width: float = 100,
height: float = 100,
rotation: float = 0,
z_index: int = 0
)
```
#### TextBoxData
Text element with formatting.
**Constructor:**
```python
TextBoxData(
text_content: str = "",
font_settings: Dict = None,
alignment: str = "left",
x: float = 0,
y: float = 0,
width: float = 100,
height: float = 100
)
```
**Font Settings:**
```python
{
"family": "Arial",
"size": 12,
"color": (0, 0, 0) # RGB tuple
}
```
#### Project
Main project container.
**Methods:**
- `add_page(page: Page)` - Add page to project
- `remove_page(page: Page)` - Remove page
- `serialize() -> Dict` - Save to dictionary
- `deserialize(data: Dict)` - Load from dictionary
**Attributes:**
- `name: str` - Project name
- `pages: List[Page]` - List of pages
- `page_size_mm: Tuple[float, float]` - Page dimensions
- `working_dpi: int` - Display DPI
- `export_dpi: int` - Export DPI
- `asset_manager: AssetManager` - Asset handler
- `history: CommandHistory` - Undo/redo history
#### Page
Single page in project.
**Constructor:**
```python
Page(
layout: PageLayout = None,
page_number: int = 1,
is_double_spread: bool = False
)
```
#### PageLayout
Element container for a page.
**Methods:**
- `add_element(element: BaseLayoutElement)` - Add element
- `remove_element(element: BaseLayoutElement)` - Remove element
- `render(dpi: int)` - Render all elements
**Attributes:**
- `elements: List[BaseLayoutElement]` - Page elements
- `width: float` - Page width in mm
- `height: float` - Page height in mm
## 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