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
# 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
# Install with development dependencies
pip install -e ".[dev]"
Quick Start
Running the Application
pyphotoalbum
Or run directly from source:
python pyPhotoAlbum/main.py
Basic Usage Example
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:
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 for details on the refactoring process.
Core Components
Models (models.py)
Base classes for layout elements:
# 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)
# 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:
# 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:
# 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:
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:
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
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
# 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 structureassets/- All referenced images
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
{
"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
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
# 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:
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:
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
# 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
- Fork the repository
- Create a feature branch
- Write tests for new features
- Ensure all tests pass
- Submit a pull request
Usage Examples
See the examples/ directory for complete working examples:
basic_usage.py- Creating projects and adding imagestemplate_example.py- Working with templatesgenerate_screenshots.py- Creating documentation screenshots
Run examples:
cd examples
python basic_usage.py
API Reference
Key Classes
BaseLayoutElement (Abstract)
Base class for all layout elements.
Methods:
render()- Render element using OpenGLserialize() -> Dict- Convert to dictionarydeserialize(data: Dict)- Load from dictionary
Attributes:
position: Tuple[float, float]- (x, y) in mmsize: Tuple[float, float]- (width, height) in mmrotation: float- Rotation angle in degreesz_index: int- Layer order
ImageData
Image element with crop support.
Constructor:
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:
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:
{
"family": "Arial",
"size": 12,
"color": (0, 0, 0) # RGB tuple
}
Project
Main project container.
Methods:
add_page(page: Page)- Add page to projectremove_page(page: Page)- Remove pageserialize() -> Dict- Save to dictionarydeserialize(data: Dict)- Load from dictionary
Attributes:
name: str- Project namepages: List[Page]- List of pagespage_size_mm: Tuple[float, float]- Page dimensionsworking_dpi: int- Display DPIexport_dpi: int- Export DPIasset_manager: AssetManager- Asset handlerhistory: CommandHistory- Undo/redo history
Page
Single page in project.
Constructor:
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 elementremove_element(element: BaseLayoutElement)- Remove elementrender(dpi: int)- Render all elements
Attributes:
elements: List[BaseLayoutElement]- Page elementswidth: float- Page width in mmheight: float- Page height in mm
Keyboard Shortcuts
Ctrl+Z- UndoCtrl+Y- RedoCtrl+S- Save projectCtrl+O- Open projectCtrl+N- New projectCtrl+E- Export to PDFDelete- Delete selected elementArrow Keys- Move selected elementShift+Arrow Keys- Resize selected elementCtrl+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