Photo-Composition-Designer 0.0.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. Photo_Composition_Designer/__init__.py +0 -0
  2. Photo_Composition_Designer/__main__.py +8 -0
  3. Photo_Composition_Designer/_version.py +34 -0
  4. Photo_Composition_Designer/cli/__init__.py +0 -0
  5. Photo_Composition_Designer/cli/__main__.py +8 -0
  6. Photo_Composition_Designer/cli/cli.py +106 -0
  7. Photo_Composition_Designer/common/Anniversaries.py +93 -0
  8. Photo_Composition_Designer/common/Locations.py +87 -0
  9. Photo_Composition_Designer/common/MoonPhase.py +85 -0
  10. Photo_Composition_Designer/common/Photo.py +113 -0
  11. Photo_Composition_Designer/common/__init__.py +0 -0
  12. Photo_Composition_Designer/common/logging.py +216 -0
  13. Photo_Composition_Designer/config/__init__.py +0 -0
  14. Photo_Composition_Designer/config/config.py +321 -0
  15. Photo_Composition_Designer/core/__init__.py +0 -0
  16. Photo_Composition_Designer/core/base.py +383 -0
  17. Photo_Composition_Designer/gui/GuiLogWriter.py +79 -0
  18. Photo_Composition_Designer/gui/__init__.py +0 -0
  19. Photo_Composition_Designer/gui/__main__.py +8 -0
  20. Photo_Composition_Designer/gui/gui.py +565 -0
  21. Photo_Composition_Designer/image/CalendarRenderer.py +319 -0
  22. Photo_Composition_Designer/image/CollageRenderer.py +433 -0
  23. Photo_Composition_Designer/image/DescriptionRenderer.py +74 -0
  24. Photo_Composition_Designer/image/MapRenderer.py +101 -0
  25. Photo_Composition_Designer/image/__init__.py +0 -0
  26. Photo_Composition_Designer/tools/DescriptionsFileGenerator.py +44 -0
  27. Photo_Composition_Designer/tools/GeoPlotter.py +211 -0
  28. Photo_Composition_Designer/tools/Helpers.py +18 -0
  29. Photo_Composition_Designer/tools/ImageDistributor.py +153 -0
  30. Photo_Composition_Designer/tools/__init__.py +0 -0
  31. __init__.py +0 -0
  32. firewall_handler.py +198 -0
  33. main.py +146 -0
  34. path_handler.py +10 -0
  35. photo_composition_designer-0.0.7.dist-info/METADATA +205 -0
  36. photo_composition_designer-0.0.7.dist-info/RECORD +40 -0
  37. photo_composition_designer-0.0.7.dist-info/WHEEL +5 -0
  38. photo_composition_designer-0.0.7.dist-info/entry_points.txt +3 -0
  39. photo_composition_designer-0.0.7.dist-info/licenses/LICENSE +24 -0
  40. photo_composition_designer-0.0.7.dist-info/top_level.txt +5 -0
@@ -0,0 +1,383 @@
1
+ # Photo_Composition_Designer/core/base.py
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import re
6
+ from datetime import timedelta
7
+ from pathlib import Path
8
+
9
+ from PIL import Image, ImageDraw
10
+
11
+ from Photo_Composition_Designer.common.Locations import Locations
12
+ from Photo_Composition_Designer.common.Photo import Photo, get_photos_from_dir
13
+ from Photo_Composition_Designer.config.config import ConfigParameterManager
14
+ from Photo_Composition_Designer.image.CalendarRenderer import (
15
+ CalendarRenderer,
16
+ from_config,
17
+ )
18
+ from Photo_Composition_Designer.image.CollageRenderer import CollageRenderer
19
+ from Photo_Composition_Designer.image.DescriptionRenderer import DescriptionRenderer
20
+ from Photo_Composition_Designer.image.MapRenderer import MapRenderer
21
+ from Photo_Composition_Designer.tools.Helpers import load_font
22
+
23
+
24
+ class CompositionDesigner:
25
+ """
26
+ CompositionDesigner adapted to the new ConfigParameterManager.
27
+
28
+ - Converts mm-based sizes in the config to pixels using config.size.dpi.value
29
+ - Uses create_calendar_generator_from_config to create a CalendarGenerator
30
+ - Accesses parameters through config.<category>.<param>.value
31
+ """
32
+
33
+ def __init__(self, config: ConfigParameterManager | None = None):
34
+ self.config = config or ConfigParameterManager()
35
+ self.dpi: int = int(self.config.size.dpi.value)
36
+ # load locations config path and create Locations instance
37
+ locations_cfg_path = Path(self.config.general.locationsConfig.value)
38
+ self.locations = Locations(locations_cfg_path).locations_dict
39
+
40
+ # mm-based -> pixel helper bound to this instance
41
+ self._mm_to_px = lambda mm: int(round(float(mm) * self.dpi / 25.4))
42
+ self.fontSizeSmall = int(
43
+ config.layout.fontSizeSmall.value
44
+ * config.size.calendarHeight.value
45
+ * config.size.dpi.value
46
+ / 25.4
47
+ )
48
+
49
+ # basic properties
50
+ self.compositionTitle: str | None = self.config.general.compositionTitle.value or ""
51
+ self.photoDir: Path = Path(self.config.general.photoDirectory.value).expanduser().resolve()
52
+ self.outputDir: Path = (self.photoDir.parent / "collages").resolve()
53
+ os.makedirs(self.outputDir, exist_ok=True)
54
+ self.descriptions = self._get_description(self.photoDir)
55
+
56
+ # size in pixels
57
+ self.width_px = self._mm_to_px(self.config.size.width.value)
58
+ self.height_px = self._mm_to_px(self.config.size.height.value)
59
+
60
+ # margins / spacing in pixels
61
+ self.margin_top_px = self._mm_to_px(self.config.layout.marginTop.value)
62
+ self.margin_bottom_px = self._mm_to_px(self.config.layout.marginBottom.value)
63
+ self.margin_sides_px = self._mm_to_px(self.config.layout.marginSides.value)
64
+ self.spacing_px = self._mm_to_px(self.config.layout.spacing.value)
65
+
66
+ # calendar sizes
67
+ self.calendar_height_px = self._mm_to_px(self.config.size.calendarHeight.value)
68
+ self.map_width_px = self._mm_to_px(self.config.size.mapWidth.value)
69
+ self.map_height_px = self._mm_to_px(self.config.size.mapHeight.value)
70
+
71
+ # colors (Color objects have .to_pil() in your calendar factory)
72
+ # Use the calendar factory which expects the full config object
73
+ self.calendarObj: CalendarRenderer = from_config(self.config)
74
+
75
+ # colors
76
+ background_color = self.config.colors.backgroundColor.value.to_pil()
77
+ text_color1 = self.config.colors.textColor1.value.to_pil()
78
+ text_color2 = self.config.colors.textColor2.value.to_pil()
79
+
80
+ # Create other helpers/generators — pass config object for them to pull values from.
81
+ # If their constructors changed, update these lines accordingly.
82
+ self.mapGenerator: MapRenderer = MapRenderer(
83
+ self.map_height_px,
84
+ self.map_width_px,
85
+ self.config.geo.minimalExtension.value,
86
+ background_color,
87
+ text_color1,
88
+ )
89
+ self.descGenerator: DescriptionRenderer = DescriptionRenderer(
90
+ self.width_px,
91
+ self.fontSizeSmall,
92
+ self.spacing_px,
93
+ self.margin_sides_px,
94
+ background_color,
95
+ text_color2,
96
+ )
97
+
98
+ # startDate: if title present we keep the previous behavior (shift -7 days)
99
+
100
+ # Photo layout manager expects pixel dims: width, collage_height, spacing, backgroundColor
101
+ collage_height_px = self.get_available_collage_height_px()
102
+ self.layoutManager: CollageRenderer = CollageRenderer(
103
+ self.width_px, collage_height_px, self.spacing_px, background_color
104
+ )
105
+ start_date_cfg = self.config.calendar.startDate.value
106
+ if self.compositionTitle:
107
+ self.startDate = start_date_cfg - timedelta(days=7)
108
+ else:
109
+ self.startDate = start_date_cfg
110
+
111
+ # ---------------------------------------------------------------------
112
+ # Helpers: unit conversions & derived sizes
113
+ # ---------------------------------------------------------------------
114
+ def get_available_collage_height_px(self) -> int:
115
+ """
116
+ Compute available vertical space for the collage area in pixels.
117
+ This subtracts calendar and description heights when configured.
118
+ """
119
+ available_height = self.height_px
120
+
121
+ # calendar or title reduces space
122
+ if self.config.calendar.useCalendar.value or bool(self.compositionTitle):
123
+ available_height -= self.calendar_height_px + self.margin_bottom_px + self.margin_top_px
124
+
125
+ # description area
126
+ if self.config.layout.usePhotoDescription.value:
127
+ # descGenerator should expose .height in pixels like before; if not, compute it
128
+ desc_height = getattr(self.descGenerator, "height", None)
129
+ if desc_height is None:
130
+ # fallback: estimate description height using layout font sizes & calendarHeight
131
+ desc_height = self._mm_to_px(self.config.size.calendarHeight.value // 4)
132
+ available_height -= desc_height
133
+
134
+ # ensure positive height
135
+ return max(0, int(available_height))
136
+
137
+ # ---------------------------------------------------------------------
138
+ # Composition rendering
139
+ # ---------------------------------------------------------------------
140
+ def _generate_composition(
141
+ self,
142
+ photos: list[Photo],
143
+ date,
144
+ photo_description: str = "",
145
+ is_title=False,
146
+ ) -> Image.Image:
147
+ """
148
+ Creates a composition with pictures, a calendar and a map of Europe with photo locations.
149
+ """
150
+ background_color = self.config.colors.backgroundColor.value.to_pil()
151
+ text_color2 = self.config.colors.textColor2.value.to_pil()
152
+
153
+ composition = Image.new("RGB", (self.width_px, self.height_px), background_color)
154
+ available_cal_width = self.width_px
155
+
156
+ # add title or calendar
157
+ if is_title and self.compositionTitle:
158
+ title_img = self.calendarObj.generateTitle(
159
+ self.compositionTitle, available_cal_width, self.calendar_height_px
160
+ )
161
+ composition.paste(
162
+ title_img, (self.margin_sides_px, self.height_px - self.calendar_height_px)
163
+ )
164
+ elif self.config.calendar.useCalendar.value:
165
+ if self.config.geo.usePhotoLocationMaps.value:
166
+ available_cal_width -= self.map_width_px + self.margin_sides_px
167
+ calendar_img = self.calendarObj.generate(
168
+ date, available_cal_width, self.calendar_height_px
169
+ )
170
+ composition.paste(
171
+ calendar_img,
172
+ (
173
+ self.margin_sides_px,
174
+ self.height_px - self.calendar_height_px - self.margin_bottom_px,
175
+ ),
176
+ )
177
+
178
+ # description area
179
+ if self.config.layout.usePhotoDescription.value:
180
+ description_img = self.descGenerator.generate(photo_description)
181
+ desc_h = getattr(self.descGenerator, "height", description_img.size[1])
182
+ composition.paste(
183
+ description_img,
184
+ (
185
+ 0,
186
+ self.height_px - self.calendar_height_px - desc_h - self.margin_bottom_px,
187
+ ),
188
+ )
189
+
190
+ if len(photos) == 0:
191
+ print("No pictures found.")
192
+ return composition
193
+
194
+ # Arrange image composition
195
+ collage = self.layoutManager.generate([photo.get_image() for photo in photos])
196
+ composition.paste(collage, (0, self.margin_top_px))
197
+
198
+ # add location map (if configured and not the title page)
199
+ if self.config.geo.usePhotoLocationMaps.value and not is_title:
200
+ coordinates = [loc for photo in photos if (loc := photo.get_location()) is not None]
201
+ imgMap = self.mapGenerator.generate(coordinates)
202
+ composition.paste(
203
+ imgMap,
204
+ (
205
+ self.width_px - self.map_width_px - self.margin_sides_px,
206
+ self.height_px - self.map_height_px - self.margin_bottom_px,
207
+ ),
208
+ )
209
+
210
+ # draw dates of images into lower-right corner (max 3 unique dates)
211
+ image_dates = [d for photo in photos if (d := photo.get_date()) is not None]
212
+ unique_dates = set()
213
+ date_str = ""
214
+ for d in image_dates:
215
+ formatted = d.strftime("%d. %b ")
216
+ if formatted not in unique_dates:
217
+ unique_dates.add(formatted)
218
+ date_str += formatted
219
+ if len(unique_dates) >= 3:
220
+ break
221
+
222
+ draw = ImageDraw.Draw(composition)
223
+
224
+ # Build small font size in px:
225
+ # In CalendarGenerator factory you used:
226
+ # font_px = layout.fontSizeSmall.value * size.calendarHeight.value * dpi / 25.4
227
+ font_small_px = int(
228
+ float(self.config.layout.fontSizeSmall.value)
229
+ * float(self.config.size.calendarHeight.value)
230
+ * self.dpi
231
+ / 25.4
232
+ * 0.8
233
+ )
234
+ # fallback
235
+ if font_small_px <= 0:
236
+ font_small_px = max(10, int(self.dpi * 0.04))
237
+
238
+ font = load_font("DejaVuSans.ttf", font_small_px)
239
+
240
+ # Anchor rd expects coordinates relative to lower-right;
241
+ # to put text inside margins we shift left/up
242
+ x = self.width_px - self.margin_sides_px
243
+ y = self.height_px - self.margin_bottom_px
244
+ draw.text((x, y), date_str, font=font, fill=text_color2, anchor="rd")
245
+
246
+ return composition
247
+
248
+ @staticmethod
249
+ def _get_description(folder_path: Path) -> list[str]:
250
+ """
251
+ Search for a .txt file in the folder and return list(lines) without leading 'Label: ' parts.
252
+ Returns empty string or list when none found.
253
+ """
254
+ photo_description: list[str] = [""]
255
+ if not folder_path.exists():
256
+ return photo_description
257
+ text_files = [
258
+ folder_path / file
259
+ for file in sorted(os.listdir(folder_path))
260
+ if file.lower().endswith(".txt")
261
+ ]
262
+ if text_files:
263
+ text_file = text_files[0]
264
+ with open(text_file, "r", encoding="utf-8") as f:
265
+ lines = [line.strip() for line in f.readlines() if line.strip()]
266
+ photo_description = [re.sub(r"^[^:]*:\s*", "", line) for line in lines]
267
+ if not photo_description:
268
+ photo_description = [text_file.stem]
269
+ return photo_description
270
+
271
+ def generate_compositions_from_folder(self, folder_name: str) -> Image.Image | None:
272
+ """
273
+ Generates a single collage for the given folder name.
274
+ Returns True if a composition was generated, False if skipped.
275
+ """
276
+ folder_path = self.photoDir / folder_name
277
+
278
+ if not folder_path.is_dir():
279
+ print(f"{folder_path} is not a valid directory. Skipping...")
280
+ return None
281
+
282
+ # Extract photos
283
+ photos = get_photos_from_dir(folder_path, self.locations)
284
+ if not photos:
285
+ print(f"No images found in {folder_path}, skipping...")
286
+ return None
287
+
288
+ # Determine description (folder-level overrides global)
289
+ # Week index must be inferred from folder ordering
290
+ sorted_folders = sorted(
291
+ [f for f in os.listdir(self.photoDir) if (self.photoDir / f).is_dir()]
292
+ )
293
+ try:
294
+ week_index = sorted_folders.index(folder_name)
295
+ except ValueError:
296
+ print(f"Folder '{folder_name}' not found in photoDirectory (unexpected).")
297
+ return None
298
+
299
+ global_description = (
300
+ self.descriptions[week_index] if week_index < len(self.descriptions) else ""
301
+ )
302
+ collage_description: str = self._get_description(folder_path)[0] or global_description
303
+
304
+ start_date = self.startDate + timedelta(weeks=week_index)
305
+
306
+ composition = self._generate_composition(
307
+ photos, start_date, collage_description, is_title=week_index == 0
308
+ )
309
+
310
+ return composition
311
+
312
+ def generate_compositions_from_folders(self):
313
+ """
314
+ Generates collages for all weeks from the specified folder.
315
+ """
316
+ # Precompute folder order for consistent week indexing
317
+ sorted_folders = sorted(
318
+ [f for f in os.listdir(self.photoDir) if (self.photoDir / f).is_dir()]
319
+ )
320
+
321
+ for folder_name in sorted_folders:
322
+ # We call the new method, which handles everything
323
+ composition = self.generate_compositions_from_folder(folder_name)
324
+ if composition:
325
+ self.save(composition, folder_name)
326
+
327
+ if self.config.layout.generatePdf.value:
328
+ self.generate_pdf(self.outputDir)
329
+
330
+ def save(self, composition: Image.Image, element: str):
331
+ # save with configured quality/dpi
332
+ output_prefix = f"collage_{element}"
333
+ output_file_name = f"{output_prefix}.jpg"
334
+ output_path = self.outputDir / output_file_name
335
+ jpg_quality = int(self.config.size.jpgQuality.value)
336
+ dpi_tuple = (self.dpi, self.dpi)
337
+ composition.save(output_path, quality=jpg_quality, dpi=dpi_tuple)
338
+ print(f"Composition saved: {output_path}")
339
+
340
+ def generate_pdf(self, collages_dir: Path | str, output_pdf: str = "output.pdf"):
341
+ """
342
+ Creates a PDF file from all images in a directory.
343
+ """
344
+ collages_dir = Path(collages_dir)
345
+ image_extensions = {".jpg", ".jpeg", ".png", ".bmp", ".gif"}
346
+
347
+ image_files = sorted(
348
+ [
349
+ f
350
+ for f in os.listdir(collages_dir)
351
+ if os.path.splitext(f)[1].lower() in image_extensions
352
+ ]
353
+ )
354
+
355
+ if not image_files:
356
+ print("No images found in the directory.")
357
+ return
358
+
359
+ image_list: list[Image.Image] = []
360
+ for image_file in image_files:
361
+ img_path = collages_dir / image_file
362
+ img = Image.open(img_path).convert("RGB")
363
+ image_list.append(img)
364
+
365
+ first_image, *remaining_images = image_list
366
+ output_path = collages_dir / output_pdf
367
+ first_image.save(
368
+ str(output_path),
369
+ save_all=True,
370
+ append_images=remaining_images,
371
+ quality=int(self.config.size.jpgQuality.value),
372
+ dpi=(self.dpi, self.dpi),
373
+ )
374
+ print(f"PDF successfully created: {output_path}")
375
+
376
+
377
+ if __name__ == "__main__":
378
+ # Example usage: read default config (or pass path to config file)
379
+ cfg_file = None
380
+ # If you want to use a specific config file, you can set cfg_file = "config/config.yaml"
381
+ cfg = ConfigParameterManager(cfg_file)
382
+ cd = CompositionDesigner(cfg)
383
+ cd.generate_compositions_from_folders()
@@ -0,0 +1,79 @@
1
+ import os
2
+ import subprocess
3
+ import sys
4
+ import tkinter as tk
5
+ from pathlib import Path
6
+
7
+ from Photo_Composition_Designer.common.logging import get_logger
8
+
9
+
10
+ class GuiLogWriter:
11
+ """Log writer that handles GUI text widget updates in a thread-safe way."""
12
+
13
+ def __init__(self, text_widget):
14
+ self.text_widget = text_widget
15
+ self.root = text_widget.winfo_toplevel()
16
+ self.hyperlink_tags = {} # To store clickable links
17
+
18
+ def write(self, text):
19
+ """Write text to the widget in a thread-safe manner."""
20
+ # Schedule the GUI update in the main thread
21
+ self.root.after(0, self._update_text, text)
22
+
23
+ def _update_text(self, text):
24
+ """Update the text widget (must be called from main thread)."""
25
+ try:
26
+ current_end = self.text_widget.index(tk.END)
27
+ self.text_widget.insert(tk.END, text)
28
+
29
+ # Check for a directory path (simplified regex for common path formats)
30
+ # This regex looks for paths that start with a drive letter (C:\), a forward slash (/)
31
+ # or a backslash (\) followed by word characters, and ends with a word character.
32
+ # This is a basic approach; more robust path detection might be needed for edge cases.
33
+ import re
34
+
35
+ path_match = re.search(
36
+ r"([A-Za-z]:[\\/][\S ]*|[\\][\\/][\S ]*|[\w/.-]+[/][\S ]*)\b", text
37
+ )
38
+ if path_match:
39
+ path = path_match.group(0).strip()
40
+ # Ensure the path exists and is a directory to make it clickable
41
+ if Path(path).is_dir():
42
+ start_index = self.text_widget.search(path, current_end, tk.END)
43
+ if start_index:
44
+ end_index = f"{start_index}+{len(path)}c"
45
+ tag_name = f"link_{len(self.hyperlink_tags)}"
46
+ self.text_widget.tag_config(tag_name, foreground="blue", underline=True)
47
+ self.text_widget.tag_bind(
48
+ tag_name, "<Button-1>", lambda e, p=path: self._open_path_in_explorer(p)
49
+ )
50
+ self.text_widget.tag_bind(
51
+ tag_name, "<Enter>", lambda e: self.text_widget.config(cursor="hand2")
52
+ )
53
+ self.text_widget.tag_bind(
54
+ tag_name, "<Leave>", lambda e: self.text_widget.config(cursor="")
55
+ )
56
+ self.text_widget.tag_add(tag_name, start_index, end_index)
57
+ self.hyperlink_tags[tag_name] = path
58
+
59
+ self.text_widget.see(tk.END)
60
+ self.text_widget.update_idletasks()
61
+ except tk.TclError:
62
+ # Widget might be destroyed
63
+ pass
64
+
65
+ def _open_path_in_explorer(self, path):
66
+ """Opens the given path in the file explorer."""
67
+ try:
68
+ if sys.platform == "win32":
69
+ os.startfile(path)
70
+ elif sys.platform == "darwin":
71
+ subprocess.Popen(["open", path])
72
+ else:
73
+ subprocess.Popen(["xdg-open", path])
74
+ except Exception as e:
75
+ get_logger("gui.main").error(f"Failed to open path {path}: {e}")
76
+
77
+ def flush(self):
78
+ """Flush method for compatibility."""
79
+ pass
File without changes
@@ -0,0 +1,8 @@
1
+ """GUI entry point for project_name."""
2
+
3
+ import sys # pragma: no cover
4
+
5
+ from .gui import main # pragma: no cover
6
+
7
+ if __name__ == "__main__": # pragma: no cover
8
+ sys.exit(main())