Coverage for pyPhotoAlbum/template_manager.py: 94%
210 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
1"""
2Template management system for pyPhotoAlbum
3"""
5import json
6import os
7from pathlib import Path
8from typing import List, Dict, Any, Tuple, Optional
9from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData
10from pyPhotoAlbum.page_layout import PageLayout
11from pyPhotoAlbum.project import Page
14class Template:
15 """Class representing a page layout template"""
17 def __init__(
18 self, name: str = "Untitled Template", description: str = "", page_size_mm: Tuple[float, float] = (210, 297)
19 ):
20 self.name = name
21 self.description = description
22 self.page_size_mm = page_size_mm
23 self.elements: List[BaseLayoutElement] = []
25 def add_element(self, element: BaseLayoutElement):
26 """Add an element to the template"""
27 self.elements.append(element)
29 def to_dict(self) -> Dict[str, Any]:
30 """Serialize template to dictionary"""
31 return {
32 "name": self.name,
33 "description": self.description,
34 "page_size_mm": self.page_size_mm,
35 "elements": [elem.serialize() for elem in self.elements],
36 }
38 @classmethod
39 def from_dict(cls, data: Dict[str, Any]) -> "Template":
40 """Deserialize template from dictionary"""
41 template = cls(
42 name=data.get("name", "Untitled Template"),
43 description=data.get("description", ""),
44 page_size_mm=tuple(data.get("page_size_mm", (210, 297))),
45 )
47 # Deserialize elements
48 for elem_data in data.get("elements", []):
49 elem_type = elem_data.get("type")
50 elem: BaseLayoutElement
51 if elem_type == "placeholder":
52 elem = PlaceholderData()
53 elif elem_type == "textbox":
54 elem = TextBoxData()
55 else:
56 continue # Skip image elements in templates
58 elem.deserialize(elem_data)
59 template.add_element(elem)
61 return template
63 def save_to_file(self, file_path: str):
64 """Save template to JSON file"""
65 with open(file_path, "w") as f:
66 json.dump(self.to_dict(), f, indent=2)
68 @classmethod
69 def load_from_file(cls, file_path: str) -> "Template":
70 """Load template from JSON file"""
71 with open(file_path, "r") as f:
72 data = json.load(f)
73 return cls.from_dict(data)
76class TemplateManager:
77 """Manager for template operations"""
79 def __init__(self, project=None):
80 self.templates_dir = self._get_templates_directory()
81 self._ensure_templates_directory()
82 self.project = project # Optional project for embedded templates
84 def _get_templates_directory(self) -> Path:
85 """Get the templates directory path"""
86 # User templates directory
87 home = Path.home()
88 templates_dir = home / ".pyphotoalbum" / "templates"
89 return templates_dir
91 def _get_builtin_templates_directory(self) -> Path:
92 """Get the built-in templates directory path"""
93 # Built-in templates in the application directory
94 app_dir = Path(__file__).parent
95 return app_dir / "templates"
97 def _ensure_templates_directory(self):
98 """Create templates directory if it doesn't exist"""
99 self.templates_dir.mkdir(parents=True, exist_ok=True)
101 # Also ensure built-in templates directory exists
102 builtin_dir = self._get_builtin_templates_directory()
103 builtin_dir.mkdir(parents=True, exist_ok=True)
105 def list_templates(self) -> List[str]:
106 """List all available template names (embedded + user + built-in)"""
107 templates = []
109 # List embedded templates (priority)
110 if self.project and self.project.embedded_templates:
111 for template_name in self.project.embedded_templates.keys():
112 templates.append(f"[Embedded] {template_name}")
114 # List user templates
115 if self.templates_dir.exists():
116 for file in self.templates_dir.glob("*.json"):
117 templates.append(file.stem)
119 # List built-in templates
120 builtin_dir = self._get_builtin_templates_directory()
121 if builtin_dir.exists():
122 for file in builtin_dir.glob("*.json"):
123 template_name = f"[Built-in] {file.stem}"
124 templates.append(template_name)
126 return sorted(templates)
128 def load_template(self, name: str) -> Template:
129 """
130 Load a template by name with priority: embedded > user > built-in.
132 Args:
133 name: Template name (may include prefix like '[Embedded]' or '[Built-in]')
135 Returns:
136 Template instance
137 """
138 # Check if it's an embedded template (priority)
139 if name.startswith("[Embedded] "):
140 actual_name = name.replace("[Embedded] ", "")
141 if self.project and actual_name in self.project.embedded_templates:
142 template_data = self.project.embedded_templates[actual_name]
143 return Template.from_dict(template_data)
144 raise FileNotFoundError(f"Embedded template '{actual_name}' not found")
146 # Check embedded templates even without prefix (for backward compatibility)
147 if self.project and name in self.project.embedded_templates:
148 template_data = self.project.embedded_templates[name]
149 return Template.from_dict(template_data)
151 # Check if it's a built-in template
152 if name.startswith("[Built-in] "):
153 actual_name = name.replace("[Built-in] ", "")
154 template_path = self._get_builtin_templates_directory() / f"{actual_name}.json"
155 else:
156 # User template
157 template_path = self.templates_dir / f"{name}.json"
159 if not template_path.exists():
160 raise FileNotFoundError(f"Template '{name}' not found")
162 return Template.load_from_file(str(template_path))
164 def save_template(self, template: Template, embed_in_project: bool = False):
165 """
166 Save a template to filesystem or embed in project.
168 Args:
169 template: Template to save
170 embed_in_project: If True, embed in project instead of saving to filesystem
171 """
172 if embed_in_project and self.project:
173 # Embed in project
174 self.project.embedded_templates[template.name] = template.to_dict()
175 print(f"Embedded template '{template.name}' in project")
176 else:
177 # Save to filesystem
178 template_path = self.templates_dir / f"{template.name}.json"
179 template.save_to_file(str(template_path))
181 def delete_template(self, name: str):
182 """Delete a template (embedded or user templates only)"""
183 if name.startswith("[Built-in] "):
184 raise PermissionError("Cannot delete built-in templates")
186 # Check if it's an embedded template
187 if name.startswith("[Embedded] "):
188 actual_name = name.replace("[Embedded] ", "")
189 if self.project and actual_name in self.project.embedded_templates:
190 del self.project.embedded_templates[actual_name]
191 print(f"Removed embedded template '{actual_name}'")
192 return
193 raise FileNotFoundError(f"Embedded template '{actual_name}' not found")
195 # User template from filesystem
196 template_path = self.templates_dir / f"{name}.json"
197 if template_path.exists():
198 template_path.unlink()
200 def embed_template(self, template: Template):
201 """
202 Embed a template in the project.
204 Args:
205 template: Template to embed
206 """
207 if not self.project:
208 raise RuntimeError("No project associated with this TemplateManager")
210 self.project.embedded_templates[template.name] = template.to_dict()
211 print(f"Embedded template '{template.name}' in project")
213 def create_template_from_page(self, page: Page, name: str, description: str = "") -> Template:
214 """
215 Create a template from an existing page.
216 Converts all ImageData elements to PlaceholderData.
217 """
218 template = Template(name=name, description=description, page_size_mm=page.layout.size)
220 # Convert elements
221 for element in page.layout.elements:
222 if isinstance(element, ImageData):
223 # Convert image to placeholder
224 placeholder = PlaceholderData(
225 placeholder_type="image",
226 x=element.position[0],
227 y=element.position[1],
228 width=element.size[0],
229 height=element.size[1],
230 rotation=element.rotation,
231 z_index=element.z_index,
232 )
233 template.add_element(placeholder)
234 elif isinstance(element, TextBoxData):
235 # Keep text boxes as-is
236 text_box = TextBoxData(
237 text_content=element.text_content,
238 font_settings=element.font_settings,
239 alignment=element.alignment,
240 x=element.position[0],
241 y=element.position[1],
242 width=element.size[0],
243 height=element.size[1],
244 rotation=element.rotation,
245 z_index=element.z_index,
246 )
247 template.add_element(text_box)
248 elif isinstance(element, PlaceholderData):
249 # Keep placeholders as-is
250 placeholder = PlaceholderData(
251 placeholder_type=element.placeholder_type,
252 default_content=element.default_content,
253 x=element.position[0],
254 y=element.position[1],
255 width=element.size[0],
256 height=element.size[1],
257 rotation=element.rotation,
258 z_index=element.z_index,
259 )
260 template.add_element(placeholder)
262 return template
264 def scale_template_elements(
265 self,
266 elements: List[BaseLayoutElement],
267 from_size: Tuple[float, float],
268 to_size: Tuple[float, float],
269 scale_mode: str = "proportional",
270 margin_percent: float = 0.0,
271 ) -> List[BaseLayoutElement]:
272 """
273 Scale template elements to fit target page size with adjustable margins.
275 Args:
276 elements: List of elements to scale
277 from_size: Original template size (width, height) in mm
278 to_size: Target page size (width, height) in mm
279 scale_mode: "proportional", "stretch", or "center"
280 margin_percent: Percentage of page size to use for margins (0-10%)
282 Returns:
283 List of scaled elements
284 """
285 from_width, from_height = from_size
286 to_width, to_height = to_size
288 # Calculate target margins from percentage
289 margin_x = to_width * (margin_percent / 100.0)
290 margin_y = to_height * (margin_percent / 100.0)
292 # Available content area after margins
293 content_width = to_width - (2 * margin_x)
294 content_height = to_height - (2 * margin_y)
296 # Calculate scale factors based on mode
297 if scale_mode == "stretch":
298 # Stretch to fill content area independently in each dimension
299 scale_x = content_width / from_width
300 scale_y = content_height / from_height
301 offset_x = margin_x
302 offset_y = margin_y
303 elif scale_mode == "proportional":
304 # Maintain aspect ratio - scale uniformly to fit content area
305 scale = min(content_width / from_width, content_height / from_height)
306 scale_x = scale
307 scale_y = scale
308 # Center the scaled content within the page
309 scaled_width = from_width * scale
310 scaled_height = from_height * scale
311 offset_x = (to_width - scaled_width) / 2
312 offset_y = (to_height - scaled_height) / 2
313 else: # "center"
314 # No scaling, just center on page
315 scale_x = 1.0
316 scale_y = 1.0
317 offset_x = (to_width - from_width) / 2
318 offset_y = (to_height - from_height) / 2
320 scaled_elements: List[BaseLayoutElement] = []
321 for element in elements:
322 # Create a new element of the same type
323 new_elem: BaseLayoutElement
324 if isinstance(element, PlaceholderData):
325 new_elem = PlaceholderData(
326 placeholder_type=element.placeholder_type, default_content=element.default_content
327 )
328 elif isinstance(element, TextBoxData):
329 new_elem = TextBoxData(
330 text_content=element.text_content,
331 font_settings=element.font_settings.copy() if element.font_settings else None,
332 alignment=element.alignment,
333 )
334 else:
335 continue # Skip other types
337 # Scale position and size (still in mm)
338 old_x, old_y = element.position
339 old_w, old_h = element.size
341 new_elem.position = (old_x * scale_x + offset_x, old_y * scale_y + offset_y)
342 new_elem.size = (old_w * scale_x, old_h * scale_y)
343 new_elem.rotation = element.rotation
344 new_elem.z_index = element.z_index
346 scaled_elements.append(new_elem)
348 # Convert all elements from mm to pixels (DPI conversion)
349 # The rest of the application uses pixels, not mm
350 dpi = 300 # Default DPI (should match project working_dpi if available)
351 if self.project:
352 dpi = self.project.working_dpi
354 mm_to_px = dpi / 25.4
356 for elem in scaled_elements:
357 # Convert position from mm to pixels
358 elem.position = (elem.position[0] * mm_to_px, elem.position[1] * mm_to_px)
359 # Convert size from mm to pixels
360 elem.size = (elem.size[0] * mm_to_px, elem.size[1] * mm_to_px)
362 return scaled_elements
364 def apply_template_to_page(
365 self,
366 template: Template,
367 page: Page,
368 mode: str = "replace",
369 scale_mode: str = "proportional",
370 margin_percent: float = 2.5,
371 auto_embed: bool = True,
372 ):
373 """
374 Apply template to an existing page with adjustable margins.
376 Args:
377 template: Template to apply
378 page: Target page
379 mode: "replace" to clear page and add placeholders,
380 "reflow" to keep existing content and reposition
381 scale_mode: "proportional", "stretch", or "center"
382 margin_percent: Percentage of page size to use for margins (0-10%)
383 auto_embed: If True, automatically embed template in project
384 """
385 # Auto-embed template if requested and not already embedded
386 if auto_embed and self.project:
387 if template.name not in self.project.embedded_templates:
388 self.embed_template(template)
390 if mode == "replace":
391 # Clear existing elements
392 page.layout.elements.clear()
394 # Scale template elements to fit page
395 scaled_elements = self.scale_template_elements(
396 template.elements, template.page_size_mm, page.layout.size, scale_mode, margin_percent
397 )
399 # Add scaled elements to page
400 for element in scaled_elements:
401 page.layout.add_element(element)
403 elif mode == "reflow":
404 # Keep existing content but reposition to template slots
405 existing_images = [e for e in page.layout.elements if isinstance(e, ImageData)]
406 existing_text = [e for e in page.layout.elements if isinstance(e, TextBoxData)]
408 # Get template placeholders (scaled)
409 scaled_elements = self.scale_template_elements(
410 template.elements, template.page_size_mm, page.layout.size, scale_mode, margin_percent
411 )
413 template_placeholders = [e for e in scaled_elements if isinstance(e, PlaceholderData)]
414 template_text = [e for e in scaled_elements if isinstance(e, TextBoxData)]
416 # Clear page
417 page.layout.elements.clear()
419 # Reflow images into placeholder slots
420 for i, placeholder in enumerate(template_placeholders):
421 if i < len(existing_images):
422 # Use existing image, update position/size
423 img = existing_images[i]
424 img.position = placeholder.position
425 img.size = placeholder.size
426 img.z_index = placeholder.z_index
427 page.layout.add_element(img)
428 else:
429 # Add placeholder if no more images
430 page.layout.add_element(placeholder)
432 # Add remaining images (if any) at their original positions
433 for img in existing_images[len(template_placeholders) :]:
434 page.layout.add_element(img)
436 # Add template text boxes
437 for text_elem in template_text:
438 page.layout.add_element(text_elem)
440 def create_page_from_template(
441 self,
442 template: Template,
443 page_number: int = 1,
444 target_size_mm: Optional[Tuple[float, float]] = None,
445 scale_mode: str = "proportional",
446 margin_percent: float = 2.5,
447 auto_embed: bool = True,
448 ) -> Page:
449 """
450 Create a new page from a template.
452 Args:
453 template: Template to use
454 page_number: Page number for the new page
455 target_size_mm: Target page size (if different from template)
456 scale_mode: Scaling mode if target_size_mm is provided
457 margin_percent: Percentage of page size to use for margins (0-10%)
458 auto_embed: If True, automatically embed template in project
460 Returns:
461 New Page instance with template layout
462 """
463 # Auto-embed template if requested and not already embedded
464 if auto_embed and self.project:
465 if template.name not in self.project.embedded_templates:
466 self.embed_template(template)
468 # Determine page size
469 if target_size_mm is None:
470 page_size = template.page_size_mm
471 elements = [e for e in template.elements] # Copy elements as-is
472 else:
473 page_size = target_size_mm
474 # Scale template elements with margins
475 elements = self.scale_template_elements(
476 template.elements, template.page_size_mm, target_size_mm, scale_mode, margin_percent
477 )
479 # Create new page layout
480 layout = PageLayout(width=page_size[0], height=page_size[1])
482 # Add elements
483 for element in elements:
484 layout.add_element(element)
486 # Create and return page
487 page = Page(layout=layout, page_number=page_number)
488 return page