fotolab 0.34.1__tar.gz → 0.34.3__tar.gz

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 (43) hide show
  1. {fotolab-0.34.1/src/fotolab.egg-info → fotolab-0.34.3}/PKG-INFO +1 -1
  2. {fotolab-0.34.1 → fotolab-0.34.3}/pyproject.toml +8 -8
  3. {fotolab-0.34.1 → fotolab-0.34.3}/src/fotolab/__init__.py +12 -0
  4. {fotolab-0.34.1 → fotolab-0.34.3}/src/fotolab/subcommands/__init__.py +1 -0
  5. {fotolab-0.34.1 → fotolab-0.34.3}/src/fotolab/subcommands/animate.py +6 -30
  6. {fotolab-0.34.1 → fotolab-0.34.3}/src/fotolab/subcommands/auto.py +3 -10
  7. {fotolab-0.34.1 → fotolab-0.34.3}/src/fotolab/subcommands/border.py +14 -37
  8. fotolab-0.34.1/src/fotolab/subcommands/montage.py → fotolab-0.34.3/src/fotolab/subcommands/common.py +21 -46
  9. {fotolab-0.34.1 → fotolab-0.34.3}/src/fotolab/subcommands/contrast.py +27 -50
  10. {fotolab-0.34.1 → fotolab-0.34.3}/src/fotolab/subcommands/halftone.py +18 -41
  11. {fotolab-0.34.1 → fotolab-0.34.3}/src/fotolab/subcommands/info.py +3 -1
  12. fotolab-0.34.3/src/fotolab/subcommands/montage.py +72 -0
  13. {fotolab-0.34.1 → fotolab-0.34.3}/src/fotolab/subcommands/resize.py +59 -83
  14. {fotolab-0.34.1 → fotolab-0.34.3}/src/fotolab/subcommands/rotate.py +12 -35
  15. {fotolab-0.34.1 → fotolab-0.34.3}/src/fotolab/subcommands/sharpen.py +22 -43
  16. {fotolab-0.34.1 → fotolab-0.34.3}/src/fotolab/subcommands/watermark.py +17 -38
  17. {fotolab-0.34.1 → fotolab-0.34.3/src/fotolab.egg-info}/PKG-INFO +1 -1
  18. {fotolab-0.34.1 → fotolab-0.34.3}/src/fotolab.egg-info/SOURCES.txt +1 -0
  19. fotolab-0.34.3/tests/test_resize_subcommand.py +63 -0
  20. fotolab-0.34.1/tests/test_resize_subcommand.py +0 -20
  21. {fotolab-0.34.1 → fotolab-0.34.3}/LICENSE.md +0 -0
  22. {fotolab-0.34.1 → fotolab-0.34.3}/README.md +0 -0
  23. {fotolab-0.34.1 → fotolab-0.34.3}/setup.cfg +0 -0
  24. {fotolab-0.34.1 → fotolab-0.34.3}/src/fotolab/__main__.py +0 -0
  25. {fotolab-0.34.1 → fotolab-0.34.3}/src/fotolab/cli.py +0 -0
  26. {fotolab-0.34.1 → fotolab-0.34.3}/src/fotolab/subcommands/env.py +0 -0
  27. {fotolab-0.34.1 → fotolab-0.34.3}/src/fotolab.egg-info/dependency_links.txt +0 -0
  28. {fotolab-0.34.1 → fotolab-0.34.3}/src/fotolab.egg-info/entry_points.txt +0 -0
  29. {fotolab-0.34.1 → fotolab-0.34.3}/src/fotolab.egg-info/requires.txt +0 -0
  30. {fotolab-0.34.1 → fotolab-0.34.3}/src/fotolab.egg-info/top_level.txt +0 -0
  31. {fotolab-0.34.1 → fotolab-0.34.3}/tests/test_animate_subcommand.py +0 -0
  32. {fotolab-0.34.1 → fotolab-0.34.3}/tests/test_auto_subcommand.py +0 -0
  33. {fotolab-0.34.1 → fotolab-0.34.3}/tests/test_border_subcommand.py +0 -0
  34. {fotolab-0.34.1 → fotolab-0.34.3}/tests/test_contrast_subcommand.py +0 -0
  35. {fotolab-0.34.1 → fotolab-0.34.3}/tests/test_env_subcommand.py +0 -0
  36. {fotolab-0.34.1 → fotolab-0.34.3}/tests/test_halftone_subcommand.py +0 -0
  37. {fotolab-0.34.1 → fotolab-0.34.3}/tests/test_help_flag.py +0 -0
  38. {fotolab-0.34.1 → fotolab-0.34.3}/tests/test_info_subcommand.py +0 -0
  39. {fotolab-0.34.1 → fotolab-0.34.3}/tests/test_montage_subcommand.py +0 -0
  40. {fotolab-0.34.1 → fotolab-0.34.3}/tests/test_quiet_flag.py +0 -0
  41. {fotolab-0.34.1 → fotolab-0.34.3}/tests/test_rotate_subcommand.py +0 -0
  42. {fotolab-0.34.1 → fotolab-0.34.3}/tests/test_sharpen_subcommand.py +0 -0
  43. {fotolab-0.34.1 → fotolab-0.34.3}/tests/test_watermark_subcommand.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fotolab
3
- Version: 0.34.1
3
+ Version: 0.34.3
4
4
  Summary: A console program to manipulate photos.
5
5
  Author-email: Kian-Meng Ang <kianmeng@cpan.org>
6
6
  License-Expression: AGPL-3.0-or-later
@@ -1,13 +1,6 @@
1
- [build-system]
2
- requires = ["setuptools>=61.0"]
3
- build-backend = "setuptools.build_meta"
4
-
5
- [tool.setuptools.packages.find]
6
- where = ["src"]
7
-
8
1
  [project]
9
2
  name = "fotolab"
10
- version = "0.34.1"
3
+ version = "0.34.3"
11
4
  description = "A console program to manipulate photos."
12
5
  authors = [{name = "Kian-Meng Ang", email = "kianmeng@cpan.org"}]
13
6
  requires-python = ">=3.10"
@@ -59,6 +52,13 @@ lint = [
59
52
  "ruff",
60
53
  ]
61
54
 
55
+ [build-system]
56
+ requires = ["setuptools>=61.0"]
57
+ build-backend = "setuptools.build_meta"
58
+
59
+ [tool.setuptools.packages.find]
60
+ where = ["src"]
61
+
62
62
  # verify through: uv run ruff check --show-settings
63
63
  [tool.ruff]
64
64
  line-length = 79
@@ -22,6 +22,8 @@ import logging
22
22
  import os
23
23
  import subprocess
24
24
  import sys
25
+ from contextlib import contextmanager
26
+ from typing import Iterator
25
27
 
26
28
  from PIL import Image
27
29
 
@@ -98,3 +100,13 @@ def open_image(filename: Path):
98
100
 
99
101
  except (OSError, FileNotFoundError) as error:
100
102
  log.error("Error opening image: %s -> %s", filename, error)
103
+
104
+
105
+ @contextmanager
106
+ def load_image(filename: Path) -> Iterator[Image.Image]:
107
+ """Load image using a context manager to ensure file handle is closed."""
108
+ try:
109
+ image = Image.open(filename)
110
+ yield image
111
+ finally:
112
+ image.close()
@@ -26,6 +26,7 @@ def build_subparser(subparsers):
26
26
  subcommands = {
27
27
  name: importlib.import_module(name)
28
28
  for finder, name, ispkg in iter_namespace
29
+ if not name.endswith(".common")
29
30
  }
30
31
 
31
32
  for subcommand in subcommands.values():
@@ -20,9 +20,9 @@ import logging
20
20
  from pathlib import Path
21
21
  from contextlib import ExitStack
22
22
 
23
- from PIL import Image
24
23
 
25
- from fotolab import open_image
24
+ from fotolab import load_image, open_image
25
+ from .common import add_common_arguments, log_args_decorator
26
26
 
27
27
  log = logging.getLogger(__name__)
28
28
 
@@ -48,14 +48,7 @@ def build_subparser(subparsers) -> None:
48
48
 
49
49
  animate_parser.set_defaults(func=run)
50
50
 
51
- animate_parser.add_argument(
52
- dest="image_paths",
53
- help="set the image filenames",
54
- nargs="+",
55
- type=str,
56
- default=None,
57
- metavar="IMAGE_PATHS",
58
- )
51
+ add_common_arguments(animate_parser)
59
52
 
60
53
  animate_parser.add_argument(
61
54
  "-f",
@@ -91,15 +84,6 @@ def build_subparser(subparsers) -> None:
91
84
  metavar="LOOP",
92
85
  )
93
86
 
94
- animate_parser.add_argument(
95
- "-op",
96
- "--open",
97
- default=False,
98
- action="store_true",
99
- dest="open",
100
- help="open the image using default program (default: '%(default)s')",
101
- )
102
-
103
87
  animate_parser.add_argument(
104
88
  "--webp-quality",
105
89
  dest="webp_quality",
@@ -131,14 +115,6 @@ def build_subparser(subparsers) -> None:
131
115
  metavar="METHOD",
132
116
  )
133
117
 
134
- animate_parser.add_argument(
135
- "-od",
136
- "--output-dir",
137
- dest="output_dir",
138
- default="output",
139
- help="set default output folder (default: '%(default)s')",
140
- )
141
-
142
118
  animate_parser.add_argument(
143
119
  "-of",
144
120
  "--output-filename",
@@ -148,6 +124,7 @@ def build_subparser(subparsers) -> None:
148
124
  )
149
125
 
150
126
 
127
+ @log_args_decorator
151
128
  def run(args: argparse.Namespace) -> None:
152
129
  """Run animate subcommand.
153
130
 
@@ -157,17 +134,16 @@ def run(args: argparse.Namespace) -> None:
157
134
  Returns:
158
135
  None
159
136
  """
160
- log.debug(args)
161
137
 
162
138
  image_filepaths = [Path(f) for f in args.image_paths]
163
139
  first_image_filepath = image_filepaths[0]
164
140
  other_frames = []
165
141
 
166
142
  with ExitStack() as stack:
167
- main_frame = stack.enter_context(Image.open(first_image_filepath))
143
+ main_frame = stack.enter_context(load_image(first_image_filepath))
168
144
 
169
145
  for image_filepath in image_filepaths[1:]:
170
- img = stack.enter_context(Image.open(image_filepath))
146
+ img = stack.enter_context(load_image(image_filepath))
171
147
  other_frames.append(img)
172
148
 
173
149
  if args.output_filename:
@@ -23,6 +23,7 @@ import fotolab.subcommands.contrast
23
23
  import fotolab.subcommands.resize
24
24
  import fotolab.subcommands.sharpen
25
25
  import fotolab.subcommands.watermark
26
+ from .common import add_common_arguments, log_args_decorator
26
27
 
27
28
  log = logging.getLogger(__name__)
28
29
 
@@ -35,14 +36,7 @@ def build_subparser(subparsers) -> None:
35
36
 
36
37
  auto_parser.set_defaults(func=run)
37
38
 
38
- auto_parser.add_argument(
39
- dest="image_paths",
40
- help="set the image filename",
41
- nargs="+",
42
- type=str,
43
- default=None,
44
- metavar="IMAGE_PATHS",
45
- )
39
+ add_common_arguments(auto_parser)
46
40
 
47
41
  auto_parser.add_argument(
48
42
  "-t",
@@ -65,6 +59,7 @@ def build_subparser(subparsers) -> None:
65
59
  )
66
60
 
67
61
 
62
+ @log_args_decorator
68
63
  def run(args: argparse.Namespace) -> None:
69
64
  """Run auto subcommand.
70
65
 
@@ -106,7 +101,6 @@ def run(args: argparse.Namespace) -> None:
106
101
  combined_args.overwrite = True
107
102
  combined_args.open = False
108
103
 
109
- log.debug(args)
110
104
  log.debug(combined_args)
111
105
 
112
106
  fotolab.subcommands.resize.run(combined_args)
@@ -118,7 +112,6 @@ def run(args: argparse.Namespace) -> None:
118
112
  output_filename = (
119
113
  args.title.lower().replace(",", "").replace(" ", "_") + ".gif"
120
114
  )
121
- combined_args.output_dir = "output"
122
115
  combined_args.format = "gif"
123
116
  combined_args.duration = 2500
124
117
  combined_args.loop = 0
@@ -20,9 +20,10 @@ import logging
20
20
  from pathlib import Path
21
21
  from typing import Tuple
22
22
 
23
- from PIL import Image, ImageColor, ImageOps
23
+ from PIL import ImageColor, ImageOps
24
24
 
25
- from fotolab import save_image
25
+ from fotolab import load_image, save_image
26
+ from .common import add_common_arguments, log_args_decorator
26
27
 
27
28
  log = logging.getLogger(__name__)
28
29
 
@@ -33,14 +34,7 @@ def build_subparser(subparsers: argparse._SubParsersAction) -> None:
33
34
 
34
35
  border_parser.set_defaults(func=run)
35
36
 
36
- border_parser.add_argument(
37
- dest="image_paths",
38
- help="set the image filenames",
39
- nargs="+",
40
- type=str,
41
- default=None,
42
- metavar="IMAGE_PATHS",
43
- )
37
+ add_common_arguments(border_parser)
44
38
 
45
39
  border_parser.add_argument(
46
40
  "-c",
@@ -106,24 +100,8 @@ def build_subparser(subparsers: argparse._SubParsersAction) -> None:
106
100
  metavar="WIDTH",
107
101
  )
108
102
 
109
- border_parser.add_argument(
110
- "-op",
111
- "--open",
112
- default=False,
113
- action="store_true",
114
- dest="open",
115
- help="open the image using default program (default: '%(default)s')",
116
- )
117
-
118
- border_parser.add_argument(
119
- "-od",
120
- "--output-dir",
121
- dest="output_dir",
122
- default="output",
123
- help="set default output folder (default: '%(default)s')",
124
- )
125
-
126
103
 
104
+ @log_args_decorator
127
105
  def run(args: argparse.Namespace) -> None:
128
106
  """Run border subcommand.
129
107
 
@@ -133,18 +111,17 @@ def run(args: argparse.Namespace) -> None:
133
111
  Returns:
134
112
  None
135
113
  """
136
- log.debug(args)
137
114
 
138
115
  for image_filepath in [Path(f) for f in args.image_paths]:
139
- original_image = Image.open(image_filepath)
140
- border = get_border(args)
141
- bordered_image = ImageOps.expand(
142
- original_image,
143
- border=border,
144
- fill=ImageColor.getrgb(args.color),
145
- )
146
-
147
- save_image(args, bordered_image, image_filepath, "border")
116
+ with load_image(image_filepath) as original_image:
117
+ border = get_border(args)
118
+ bordered_image = ImageOps.expand(
119
+ original_image,
120
+ border=border,
121
+ fill=ImageColor.getrgb(args.color),
122
+ )
123
+
124
+ save_image(args, bordered_image, image_filepath, "border")
148
125
 
149
126
 
150
127
  def get_border(
@@ -2,6 +2,7 @@
2
2
  #
3
3
  # This program is free software: you can redistribute it and/or modify it under
4
4
  # the terms of the GNU Affero General Public License as published by the Free
5
+ #
5
6
  # Software Foundation, either version 3 of the License, or (at your option) any
6
7
  # later version.
7
8
  #
@@ -13,28 +14,34 @@
13
14
  # You should have received a copy of the GNU Affero General Public License
14
15
  # along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16
 
16
- """Montage subcommand."""
17
+ """Common argument parsing for subcommands."""
17
18
 
18
19
  import argparse
19
20
  import logging
20
- from pathlib import Path
21
+ from functools import wraps
22
+ from typing import Callable
21
23
 
22
- from PIL import Image
24
+ log = logging.getLogger(__name__)
23
25
 
24
- from fotolab import save_image
25
26
 
26
- log = logging.getLogger(__name__)
27
+ def log_args_decorator(func: Callable) -> Callable:
28
+ """Decorator to log the arguments passed to a function."""
27
29
 
30
+ @wraps(func)
31
+ def wrapper(args: argparse.Namespace) -> None:
32
+ log.debug(args)
33
+ return func(args)
34
+
35
+ return wrapper
28
36
 
29
- def build_subparser(subparsers) -> None:
30
- """Build the subparser."""
31
- montage_parser = subparsers.add_parser(
32
- "montage", help="montage a list of image"
33
- )
34
37
 
35
- montage_parser.set_defaults(func=run)
38
+ def add_common_arguments(parser: argparse.ArgumentParser) -> None:
39
+ """Add common arguments to a subparser.
36
40
 
37
- montage_parser.add_argument(
41
+ Args:
42
+ parser (argparse.ArgumentParser): The subparser to add arguments to.
43
+ """
44
+ parser.add_argument(
38
45
  dest="image_paths",
39
46
  help="set the image filenames",
40
47
  nargs="+",
@@ -43,7 +50,7 @@ def build_subparser(subparsers) -> None:
43
50
  metavar="IMAGE_PATHS",
44
51
  )
45
52
 
46
- montage_parser.add_argument(
53
+ parser.add_argument(
47
54
  "-op",
48
55
  "--open",
49
56
  default=False,
@@ -52,42 +59,10 @@ def build_subparser(subparsers) -> None:
52
59
  help="open the image using default program (default: '%(default)s')",
53
60
  )
54
61
 
55
- montage_parser.add_argument(
62
+ parser.add_argument(
56
63
  "-od",
57
64
  "--output-dir",
58
65
  dest="output_dir",
59
66
  default="output",
60
67
  help="set default output folder (default: '%(default)s')",
61
68
  )
62
-
63
-
64
- def run(args: argparse.Namespace) -> None:
65
- """Run montage subcommand.
66
-
67
- Args:
68
- args (argparse.Namespace): Config from command line arguments
69
-
70
- Returns:
71
- None
72
- """
73
- log.debug(args)
74
- images = []
75
- for image_path_str in args.image_paths:
76
- image_filename = Path(image_path_str)
77
- images.append(Image.open(image_filename))
78
-
79
- if len(images) < 2:
80
- raise ValueError("at least two images is required for montage")
81
-
82
- total_width = sum(img.width for img in images)
83
- total_height = max(img.height for img in images)
84
-
85
- montaged_image = Image.new("RGB", (total_width, total_height))
86
-
87
- x_offset = 0
88
- for image in images:
89
- montaged_image.paste(image, (x_offset, 0))
90
- x_offset += image.width
91
-
92
- output_image_filename = Path(args.image_paths[0])
93
- save_image(args, montaged_image, output_image_filename, "montage")
@@ -19,9 +19,10 @@ import argparse
19
19
  import logging
20
20
  from pathlib import Path
21
21
 
22
- from PIL import Image, ImageOps
22
+ from PIL import ImageOps
23
23
 
24
- from fotolab import save_image
24
+ from fotolab import load_image, save_image
25
+ from .common import add_common_arguments, log_args_decorator
25
26
 
26
27
  log = logging.getLogger(__name__)
27
28
 
@@ -50,14 +51,7 @@ def build_subparser(subparsers: argparse._SubParsersAction) -> None:
50
51
 
51
52
  contrast_parser.set_defaults(func=run)
52
53
 
53
- contrast_parser.add_argument(
54
- dest="image_paths",
55
- help="set the image filename",
56
- nargs="+",
57
- type=str,
58
- default=None,
59
- metavar="IMAGE_PATHS",
60
- )
54
+ add_common_arguments(contrast_parser)
61
55
 
62
56
  contrast_parser.add_argument(
63
57
  "-c",
@@ -73,24 +67,8 @@ def build_subparser(subparsers: argparse._SubParsersAction) -> None:
73
67
  metavar="CUTOFF",
74
68
  )
75
69
 
76
- contrast_parser.add_argument(
77
- "-op",
78
- "--open",
79
- default=False,
80
- action="store_true",
81
- dest="open",
82
- help="open the image using default program (default: '%(default)s')",
83
- )
84
-
85
- contrast_parser.add_argument(
86
- "-od",
87
- "--output-dir",
88
- dest="output_dir",
89
- default="output",
90
- help="set default output folder (default: '%(default)s')",
91
- )
92
-
93
70
 
71
+ @log_args_decorator
94
72
  def run(args: argparse.Namespace) -> None:
95
73
  """Run contrast subcommand.
96
74
 
@@ -100,28 +78,27 @@ def run(args: argparse.Namespace) -> None:
100
78
  Returns:
101
79
  None
102
80
  """
103
- log.debug(args)
104
81
 
105
82
  for image_path_str in args.image_paths:
106
- original_image = Image.open(image_path_str)
107
-
108
- if original_image.mode == "RGBA":
109
- # Split the image into RGB and Alpha channels
110
- rgb_image = original_image.convert("RGB")
111
- alpha_channel = original_image.getchannel("A")
112
-
113
- # Apply autocontrast to the RGB part
114
- contrasted_rgb = ImageOps.autocontrast(
115
- rgb_image, cutoff=args.cutoff
116
- )
117
-
118
- # Merge the contrasted RGB part with the original Alpha channel
119
- contrasted_rgb.putalpha(alpha_channel)
120
- contrast_image = contrasted_rgb
121
- else:
122
- # For other modes (like RGB, L, etc.), apply autocontrast directly
123
- contrast_image = ImageOps.autocontrast(
124
- original_image, cutoff=args.cutoff
125
- )
126
-
127
- save_image(args, contrast_image, Path(image_path_str), "contrast")
83
+ with load_image(Path(image_path_str)) as original_image:
84
+ if original_image.mode == "RGBA":
85
+ # Split the image into RGB and Alpha channels
86
+ rgb_image = original_image.convert("RGB")
87
+ alpha_channel = original_image.getchannel("A")
88
+
89
+ # Apply autocontrast to the RGB part
90
+ contrasted_rgb = ImageOps.autocontrast(
91
+ rgb_image, cutoff=args.cutoff
92
+ )
93
+
94
+ # Merge the contrasted RGB part with the original Alpha channel
95
+ contrasted_rgb.putalpha(alpha_channel)
96
+ contrast_image = contrasted_rgb
97
+ else:
98
+ # For other modes (like RGB, L, etc.), apply autocontrast
99
+ # directly
100
+ contrast_image = ImageOps.autocontrast(
101
+ original_image, cutoff=args.cutoff
102
+ )
103
+
104
+ save_image(args, contrast_image, Path(image_path_str), "contrast")
@@ -23,7 +23,8 @@ from typing import NamedTuple
23
23
 
24
24
  from PIL import Image, ImageDraw
25
25
 
26
- from fotolab import save_gif_image, save_image
26
+ from fotolab import load_image, save_gif_image, save_image
27
+ from .common import add_common_arguments, log_args_decorator
27
28
 
28
29
  log = logging.getLogger(__name__)
29
30
 
@@ -44,14 +45,7 @@ def build_subparser(subparsers) -> None:
44
45
 
45
46
  halftone_parser.set_defaults(func=run)
46
47
 
47
- halftone_parser.add_argument(
48
- dest="image_paths",
49
- help="set the image filename",
50
- nargs="+",
51
- type=str,
52
- default=None,
53
- metavar="IMAGE_PATHS",
54
- )
48
+ add_common_arguments(halftone_parser)
55
49
 
56
50
  halftone_parser.add_argument(
57
51
  "-ba",
@@ -62,23 +56,6 @@ def build_subparser(subparsers) -> None:
62
56
  help="generate a GIF showing before and after changes",
63
57
  )
64
58
 
65
- halftone_parser.add_argument(
66
- "-op",
67
- "--open",
68
- default=False,
69
- action="store_true",
70
- dest="open",
71
- help="open the image using default program (default: '%(default)s')",
72
- )
73
-
74
- halftone_parser.add_argument(
75
- "-od",
76
- "--output-dir",
77
- dest="output_dir",
78
- default="output",
79
- help="set default output folder (default: '%(default)s')",
80
- )
81
-
82
59
  halftone_parser.add_argument(
83
60
  "-c",
84
61
  "--cells",
@@ -100,6 +77,7 @@ def build_subparser(subparsers) -> None:
100
77
  )
101
78
 
102
79
 
80
+ @log_args_decorator
103
81
  def run(args: argparse.Namespace) -> None:
104
82
  """Run halftone subcommand.
105
83
 
@@ -109,25 +87,24 @@ def run(args: argparse.Namespace) -> None:
109
87
  Returns:
110
88
  None
111
89
  """
112
- log.debug(args)
113
90
 
114
91
  for image_path_str in args.image_paths:
115
92
  image_filename = Path(image_path_str)
116
- original_image = Image.open(image_filename)
117
- halftone_image = create_halftone_image(
118
- original_image, args.cells, args.grayscale
119
- )
120
-
121
- if args.before_after:
122
- save_gif_image(
123
- args,
124
- image_filename,
125
- original_image,
126
- halftone_image,
127
- "halftone",
93
+ with load_image(image_filename) as original_image:
94
+ halftone_image = create_halftone_image(
95
+ original_image, args.cells, args.grayscale
128
96
  )
129
- else:
130
- save_image(args, halftone_image, image_filename, "halftone")
97
+
98
+ if args.before_after:
99
+ save_gif_image(
100
+ args,
101
+ image_filename,
102
+ original_image,
103
+ halftone_image,
104
+ "halftone",
105
+ )
106
+ else:
107
+ save_image(args, halftone_image, image_filename, "halftone")
131
108
 
132
109
 
133
110
  def _draw_halftone_dot(
@@ -20,6 +20,8 @@ import logging
20
20
 
21
21
  from PIL import ExifTags, Image
22
22
 
23
+ from .common import log_args_decorator
24
+
23
25
  log = logging.getLogger(__name__)
24
26
 
25
27
 
@@ -62,6 +64,7 @@ def build_subparser(subparsers) -> None:
62
64
  )
63
65
 
64
66
 
67
+ @log_args_decorator
65
68
  def run(args: argparse.Namespace) -> None:
66
69
  """Run info subcommand.
67
70
 
@@ -71,7 +74,6 @@ def run(args: argparse.Namespace) -> None:
71
74
  Returns:
72
75
  None
73
76
  """
74
- log.debug(args)
75
77
 
76
78
  with Image.open(args.image_filename) as image:
77
79
  exif_tags = extract_exif_tags(image, args.sort)
@@ -0,0 +1,72 @@
1
+ # Copyright (C) 2024,2025 Kian-Meng Ang
2
+ #
3
+ # This program is free software: you can redistribute it and/or modify it under
4
+ # the terms of the GNU Affero General Public License as published by the Free
5
+ # Software Foundation, either version 3 of the License, or (at your option) any
6
+ # later version.
7
+ #
8
+ # This program is distributed in the hope that it will be useful, but WITHOUT
9
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
10
+ # FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
11
+ # details.
12
+ #
13
+ # You should have received a copy of the GNU Affero General Public License
14
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
15
+
16
+ """Montage subcommand."""
17
+
18
+ import argparse
19
+ import logging
20
+ from pathlib import Path
21
+ from contextlib import ExitStack
22
+
23
+ from PIL import Image
24
+
25
+ from fotolab import load_image, save_image
26
+ from .common import add_common_arguments, log_args_decorator
27
+
28
+ log = logging.getLogger(__name__)
29
+
30
+
31
+ def build_subparser(subparsers) -> None:
32
+ """Build the subparser."""
33
+ montage_parser = subparsers.add_parser(
34
+ "montage", help="montage a list of image"
35
+ )
36
+
37
+ montage_parser.set_defaults(func=run)
38
+
39
+ add_common_arguments(montage_parser)
40
+
41
+
42
+ @log_args_decorator
43
+ def run(args: argparse.Namespace) -> None:
44
+ """Run montage subcommand.
45
+
46
+ Args:
47
+ args (argparse.Namespace): Config from command line arguments
48
+
49
+ Returns:
50
+ None
51
+ """
52
+ images = []
53
+ with ExitStack() as stack:
54
+ for image_path_str in args.image_paths:
55
+ image_filename = Path(image_path_str)
56
+ images.append(stack.enter_context(load_image(image_filename)))
57
+
58
+ if len(images) < 2:
59
+ raise ValueError("at least two images is required for montage")
60
+
61
+ total_width = sum(img.width for img in images)
62
+ total_height = max(img.height for img in images)
63
+
64
+ montaged_image = Image.new("RGB", (total_width, total_height))
65
+
66
+ x_offset = 0
67
+ for image in images:
68
+ montaged_image.paste(image, (x_offset, 0))
69
+ x_offset += image.width
70
+
71
+ output_image_filename = Path(args.image_paths[0])
72
+ save_image(args, montaged_image, output_image_filename, "montage")