# 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 ### 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