templates embedded with project files
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

This commit is contained in:
Duncan Tourolle 2025-10-28 23:30:39 +01:00
parent 9ed8976885
commit 4bf978b75a
3 changed files with 398 additions and 10 deletions

View File

@ -109,6 +109,9 @@ class Project:
self.export_dpi = 300 # Default export DPI self.export_dpi = 300 # Default export DPI
self.page_spacing_mm = 10.0 # Default spacing between pages (1cm) self.page_spacing_mm = 10.0 # Default spacing between pages (1cm)
# Embedded templates - templates that travel with the project
self.embedded_templates: Dict[str, Dict[str, Any]] = {}
# Initialize asset manager # Initialize asset manager
self.asset_manager = AssetManager(self.folder_path) self.asset_manager = AssetManager(self.folder_path)
@ -195,6 +198,7 @@ class Project:
"working_dpi": self.working_dpi, "working_dpi": self.working_dpi,
"export_dpi": self.export_dpi, "export_dpi": self.export_dpi,
"page_spacing_mm": self.page_spacing_mm, "page_spacing_mm": self.page_spacing_mm,
"embedded_templates": self.embedded_templates,
"pages": [page.serialize() for page in self.pages], "pages": [page.serialize() for page in self.pages],
"history": self.history.serialize(), "history": self.history.serialize(),
"asset_manager": self.asset_manager.serialize() "asset_manager": self.asset_manager.serialize()
@ -211,6 +215,10 @@ class Project:
self.working_dpi = data.get("working_dpi", 300) self.working_dpi = data.get("working_dpi", 300)
self.export_dpi = data.get("export_dpi", 300) self.export_dpi = data.get("export_dpi", 300)
self.page_spacing_mm = data.get("page_spacing_mm", 10.0) self.page_spacing_mm = data.get("page_spacing_mm", 10.0)
# Deserialize embedded templates
self.embedded_templates = data.get("embedded_templates", {})
self.pages = [] self.pages = []
# Deserialize asset manager # Deserialize asset manager

View File

@ -73,9 +73,10 @@ class Template:
class TemplateManager: class TemplateManager:
"""Manager for template operations""" """Manager for template operations"""
def __init__(self): def __init__(self, project=None):
self.templates_dir = self._get_templates_directory() self.templates_dir = self._get_templates_directory()
self._ensure_templates_directory() self._ensure_templates_directory()
self.project = project # Optional project for embedded templates
def _get_templates_directory(self) -> Path: def _get_templates_directory(self) -> Path:
"""Get the templates directory path""" """Get the templates directory path"""
@ -99,9 +100,14 @@ class TemplateManager:
builtin_dir.mkdir(parents=True, exist_ok=True) builtin_dir.mkdir(parents=True, exist_ok=True)
def list_templates(self) -> List[str]: def list_templates(self) -> List[str]:
"""List all available template names (user + built-in)""" """List all available template names (embedded + user + built-in)"""
templates = [] 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 # List user templates
if self.templates_dir.exists(): if self.templates_dir.exists():
for file in self.templates_dir.glob("*.json"): for file in self.templates_dir.glob("*.json"):
@ -117,12 +123,34 @@ class TemplateManager:
return sorted(templates) return sorted(templates)
def load_template(self, name: str) -> Template: def load_template(self, name: str) -> Template:
"""Load a template by name""" """
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 # Check if it's a built-in template
if name.startswith("[Built-in] "): if name.startswith("[Built-in] "):
actual_name = name.replace("[Built-in] ", "") actual_name = name.replace("[Built-in] ", "")
template_path = self._get_builtin_templates_directory() / f"{actual_name}.json" template_path = self._get_builtin_templates_directory() / f"{actual_name}.json"
else: else:
# User template
template_path = self.templates_dir / f"{name}.json" template_path = self.templates_dir / f"{name}.json"
if not template_path.exists(): if not template_path.exists():
@ -130,20 +158,55 @@ class TemplateManager:
return Template.load_from_file(str(template_path)) return Template.load_from_file(str(template_path))
def save_template(self, template: Template): def save_template(self, template: Template, embed_in_project: bool = False):
"""Save a template""" """
template_path = self.templates_dir / f"{template.name}.json" Save a template to filesystem or embed in project.
template.save_to_file(str(template_path))
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): def delete_template(self, name: str):
"""Delete a template (only user templates)""" """Delete a template (embedded or user templates only)"""
if name.startswith("[Built-in] "): if name.startswith("[Built-in] "):
raise PermissionError("Cannot delete built-in templates") 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" template_path = self.templates_dir / f"{name}.json"
if template_path.exists(): if template_path.exists():
template_path.unlink() 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: def create_template_from_page(self, page: Page, name: str, description: str = "") -> Template:
""" """
Create a template from an existing page. Create a template from an existing page.
@ -297,7 +360,8 @@ class TemplateManager:
page: Page, page: Page,
mode: str = "replace", mode: str = "replace",
scale_mode: str = "proportional", scale_mode: str = "proportional",
margin_percent: float = 2.5 margin_percent: float = 2.5,
auto_embed: bool = True
): ):
""" """
Apply template to an existing page with adjustable margins. Apply template to an existing page with adjustable margins.
@ -309,7 +373,13 @@ class TemplateManager:
"reflow" to keep existing content and reposition "reflow" to keep existing content and reposition
scale_mode: "proportional", "stretch", or "center" scale_mode: "proportional", "stretch", or "center"
margin_percent: Percentage of page size to use for margins (0-10%) 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": if mode == "replace":
# Clear existing elements # Clear existing elements
page.layout.elements.clear() page.layout.elements.clear()
@ -373,7 +443,8 @@ class TemplateManager:
template: Template, template: Template,
page_number: int = 1, page_number: int = 1,
target_size_mm: Optional[Tuple[float, float]] = None, target_size_mm: Optional[Tuple[float, float]] = None,
scale_mode: str = "proportional" scale_mode: str = "proportional",
auto_embed: bool = True
) -> Page: ) -> Page:
""" """
Create a new page from a template. Create a new page from a template.
@ -383,10 +454,16 @@ class TemplateManager:
page_number: Page number for the new page page_number: Page number for the new page
target_size_mm: Target page size (if different from template) target_size_mm: Target page size (if different from template)
scale_mode: Scaling mode if target_size_mm is provided scale_mode: Scaling mode if target_size_mm is provided
auto_embed: If True, automatically embed template in project
Returns: Returns:
New Page instance with template layout 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 # Determine page size
if target_size_mm is None: if target_size_mm is None:
page_size = template.page_size_mm page_size = template.page_size_mm

View File

@ -0,0 +1,303 @@
"""
Tests for embedded template functionality
"""
import pytest
import tempfile
import os
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.template_manager import TemplateManager, Template
from pyPhotoAlbum.models import PlaceholderData, TextBoxData
from pyPhotoAlbum.page_layout import PageLayout
def test_embed_template_in_project():
"""Test embedding a template in a project"""
# Create a project
project = Project(name="Test Project")
# Create a template manager with the project
template_manager = TemplateManager(project=project)
# Create a simple template
template = Template(name="Test Template", description="A test template")
placeholder = PlaceholderData(
placeholder_type="image",
x=10, y=10, width=100, height=100
)
template.add_element(placeholder)
# Embed the template
template_manager.embed_template(template)
# Verify it's embedded
assert "Test Template" in project.embedded_templates
assert project.embedded_templates["Test Template"]["name"] == "Test Template"
assert len(project.embedded_templates["Test Template"]["elements"]) == 1
def test_load_embedded_template():
"""Test loading an embedded template"""
# Create a project
project = Project(name="Test Project")
# Create a template manager with the project
template_manager = TemplateManager(project=project)
# Create and embed a template
template = Template(name="Test Template", description="A test template")
placeholder = PlaceholderData(
placeholder_type="image",
x=10, y=10, width=100, height=100
)
template.add_element(placeholder)
template_manager.embed_template(template)
# Load the embedded template
loaded_template = template_manager.load_template("Test Template")
assert loaded_template.name == "Test Template"
assert loaded_template.description == "A test template"
assert len(loaded_template.elements) == 1
def test_list_embedded_templates():
"""Test listing embedded templates alongside filesystem templates"""
# Create a project
project = Project(name="Test Project")
# Create a template manager with the project
template_manager = TemplateManager(project=project)
# Embed some templates
for i in range(3):
template = Template(name=f"Embedded_{i}")
template_manager.embed_template(template)
# List all templates
templates = template_manager.list_templates()
# Check embedded templates are listed with prefix
embedded_templates = [t for t in templates if t.startswith("[Embedded]")]
assert len(embedded_templates) == 3
assert "[Embedded] Embedded_0" in templates
assert "[Embedded] Embedded_1" in templates
assert "[Embedded] Embedded_2" in templates
def test_embedded_template_priority():
"""Test that embedded templates take priority over filesystem templates"""
# Create a project
project = Project(name="Test Project")
# Create a template manager with the project
template_manager = TemplateManager(project=project)
# Embed a template with a common name
embedded_template = Template(name="Common", description="Embedded version")
template_manager.embed_template(embedded_template)
# Load by name without prefix (should get embedded version)
loaded = template_manager.load_template("Common")
assert loaded.description == "Embedded version"
def test_serialize_project_with_embedded_templates():
"""Test serializing a project with embedded templates"""
# Create a project
project = Project(name="Test Project")
# Create a template manager with the project
template_manager = TemplateManager(project=project)
# Create and embed a template
template = Template(name="Test Template", description="A test template")
placeholder = PlaceholderData(
placeholder_type="image",
x=10, y=10, width=100, height=100
)
template.add_element(placeholder)
template_manager.embed_template(template)
# Serialize the project
serialized = project.serialize()
# Verify embedded templates are in serialization
assert "embedded_templates" in serialized
assert "Test Template" in serialized["embedded_templates"]
assert serialized["embedded_templates"]["Test Template"]["name"] == "Test Template"
def test_deserialize_project_with_embedded_templates():
"""Test deserializing a project with embedded templates"""
# Create a project with embedded template
project = Project(name="Test Project")
template_manager = TemplateManager(project=project)
template = Template(name="Test Template", description="A test template")
placeholder = PlaceholderData(
placeholder_type="image",
x=10, y=10, width=100, height=100
)
template.add_element(placeholder)
template_manager.embed_template(template)
# Serialize the project
serialized = project.serialize()
# Create a new project and deserialize
new_project = Project(name="New Project")
new_project.deserialize(serialized)
# Verify embedded templates were restored
assert "Test Template" in new_project.embedded_templates
assert new_project.embedded_templates["Test Template"]["name"] == "Test Template"
# Verify we can load the template from the new project
new_template_manager = TemplateManager(project=new_project)
loaded_template = new_template_manager.load_template("Test Template")
assert loaded_template.name == "Test Template"
assert len(loaded_template.elements) == 1
def test_auto_embed_on_apply():
"""Test that templates are automatically embedded when applied"""
# Create a project and page
project = Project(name="Test Project")
page = Page()
project.add_page(page)
# Create a template manager with the project
template_manager = TemplateManager(project=project)
# Create a template (not embedded yet)
template = Template(name="Auto Embed Test", description="Should auto-embed")
placeholder = PlaceholderData(
placeholder_type="image",
x=10, y=10, width=100, height=100
)
template.add_element(placeholder)
# Apply template with auto_embed=True (default)
template_manager.apply_template_to_page(template, page)
# Verify template was auto-embedded
assert "Auto Embed Test" in project.embedded_templates
def test_auto_embed_on_create_page():
"""Test that templates are automatically embedded when creating pages"""
# Create a project
project = Project(name="Test Project")
# Create a template manager with the project
template_manager = TemplateManager(project=project)
# Create a template (not embedded yet)
template = Template(name="Auto Embed Page Test", description="Should auto-embed")
placeholder = PlaceholderData(
placeholder_type="image",
x=10, y=10, width=100, height=100
)
template.add_element(placeholder)
# Create page from template with auto_embed=True (default)
page = template_manager.create_page_from_template(template, page_number=1)
# Verify template was auto-embedded
assert "Auto Embed Page Test" in project.embedded_templates
def test_delete_embedded_template():
"""Test deleting an embedded template"""
# Create a project
project = Project(name="Test Project")
# Create a template manager with the project
template_manager = TemplateManager(project=project)
# Embed a template
template = Template(name="To Delete")
template_manager.embed_template(template)
assert "To Delete" in project.embedded_templates
# Delete the embedded template
template_manager.delete_template("[Embedded] To Delete")
assert "To Delete" not in project.embedded_templates
def test_embedded_template_with_text():
"""Test embedding template with text elements"""
# Create a project
project = Project(name="Test Project")
# Create a template manager with the project
template_manager = TemplateManager(project=project)
# Create a template with text
template = Template(name="Text Template")
textbox = TextBoxData(
text_content="Sample Text",
x=10, y=10, width=200, height=50
)
template.add_element(textbox)
# Embed and reload
template_manager.embed_template(template)
loaded = template_manager.load_template("Text Template")
assert len(loaded.elements) == 1
assert isinstance(loaded.elements[0], TextBoxData)
assert loaded.elements[0].text_content == "Sample Text"
def test_roundtrip_serialization():
"""Test complete roundtrip: create project, embed template, serialize, deserialize"""
# Create a project with pages and embedded template
project = Project(name="Roundtrip Test")
template_manager = TemplateManager(project=project)
# Create a template
template = Template(name="Roundtrip Template", page_size_mm=(200, 300))
placeholder1 = PlaceholderData(
placeholder_type="image",
x=10, y=10, width=80, height=80
)
placeholder2 = PlaceholderData(
placeholder_type="image",
x=110, y=10, width=80, height=80
)
template.add_element(placeholder1)
template.add_element(placeholder2)
# Create a page from this template
page = template_manager.create_page_from_template(template, page_number=1)
project.add_page(page)
# Serialize
serialized = project.serialize()
# Create new project and deserialize
new_project = Project(name="New Roundtrip")
new_project.deserialize(serialized)
# Verify embedded template
assert "Roundtrip Template" in new_project.embedded_templates
# Verify we can use the template
new_template_manager = TemplateManager(project=new_project)
loaded_template = new_template_manager.load_template("Roundtrip Template")
assert loaded_template.name == "Roundtrip Template"
assert loaded_template.page_size_mm == (200, 300)
assert len(loaded_template.elements) == 2
# Create another page from the loaded template
new_page = new_template_manager.create_page_from_template(
loaded_template,
page_number=2,
auto_embed=False # Don't embed again
)
assert len(new_page.layout.elements) == 2