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
File without changes
@@ -0,0 +1,8 @@
1
+ """Entry point for Photo_Composition_Designer."""
2
+
3
+ import sys # pragma: no cover
4
+
5
+ from .cli.cli import main # pragma: no cover
6
+
7
+ if __name__ == "__main__": # pragma: no cover
8
+ sys.exit(main())
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '0.0.7'
32
+ __version_tuple__ = version_tuple = (0, 0, 7)
33
+
34
+ __commit_id__ = commit_id = None
File without changes
@@ -0,0 +1,8 @@
1
+ """CLI entry point for project_name."""
2
+
3
+ import sys # pragma: no cover
4
+
5
+ from .cli import main # pragma: no cover
6
+
7
+ if __name__ == "__main__": # pragma: no cover
8
+ sys.exit(main())
@@ -0,0 +1,106 @@
1
+ """CLI interface for Photo-Composition-Designer using the generic config framework.
2
+
3
+ This file uses the CliGenerator from the generic config framework.
4
+ """
5
+
6
+ import os.path
7
+
8
+ from config_cli_gui.cli import CliGenerator
9
+
10
+ from Photo_Composition_Designer.common.logging import initialize_logging
11
+ from Photo_Composition_Designer.config.config import ConfigParameterManager
12
+ from Photo_Composition_Designer.core.base import CompositionDesigner
13
+
14
+
15
+ def validate_config(config_manager: ConfigParameterManager) -> bool:
16
+ """Validate the configuration parameters.
17
+
18
+ Args:
19
+ config_manager: Configuration manager instance
20
+ logger: Logger instance for error reporting
21
+
22
+ Returns:
23
+ True if configuration is valid, False otherwise
24
+ """
25
+ # Initialize logging system
26
+ logger_manager = initialize_logging(config_manager)
27
+ logger = logger_manager.get_logger("Photo_Composition_Designer.cli")
28
+
29
+ # Get CLI category and check required parameters
30
+ cli_parameters = config_manager.get_cli_parameters()
31
+ if not cli_parameters:
32
+ logger.error("No CLI configuration found")
33
+
34
+ for param in cli_parameters:
35
+ if param.name == "photoDirectory":
36
+ photo_dir = param.value
37
+ if os.path.exists(photo_dir):
38
+ logger.debug(f"Input file validation passed: {photo_dir}")
39
+ return True
40
+ else:
41
+ logger.debug(f"Input file not found: {photo_dir}")
42
+
43
+ return False
44
+
45
+
46
+ def run_main_processing(config_manager: ConfigParameterManager) -> int:
47
+ """Main processing function that gets called by the CLI generator.
48
+
49
+ Args:
50
+ config_manager: Configuration manager with all settings
51
+
52
+ Returns:
53
+ Exit code (0 for success, non-zero for error)
54
+ """
55
+ # Initialize logging system
56
+ logger_manager = initialize_logging(config_manager)
57
+ logger = logger_manager.get_logger("Photo_Composition_Designer.cli")
58
+
59
+ try:
60
+ # Log startup information
61
+ logger.info("Starting Photo_Composition_Designer CLI")
62
+ logger_manager.log_config_summary()
63
+
64
+ # Validate configuration
65
+ if not validate_config(config_manager):
66
+ logger.error("Configuration validation failed")
67
+ return 1
68
+
69
+ # Create and run Composition Designer
70
+ logger.info("Starting conversion process")
71
+ composition_designer = CompositionDesigner(config_manager)
72
+ composition_designer.generate_compositions_from_folders()
73
+ logger.info("Conversion process completed")
74
+
75
+ logger.info("CLI processing completed successfully")
76
+ return 0
77
+
78
+ except Exception as e:
79
+ logger.error(f"Processing failed: {e}")
80
+ logger.debug("Full traceback:", exc_info=True)
81
+ return 1
82
+
83
+
84
+ def main():
85
+ """Main entry point for the CLI application."""
86
+ # Create the base configuration manager
87
+ config_manager = ConfigParameterManager()
88
+
89
+ # Create CLI generator
90
+ cli_generator = CliGenerator(
91
+ config_manager=config_manager, app_name="Photo_Composition_Designer"
92
+ )
93
+
94
+ # Run the CLI with our main processing function
95
+ return cli_generator.run_cli(
96
+ main_function=run_main_processing,
97
+ description="Process GPX files with various operations like compression, "
98
+ "merging, and POI extraction",
99
+ validator=validate_config,
100
+ )
101
+
102
+
103
+ if __name__ == "__main__":
104
+ import sys
105
+
106
+ sys.exit(main())
@@ -0,0 +1,93 @@
1
+ import os
2
+ from collections import defaultdict
3
+
4
+ from path_handler import get_base_path
5
+
6
+
7
+ class Anniversaries:
8
+ def __init__(self, anniversaries_file=None):
9
+ if not anniversaries_file:
10
+ base_path = get_base_path()
11
+ anniversaries_file = base_path / "anniversaries.ini"
12
+
13
+ self.anniversary_dict = defaultdict(str) # Dictionary fΓΌr die Anniversaries
14
+
15
+ if not os.path.exists(anniversaries_file):
16
+ return
17
+
18
+ # Preprocess the config file to remove comments and parse lines
19
+ with open(anniversaries_file, "r", encoding="utf-8") as file:
20
+ category = None
21
+ for line in file:
22
+ # Remove comments and strip whitespace
23
+ line = line.split(";", 1)[0].strip()
24
+ if not line: # Skip empty lines
25
+ continue
26
+
27
+ # Detect category headers
28
+ if line.startswith("[") and line.endswith("]"):
29
+ category = line[1:-1] # Extract category name
30
+ continue
31
+
32
+ # Skip invalid lines without a current category
33
+ if not category:
34
+ continue
35
+
36
+ # Process valid data lines within the category
37
+ self._process_line(line, category)
38
+
39
+ def _process_line(self, line, category):
40
+ """
41
+ Processes a single line of data and adds it to the anniversary dictionary.
42
+ """
43
+ if "=" not in line:
44
+ return # Skip malformed lines
45
+ name, date = map(str.strip, line.split("=", 1))
46
+
47
+ day, month, *year = date.split(".")
48
+ year = int(year[0]) if year and year[0] else None
49
+
50
+ # Define label formatters for each category
51
+ label_formatter = {
52
+ "Birthdays": lambda _name, _year: f"{_name} {str(_year)[-2:]}" if _year else _name,
53
+ "Dates of death": lambda _name, _year: f"{_name} ✝ {str(_year)[-2:]}"
54
+ if _year
55
+ else f"{_name} ✝",
56
+ "Weddings": lambda _name, _year: f"{_name} ⚭ {str(_year)[-2:]}"
57
+ if _year
58
+ else f"{_name} ⚭",
59
+ }.get(category, lambda _name, _year: _name) # Default formatter if category is unknown
60
+
61
+ label = label_formatter(name, year)
62
+ self._add_to_dict(int(day), int(month), label)
63
+
64
+ def _add_to_dict(self, day, month, label):
65
+ """
66
+ Adds an entry to the dictionary; merges labels in case of conflicts.
67
+ """
68
+ key = (day, month)
69
+ if key in self.anniversary_dict:
70
+ if label not in self.anniversary_dict[key]:
71
+ self.anniversary_dict[key] += f", {label}"
72
+ else:
73
+ self.anniversary_dict[key] = label
74
+
75
+ def __getitem__(self, key):
76
+ return self.anniversary_dict.get(key)
77
+
78
+ def __setitem__(self, key, value):
79
+ self.anniversary_dict[key] = value
80
+
81
+ def __contains__(self, key):
82
+ return key in self.anniversary_dict
83
+
84
+ def items(self):
85
+ return self.anniversary_dict.items()
86
+
87
+ def __repr__(self):
88
+ return f"Anniversaries({dict(self.anniversary_dict)})"
89
+
90
+
91
+ if __name__ == "__main__":
92
+ annis = Anniversaries()
93
+ print(annis.items())
@@ -0,0 +1,87 @@
1
+ import os
2
+ from collections import defaultdict
3
+
4
+ from path_handler import get_base_path
5
+
6
+
7
+ class Locations:
8
+ """
9
+ This class is used to provide position information if it is not saved in the image.
10
+ This class searches a locations.ini, which has the following structure below.
11
+
12
+ It provides a dict that contains locations as tuple:
13
+
14
+ [EUROPE]
15
+ London = 51.5074, -0.1278
16
+ Bavaria = 48.7904, 11.4979
17
+
18
+ [AMERICA]
19
+ United States = 37.0902, -95.7129
20
+
21
+ """
22
+
23
+ def __init__(self, locations_file=None):
24
+ if not locations_file:
25
+ base_path = get_base_path()
26
+ locations_file = base_path / "locations_en.ini"
27
+
28
+ self.locations_dict = defaultdict(tuple) # Dictionary for the locations
29
+
30
+ if not os.path.exists(locations_file):
31
+ return
32
+
33
+ # Preprocess the config file to remove comments and parse lines
34
+ with open(locations_file, "r", encoding="utf-8") as file:
35
+ current_category = None
36
+ for line in file:
37
+ # Remove comments and strip whitespace
38
+ line = line.split(";", 1)[0].strip()
39
+ if not line: # Skip empty lines
40
+ continue
41
+
42
+ # Detect category headers
43
+ if line.startswith("[") and line.endswith("]"):
44
+ current_category = line[1:-1]
45
+ continue
46
+
47
+ # Process valid data lines within the category
48
+ self._process_line(line, current_category)
49
+
50
+ def _process_line(self, line, category):
51
+ """
52
+ Processes a single line of data and adds it to the locations dictionary.
53
+ """
54
+ if "=" not in line:
55
+ return # Skip malformed lines
56
+ city, coordinates = map(str.strip, line.split("=", 1))
57
+ try:
58
+ lat, lon = map(float, coordinates.split(","))
59
+ self._add_to_dict(city, (lat, lon), category)
60
+ except Exception as e:
61
+ print(f'Error processing line "{line}": {e}')
62
+
63
+ def _add_to_dict(self, city: str, coordinates, _):
64
+ """
65
+ Adds an entry to the dictionary under the given category.
66
+ """
67
+ self.locations_dict[city.lower()] = coordinates
68
+
69
+ def __getitem__(self, key):
70
+ return self.locations_dict.get(key)
71
+
72
+ def __setitem__(self, key, value):
73
+ self.locations_dict[key] = value
74
+
75
+ def __contains__(self, key):
76
+ return key in self.locations_dict
77
+
78
+ def items(self):
79
+ return self.locations_dict.items()
80
+
81
+ def __repr__(self):
82
+ return f"Locations({dict(self.locations_dict)})"
83
+
84
+
85
+ if __name__ == "__main__":
86
+ locations = Locations()
87
+ print(locations.items())
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+
5
+ from astral import moon
6
+
7
+
8
+ class MoonPhase:
9
+ """Utility class for computing Unicode moon phase symbols."""
10
+
11
+ DETAILED = False
12
+
13
+ @staticmethod
14
+ def get_moon_phase_symbol_light(d: datetime) -> str:
15
+ """
16
+ Return the Unicode symbol representing the Moon's illuminated
17
+ phase for the given date.
18
+
19
+ This method uses the actual lunar phase from Astral and maps it
20
+ to a Unicode icon using `get_moon_symbol()`.
21
+ """
22
+ return MoonPhase.get_moon_symbol(moon.phase(d))
23
+
24
+ @staticmethod
25
+ def get_moon_phase_symbol_dark(d: datetime) -> str:
26
+ """
27
+ Return the Unicode symbol for the Moon as it would appear in
28
+ an inverted-illumination (dark-mode) scheme. This is helpful
29
+ for bright background.
30
+
31
+ This is computed by shifting the phase forward by 14 (half a
32
+ lunar cycle), effectively flipping the illuminated side.
33
+ """
34
+ return MoonPhase.get_moon_symbol((moon.phase(d) + 14) % 28)
35
+
36
+ @staticmethod
37
+ def get_moon_symbol(phase: float) -> str:
38
+ """
39
+ Map an Astral moon-phase value to a Unicode symbol.
40
+
41
+ Parameters
42
+ ----------
43
+ phase : float
44
+ The moon phase value from Astral (0–29.53 approx).
45
+
46
+ Returns
47
+ -------
48
+ str
49
+ A Unicode moon-phase emoji corresponding to the integer
50
+ phase bucket. Only major phases are returned unless
51
+ `DETAILED` is enabled.
52
+
53
+ Notes
54
+ -----
55
+ Recognized phases:
56
+
57
+ 0 β†’ πŸŒ‘ new moon
58
+ 4 β†’ πŸŒ’ waxing crescent (detailed mode only)
59
+ 7 β†’ πŸŒ“ first quarter
60
+ 10 β†’ πŸŒ” waxing gibbous (detailed mode only)
61
+ 14 β†’ πŸŒ• full moon
62
+ 18 β†’ πŸŒ– waning gibbous (detailed mode only)
63
+ 21 β†’ πŸŒ— last quarter
64
+ 25 β†’ 🌘 waning crescent (detailed mode only)
65
+
66
+ All other values return the empty string.
67
+ """
68
+ p = int(phase)
69
+ if p == 0:
70
+ return "πŸŒ‘"
71
+ if p == 4 and MoonPhase.DETAILED:
72
+ return "πŸŒ’"
73
+ if p == 7:
74
+ return "πŸŒ“"
75
+ if p == 10 and MoonPhase.DETAILED:
76
+ return "πŸŒ”"
77
+ if p == 14:
78
+ return "πŸŒ•"
79
+ if p == 18 and MoonPhase.DETAILED:
80
+ return "πŸŒ–"
81
+ if p == 21:
82
+ return "πŸŒ—"
83
+ if p == 25 and MoonPhase.DETAILED:
84
+ return "🌘"
85
+ return ""
@@ -0,0 +1,113 @@
1
+ import os
2
+ import re
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+
6
+ import exifread
7
+ from PIL import Image
8
+
9
+
10
+ class Photo:
11
+ DATE_PATTERN_FULL = re.compile(
12
+ r"(?:(\d{4})[-_]?(\d{2})[-_]?(\d{2})[-_]?(\d{2})[-_]?(\d{2})[-_]?(\d{2}))"
13
+ )
14
+ DATE_PATTERN_NO_TIME = re.compile(r"(?:(\d{4})[-_]?(\d{2})[-_]?(\d{2}))")
15
+
16
+ def __init__(self, file_path: Path, locations=None):
17
+ self.file_path: Path = Path(file_path)
18
+ self._locations: dict[str, tuple[float, float]] = locations
19
+ if not self.file_path.exists():
20
+ raise FileNotFoundError(f"File not found: {self.file_path}")
21
+
22
+ def get_location(self) -> tuple[float, float] | None:
23
+ return self.get_location_from_exif() or self.get_location_from_name()
24
+
25
+ def get_location_from_exif(self) -> tuple[float, float] | None:
26
+ """Returns the GPS coordinates from EXIF data if available."""
27
+ with open(self.file_path, "rb") as img_file:
28
+ tags = exifread.process_file(img_file, details=False)
29
+ if "GPS GPSLatitude" in tags and "GPS GPSLongitude" in tags:
30
+ lat = self._convert_to_decimal(tags["GPS GPSLatitude"].values)
31
+ lon = self._convert_to_decimal(tags["GPS GPSLongitude"].values)
32
+ if tags.get("GPS GPSLatitudeRef") and tags["GPS GPSLatitudeRef"].values[0] == "S":
33
+ lat = -lat
34
+ if tags.get("GPS GPSLongitudeRef") and tags["GPS GPSLongitudeRef"].values[0] == "W":
35
+ lon = -lon
36
+ return lat, lon
37
+ return None
38
+
39
+ def get_location_from_name(self) -> tuple[float, float] | None:
40
+ location = self._locations
41
+ file_name = self.file_path.name.lower()
42
+
43
+ for place in location:
44
+ if re.search(rf"\b{re.escape(place.lower())}\b", file_name):
45
+ return location[place]
46
+
47
+ return None
48
+
49
+ def get_date(self) -> datetime | None:
50
+ """Returns the date from EXIF data or filename if available."""
51
+ date = self._extract_date_from_exif()
52
+ if date:
53
+ return date
54
+ return self._extract_date_from_filename()
55
+
56
+ def get_image(self) -> Image.Image | None:
57
+ """Returns an Image object if the file can be opened."""
58
+ try:
59
+ return Image.open(self.file_path)
60
+ except Exception as e:
61
+ print(f"Error opening image: {e}")
62
+ return None
63
+
64
+ @staticmethod
65
+ def _convert_to_decimal(dms) -> float:
66
+ """Converts degrees, minutes, and seconds to decimal degrees."""
67
+ return float(dms[0]) + float(dms[1]) / 60 + float(dms[2]) / 3600
68
+
69
+ def _extract_date_from_exif(self) -> datetime | None:
70
+ """Reads EXIF date, if available."""
71
+ with open(self.file_path, "rb") as img_file:
72
+ tags = exifread.process_file(img_file, details=False)
73
+ if "EXIF DateTimeOriginal" in tags:
74
+ try:
75
+ date_str = str(tags["EXIF DateTimeOriginal"])
76
+ return datetime.strptime(date_str, "%Y:%m:%d %H:%M:%S")
77
+ except ValueError:
78
+ pass
79
+ return None
80
+
81
+ def _extract_date_from_filename(self) -> datetime | None:
82
+ """Attempts to extract date from file name."""
83
+ match = self.DATE_PATTERN_FULL.search(self.file_path.name)
84
+ if match:
85
+ return datetime(*map(int, match.groups()))
86
+ match = self.DATE_PATTERN_NO_TIME.search(self.file_path.name)
87
+ if match:
88
+ return datetime(*map(int, match.groups()), 12, 0, 0)
89
+ return datetime.max
90
+
91
+
92
+ def get_photos_from_dir(
93
+ image_folder: Path, locations: dict[str, tuple[float, float]] = None
94
+ ) -> list["Photo"] | None:
95
+ """Liest alle Bilddateien aus einem Ordner ein und gibt eine Liste von Photo-Objekten zurΓΌck."""
96
+
97
+ folder_path = Path(image_folder)
98
+
99
+ if not folder_path.is_dir():
100
+ raise ValueError(f"Folder '{image_folder}' does not exist.")
101
+
102
+ # Alle Bilddateien sammeln
103
+ image_files = [
104
+ os.path.join(image_folder, file)
105
+ for file in sorted(os.listdir(image_folder))
106
+ if file.lower().endswith((".png", ".jpg", ".jpeg"))
107
+ ]
108
+
109
+ if not image_files:
110
+ print(f"No images found in '{image_folder}'.")
111
+ return None
112
+
113
+ return [Photo(Path(file), locations) for file in image_files] # Photo-Objekte erstellen
File without changes