pyPhotoAlbum/pyPhotoAlbum/template_manager.py
Duncan Tourolle 4bf978b75a
All checks were successful
Python CI / test (push) Successful in 56s
Lint / lint (push) Successful in 1m4s
Tests / test (3.10) (push) Successful in 43s
Tests / test (3.11) (push) Successful in 45s
Tests / test (3.9) (push) Successful in 41s
templates embedded with project files
2025-10-28 23:30:39 +01:00

491 lines
18 KiB
Python

"""
Template management system for pyPhotoAlbum
"""
import json
import os
from pathlib import Path
from typing import List, Dict, Any, Tuple, Optional
from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData
from pyPhotoAlbum.page_layout import PageLayout
from pyPhotoAlbum.project import Page
class Template:
"""Class representing a page layout template"""
def __init__(self, name: str = "Untitled Template", description: str = "", page_size_mm: Tuple[float, float] = (210, 297)):
self.name = name
self.description = description
self.page_size_mm = page_size_mm
self.elements: List[BaseLayoutElement] = []
def add_element(self, element: BaseLayoutElement):
"""Add an element to the template"""
self.elements.append(element)
def to_dict(self) -> Dict[str, Any]:
"""Serialize template to dictionary"""
return {
"name": self.name,
"description": self.description,
"page_size_mm": self.page_size_mm,
"elements": [elem.serialize() for elem in self.elements]
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Template':
"""Deserialize template from dictionary"""
template = cls(
name=data.get("name", "Untitled Template"),
description=data.get("description", ""),
page_size_mm=tuple(data.get("page_size_mm", (210, 297)))
)
# Deserialize elements
for elem_data in data.get("elements", []):
elem_type = elem_data.get("type")
if elem_type == "placeholder":
elem = PlaceholderData()
elif elem_type == "textbox":
elem = TextBoxData()
else:
continue # Skip image elements in templates
elem.deserialize(elem_data)
template.add_element(elem)
return template
def save_to_file(self, file_path: str):
"""Save template to JSON file"""
with open(file_path, 'w') as f:
json.dump(self.to_dict(), f, indent=2)
@classmethod
def load_from_file(cls, file_path: str) -> 'Template':
"""Load template from JSON file"""
with open(file_path, 'r') as f:
data = json.load(f)
return cls.from_dict(data)
class TemplateManager:
"""Manager for template operations"""
def __init__(self, project=None):
self.templates_dir = self._get_templates_directory()
self._ensure_templates_directory()
self.project = project # Optional project for embedded templates
def _get_templates_directory(self) -> Path:
"""Get the templates directory path"""
# User templates directory
home = Path.home()
templates_dir = home / ".pyphotoalbum" / "templates"
return templates_dir
def _get_builtin_templates_directory(self) -> Path:
"""Get the built-in templates directory path"""
# Built-in templates in the application directory
app_dir = Path(__file__).parent
return app_dir / "templates"
def _ensure_templates_directory(self):
"""Create templates directory if it doesn't exist"""
self.templates_dir.mkdir(parents=True, exist_ok=True)
# Also ensure built-in templates directory exists
builtin_dir = self._get_builtin_templates_directory()
builtin_dir.mkdir(parents=True, exist_ok=True)
def list_templates(self) -> List[str]:
"""List all available template names (embedded + user + built-in)"""
templates = []
# List embedded templates (priority)
if self.project and self.project.embedded_templates:
for template_name in self.project.embedded_templates.keys():
templates.append(f"[Embedded] {template_name}")
# List user templates
if self.templates_dir.exists():
for file in self.templates_dir.glob("*.json"):
templates.append(file.stem)
# List built-in templates
builtin_dir = self._get_builtin_templates_directory()
if builtin_dir.exists():
for file in builtin_dir.glob("*.json"):
template_name = f"[Built-in] {file.stem}"
templates.append(template_name)
return sorted(templates)
def load_template(self, name: str) -> Template:
"""
Load a template by name with priority: embedded > user > built-in.
Args:
name: Template name (may include prefix like '[Embedded]' or '[Built-in]')
Returns:
Template instance
"""
# Check if it's an embedded template (priority)
if name.startswith("[Embedded] "):
actual_name = name.replace("[Embedded] ", "")
if self.project and actual_name in self.project.embedded_templates:
template_data = self.project.embedded_templates[actual_name]
return Template.from_dict(template_data)
raise FileNotFoundError(f"Embedded template '{actual_name}' not found")
# Check embedded templates even without prefix (for backward compatibility)
if self.project and name in self.project.embedded_templates:
template_data = self.project.embedded_templates[name]
return Template.from_dict(template_data)
# Check if it's a built-in template
if name.startswith("[Built-in] "):
actual_name = name.replace("[Built-in] ", "")
template_path = self._get_builtin_templates_directory() / f"{actual_name}.json"
else:
# User template
template_path = self.templates_dir / f"{name}.json"
if not template_path.exists():
raise FileNotFoundError(f"Template '{name}' not found")
return Template.load_from_file(str(template_path))
def save_template(self, template: Template, embed_in_project: bool = False):
"""
Save a template to filesystem or embed in project.
Args:
template: Template to save
embed_in_project: If True, embed in project instead of saving to filesystem
"""
if embed_in_project and self.project:
# Embed in project
self.project.embedded_templates[template.name] = template.to_dict()
print(f"Embedded template '{template.name}' in project")
else:
# Save to filesystem
template_path = self.templates_dir / f"{template.name}.json"
template.save_to_file(str(template_path))
def delete_template(self, name: str):
"""Delete a template (embedded or user templates only)"""
if name.startswith("[Built-in] "):
raise PermissionError("Cannot delete built-in templates")
# Check if it's an embedded template
if name.startswith("[Embedded] "):
actual_name = name.replace("[Embedded] ", "")
if self.project and actual_name in self.project.embedded_templates:
del self.project.embedded_templates[actual_name]
print(f"Removed embedded template '{actual_name}'")
return
raise FileNotFoundError(f"Embedded template '{actual_name}' not found")
# User template from filesystem
template_path = self.templates_dir / f"{name}.json"
if template_path.exists():
template_path.unlink()
def embed_template(self, template: Template):
"""
Embed a template in the project.
Args:
template: Template to embed
"""
if not self.project:
raise RuntimeError("No project associated with this TemplateManager")
self.project.embedded_templates[template.name] = template.to_dict()
print(f"Embedded template '{template.name}' in project")
def create_template_from_page(self, page: Page, name: str, description: str = "") -> Template:
"""
Create a template from an existing page.
Converts all ImageData elements to PlaceholderData.
"""
template = Template(
name=name,
description=description,
page_size_mm=page.layout.size
)
# Convert elements
for element in page.layout.elements:
if isinstance(element, ImageData):
# Convert image to placeholder
placeholder = PlaceholderData(
placeholder_type="image",
x=element.position[0],
y=element.position[1],
width=element.size[0],
height=element.size[1],
rotation=element.rotation,
z_index=element.z_index
)
template.add_element(placeholder)
elif isinstance(element, TextBoxData):
# Keep text boxes as-is
text_box = TextBoxData(
text_content=element.text_content,
font_settings=element.font_settings,
alignment=element.alignment,
x=element.position[0],
y=element.position[1],
width=element.size[0],
height=element.size[1],
rotation=element.rotation,
z_index=element.z_index
)
template.add_element(text_box)
elif isinstance(element, PlaceholderData):
# Keep placeholders as-is
placeholder = PlaceholderData(
placeholder_type=element.placeholder_type,
default_content=element.default_content,
x=element.position[0],
y=element.position[1],
width=element.size[0],
height=element.size[1],
rotation=element.rotation,
z_index=element.z_index
)
template.add_element(placeholder)
return template
def scale_template_elements(
self,
elements: List[BaseLayoutElement],
from_size: Tuple[float, float],
to_size: Tuple[float, float],
scale_mode: str = "proportional",
margin_percent: float = 0.0
) -> List[BaseLayoutElement]:
"""
Scale template elements to fit target page size with adjustable margins.
Args:
elements: List of elements to scale
from_size: Original template size (width, height) in mm
to_size: Target page size (width, height) in mm
scale_mode: "proportional", "stretch", or "center"
margin_percent: Percentage of page size to use for margins (0-10%)
Returns:
List of scaled elements
"""
from_width, from_height = from_size
to_width, to_height = to_size
# Calculate target margins from percentage
margin_x = to_width * (margin_percent / 100.0)
margin_y = to_height * (margin_percent / 100.0)
# Available content area after margins
content_width = to_width - (2 * margin_x)
content_height = to_height - (2 * margin_y)
# Calculate scale factors based on mode
if scale_mode == "stretch":
# Stretch to fill content area independently in each dimension
scale_x = content_width / from_width
scale_y = content_height / from_height
offset_x = margin_x
offset_y = margin_y
elif scale_mode == "proportional":
# Maintain aspect ratio - scale uniformly to fit content area
scale = min(content_width / from_width, content_height / from_height)
scale_x = scale
scale_y = scale
# Center the scaled content within the page
scaled_width = from_width * scale
scaled_height = from_height * scale
offset_x = (to_width - scaled_width) / 2
offset_y = (to_height - scaled_height) / 2
else: # "center"
# No scaling, just center on page
scale_x = 1.0
scale_y = 1.0
offset_x = (to_width - from_width) / 2
offset_y = (to_height - from_height) / 2
scaled_elements = []
for element in elements:
# Create a new element of the same type
if isinstance(element, PlaceholderData):
new_elem = PlaceholderData(
placeholder_type=element.placeholder_type,
default_content=element.default_content
)
elif isinstance(element, TextBoxData):
new_elem = TextBoxData(
text_content=element.text_content,
font_settings=element.font_settings.copy() if element.font_settings else None,
alignment=element.alignment
)
else:
continue # Skip other types
# Scale position and size
old_x, old_y = element.position
old_w, old_h = element.size
new_elem.position = (
old_x * scale_x + offset_x,
old_y * scale_y + offset_y
)
new_elem.size = (
old_w * scale_x,
old_h * scale_y
)
new_elem.rotation = element.rotation
new_elem.z_index = element.z_index
scaled_elements.append(new_elem)
return scaled_elements
def apply_template_to_page(
self,
template: Template,
page: Page,
mode: str = "replace",
scale_mode: str = "proportional",
margin_percent: float = 2.5,
auto_embed: bool = True
):
"""
Apply template to an existing page with adjustable margins.
Args:
template: Template to apply
page: Target page
mode: "replace" to clear page and add placeholders,
"reflow" to keep existing content and reposition
scale_mode: "proportional", "stretch", or "center"
margin_percent: Percentage of page size to use for margins (0-10%)
auto_embed: If True, automatically embed template in project
"""
# Auto-embed template if requested and not already embedded
if auto_embed and self.project:
if template.name not in self.project.embedded_templates:
self.embed_template(template)
if mode == "replace":
# Clear existing elements
page.layout.elements.clear()
# Scale template elements to fit page
scaled_elements = self.scale_template_elements(
template.elements,
template.page_size_mm,
page.layout.size,
scale_mode,
margin_percent
)
# Add scaled elements to page
for element in scaled_elements:
page.layout.add_element(element)
elif mode == "reflow":
# Keep existing content but reposition to template slots
existing_images = [e for e in page.layout.elements if isinstance(e, ImageData)]
existing_text = [e for e in page.layout.elements if isinstance(e, TextBoxData)]
# Get template placeholders (scaled)
scaled_elements = self.scale_template_elements(
template.elements,
template.page_size_mm,
page.layout.size,
scale_mode,
margin_percent
)
template_placeholders = [e for e in scaled_elements if isinstance(e, PlaceholderData)]
template_text = [e for e in scaled_elements if isinstance(e, TextBoxData)]
# Clear page
page.layout.elements.clear()
# Reflow images into placeholder slots
for i, placeholder in enumerate(template_placeholders):
if i < len(existing_images):
# Use existing image, update position/size
img = existing_images[i]
img.position = placeholder.position
img.size = placeholder.size
img.z_index = placeholder.z_index
page.layout.add_element(img)
else:
# Add placeholder if no more images
page.layout.add_element(placeholder)
# Add remaining images (if any) at their original positions
for img in existing_images[len(template_placeholders):]:
page.layout.add_element(img)
# Add template text boxes
for text_elem in template_text:
page.layout.add_element(text_elem)
def create_page_from_template(
self,
template: Template,
page_number: int = 1,
target_size_mm: Optional[Tuple[float, float]] = None,
scale_mode: str = "proportional",
auto_embed: bool = True
) -> Page:
"""
Create a new page from a template.
Args:
template: Template to use
page_number: Page number for the new page
target_size_mm: Target page size (if different from template)
scale_mode: Scaling mode if target_size_mm is provided
auto_embed: If True, automatically embed template in project
Returns:
New Page instance with template layout
"""
# Auto-embed template if requested and not already embedded
if auto_embed and self.project:
if template.name not in self.project.embedded_templates:
self.embed_template(template)
# Determine page size
if target_size_mm is None:
page_size = template.page_size_mm
elements = [e for e in template.elements] # Copy elements as-is
else:
page_size = target_size_mm
# Scale template elements
elements = self.scale_template_elements(
template.elements,
template.page_size_mm,
target_size_mm,
scale_mode
)
# Create new page layout
layout = PageLayout(width=page_size[0], height=page_size[1])
# Add elements
for element in elements:
layout.add_element(element)
# Create and return page
page = Page(layout=layout, page_number=page_number)
return page