1 """

2 Template management system for pyPhotoAlbum

3 """

4

5 import json

6 import os

7 from pathlib import Path

8 from typing import List, Dict, Any, Tuple, Optional

9 from pyPhotoAlbum.models import BaseLayoutElement, ImageData, PlaceholderData, TextBoxData

10 from pyPhotoAlbum.page_layout import PageLayout

11 from pyPhotoAlbum.project import Page

12

13

14 class 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

76 class 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