491 lines
18 KiB
Python
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
|