first commit
Some checks failed
Lint / lint (push) Failing after 2m46s
Tests / test (3.11) (push) Has been cancelled
Tests / test (3.9) (push) Has been cancelled
Tests / test (3.10) (push) Has been cancelled

This commit is contained in:
Duncan Tourolle 2025-10-21 22:02:49 +02:00
commit 46585228fd
50 changed files with 12567 additions and 0 deletions

38
.gitea/workflows/lint.yml Normal file
View 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

View 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
View 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
View 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
View 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
View 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()

View 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()

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

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

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

File diff suppressed because it is too large Load Diff

180
pyPhotoAlbum/main.py Normal file
View 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()

View File

@ -0,0 +1,7 @@
"""
Mixin modules for pyPhotoAlbum
"""
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
__all__ = ['ApplicationStateMixin']

174
pyPhotoAlbum/mixins/base.py Normal file
View 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()

View 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',
]

View 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)

View 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)

View 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°")

View 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}")

View 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)

View 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")

View 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)

View 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}")

View 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
View 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
View 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", [])

View 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)

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

View 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

View 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

View 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()

View 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
View 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))

View 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

View 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": ""
}
]
}

View 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
View 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
View File

@ -0,0 +1,3 @@
"""
Unit tests for pyPhotoAlbum
"""

105
tests/conftest.py Normal file
View 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
View 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
View 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
View 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
View 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

View 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"