fotolab 0.34.1__py3-none-any.whl → 0.34.3__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.
fotolab/__init__.py CHANGED
@@ -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(
@@ -0,0 +1,68 @@
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
+ #
6
+ # Software Foundation, either version 3 of the License, or (at your option) any
7
+ # later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful, but WITHOUT
10
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11
+ # FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
12
+ # details.
13
+ #
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ """Common argument parsing for subcommands."""
18
+
19
+ import argparse
20
+ import logging
21
+ from functools import wraps
22
+ from typing import Callable
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+
27
+ def log_args_decorator(func: Callable) -> Callable:
28
+ """Decorator to log the arguments passed to a function."""
29
+
30
+ @wraps(func)
31
+ def wrapper(args: argparse.Namespace) -> None:
32
+ log.debug(args)
33
+ return func(args)
34
+
35
+ return wrapper
36
+
37
+
38
+ def add_common_arguments(parser: argparse.ArgumentParser) -> None:
39
+ """Add common arguments to a subparser.
40
+
41
+ Args:
42
+ parser (argparse.ArgumentParser): The subparser to add arguments to.
43
+ """
44
+ parser.add_argument(
45
+ dest="image_paths",
46
+ help="set the image filenames",
47
+ nargs="+",
48
+ type=str,
49
+ default=None,
50
+ metavar="IMAGE_PATHS",
51
+ )
52
+
53
+ parser.add_argument(
54
+ "-op",
55
+ "--open",
56
+ default=False,
57
+ action="store_true",
58
+ dest="open",
59
+ help="open the image using default program (default: '%(default)s')",
60
+ )
61
+
62
+ parser.add_argument(
63
+ "-od",
64
+ "--output-dir",
65
+ dest="output_dir",
66
+ default="output",
67
+ help="set default output folder (default: '%(default)s')",
68
+ )
@@ -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)
@@ -18,10 +18,12 @@
18
18
  import argparse
19
19
  import logging
20
20
  from pathlib import Path
21
+ from contextlib import ExitStack
21
22
 
22
23
  from PIL import Image
23
24
 
24
- from fotolab import save_image
25
+ from fotolab import load_image, save_image
26
+ from .common import add_common_arguments, log_args_decorator
25
27
 
26
28
  log = logging.getLogger(__name__)
27
29
 
@@ -34,33 +36,10 @@ def build_subparser(subparsers) -> None:
34
36
 
35
37
  montage_parser.set_defaults(func=run)
36
38
 
37
- montage_parser.add_argument(
38
- dest="image_paths",
39
- help="set the image filenames",
40
- nargs="+",
41
- type=str,
42
- default=None,
43
- metavar="IMAGE_PATHS",
44
- )
45
-
46
- montage_parser.add_argument(
47
- "-op",
48
- "--open",
49
- default=False,
50
- action="store_true",
51
- dest="open",
52
- help="open the image using default program (default: '%(default)s')",
53
- )
54
-
55
- montage_parser.add_argument(
56
- "-od",
57
- "--output-dir",
58
- dest="output_dir",
59
- default="output",
60
- help="set default output folder (default: '%(default)s')",
61
- )
39
+ add_common_arguments(montage_parser)
62
40
 
63
41
 
42
+ @log_args_decorator
64
43
  def run(args: argparse.Namespace) -> None:
65
44
  """Run montage subcommand.
66
45
 
@@ -70,24 +49,24 @@ def run(args: argparse.Namespace) -> None:
70
49
  Returns:
71
50
  None
72
51
  """
73
- log.debug(args)
74
52
  images = []
75
- for image_path_str in args.image_paths:
76
- image_filename = Path(image_path_str)
77
- images.append(Image.open(image_filename))
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)))
78
57
 
79
- if len(images) < 2:
80
- raise ValueError("at least two images is required for montage")
58
+ if len(images) < 2:
59
+ raise ValueError("at least two images is required for montage")
81
60
 
82
- total_width = sum(img.width for img in images)
83
- total_height = max(img.height for img in images)
61
+ total_width = sum(img.width for img in images)
62
+ total_height = max(img.height for img in images)
84
63
 
85
- montaged_image = Image.new("RGB", (total_width, total_height))
64
+ montaged_image = Image.new("RGB", (total_width, total_height))
86
65
 
87
- x_offset = 0
88
- for image in images:
89
- montaged_image.paste(image, (x_offset, 0))
90
- x_offset += image.width
66
+ x_offset = 0
67
+ for image in images:
68
+ montaged_image.paste(image, (x_offset, 0))
69
+ x_offset += image.width
91
70
 
92
- output_image_filename = Path(args.image_paths[0])
93
- save_image(args, montaged_image, output_image_filename, "montage")
71
+ output_image_filename = Path(args.image_paths[0])
72
+ save_image(args, montaged_image, output_image_filename, "montage")
@@ -18,12 +18,12 @@
18
18
  import argparse
19
19
  import logging
20
20
  import math
21
- import sys
22
21
  from pathlib import Path
23
22
 
24
23
  from PIL import Image, ImageColor
25
24
 
26
- from fotolab import save_image
25
+ from fotolab import load_image, save_image
26
+ from .common import add_common_arguments, log_args_decorator
27
27
 
28
28
  log = logging.getLogger(__name__)
29
29
 
@@ -37,14 +37,7 @@ def build_subparser(subparsers) -> None:
37
37
 
38
38
  resize_parser.set_defaults(func=run)
39
39
 
40
- resize_parser.add_argument(
41
- dest="image_paths",
42
- help="set the image filename",
43
- nargs="+",
44
- type=str,
45
- default=None,
46
- metavar="IMAGE_PATHS",
47
- )
40
+ add_common_arguments(resize_parser)
48
41
 
49
42
  resize_parser.add_argument(
50
43
  "-c",
@@ -65,67 +58,31 @@ def build_subparser(subparsers) -> None:
65
58
  ),
66
59
  )
67
60
 
68
- if "-c" in sys.argv or "--canvas" in sys.argv:
69
- resize_parser.add_argument(
70
- "-W",
71
- "--width",
72
- dest="width",
73
- help="set the width of the image (default: '%(default)s')",
74
- type=int,
75
- required=True,
76
- metavar="WIDTH",
77
- )
78
-
79
- resize_parser.add_argument(
80
- "-H",
81
- "--height",
82
- dest="height",
83
- help="set the height of the image (default: '%(default)s')",
84
- type=int,
85
- required=True,
86
- metavar="HEIGHT",
87
- )
88
- else:
89
- group = resize_parser.add_mutually_exclusive_group(required=False)
90
-
91
- group.add_argument(
92
- "-W",
93
- "--width",
94
- dest="width",
95
- help="set the width of the image (default: '%(default)s')",
96
- type=int,
97
- default=DEFAULT_WIDTH,
98
- metavar="WIDTH",
99
- )
100
-
101
- group.add_argument(
102
- "-H",
103
- "--height",
104
- dest="height",
105
- help="set the height of the image (default: '%(default)s')",
106
- type=int,
107
- default=DEFAULT_HEIGHT,
108
- metavar="HEIGHT",
109
- )
110
-
61
+ # Define width and height arguments as optional with defaults.
62
+ # The conditional logic (required/mutually exclusive) is now handled in the
63
+ # run function.
111
64
  resize_parser.add_argument(
112
- "-op",
113
- "--open",
114
- default=False,
115
- action="store_true",
116
- dest="open",
117
- help="open the image using default program (default: '%(default)s')",
65
+ "-W",
66
+ "--width",
67
+ dest="width",
68
+ help="set the width of the image (default: '%(default)s')",
69
+ type=int,
70
+ default=DEFAULT_WIDTH,
71
+ metavar="WIDTH",
118
72
  )
119
73
 
120
74
  resize_parser.add_argument(
121
- "-od",
122
- "--output-dir",
123
- dest="output_dir",
124
- default="output",
125
- help="set default output folder (default: '%(default)s')",
75
+ "-H",
76
+ "--height",
77
+ dest="height",
78
+ help="set the height of the image (default: '%(default)s')",
79
+ type=int,
80
+ default=DEFAULT_HEIGHT,
81
+ metavar="HEIGHT",
126
82
  )
127
83
 
128
84
 
85
+ @log_args_decorator
129
86
  def run(args: argparse.Namespace) -> None:
130
87
  """Run resize subcommand.
131
88
 
@@ -135,15 +92,32 @@ def run(args: argparse.Namespace) -> None:
135
92
  Returns:
136
93
  None
137
94
  """
138
- log.debug(args)
95
+
96
+ width_provided = args.width != DEFAULT_WIDTH
97
+ height_provided = args.height != DEFAULT_HEIGHT
98
+
99
+ if args.canvas:
100
+ # Canvas mode: Both width and height are required
101
+ if not (width_provided and height_provided):
102
+ raise SystemExit(
103
+ "error: argument -W/--width and -H/--height are required when "
104
+ "using --canvas"
105
+ )
106
+ else:
107
+ # Resize mode: Width and height are mutually exclusive
108
+ if width_provided and height_provided:
109
+ raise SystemExit(
110
+ "error: argument -W/--width and -H/--height are mutually "
111
+ "exclusive when not using --canvas"
112
+ )
139
113
 
140
114
  for image_filepath in [Path(f) for f in args.image_paths]:
141
- original_image = Image.open(image_filepath)
142
- if args.canvas:
143
- resized_image = _resize_image_onto_canvas(original_image, args)
144
- else:
145
- resized_image = _resize_image(original_image, args)
146
- save_image(args, resized_image, image_filepath, "resize")
115
+ with load_image(image_filepath) as original_image:
116
+ if args.canvas:
117
+ resized_image = _resize_image_onto_canvas(original_image, args)
118
+ else:
119
+ resized_image = _resize_image(original_image, args)
120
+ save_image(args, resized_image, image_filepath, "resize")
147
121
 
148
122
 
149
123
  def _resize_image_onto_canvas(original_image, args):
@@ -176,18 +150,20 @@ def _calc_new_image_dimension(image, args) -> tuple:
176
150
 
177
151
  original_aspect_ratio = old_width / old_height
178
152
 
179
- if new_width != DEFAULT_WIDTH and new_height == DEFAULT_HEIGHT:
180
- # user provided width, calculate height to maintain aspect ratio
181
- new_height = math.ceil(new_width / original_aspect_ratio)
182
- log.debug("new height calculated based on width: %d", new_height)
183
- elif new_height != DEFAULT_HEIGHT and new_width == DEFAULT_WIDTH:
184
- # user provided height, calculate width to maintain aspect ratio
185
- new_width = math.ceil(new_height * original_aspect_ratio)
186
- log.debug("new width calculated based on height: %d", new_width)
187
-
188
- # if both are default, no calculation needed, use defaults
189
- # due to argparse's mutually exclusive group, it's not possible for both
190
- # new_width and new_height to be non-default when --canvas is False
153
+ match (new_width != DEFAULT_WIDTH, new_height != DEFAULT_HEIGHT):
154
+ case (True, False):
155
+ # user provided width, calculate height to maintain aspect ratio
156
+ new_height = math.ceil(new_width / original_aspect_ratio)
157
+ log.debug("new height calculated based on width: %d", new_height)
158
+ case (False, True):
159
+ # user provided height, calculate width to maintain aspect ratio
160
+ new_width = math.ceil(new_height * original_aspect_ratio)
161
+ log.debug("new width calculated based on height: %d", new_width)
162
+ case _:
163
+ # if both are default, no calculation needed, use defaults.
164
+ # The case where both are non-default is disallowed by argparse
165
+ # when --canvas is False, so we do nothing here.
166
+ pass
191
167
 
192
168
  log.debug("new image dimension: %d x %d", new_width, new_height)
193
169
  return (new_width, new_height)
@@ -21,7 +21,8 @@ from pathlib import Path
21
21
 
22
22
  from PIL import Image
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
 
@@ -32,14 +33,7 @@ def build_subparser(subparsers) -> None:
32
33
 
33
34
  rotate_parser.set_defaults(func=run)
34
35
 
35
- rotate_parser.add_argument(
36
- dest="image_paths",
37
- help="set the image filenames",
38
- nargs="+",
39
- type=str,
40
- default=None,
41
- metavar="IMAGE_PATHS",
42
- )
36
+ add_common_arguments(rotate_parser)
43
37
 
44
38
  rotate_parser.add_argument(
45
39
  "-r",
@@ -56,24 +50,8 @@ def build_subparser(subparsers) -> None:
56
50
  help="Rotate clockwise (default: '%(default)s)",
57
51
  )
58
52
 
59
- rotate_parser.add_argument(
60
- "-op",
61
- "--open",
62
- default=False,
63
- action="store_true",
64
- dest="open",
65
- help="open the image using default program (default: '%(default)s')",
66
- )
67
-
68
- rotate_parser.add_argument(
69
- "-od",
70
- "--output-dir",
71
- dest="output_dir",
72
- default="output",
73
- help="set default output folder (default: '%(default)s')",
74
- )
75
-
76
53
 
54
+ @log_args_decorator
77
55
  def run(args: argparse.Namespace) -> None:
78
56
  """Run rotate subcommand.
79
57
 
@@ -83,7 +61,6 @@ def run(args: argparse.Namespace) -> None:
83
61
  Returns:
84
62
  None
85
63
  """
86
- log.debug(args)
87
64
 
88
65
  rotation = -args.rotation if args.clockwise else args.rotation
89
66
  log.debug(f"Rotation angle: {rotation} degrees")
@@ -91,11 +68,11 @@ def run(args: argparse.Namespace) -> None:
91
68
  for image_path_str in args.image_paths:
92
69
  image_filename = Path(image_path_str)
93
70
  log.debug(f"Processing image: {image_filename}")
94
- original_image = Image.open(image_filename)
95
- log.debug(f"Original image size: {original_image.size}")
96
- rotated_image = original_image.rotate(
97
- rotation, expand=True, resample=Image.Resampling.BICUBIC
98
- )
99
- log.debug(f"Rotated image size: {rotated_image.size}")
100
- save_image(args, rotated_image, image_filename, "rotate")
101
- log.debug(f"Image saved: {image_filename}")
71
+ with load_image(image_filename) as original_image:
72
+ log.debug(f"Original image size: {original_image.size}")
73
+ rotated_image = original_image.rotate(
74
+ rotation, expand=True, resample=Image.Resampling.BICUBIC
75
+ )
76
+ log.debug(f"Rotated image size: {rotated_image.size}")
77
+ save_image(args, rotated_image, image_filename, "rotate")
78
+ log.debug(f"Image saved: {image_filename}")
@@ -19,9 +19,10 @@ import argparse
19
19
  import logging
20
20
  from pathlib import Path
21
21
 
22
- from PIL import Image, ImageFilter
22
+ from PIL import ImageFilter
23
23
 
24
- from fotolab import save_gif_image, save_image
24
+ from fotolab import load_image, save_gif_image, save_image
25
+ from .common import add_common_arguments, log_args_decorator
25
26
 
26
27
  log = logging.getLogger(__name__)
27
28
 
@@ -32,14 +33,7 @@ def build_subparser(subparsers) -> None:
32
33
 
33
34
  sharpen_parser.set_defaults(func=run)
34
35
 
35
- sharpen_parser.add_argument(
36
- dest="image_paths",
37
- help="set the image filenames",
38
- nargs="+",
39
- type=str,
40
- default=None,
41
- metavar="IMAGE_PATHS",
42
- )
36
+ add_common_arguments(sharpen_parser)
43
37
 
44
38
  sharpen_parser.add_argument(
45
39
  "-r",
@@ -86,24 +80,8 @@ def build_subparser(subparsers) -> None:
86
80
  help="generate a GIF showing before and after changes",
87
81
  )
88
82
 
89
- sharpen_parser.add_argument(
90
- "-op",
91
- "--open",
92
- default=False,
93
- action="store_true",
94
- dest="open",
95
- help="open the image using default program (default: '%(default)s')",
96
- )
97
-
98
- sharpen_parser.add_argument(
99
- "-od",
100
- "--output-dir",
101
- dest="output_dir",
102
- default="output",
103
- help="set default output folder (default: '%(default)s')",
104
- )
105
-
106
83
 
84
+ @log_args_decorator
107
85
  def run(args: argparse.Namespace) -> None:
108
86
  """Run sharpen subcommand.
109
87
 
@@ -113,22 +91,23 @@ def run(args: argparse.Namespace) -> None:
113
91
  Returns:
114
92
  None
115
93
  """
116
- log.debug(args)
117
94
 
118
95
  for image_path_str in args.image_paths:
119
- original_image = Image.open(image_path_str)
120
- sharpen_image = original_image.filter(
121
- ImageFilter.UnsharpMask(
122
- args.radius, percent=args.percent, threshold=args.threshold
123
- )
124
- )
125
- if args.before_after:
126
- save_gif_image(
127
- args,
128
- Path(image_path_str),
129
- original_image,
130
- sharpen_image,
131
- "sharpen",
96
+ with load_image(Path(image_path_str)) as original_image:
97
+ sharpen_image = original_image.filter(
98
+ ImageFilter.UnsharpMask(
99
+ args.radius, percent=args.percent, threshold=args.threshold
100
+ )
132
101
  )
133
- else:
134
- save_image(args, sharpen_image, Path(image_path_str), "sharpen")
102
+ if args.before_after:
103
+ save_gif_image(
104
+ args,
105
+ Path(image_path_str),
106
+ original_image,
107
+ sharpen_image,
108
+ "sharpen",
109
+ )
110
+ else:
111
+ save_image(
112
+ args, sharpen_image, Path(image_path_str), "sharpen"
113
+ )
@@ -23,8 +23,9 @@ from typing import Tuple
23
23
 
24
24
  from PIL import Image, ImageColor, ImageDraw, ImageFont, ImageSequence
25
25
 
26
- from fotolab import save_image
26
+ from fotolab import load_image, save_image
27
27
  from fotolab.subcommands.info import get_formatted_camera_info
28
+ from .common import add_common_arguments, log_args_decorator
28
29
 
29
30
  log: logging.Logger = logging.getLogger(__name__)
30
31
 
@@ -42,14 +43,7 @@ def build_subparser(subparsers: argparse._SubParsersAction) -> None:
42
43
 
43
44
  watermark_parser.set_defaults(func=run)
44
45
 
45
- watermark_parser.add_argument(
46
- dest="image_paths",
47
- help="set the image filenames",
48
- nargs="+",
49
- type=str,
50
- default=None,
51
- metavar="IMAGE_PATHS",
52
- )
46
+ add_common_arguments(watermark_parser)
53
47
 
54
48
  watermark_parser.add_argument(
55
49
  "-t",
@@ -161,24 +155,8 @@ def build_subparser(subparsers: argparse._SubParsersAction) -> None:
161
155
  help="lowercase the watermark text",
162
156
  )
163
157
 
164
- watermark_parser.add_argument(
165
- "-op",
166
- "--open",
167
- default=False,
168
- action="store_true",
169
- dest="open",
170
- help="open the image using default program (default: '%(default)s')",
171
- )
172
-
173
- watermark_parser.add_argument(
174
- "-od",
175
- "--output-dir",
176
- dest="output_dir",
177
- default="output",
178
- help="set default output folder (default: '%(default)s')",
179
- )
180
-
181
158
 
159
+ @log_args_decorator
182
160
  def run(args: argparse.Namespace) -> None:
183
161
  """Run watermark subcommand.
184
162
 
@@ -188,11 +166,22 @@ def run(args: argparse.Namespace) -> None:
188
166
  Returns:
189
167
  None
190
168
  """
191
- log.debug(args)
192
169
 
193
170
  for image_path_str in args.image_paths:
194
171
  try:
195
- image: Image.Image = Image.open(image_path_str)
172
+ with load_image(Path(image_path_str)) as image:
173
+ if image.format == "GIF":
174
+ watermark_gif_image(image, image_path_str, args)
175
+ else:
176
+ watermarked_image: Image.Image = watermark_non_gif_image(
177
+ image, args
178
+ )
179
+ save_image(
180
+ args,
181
+ watermarked_image,
182
+ Path(image_path_str),
183
+ "watermark",
184
+ )
196
185
  except FileNotFoundError:
197
186
  log.error("Image file not found: %s", image_path_str)
198
187
  continue
@@ -200,16 +189,6 @@ def run(args: argparse.Namespace) -> None:
200
189
  log.error("Could not open image %s: %s", image_path_str, e)
201
190
  continue
202
191
 
203
- if image.format == "GIF":
204
- watermark_gif_image(image, image_path_str, args)
205
- else:
206
- watermarked_image: Image.Image = watermark_non_gif_image(
207
- image, args
208
- )
209
- save_image(
210
- args, watermarked_image, Path(image_path_str), "watermark"
211
- )
212
-
213
192
 
214
193
  def watermark_gif_image(
215
194
  original_image: Image.Image, output_filename: str, args: argparse.Namespace
@@ -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
@@ -0,0 +1,23 @@
1
+ fotolab/__init__.py,sha256=xYHsuP-sNzx6xCzWpierNFJfW1qKEzDd54-UYsHgX5o,3481
2
+ fotolab/__main__.py,sha256=Wk11t_zKs3wJ-okM3yv7Mtj1pRwQZu3NCmpteBwn4Hs,810
3
+ fotolab/cli.py,sha256=Ebx6RQM5Dr8PsIqQ4NOySxnGMzGGI0U7CTawYfHROlk,4499
4
+ fotolab/subcommands/__init__.py,sha256=LHKTjyxfB_s5NZwmT9zYuQuP_86rYjcio7FOGdqD1_M,1169
5
+ fotolab/subcommands/animate.py,sha256=JU0eAXyciXUivRJJVq14YuoM_S6h5Yq9RAy0pDUcXMk,5339
6
+ fotolab/subcommands/auto.py,sha256=Lt_htnIYxUavWfd2hAjduGuhG0TxS2ZkCVXW0-gg7Xo,3434
7
+ fotolab/subcommands/border.py,sha256=W31MgIYpLM6PrxN3nVM1U-eqAfh-DHY6iS6ONDKEhNk,4227
8
+ fotolab/subcommands/common.py,sha256=oSoCcL8rWlmV4_rZ-zc3VFISlG1zbC6xkYMKbS7TP9s,1930
9
+ fotolab/subcommands/contrast.py,sha256=kO9t3uThdz225vssPUJ1GpTNtQSa3u5qBGYD9UoAEs4,3283
10
+ fotolab/subcommands/env.py,sha256=QoxRvzZKgmoHTUxDV4QYhdChCpMWs5TbXFY_qIpIQpE,1469
11
+ fotolab/subcommands/halftone.py,sha256=6XVhGvL4sf1rn8V9wv1ZXZwgRc36tIH9V8l4TAPXH1s,6446
12
+ fotolab/subcommands/info.py,sha256=pfCiLYE1zfaMdGtgaGYvwuOkaSDCeTFuvaflp25HS0U,3603
13
+ fotolab/subcommands/montage.py,sha256=QZ-VPVJG29nPGHpiFv5fLpEF4XfBBE3cvQUJgluiKho,2233
14
+ fotolab/subcommands/resize.py,sha256=AoynJy-GwbhFStAFQ_909AOp98usACz6428WvO-zinU,5483
15
+ fotolab/subcommands/rotate.py,sha256=jNcvsuTrktHjf6B2HC7ygHBezykF4YEZ08dqBsLT3oI,2456
16
+ fotolab/subcommands/sharpen.py,sha256=r7bvE6zmoaKsA6LBqLuAqWbSXWeZj8WAojLhQnFVb4g,3180
17
+ fotolab/subcommands/watermark.py,sha256=2wi5fU6Ax2q2B9133J7_smPPSafeWeR0jkA--_oi8Sw,10977
18
+ fotolab-0.34.3.dist-info/licenses/LICENSE.md,sha256=tGtFDwxWTjuR9syrJoSv1Hiffd2u8Tu8cYClfrXS_YU,31956
19
+ fotolab-0.34.3.dist-info/METADATA,sha256=F8bwUkjAIJ1UF-FcEGIuOcvlWx-GM4eAeXz2R7q07Qw,15101
20
+ fotolab-0.34.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
21
+ fotolab-0.34.3.dist-info/entry_points.txt,sha256=0e1go9plFpqj5FP-OpV2acxTAx3ViI59PMXuhejvgcQ,45
22
+ fotolab-0.34.3.dist-info/top_level.txt,sha256=XUJ3gdpsbjohoZCLdVlbQrxAUDkbQg7WwGQG2DaN0t4,8
23
+ fotolab-0.34.3.dist-info/RECORD,,
@@ -1,22 +0,0 @@
1
- fotolab/__init__.py,sha256=0UZ6OZFCRkc2Fwc4LT1UtE9JdK3uq0uL8fSSVSOuVyo,3161
2
- fotolab/__main__.py,sha256=Wk11t_zKs3wJ-okM3yv7Mtj1pRwQZu3NCmpteBwn4Hs,810
3
- fotolab/cli.py,sha256=Ebx6RQM5Dr8PsIqQ4NOySxnGMzGGI0U7CTawYfHROlk,4499
4
- fotolab/subcommands/__init__.py,sha256=l3DlIaJ3u3jGjnC1H1yV8LZ_nPqOLJ6gikD4BCaMAQ0,1129
5
- fotolab/subcommands/animate.py,sha256=3nXWeQ0l6hFMrFfxba9py60b_6Hv2WJwR_VuYzzrexw,5867
6
- fotolab/subcommands/auto.py,sha256=j34xiZlbl8qFpW8kFr3Z-J0Xk08Qiz8Stn1O9I2y1io,3572
7
- fotolab/subcommands/border.py,sha256=N_ptzy3vgHcmd7WwGcnSgJ34usNGmlGNT4xJ5D0hD5I,4703
8
- fotolab/subcommands/contrast.py,sha256=JRnGPSQJYweZkqBIosFcJHYuO3bryC3WuHQStmlbYK0,3699
9
- fotolab/subcommands/env.py,sha256=QoxRvzZKgmoHTUxDV4QYhdChCpMWs5TbXFY_qIpIQpE,1469
10
- fotolab/subcommands/halftone.py,sha256=ScJB54gnmUpxh9wYCt_Y7ertZl-gYW_WPAPBHC44ReE,6894
11
- fotolab/subcommands/info.py,sha256=H3voMi67cKoHT2Mu4RUNQBPdb_MspetPjhOvy-YyNnE,3563
12
- fotolab/subcommands/montage.py,sha256=bjn-RlbXur38-5YNyyTs0kI2Z-caVDZtBt5EDuH5D1M,2598
13
- fotolab/subcommands/resize.py,sha256=8JKHLJii6qq9eQ3QvqF56tjriRpebPZewU71xOucdug,5804
14
- fotolab/subcommands/rotate.py,sha256=M6i34mgD10F9jFlXQGP6-l78BwZY8Nh-eNteAEBOjH4,2925
15
- fotolab/subcommands/sharpen.py,sha256=Swd2IBO2ENc0g-Ukl-Jtj7OXaytFcTN8qEx8MRchFGE,3582
16
- fotolab/subcommands/watermark.py,sha256=9mP2PWaXjAyoMsZR4mkemx18gL0pS90Qx9lT5CuOBkA,11343
17
- fotolab-0.34.1.dist-info/licenses/LICENSE.md,sha256=tGtFDwxWTjuR9syrJoSv1Hiffd2u8Tu8cYClfrXS_YU,31956
18
- fotolab-0.34.1.dist-info/METADATA,sha256=Nln4axVoaQ3tiFdDwyL0SkJMvpc9N_LixKjSD-Bkv2A,15101
19
- fotolab-0.34.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
20
- fotolab-0.34.1.dist-info/entry_points.txt,sha256=0e1go9plFpqj5FP-OpV2acxTAx3ViI59PMXuhejvgcQ,45
21
- fotolab-0.34.1.dist-info/top_level.txt,sha256=XUJ3gdpsbjohoZCLdVlbQrxAUDkbQg7WwGQG2DaN0t4,8
22
- fotolab-0.34.1.dist-info/RECORD,,