Improved undo-redo system
This commit is contained in:
parent
ddcfada636
commit
5257c6654a
@ -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
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
162
pyPhotoAlbum/mixins/interaction_undo.py
Normal file
162
pyPhotoAlbum/mixins/interaction_undo.py
Normal 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()
|
||||
@ -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',
|
||||
]
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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()
|
||||
|
||||
199
pyPhotoAlbum/mixins/operations/zorder_ops.py
Normal file
199
pyPhotoAlbum/mixins/operations/zorder_ops.py
Normal 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}")
|
||||
@ -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
381
tests/test_zorder.py
Normal 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)
|
||||
Loading…
x
Reference in New Issue
Block a user