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

1""" 

2Template management system for pyPhotoAlbum 

3""" 

4 

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 

12 

13 

14class Template: 

15 """Class representing a page layout template""" 

16 

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] = [] 

24 

25 def add_element(self, element: BaseLayoutElement): 

26 """Add an element to the template""" 

27 self.elements.append(element) 

28 

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 } 

37 

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 ) 

46 

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 

57 

58 elem.deserialize(elem_data) 

59 template.add_element(elem) 

60 

61 return template 

62 

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) 

67 

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) 

74 

75 

76class TemplateManager: 

77 """Manager for template operations""" 

78 

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 

83 

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 

90 

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" 

96 

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) 

100 

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) 

104 

105 def list_templates(self) -> List[str]: 

106 """List all available template names (embedded + user + built-in)""" 

107 templates = [] 

108 

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}") 

113 

114 # List user templates 

115 if self.templates_dir.exists(): 

116 for file in self.templates_dir.glob("*.json"): 

117 templates.append(file.stem) 

118 

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) 

125 

126 return sorted(templates) 

127 

128 def load_template(self, name: str) -> Template: 

129 """ 

130 Load a template by name with priority: embedded > user > built-in. 

131 

132 Args: 

133 name: Template name (may include prefix like '[Embedded]' or '[Built-in]') 

134 

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") 

145 

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) 

150 

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" 

158 

159 if not template_path.exists(): 

160 raise FileNotFoundError(f"Template '{name}' not found") 

161 

162 return Template.load_from_file(str(template_path)) 

163 

164 def save_template(self, template: Template, embed_in_project: bool = False): 

165 """ 

166 Save a template to filesystem or embed in project. 

167 

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)) 

180 

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") 

185 

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") 

194 

195 # User template from filesystem 

196 template_path = self.templates_dir / f"{name}.json" 

197 if template_path.exists(): 

198 template_path.unlink() 

199 

200 def embed_template(self, template: Template): 

201 """ 

202 Embed a template in the project. 

203 

204 Args: 

205 template: Template to embed 

206 """ 

207 if not self.project: 

208 raise RuntimeError("No project associated with this TemplateManager") 

209 

210 self.project.embedded_templates[template.name] = template.to_dict() 

211 print(f"Embedded template '{template.name}' in project") 

212 

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) 

219 

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) 

261 

262 return template 

263 

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. 

274 

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%) 

281 

282 Returns: 

283 List of scaled elements 

284 """ 

285 from_width, from_height = from_size 

286 to_width, to_height = to_size 

287 

288 # Calculate target margins from percentage 

289 margin_x = to_width * (margin_percent / 100.0) 

290 margin_y = to_height * (margin_percent / 100.0) 

291 

292 # Available content area after margins 

293 content_width = to_width - (2 * margin_x) 

294 content_height = to_height - (2 * margin_y) 

295 

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 

319 

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 

336 

337 # Scale position and size (still in mm) 

338 old_x, old_y = element.position 

339 old_w, old_h = element.size 

340 

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 

345 

346 scaled_elements.append(new_elem) 

347 

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 

353 

354 mm_to_px = dpi / 25.4 

355 

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) 

361 

362 return scaled_elements 

363 

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. 

375 

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) 

389 

390 if mode == "replace": 

391 # Clear existing elements 

392 page.layout.elements.clear() 

393 

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 ) 

398 

399 # Add scaled elements to page 

400 for element in scaled_elements: 

401 page.layout.add_element(element) 

402 

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)] 

407 

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 ) 

412 

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)] 

415 

416 # Clear page 

417 page.layout.elements.clear() 

418 

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) 

431 

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) 

435 

436 # Add template text boxes 

437 for text_elem in template_text: 

438 page.layout.add_element(text_elem) 

439 

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. 

451 

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 

459 

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) 

467 

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 ) 

478 

479 # Create new page layout 

480 layout = PageLayout(width=page_size[0], height=page_size[1]) 

481 

482 # Add elements 

483 for element in elements: 

484 layout.add_element(element) 

485 

486 # Create and return page 

487 page = Page(layout=layout, page_number=page_number) 

488 return page