Improved undo-redo system
All checks were successful
Python CI / test (push) Successful in 55s
Lint / lint (push) Successful in 1m4s
Tests / test (3.10) (push) Successful in 42s
Tests / test (3.11) (push) Successful in 45s
Tests / test (3.9) (push) Successful in 41s

This commit is contained in:
Duncan Tourolle 2025-10-28 21:03:30 +01:00
parent ddcfada636
commit 5257c6654a
15 changed files with 1020 additions and 1418 deletions

View File

@ -1,90 +0,0 @@
# 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

View File

@ -1,426 +0,0 @@
#!/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

@ -1,427 +0,0 @@
#!/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

@ -1,437 +0,0 @@
#!/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()

View File

@ -480,6 +480,117 @@ class ResizeElementsCommand(Command):
return ResizeElementsCommand(changes)
class ChangeZOrderCommand(Command):
"""Command for changing element z-order (list position)"""
def __init__(self, page_layout, element: BaseLayoutElement, old_index: int, new_index: int):
self.page_layout = page_layout
self.element = element
self.old_index = old_index
self.new_index = new_index
def execute(self):
"""Move element to new position in list"""
elements = self.page_layout.elements
if self.element in elements:
elements.remove(self.element)
elements.insert(self.new_index, self.element)
def undo(self):
"""Move element back to old position in list"""
elements = self.page_layout.elements
if self.element in elements:
elements.remove(self.element)
elements.insert(self.old_index, self.element)
def redo(self):
"""Move element to new position again"""
self.execute()
def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary"""
return {
"type": "change_zorder",
"element": self.element.serialize(),
"old_index": self.old_index,
"new_index": self.new_index
}
@staticmethod
def deserialize(data: Dict[str, Any], project) -> 'ChangeZOrderCommand':
"""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 ChangeZOrderCommand(
None, # page_layout will be set by CommandHistory
element,
data["old_index"],
data["new_index"]
)
class StateChangeCommand(Command):
"""
Generic command for operations that change state.
This command captures before/after snapshots of state and can restore them.
Used by the @undoable_operation decorator.
"""
def __init__(self, description: str, restore_func, before_state: Any, after_state: Any = None):
"""
Args:
description: Human-readable description of the operation
restore_func: Function to restore state: restore_func(state)
before_state: State before the operation
after_state: State after the operation (captured during execute)
"""
self.description = description
self.restore_func = restore_func
self.before_state = before_state
self.after_state = after_state
def execute(self):
"""State is already applied, just store after_state if not set"""
# After state is captured by decorator after operation runs
pass
def undo(self):
"""Restore to before state"""
self.restore_func(self.before_state)
def redo(self):
"""Restore to after state"""
self.restore_func(self.after_state)
def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary"""
# For now, state change commands are not serialized
# This could be enhanced later if needed
return {
"type": "state_change",
"description": self.description
}
@staticmethod
def deserialize(data: Dict[str, Any], project) -> 'StateChangeCommand':
"""Deserialize from dictionary"""
# Not implemented - would need to serialize state
raise NotImplementedError("StateChangeCommand deserialization not yet supported")
class CommandHistory:
"""Manages undo/redo command history"""
@ -604,6 +715,8 @@ class CommandHistory:
return AlignElementsCommand.deserialize(data, project)
elif cmd_type == "resize_elements":
return ResizeElementsCommand.deserialize(data, project)
elif cmd_type == "change_zorder":
return ChangeZOrderCommand.deserialize(data, project)
else:
print(f"Warning: Unknown command type: {cmd_type}")
return None

View File

@ -72,8 +72,12 @@ class RibbonAction:
Returns:
The decorated function with metadata attached
"""
# Store metadata on function
func._ribbon_action = {
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
# Store metadata on wrapper function
wrapper._ribbon_action = {
'label': self.label,
'tooltip': self.tooltip,
'tab': self.tab,
@ -86,10 +90,6 @@ class RibbonAction:
'min_selection': self.min_selection
}
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@ -174,15 +174,15 @@ class NumericalInput:
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)
# Store metadata on wrapper function
wrapper._numerical_input = {
'fields': self.fields
}
return wrapper
@ -200,3 +200,129 @@ def numerical_input(fields: list) -> Callable:
NumericalInput decorator instance
"""
return NumericalInput(fields=fields)
class UndoableOperation:
"""
Decorator to automatically create undo/redo commands for operations.
This decorator captures state before and after an operation, then creates
a StateChangeCommand for undo/redo functionality.
Example:
@undoable_operation(capture='page_elements')
def apply_template(self):
# Just implement the operation
self.template_manager.apply_template(...)
# Decorator handles undo/redo automatically
"""
def __init__(self, capture: str = 'page_elements', description: str = None):
"""
Initialize the undoable operation decorator.
Args:
capture: What to capture for undo/redo:
- 'page_elements': Capture elements of current page
- 'custom': Operation provides its own capture logic
description: Human-readable description (defaults to function name)
"""
self.capture = capture
self.description = description
def __call__(self, func: Callable) -> Callable:
"""
Decorate the function with automatic undo/redo.
Args:
func: The function to decorate
Returns:
The decorated function
"""
@wraps(func)
def wrapper(self_instance, *args, **kwargs):
# Get description
description = self.description or func.__name__.replace('_', ' ').title()
# Capture before state
before_state = self._capture_state(self_instance, self.capture)
# Execute the operation
result = func(self_instance, *args, **kwargs)
# Capture after state
after_state = self._capture_state(self_instance, self.capture)
# Create restore function
def restore_state(state):
self._restore_state(self_instance, self.capture, state)
# Update view after restoring
if hasattr(self_instance, 'update_view'):
self_instance.update_view()
# Create and execute command
from pyPhotoAlbum.commands import StateChangeCommand
cmd = StateChangeCommand(description, restore_state, before_state, after_state)
if hasattr(self_instance, 'project') and hasattr(self_instance.project, 'history'):
self_instance.project.history.execute(cmd)
print(f"Undoable operation '{description}' executed")
return result
return wrapper
def _capture_state(self, instance, capture_type: str):
"""Capture current state based on capture type"""
if capture_type == 'page_elements':
# Capture elements from current page
current_page = instance.get_current_page() if hasattr(instance, 'get_current_page') else None
if current_page:
# Deep copy elements
import copy
return [copy.deepcopy(elem.serialize()) for elem in current_page.layout.elements]
return []
return None
def _restore_state(self, instance, capture_type: str, state):
"""Restore state based on capture type"""
if capture_type == 'page_elements':
# Restore elements to current page
current_page = instance.get_current_page() if hasattr(instance, 'get_current_page') else None
if current_page and state is not None:
# Clear existing elements
current_page.layout.elements.clear()
# Restore elements from serialized state
from pyPhotoAlbum.models import ImageData, PlaceholderData, TextBoxData
for elem_data in state:
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)
current_page.layout.add_element(elem)
def undoable_operation(capture: str = 'page_elements', description: str = None) -> Callable:
"""
Convenience function for the UndoableOperation decorator.
This provides a lowercase function-style interface to the decorator.
Args:
capture: What to capture for undo/redo
description: Human-readable description of the operation
Returns:
UndoableOperation decorator instance
"""
return UndoableOperation(capture=capture, description=description)

View File

@ -7,9 +7,10 @@ from PyQt6.QtCore import Qt
from OpenGL.GL import *
from pyPhotoAlbum.models import ImageData, PlaceholderData
from pyPhotoAlbum.commands import AddElementCommand
from pyPhotoAlbum.mixins.interaction_undo import UndoableInteractionMixin
class GLWidget(QOpenGLWidget):
class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
"""OpenGL widget for rendering pages and handling user interaction"""
def __init__(self, parent=None):
@ -427,7 +428,8 @@ class GLWidget(QOpenGLWidget):
if len(self.selected_elements) == 1 and self.selected_element:
if self.rotation_mode:
# In rotation mode, start rotation
# In rotation mode, start rotation tracking
self._begin_rotate(self.selected_element)
self.drag_start_pos = (x, y)
self.rotation_start_angle = self.selected_element.rotation
self.is_dragging = True
@ -436,6 +438,7 @@ class GLWidget(QOpenGLWidget):
# In normal mode, check for resize handles
handle = self._get_resize_handle_at(x, y)
if handle:
self._begin_resize(self.selected_element)
self.resize_handle = handle
self.drag_start_pos = (x, y)
self.resize_start_pos = self.selected_element.position
@ -455,6 +458,7 @@ class GLWidget(QOpenGLWidget):
self.drag_start_pos = (x, y)
self.drag_start_element_pos = element.position
if not self.rotation_mode:
self._begin_move(element)
self.is_dragging = True
else:
if not ctrl_pressed:
@ -579,28 +583,14 @@ class GLWidget(QOpenGLWidget):
def mouseReleaseEvent(self, event):
"""Handle mouse release events"""
if event.button() == Qt.MouseButton.LeftButton:
# If we were rotating, create a RotateElementCommand for undo/redo
if self.rotation_mode and self.rotation_start_angle is not None and self.selected_element:
from pyPhotoAlbum.commands import RotateElementCommand
new_angle = self.selected_element.rotation
if abs(new_angle - self.rotation_start_angle) > 0.1: # Only create command if angle changed
main_window = self.window()
if hasattr(main_window, 'project'):
cmd = RotateElementCommand(
self.selected_element,
self.rotation_start_angle,
new_angle
)
main_window.project.history.execute(cmd)
print(f"Rotation command created: {self.rotation_start_angle:.1f}° → {new_angle:.1f}°")
self.rotation_start_angle = None
# End any active interaction - mixin will create appropriate undo/redo command
self._end_interaction()
self.is_dragging = False
self.drag_start_pos = None
self.drag_start_element_pos = None
self.resize_handle = None
self.rotation_start_angle = None
self.snap_state = {
'is_snapped': False,
'last_position': None,
@ -686,9 +676,8 @@ class GLWidget(QOpenGLWidget):
# Convert screen coordinates to page-local coordinates
page_x, page_y = renderer.screen_to_page(x, y)
# Check elements in this page (highest z-index first)
elements = sorted(page.layout.elements, key=lambda e: e.z_index, reverse=True)
for element in elements:
# Check elements in this page (highest in list = on top, so check in reverse)
for element in reversed(page.layout.elements):
ex, ey = element.position
ew, eh = element.size

View File

@ -32,6 +32,7 @@ from pyPhotoAlbum.mixins.operations import (
AlignmentOperationsMixin,
DistributionOperationsMixin,
SizeOperationsMixin,
ZOrderOperationsMixin,
)
@ -47,6 +48,7 @@ class MainWindow(
AlignmentOperationsMixin,
DistributionOperationsMixin,
SizeOperationsMixin,
ZOrderOperationsMixin,
):
"""
Main application window using mixin architecture.

View File

@ -0,0 +1,162 @@
"""
Mixin for automatic undo/redo handling in interactive mouse operations
"""
from typing import Optional
from pyPhotoAlbum.models import BaseLayoutElement
class UndoableInteractionMixin:
"""
Mixin providing automatic undo/redo for interactive mouse operations.
This mixin tracks the state of elements before interactive operations
(move, resize, rotate) and automatically creates appropriate Command
objects when the interaction completes.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Interaction tracking state
self._interaction_element: Optional[BaseLayoutElement] = None
self._interaction_type: Optional[str] = None
self._interaction_start_pos: Optional[tuple] = None
self._interaction_start_size: Optional[tuple] = None
self._interaction_start_rotation: Optional[float] = None
def _begin_move(self, element: BaseLayoutElement):
"""
Begin tracking a move operation.
Args:
element: The element being moved
"""
self._interaction_element = element
self._interaction_type = 'move'
self._interaction_start_pos = element.position
self._interaction_start_size = None
self._interaction_start_rotation = None
def _begin_resize(self, element: BaseLayoutElement):
"""
Begin tracking a resize operation.
Args:
element: The element being resized
"""
self._interaction_element = element
self._interaction_type = 'resize'
self._interaction_start_pos = element.position
self._interaction_start_size = element.size
self._interaction_start_rotation = None
def _begin_rotate(self, element: BaseLayoutElement):
"""
Begin tracking a rotate operation.
Args:
element: The element being rotated
"""
self._interaction_element = element
self._interaction_type = 'rotate'
self._interaction_start_pos = None
self._interaction_start_size = None
self._interaction_start_rotation = element.rotation
def _end_interaction(self):
"""
End the current interaction and create appropriate undo/redo command.
This method checks what changed during the interaction and creates
the appropriate Command object (MoveElementCommand, ResizeElementCommand,
or RotateElementCommand).
"""
if not self._interaction_element or not self._interaction_type:
self._clear_interaction_state()
return
element = self._interaction_element
# Get main window to access project history
main_window = self.window()
if not hasattr(main_window, 'project'):
self._clear_interaction_state()
return
# Create appropriate command based on interaction type
command = None
if self._interaction_type == 'move':
# Check if position actually changed
new_pos = element.position
if self._interaction_start_pos and new_pos != self._interaction_start_pos:
# Check for significant change (> 0.1 units)
dx = abs(new_pos[0] - self._interaction_start_pos[0])
dy = abs(new_pos[1] - self._interaction_start_pos[1])
if dx > 0.1 or dy > 0.1:
from pyPhotoAlbum.commands import MoveElementCommand
command = MoveElementCommand(
element,
self._interaction_start_pos,
new_pos
)
print(f"Move command created: {self._interaction_start_pos}{new_pos}")
elif self._interaction_type == 'resize':
# Check if position or size actually changed
new_pos = element.position
new_size = element.size
if self._interaction_start_pos and self._interaction_start_size:
pos_changed = new_pos != self._interaction_start_pos
size_changed = new_size != self._interaction_start_size
if pos_changed or size_changed:
# Check for significant change
dx = abs(new_pos[0] - self._interaction_start_pos[0])
dy = abs(new_pos[1] - self._interaction_start_pos[1])
dw = abs(new_size[0] - self._interaction_start_size[0])
dh = abs(new_size[1] - self._interaction_start_size[1])
if dx > 0.1 or dy > 0.1 or dw > 0.1 or dh > 0.1:
from pyPhotoAlbum.commands import ResizeElementCommand
command = ResizeElementCommand(
element,
self._interaction_start_pos,
self._interaction_start_size,
new_pos,
new_size
)
print(f"Resize command created: {self._interaction_start_size}{new_size}")
elif self._interaction_type == 'rotate':
# Check if rotation actually changed
new_rotation = element.rotation
if self._interaction_start_rotation is not None:
if abs(new_rotation - self._interaction_start_rotation) > 0.1:
from pyPhotoAlbum.commands import RotateElementCommand
command = RotateElementCommand(
element,
self._interaction_start_rotation,
new_rotation
)
print(f"Rotation command created: {self._interaction_start_rotation:.1f}° → {new_rotation:.1f}°")
# Execute the command through history if one was created
if command:
main_window.project.history.execute(command)
# Clear interaction state
self._clear_interaction_state()
def _clear_interaction_state(self):
"""Clear all interaction tracking state"""
self._interaction_element = None
self._interaction_type = None
self._interaction_start_pos = None
self._interaction_start_size = None
self._interaction_start_rotation = None
def _cancel_interaction(self):
"""Cancel the current interaction without creating a command"""
self._clear_interaction_state()

View File

@ -11,6 +11,7 @@ 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
from pyPhotoAlbum.mixins.operations.zorder_ops import ZOrderOperationsMixin
__all__ = [
'FileOperationsMixin',
@ -22,4 +23,5 @@ __all__ = [
'AlignmentOperationsMixin',
'DistributionOperationsMixin',
'SizeOperationsMixin',
'ZOrderOperationsMixin',
]

View File

@ -48,6 +48,7 @@ class EditOperationsMixin:
tooltip="Delete selected element (Delete key)",
tab="Home",
group="Edit",
shortcut="Delete",
requires_selection=True
)
def delete_selected_element(self):

View File

@ -6,7 +6,7 @@ from PyQt6.QtWidgets import (
QInputDialog, QDialog, QVBoxLayout, QLabel, QComboBox,
QRadioButton, QButtonGroup, QPushButton, QHBoxLayout
)
from pyPhotoAlbum.decorators import ribbon_action
from pyPhotoAlbum.decorators import ribbon_action, undoable_operation
class TemplateOperationsMixin:
@ -137,6 +137,7 @@ class TemplateOperationsMixin:
group="Templates",
requires_page=True
)
@undoable_operation(capture='page_elements', description='Apply Template')
def apply_template_to_page(self):
"""Apply a template to the current page"""
current_page = self.get_current_page()

View File

@ -0,0 +1,199 @@
"""
Z-order operations mixin for pyPhotoAlbum
"""
from pyPhotoAlbum.decorators import ribbon_action
from pyPhotoAlbum.commands import ChangeZOrderCommand
class ZOrderOperationsMixin:
"""Mixin providing z-order/layer control operations"""
@ribbon_action(
label="Bring to Front",
tooltip="Bring selected element to front",
tab="Arrange",
group="Order",
shortcut="Ctrl+Shift+]",
requires_selection=True
)
def bring_to_front(self):
"""Bring selected element to front (end of list)"""
if not self.gl_widget.selected_element:
return
current_page = self.get_current_page()
if not current_page:
return
element = self.gl_widget.selected_element
elements = current_page.layout.elements
if element not in elements:
return
old_index = elements.index(element)
new_index = len(elements) - 1
if old_index == new_index:
self.show_status("Element is already at front", 2000)
return
# Create and execute command
cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index)
self.project.history.execute(cmd)
self.update_view()
self.show_status("Brought element to front (Ctrl+Z to undo)", 2000)
print(f"Brought element to front: {old_index}{new_index}")
@ribbon_action(
label="Send to Back",
tooltip="Send selected element to back",
tab="Arrange",
group="Order",
shortcut="Ctrl+Shift+[",
requires_selection=True
)
def send_to_back(self):
"""Send selected element to back (start of list)"""
if not self.gl_widget.selected_element:
return
current_page = self.get_current_page()
if not current_page:
return
element = self.gl_widget.selected_element
elements = current_page.layout.elements
if element not in elements:
return
old_index = elements.index(element)
new_index = 0
if old_index == new_index:
self.show_status("Element is already at back", 2000)
return
# Create and execute command
cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index)
self.project.history.execute(cmd)
self.update_view()
self.show_status("Sent element to back (Ctrl+Z to undo)", 2000)
print(f"Sent element to back: {old_index}{new_index}")
@ribbon_action(
label="Bring Forward",
tooltip="Bring selected element forward one layer",
tab="Arrange",
group="Order",
shortcut="Ctrl+]",
requires_selection=True
)
def bring_forward(self):
"""Move selected element forward one position in list"""
if not self.gl_widget.selected_element:
return
current_page = self.get_current_page()
if not current_page:
return
element = self.gl_widget.selected_element
elements = current_page.layout.elements
if element not in elements:
return
old_index = elements.index(element)
new_index = old_index + 1
if new_index >= len(elements):
self.show_status("Element is already at front", 2000)
return
# Create and execute command
cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index)
self.project.history.execute(cmd)
self.update_view()
self.show_status("Brought element forward (Ctrl+Z to undo)", 2000)
print(f"Brought element forward: {old_index}{new_index}")
@ribbon_action(
label="Send Backward",
tooltip="Send selected element backward one layer",
tab="Arrange",
group="Order",
shortcut="Ctrl+[",
requires_selection=True
)
def send_backward(self):
"""Move selected element backward one position in list"""
if not self.gl_widget.selected_element:
return
current_page = self.get_current_page()
if not current_page:
return
element = self.gl_widget.selected_element
elements = current_page.layout.elements
if element not in elements:
return
old_index = elements.index(element)
new_index = old_index - 1
if new_index < 0:
self.show_status("Element is already at back", 2000)
return
# Create and execute command
cmd = ChangeZOrderCommand(current_page.layout, element, old_index, new_index)
self.project.history.execute(cmd)
self.update_view()
self.show_status("Sent element backward (Ctrl+Z to undo)", 2000)
print(f"Sent element backward: {old_index}{new_index}")
@ribbon_action(
label="Swap Order",
tooltip="Swap z-order of two selected elements",
tab="Arrange",
group="Order",
shortcut="Ctrl+Shift+X",
requires_selection=True,
min_selection=2
)
def swap_order(self):
"""Swap the z-order of two selected elements"""
if len(self.gl_widget.selected_elements) != 2:
self.show_status("Please select exactly 2 elements to swap", 2000)
return
current_page = self.get_current_page()
if not current_page:
return
elements = current_page.layout.elements
selected = list(self.gl_widget.selected_elements)
# Get indices of both elements
try:
index1 = elements.index(selected[0])
index2 = elements.index(selected[1])
except ValueError:
self.show_status("Selected elements not found on current page", 2000)
return
# Swap them in the list
elements[index1], elements[index2] = elements[index2], elements[index1]
self.update_view()
self.show_status(f"Swapped z-order of elements", 2000)
print(f"Swapped elements at indices {index1} and {index2}")

View File

@ -88,8 +88,8 @@ class PageLayout:
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):
# Render elements in list order (list position = z-order)
for element in self.elements:
element.render()
# Draw page border LAST (on top of everything)
@ -184,7 +184,9 @@ class PageLayout:
self.background_color = tuple(data.get("background_color", (1.0, 1.0, 1.0)))
self.elements = []
# Deserialize elements
# Deserialize elements and sort by z_index to establish list order
# This ensures backward compatibility with projects that used z_index
elem_list = []
for elem_data in data.get("elements", []):
elem_type = elem_data.get("type")
if elem_type == "image":
@ -197,7 +199,11 @@ class PageLayout:
continue
elem.deserialize(elem_data)
self.elements.append(elem)
elem_list.append(elem)
# Sort by z_index to establish proper list order (lower z_index = earlier in list = behind)
elem_list.sort(key=lambda e: e.z_index)
self.elements = elem_list
# Deserialize grid layout
grid_data = data.get("grid_layout")

381
tests/test_zorder.py Normal file
View File

@ -0,0 +1,381 @@
"""
Unit tests for z-order operations in pyPhotoAlbum
"""
import pytest
from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
from pyPhotoAlbum.page_layout import PageLayout
from pyPhotoAlbum.commands import ChangeZOrderCommand, CommandHistory
class TestZOrderBasics:
"""Tests for basic z-order functionality"""
def test_list_order_is_render_order(self):
"""Test that list order determines render order"""
layout = PageLayout(width=210, height=297)
# Add elements in order
elem1 = ImageData(x=10, y=10, width=50, height=50)
elem2 = TextBoxData(x=20, y=20, width=50, height=50)
elem3 = PlaceholderData(x=30, y=30, width=50, height=50)
layout.add_element(elem1)
layout.add_element(elem2)
layout.add_element(elem3)
# Verify order
assert layout.elements[0] is elem1
assert layout.elements[1] is elem2
assert layout.elements[2] is elem3
def test_element_at_end_renders_on_top(self):
"""Test that element at end of list renders on top"""
layout = PageLayout(width=210, height=297)
elem1 = ImageData(x=10, y=10)
elem2 = ImageData(x=20, y=20)
layout.add_element(elem1)
layout.add_element(elem2)
# elem2 should be last (on top)
assert layout.elements[-1] is elem2
assert layout.elements.index(elem2) > layout.elements.index(elem1)
class TestChangeZOrderCommand:
"""Tests for ChangeZOrderCommand"""
def test_move_element_forward(self):
"""Test moving an element forward one position"""
layout = PageLayout()
elem1 = ImageData(x=10, y=10)
elem2 = TextBoxData(x=20, y=20)
elem3 = PlaceholderData(x=30, y=30)
layout.add_element(elem1)
layout.add_element(elem2)
layout.add_element(elem3)
# Move elem1 forward (swap with elem2)
cmd = ChangeZOrderCommand(layout, elem1, old_index=0, new_index=1)
cmd.execute()
assert layout.elements.index(elem1) == 1
assert layout.elements.index(elem2) == 0
assert layout.elements.index(elem3) == 2
def test_move_element_backward(self):
"""Test moving an element backward one position"""
layout = PageLayout()
elem1 = ImageData(x=10, y=10)
elem2 = TextBoxData(x=20, y=20)
elem3 = PlaceholderData(x=30, y=30)
layout.add_element(elem1)
layout.add_element(elem2)
layout.add_element(elem3)
# Move elem2 backward (swap with elem1)
cmd = ChangeZOrderCommand(layout, elem2, old_index=1, new_index=0)
cmd.execute()
assert layout.elements.index(elem2) == 0
assert layout.elements.index(elem1) == 1
assert layout.elements.index(elem3) == 2
def test_move_to_front(self):
"""Test moving an element to the front (end of list)"""
layout = PageLayout()
elem1 = ImageData(x=10, y=10)
elem2 = TextBoxData(x=20, y=20)
elem3 = PlaceholderData(x=30, y=30)
layout.add_element(elem1)
layout.add_element(elem2)
layout.add_element(elem3)
# Move elem1 to front
cmd = ChangeZOrderCommand(layout, elem1, old_index=0, new_index=2)
cmd.execute()
assert layout.elements[-1] is elem1
assert layout.elements.index(elem1) == 2
def test_move_to_back(self):
"""Test moving an element to the back (start of list)"""
layout = PageLayout()
elem1 = ImageData(x=10, y=10)
elem2 = TextBoxData(x=20, y=20)
elem3 = PlaceholderData(x=30, y=30)
layout.add_element(elem1)
layout.add_element(elem2)
layout.add_element(elem3)
# Move elem3 to back
cmd = ChangeZOrderCommand(layout, elem3, old_index=2, new_index=0)
cmd.execute()
assert layout.elements[0] is elem3
assert layout.elements.index(elem3) == 0
def test_undo_redo(self):
"""Test undo/redo functionality"""
layout = PageLayout()
elem1 = ImageData(x=10, y=10)
elem2 = TextBoxData(x=20, y=20)
elem3 = PlaceholderData(x=30, y=30)
layout.add_element(elem1)
layout.add_element(elem2)
layout.add_element(elem3)
original_order = list(layout.elements)
# Move elem1 forward
cmd = ChangeZOrderCommand(layout, elem1, old_index=0, new_index=1)
cmd.execute()
assert layout.elements.index(elem1) == 1
# Undo
cmd.undo()
assert layout.elements == original_order
# Redo
cmd.redo()
assert layout.elements.index(elem1) == 1
def test_command_with_history(self):
"""Test ChangeZOrderCommand with CommandHistory"""
layout = PageLayout()
history = CommandHistory()
elem1 = ImageData(x=10, y=10)
elem2 = TextBoxData(x=20, y=20)
elem3 = PlaceholderData(x=30, y=30)
layout.add_element(elem1)
layout.add_element(elem2)
layout.add_element(elem3)
# Execute command through history
cmd = ChangeZOrderCommand(layout, elem1, old_index=0, new_index=2)
history.execute(cmd)
assert layout.elements.index(elem1) == 2
assert history.can_undo()
# Undo through history
history.undo()
assert layout.elements.index(elem1) == 0
assert history.can_redo()
# Redo through history
history.redo()
assert layout.elements.index(elem1) == 2
class TestZOrderSerialization:
"""Tests for z-order serialization and deserialization"""
def test_serialize_preserves_order(self):
"""Test that serialization preserves element order"""
layout = PageLayout()
elem1 = ImageData(x=10, y=10, z_index=0)
elem2 = TextBoxData(x=20, y=20, z_index=1)
elem3 = PlaceholderData(x=30, y=30, z_index=2)
layout.add_element(elem1)
layout.add_element(elem2)
layout.add_element(elem3)
# Serialize
data = layout.serialize()
# Elements should be in order
assert len(data['elements']) == 3
assert data['elements'][0]['type'] == 'image'
assert data['elements'][1]['type'] == 'textbox'
assert data['elements'][2]['type'] == 'placeholder'
def test_deserialize_sorts_by_zindex(self):
"""Test that deserialization sorts by z_index for backward compatibility"""
layout = PageLayout()
# Create data with z_index values out of order
data = {
'size': (210, 297),
'base_width': 210,
'is_facing_page': False,
'background_color': (1.0, 1.0, 1.0),
'elements': [
{'type': 'image', 'position': (10, 10), 'size': (50, 50),
'rotation': 0, 'z_index': 2, 'image_path': '', 'crop_info': (0, 0, 1, 1)},
{'type': 'textbox', 'position': (20, 20), 'size': (50, 50),
'rotation': 0, 'z_index': 0, 'text_content': '',
'font_settings': {}, 'alignment': 'left'},
{'type': 'placeholder', 'position': (30, 30), 'size': (50, 50),
'rotation': 0, 'z_index': 1, 'placeholder_type': 'image', 'default_content': ''},
]
}
layout.deserialize(data)
# Elements should be sorted by z_index
assert len(layout.elements) == 3
assert isinstance(layout.elements[0], TextBoxData) # z_index=0
assert isinstance(layout.elements[1], PlaceholderData) # z_index=1
assert isinstance(layout.elements[2], ImageData) # z_index=2
def test_roundtrip_maintains_order(self):
"""Test that serialize/deserialize maintains element order"""
layout1 = PageLayout()
elem1 = ImageData(x=10, y=10, z_index=0)
elem2 = TextBoxData(x=20, y=20, z_index=1)
elem3 = PlaceholderData(x=30, y=30, z_index=2)
layout1.add_element(elem1)
layout1.add_element(elem2)
layout1.add_element(elem3)
# Serialize and deserialize
data = layout1.serialize()
layout2 = PageLayout()
layout2.deserialize(data)
# Order should be maintained
assert len(layout2.elements) == 3
assert isinstance(layout2.elements[0], ImageData)
assert isinstance(layout2.elements[1], TextBoxData)
assert isinstance(layout2.elements[2], PlaceholderData)
class TestZOrderEdgeCases:
"""Tests for z-order edge cases"""
def test_single_element(self):
"""Test operations with single element"""
layout = PageLayout()
elem = ImageData(x=10, y=10)
layout.add_element(elem)
# Try to move forward (should stay at index 0)
cmd = ChangeZOrderCommand(layout, elem, old_index=0, new_index=0)
cmd.execute()
assert layout.elements.index(elem) == 0
def test_empty_list(self):
"""Test operations with empty list"""
layout = PageLayout()
assert len(layout.elements) == 0
def test_move_to_same_position(self):
"""Test moving element to its current position"""
layout = PageLayout()
elem1 = ImageData(x=10, y=10)
elem2 = TextBoxData(x=20, y=20)
layout.add_element(elem1)
layout.add_element(elem2)
# Move to same position
cmd = ChangeZOrderCommand(layout, elem1, old_index=0, new_index=0)
cmd.execute()
assert layout.elements.index(elem1) == 0
assert layout.elements.index(elem2) == 1
def test_swap_adjacent_elements(self):
"""Test swapping two adjacent elements"""
layout = PageLayout()
elem1 = ImageData(x=10, y=10)
elem2 = TextBoxData(x=20, y=20)
layout.add_element(elem1)
layout.add_element(elem2)
# Swap by moving elem1 forward
elements = layout.elements
index1 = elements.index(elem1)
index2 = elements.index(elem2)
elements[index1], elements[index2] = elements[index2], elements[index1]
assert layout.elements[0] is elem2
assert layout.elements[1] is elem1
def test_multiple_zorder_changes(self):
"""Test multiple z-order changes in sequence"""
layout = PageLayout()
history = CommandHistory()
elem1 = ImageData(x=10, y=10)
elem2 = TextBoxData(x=20, y=20)
elem3 = PlaceholderData(x=30, y=30)
layout.add_element(elem1)
layout.add_element(elem2)
layout.add_element(elem3)
# Move elem1 to front
cmd1 = ChangeZOrderCommand(layout, elem1, old_index=0, new_index=2)
history.execute(cmd1)
assert layout.elements.index(elem1) == 2
# Move elem2 to front
cmd2 = ChangeZOrderCommand(layout, elem2, old_index=0, new_index=2)
history.execute(cmd2)
assert layout.elements.index(elem2) == 2
# Undo both
history.undo()
assert layout.elements.index(elem2) == 0
history.undo()
assert layout.elements.index(elem1) == 0
class TestZOrderCommandSerialization:
"""Tests for ChangeZOrderCommand serialization"""
def test_serialize_command(self):
"""Test serializing a ChangeZOrderCommand"""
layout = PageLayout()
elem = ImageData(x=10, y=10)
layout.add_element(elem)
cmd = ChangeZOrderCommand(layout, elem, old_index=0, new_index=1)
data = cmd.serialize()
assert data['type'] == 'change_zorder'
assert data['old_index'] == 0
assert data['new_index'] == 1
assert 'element' in data
def test_deserialize_command(self):
"""Test deserializing a ChangeZOrderCommand"""
data = {
'type': 'change_zorder',
'element': {
'type': 'image',
'position': (10, 10),
'size': (50, 50),
'rotation': 0,
'z_index': 0,
'image_path': '',
'crop_info': (0, 0, 1, 1)
},
'old_index': 0,
'new_index': 1
}
cmd = ChangeZOrderCommand.deserialize(data, None)
assert isinstance(cmd, ChangeZOrderCommand)
assert cmd.old_index == 0
assert cmd.new_index == 1
assert isinstance(cmd.element, ImageData)