716 lines
18 KiB
Markdown
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
|