From 4bf978b75addf5cea0d338c97e6610a24cbe8903 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Tue, 28 Oct 2025 23:30:39 +0100 Subject: [PATCH] templates embedded with project files --- pyPhotoAlbum/project.py | 8 + pyPhotoAlbum/template_manager.py | 97 +++++++++- tests/test_embedded_templates.py | 303 +++++++++++++++++++++++++++++++ 3 files changed, 398 insertions(+), 10 deletions(-) create mode 100644 tests/test_embedded_templates.py diff --git a/pyPhotoAlbum/project.py b/pyPhotoAlbum/project.py index a6fc150..22edb73 100644 --- a/pyPhotoAlbum/project.py +++ b/pyPhotoAlbum/project.py @@ -109,6 +109,9 @@ class Project: self.export_dpi = 300 # Default export DPI 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 self.asset_manager = AssetManager(self.folder_path) @@ -195,6 +198,7 @@ class Project: "working_dpi": self.working_dpi, "export_dpi": self.export_dpi, "page_spacing_mm": self.page_spacing_mm, + "embedded_templates": self.embedded_templates, "pages": [page.serialize() for page in self.pages], "history": self.history.serialize(), "asset_manager": self.asset_manager.serialize() @@ -211,6 +215,10 @@ class Project: self.working_dpi = data.get("working_dpi", 300) self.export_dpi = data.get("export_dpi", 300) self.page_spacing_mm = data.get("page_spacing_mm", 10.0) + + # Deserialize embedded templates + self.embedded_templates = data.get("embedded_templates", {}) + self.pages = [] # Deserialize asset manager diff --git a/pyPhotoAlbum/template_manager.py b/pyPhotoAlbum/template_manager.py index bc1d7df..4f590ff 100644 --- a/pyPhotoAlbum/template_manager.py +++ b/pyPhotoAlbum/template_manager.py @@ -73,9 +73,10 @@ class Template: class TemplateManager: """Manager for template operations""" - def __init__(self): + 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""" @@ -99,9 +100,14 @@ class TemplateManager: builtin_dir.mkdir(parents=True, exist_ok=True) def list_templates(self) -> List[str]: - """List all available template names (user + built-in)""" + """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"): @@ -117,12 +123,34 @@ class TemplateManager: return sorted(templates) 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 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(): @@ -130,20 +158,55 @@ class TemplateManager: return Template.load_from_file(str(template_path)) - def save_template(self, template: Template): - """Save a template""" - template_path = self.templates_dir / f"{template.name}.json" - template.save_to_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 (only user templates)""" + """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. @@ -297,7 +360,8 @@ class TemplateManager: page: Page, mode: str = "replace", 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. @@ -309,7 +373,13 @@ class TemplateManager: "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() @@ -373,7 +443,8 @@ class TemplateManager: template: Template, page_number: int = 1, target_size_mm: Optional[Tuple[float, float]] = None, - scale_mode: str = "proportional" + scale_mode: str = "proportional", + auto_embed: bool = True ) -> Page: """ Create a new page from a template. @@ -383,10 +454,16 @@ class TemplateManager: 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 diff --git a/tests/test_embedded_templates.py b/tests/test_embedded_templates.py new file mode 100644 index 0000000..18b14a0 --- /dev/null +++ b/tests/test_embedded_templates.py @@ -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