fotolab 0.29.2__tar.gz → 0.31.0__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 (57) hide show
  1. {fotolab-0.29.2 → fotolab-0.31.0}/.gitignore +1 -0
  2. {fotolab-0.29.2 → fotolab-0.31.0}/.pre-commit-config.yaml +3 -3
  3. {fotolab-0.29.2 → fotolab-0.31.0}/CHANGELOG.md +29 -11
  4. {fotolab-0.29.2 → fotolab-0.31.0}/PKG-INFO +10 -3
  5. {fotolab-0.29.2 → fotolab-0.31.0}/README.md +9 -2
  6. {fotolab-0.29.2 → fotolab-0.31.0}/fotolab/__init__.py +7 -8
  7. {fotolab-0.29.2 → fotolab-0.31.0}/fotolab/cli.py +4 -4
  8. fotolab-0.31.0/fotolab/subcommands/animate.py +193 -0
  9. {fotolab-0.29.2 → fotolab-0.31.0}/fotolab/subcommands/border.py +7 -7
  10. {fotolab-0.29.2 → fotolab-0.31.0}/fotolab/subcommands/contrast.py +3 -1
  11. {fotolab-0.29.2 → fotolab-0.31.0}/fotolab/subcommands/watermark.py +37 -6
  12. {fotolab-0.29.2 → fotolab-0.31.0}/noxfile.py +1 -0
  13. {fotolab-0.29.2 → fotolab-0.31.0}/tests/conftest.py +11 -0
  14. fotolab-0.31.0/tests/test_animate_subcommand.py +7 -0
  15. fotolab-0.31.0/tests/test_auto_subcommand.py +7 -0
  16. fotolab-0.31.0/tests/test_border_subcommand.py +6 -0
  17. fotolab-0.31.0/tests/test_contrast_subcommand.py +6 -0
  18. fotolab-0.31.0/tests/test_env_subcommand.py +23 -0
  19. fotolab-0.31.0/tests/test_halftone_subcommand.py +6 -0
  20. fotolab-0.31.0/tests/test_info_subcommand.py +6 -0
  21. fotolab-0.31.0/tests/test_montage_subcommand.py +6 -0
  22. fotolab-0.31.0/tests/test_resize_subcommand.py +6 -0
  23. fotolab-0.31.0/tests/test_rotate_subcommand.py +6 -0
  24. fotolab-0.31.0/tests/test_sharpen_subcommand.py +6 -0
  25. fotolab-0.31.0/tests/test_watermark_subcommand.py +6 -0
  26. fotolab-0.29.2/fotolab/subcommands/animate.py +0 -131
  27. fotolab-0.29.2/tests/test_env_subcommand.py +0 -10
  28. {fotolab-0.29.2 → fotolab-0.31.0}/.coveragerc +0 -0
  29. {fotolab-0.29.2 → fotolab-0.31.0}/.python-version +0 -0
  30. {fotolab-0.29.2 → fotolab-0.31.0}/CONTRIBUTING.md +0 -0
  31. {fotolab-0.29.2 → fotolab-0.31.0}/LICENSE.md +0 -0
  32. {fotolab-0.29.2 → fotolab-0.31.0}/Pipfile +0 -0
  33. {fotolab-0.29.2 → fotolab-0.31.0}/Pipfile.lock +0 -0
  34. {fotolab-0.29.2 → fotolab-0.31.0}/docs/Makefile +0 -0
  35. {fotolab-0.29.2 → fotolab-0.31.0}/docs/make.bat +0 -0
  36. {fotolab-0.29.2 → fotolab-0.31.0}/docs/source/CHANGELOG.md +0 -0
  37. {fotolab-0.29.2 → fotolab-0.31.0}/docs/source/CONTRIBUTING.md +0 -0
  38. {fotolab-0.29.2 → fotolab-0.31.0}/docs/source/LICENSE.md +0 -0
  39. {fotolab-0.29.2 → fotolab-0.31.0}/docs/source/README.md +0 -0
  40. {fotolab-0.29.2 → fotolab-0.31.0}/docs/source/_static/logo.jpg +0 -0
  41. {fotolab-0.29.2 → fotolab-0.31.0}/docs/source/conf.py +0 -0
  42. {fotolab-0.29.2 → fotolab-0.31.0}/docs/source/index.rst +0 -0
  43. {fotolab-0.29.2 → fotolab-0.31.0}/fotolab/__main__.py +0 -0
  44. {fotolab-0.29.2 → fotolab-0.31.0}/fotolab/subcommands/__init__.py +0 -0
  45. {fotolab-0.29.2 → fotolab-0.31.0}/fotolab/subcommands/auto.py +0 -0
  46. {fotolab-0.29.2 → fotolab-0.31.0}/fotolab/subcommands/env.py +0 -0
  47. {fotolab-0.29.2 → fotolab-0.31.0}/fotolab/subcommands/halftone.py +0 -0
  48. {fotolab-0.29.2 → fotolab-0.31.0}/fotolab/subcommands/info.py +0 -0
  49. {fotolab-0.29.2 → fotolab-0.31.0}/fotolab/subcommands/montage.py +0 -0
  50. {fotolab-0.29.2 → fotolab-0.31.0}/fotolab/subcommands/resize.py +0 -0
  51. {fotolab-0.29.2 → fotolab-0.31.0}/fotolab/subcommands/rotate.py +0 -0
  52. {fotolab-0.29.2 → fotolab-0.31.0}/fotolab/subcommands/sharpen.py +0 -0
  53. {fotolab-0.29.2 → fotolab-0.31.0}/generate +0 -0
  54. {fotolab-0.29.2 → fotolab-0.31.0}/pyproject.toml +0 -0
  55. {fotolab-0.29.2 → fotolab-0.31.0}/tests/__init__.py +0 -0
  56. {fotolab-0.29.2 → fotolab-0.31.0}/tests/test_help_flag.py +0 -0
  57. {fotolab-0.29.2 → fotolab-0.31.0}/tests/test_quiet_flag.py +0 -0
@@ -162,5 +162,6 @@ cython_debug/
162
162
  # heatmaps
163
163
  *.png
164
164
  .aider*
165
+ .opencode
165
166
  TODO
166
167
  output
@@ -48,7 +48,7 @@ repos:
48
48
  rev: 25.1.0
49
49
  hooks:
50
50
  - id: black
51
- language_version: python3.12
51
+ language_version: python3.13
52
52
  args:
53
53
  - --line-length=79
54
54
  - --target-version=py38
@@ -74,13 +74,13 @@ repos:
74
74
  - --remove-all-unused-imports
75
75
  language: python
76
76
  files: \.py$
77
- language_version: python3.12
77
+ language_version: python3.13
78
78
 
79
79
  - repo: https://github.com/PyCQA/flake8
80
80
  rev: 7.2.0
81
81
  hooks:
82
82
  - id: flake8
83
- language_version: python3.12
83
+ language_version: python3.13
84
84
  additional_dependencies:
85
85
  - flake8-docstrings
86
86
  - flake8-pytest-style
@@ -7,12 +7,30 @@ and this project adheres to [0-based versioning](https://0ver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## v0.31.0 (2025-05-25)
11
+
12
+ - Add basic tests for several CLI subcommands
13
+ - Add test for auto subcommand and image fixture
14
+ - Add watermark transparency option and improve error handling
15
+ - Fix markdown in changelog
16
+ - Refine `env` subcommand test assertions
17
+ - Standardize border dimension return as a tuple
18
+
19
+ ## v0.30.0 (2025-05-18)
20
+
21
+ - Add WEBP `quality`, `lossless`, and `method` args
22
+ - Bump Python version for `pre-commit` check
23
+ - Close image files after creating animation
24
+ - Update help message generated by latest Python to readme
25
+ - Update help message in readme
26
+ - Validate duration for `animate` subcommand
27
+
10
28
  ## v0.29.2 (2025-05-11)
11
29
 
12
30
  - Bump deps
13
31
  - Code format
14
32
  - Improve info subcommand by adding error handling for missing keys
15
- - Remove default value for image filename argument in info subcommand
33
+ - Remove default value for image filename argument in `info` subcommand
16
34
  - Use context manager to open image and remove redundant image close.
17
35
 
18
36
  ## v0.29.1 (2025-05-04)
@@ -25,8 +43,8 @@ and this project adheres to [0-based versioning](https://0ver.org/).
25
43
 
26
44
  ## v0.29.0 (2025-04-27)
27
45
 
28
- - Add `cells` argument to halftone subcommand and function
29
- - Add grayscale option to halftone subcommand for grayscale conversion
46
+ - Add `cells` argument to `halftone` subcommand and function
47
+ - Add `grayscale` option to `halftone` subcommand for grayscale conversion
30
48
  - Bump deps
31
49
  - Calculate dot radius relative to cell size based on brightness.
32
50
  - Refactor CLI to handle missing subcommand execution function gracefully
@@ -42,22 +60,22 @@ and this project adheres to [0-based versioning](https://0ver.org/).
42
60
  ## v0.28.5 (2025-04-13)
43
61
 
44
62
  - Group build and publish package commands together
45
- - Improve contrast subcommand and add pylint disable
46
- - Prompt to publish package in release nox job
47
- - Resolve pylint raised issue
48
- - Validate cutoff value is between 0 and 50 in contrast subcommand
63
+ - Improve `contrast` subcommand and add `pylint` disable
64
+ - Prompt to publish package in release `nox` job
65
+ - Resolve `pylint` raised issue
66
+ - Validate `cutoff` value is between 0 and 50 in `contrast` subcommand
49
67
 
50
68
  ## v0.28.4 (2025-04-06)
51
69
 
52
70
  - Build the release after release `nox` job
53
71
  - Bump `pre-commit` for `flake8`
54
72
  - Fix incorrect git options
55
- - Install flit as deps for dev environment
73
+ - Install `flit` as deps for dev environment
56
74
  - Update help message in readme
57
75
 
58
76
  ## v0.28.3 (2025-03-30)
59
77
 
60
- - Bump `pre-commit` hook for validate-project
78
+ - Bump `pre-commit` hook for `validate-project`
61
79
  - Commit changes after bump release
62
80
  - Remove exception handling as it's handled in parent calling function
63
81
  - Resolve W0718: Catching too general exception Exception
@@ -67,7 +85,7 @@ and this project adheres to [0-based versioning](https://0ver.org/).
67
85
  ## v0.28.2 (2025-03-23)
68
86
 
69
87
  - Accept width in integer instead of string
70
- - Bump pre-commit hook for validate-project
88
+ - Bump `pre-commit` hook for `validate-project`
71
89
  - Handle file not found and other exceptions when opening/processing images
72
90
  - Remove all unnecessary exceptions handling
73
91
  - Use Union for border width return type to allow int or tuple
@@ -88,7 +106,7 @@ and this project adheres to [0-based versioning](https://0ver.org/).
88
106
  - Refactor getting output filename when saving image
89
107
  - Remove `--output-dir` and `--open` args from main cli
90
108
  - Update help message in readme
91
- - Use single `save_gif_image` function in sharpen and halftone subcommand
109
+ - Use single `save_gif_image` function in sharpen and `halftone` subcommand
92
110
 
93
111
  ## v0.27.2 (2025-03-02)
94
112
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fotolab
3
- Version: 0.29.2
3
+ Version: 0.31.0
4
4
  Summary: A console program that manipulate images.
5
5
  Keywords: photography,photo
6
6
  Author-email: Kian-Meng Ang <kianmeng@cpan.org>
@@ -106,7 +106,8 @@ fotolab animate -h
106
106
 
107
107
  ```console
108
108
  usage: fotolab animate [-h] [-f FORMAT] [-d DURATION] [-l LOOP] [-op]
109
- [-od OUTPUT_DIR]
109
+ [--webp-quality QUALITY] [--webp-lossless]
110
+ [--webp-method METHOD] [-od OUTPUT_DIR]
110
111
  IMAGE_FILENAMES [IMAGE_FILENAMES ...]
111
112
 
112
113
  positional arguments:
@@ -116,10 +117,16 @@ options:
116
117
  -h, --help show this help message and exit
117
118
  -f, --format FORMAT set the image format (default: 'gif')
118
119
  -d, --duration DURATION
119
- set the duration in milliseconds (default: '2500')
120
+ set the duration in milliseconds (must be a positive
121
+ integer, default: '2500')
120
122
  -l, --loop LOOP set the loop cycle (default: '0')
121
123
  -op, --open open the image using default program (default:
122
124
  'False')
125
+ --webp-quality QUALITY
126
+ set WEBP quality (0-100, default: '80')
127
+ --webp-lossless enable WEBP lossless compression (default: 'False')
128
+ --webp-method METHOD set WEBP encoding method (0=fast, 6=slow/best,
129
+ default: '4')
123
130
  -od, --output-dir OUTPUT_DIR
124
131
  set default output folder (default: 'output')
125
132
  ```
@@ -83,7 +83,8 @@ fotolab animate -h
83
83
 
84
84
  ```console
85
85
  usage: fotolab animate [-h] [-f FORMAT] [-d DURATION] [-l LOOP] [-op]
86
- [-od OUTPUT_DIR]
86
+ [--webp-quality QUALITY] [--webp-lossless]
87
+ [--webp-method METHOD] [-od OUTPUT_DIR]
87
88
  IMAGE_FILENAMES [IMAGE_FILENAMES ...]
88
89
 
89
90
  positional arguments:
@@ -93,10 +94,16 @@ options:
93
94
  -h, --help show this help message and exit
94
95
  -f, --format FORMAT set the image format (default: 'gif')
95
96
  -d, --duration DURATION
96
- set the duration in milliseconds (default: '2500')
97
+ set the duration in milliseconds (must be a positive
98
+ integer, default: '2500')
97
99
  -l, --loop LOOP set the loop cycle (default: '0')
98
100
  -op, --open open the image using default program (default:
99
101
  'False')
102
+ --webp-quality QUALITY
103
+ set WEBP quality (0-100, default: '80')
104
+ --webp-lossless enable WEBP lossless compression (default: 'False')
105
+ --webp-method METHOD set WEBP encoding method (0=fast, 6=slow/best,
106
+ default: '4')
100
107
  -od, --output-dir OUTPUT_DIR
101
108
  set default output folder (default: 'output')
102
109
  ```
@@ -24,7 +24,7 @@ from pathlib import Path
24
24
 
25
25
  from PIL import Image
26
26
 
27
- __version__ = "0.29.2"
27
+ __version__ = "0.31.0"
28
28
 
29
29
  log = logging.getLogger(__name__)
30
30
 
@@ -59,15 +59,14 @@ def save_gif_image(
59
59
  _open_image(new_filename)
60
60
 
61
61
 
62
- def save_image(args, new_image, output_filename, subcommand):
62
+ def save_image(
63
+ args: argparse.Namespace,
64
+ new_image: Image.Image,
65
+ output_filename: str,
66
+ subcommand: str,
67
+ ) -> None:
63
68
  """Save image after image operation.
64
69
 
65
- Args:
66
- args (argparse.Namespace): Config from command line arguments
67
- new_image(PIL.Image.Image): Modified image
68
- output_filename(str): Save filename image
69
- subcommand(str): Subcommand used to call this function
70
-
71
70
  Returns:
72
71
  None
73
72
  """
@@ -15,9 +15,9 @@
15
15
 
16
16
  """A console program to manipulate photos.
17
17
 
18
- website: https://github.com/kianmeng/fotolab
19
- changelog: https://github.com/kianmeng/fotolab/blob/master/CHANGELOG.md
20
- issues: https://github.com/kianmeng/fotolab/issues
18
+ website: https://github.com/kianmeng/fotolab
19
+ changelog: https://github.com/kianmeng/fotolab/blob/master/CHANGELOG.md
20
+ issues: https://github.com/kianmeng/fotolab/issues
21
21
  """
22
22
 
23
23
  import argparse
@@ -143,7 +143,7 @@ def main(args: Optional[Sequence[str]] = None) -> None:
143
143
  # correctly
144
144
  log.error(
145
145
  "subcommand '%s' is missing its execution function.",
146
- parsed_args.command
146
+ parsed_args.command,
147
147
  )
148
148
  parser.print_help(sys.stderr)
149
149
  raise SystemExit(1)
@@ -0,0 +1,193 @@
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
+ """Animate subcommand."""
17
+
18
+ import argparse
19
+ import logging
20
+ from pathlib import Path
21
+
22
+ from PIL import Image
23
+
24
+ from fotolab import _open_image
25
+
26
+ log = logging.getLogger(__name__)
27
+
28
+
29
+ def _validate_duration(value: str) -> int:
30
+ """Validate that the duration is a positive integer."""
31
+ try:
32
+ ivalue = int(value)
33
+ if ivalue <= 0:
34
+ raise argparse.ArgumentTypeError(
35
+ f"duration must be a positive integer, but got {value}"
36
+ )
37
+ return ivalue
38
+ except ValueError as e:
39
+ raise argparse.ArgumentTypeError(
40
+ f"duration must be an integer, but got '{value}'"
41
+ ) from e
42
+
43
+
44
+ def build_subparser(subparsers) -> None:
45
+ """Build the subparser."""
46
+ animate_parser = subparsers.add_parser("animate", help="animate an image")
47
+
48
+ animate_parser.set_defaults(func=run)
49
+
50
+ animate_parser.add_argument(
51
+ dest="image_filenames",
52
+ help="set the image filenames",
53
+ nargs="+",
54
+ type=str,
55
+ default=None,
56
+ metavar="IMAGE_FILENAMES",
57
+ )
58
+
59
+ animate_parser.add_argument(
60
+ "-f",
61
+ "--format",
62
+ dest="format",
63
+ type=str,
64
+ choices=["gif", "webp"],
65
+ default="gif",
66
+ help="set the image format (default: '%(default)s')",
67
+ metavar="FORMAT",
68
+ )
69
+
70
+ animate_parser.add_argument(
71
+ "-d",
72
+ "--duration",
73
+ dest="duration",
74
+ type=_validate_duration,
75
+ default=2500,
76
+ help="set the duration in milliseconds (must be a positive integer, default: '%(default)s')",
77
+ metavar="DURATION",
78
+ )
79
+
80
+ animate_parser.add_argument(
81
+ "-l",
82
+ "--loop",
83
+ dest="loop",
84
+ type=int,
85
+ default=0,
86
+ help="set the loop cycle (default: '%(default)s')",
87
+ metavar="LOOP",
88
+ )
89
+
90
+ animate_parser.add_argument(
91
+ "-op",
92
+ "--open",
93
+ default=False,
94
+ action="store_true",
95
+ dest="open",
96
+ help="open the image using default program (default: '%(default)s')",
97
+ )
98
+
99
+ animate_parser.add_argument(
100
+ "--webp-quality",
101
+ dest="webp_quality",
102
+ type=int,
103
+ default=80,
104
+ choices=range(0, 101),
105
+ help="set WEBP quality (0-100, default: '%(default)s')",
106
+ metavar="QUALITY",
107
+ )
108
+
109
+ animate_parser.add_argument(
110
+ "--webp-lossless",
111
+ dest="webp_lossless",
112
+ default=False,
113
+ action="store_true",
114
+ help="enable WEBP lossless compression (default: '%(default)s')",
115
+ )
116
+
117
+ animate_parser.add_argument(
118
+ "--webp-method",
119
+ dest="webp_method",
120
+ type=int,
121
+ default=4,
122
+ choices=range(0, 7),
123
+ help="set WEBP encoding method (0=fast, 6=slow/best, default: '%(default)s')",
124
+ metavar="METHOD",
125
+ )
126
+
127
+ animate_parser.add_argument(
128
+ "-od",
129
+ "--output-dir",
130
+ dest="output_dir",
131
+ default="output",
132
+ help="set default output folder (default: '%(default)s')",
133
+ )
134
+
135
+
136
+ def run(args: argparse.Namespace) -> None:
137
+ """Run animate subcommand.
138
+
139
+ Args:
140
+ args (argparse.Namespace): Config from command line arguments
141
+
142
+ Returns:
143
+ None
144
+ """
145
+ log.debug(args)
146
+
147
+ first_image_filepath = args.image_filenames[0]
148
+ main_frame = None
149
+ other_frames = []
150
+
151
+ try:
152
+ main_frame = Image.open(first_image_filepath)
153
+
154
+ for image_filename in args.image_filenames[1:]:
155
+ img = Image.open(image_filename)
156
+ other_frames.append(img)
157
+
158
+ image_file = Path(first_image_filepath)
159
+ new_filename = Path(
160
+ args.output_dir,
161
+ image_file.with_name(f"animate_{image_file.stem}.{args.format}"),
162
+ )
163
+ new_filename.parent.mkdir(parents=True, exist_ok=True)
164
+
165
+ log.info("animate image: %s", new_filename)
166
+
167
+ save_kwargs = {
168
+ "format": args.format,
169
+ "append_images": other_frames,
170
+ "save_all": True,
171
+ "duration": args.duration,
172
+ "loop": args.loop,
173
+ "optimize": True, # General optimization, good for GIF
174
+ }
175
+
176
+ if args.format == "webp":
177
+ save_kwargs["quality"] = args.webp_quality
178
+ save_kwargs["lossless"] = args.webp_lossless
179
+ save_kwargs["method"] = args.webp_method
180
+ # Pillow's WEBP save doesn't use a general 'optimize' like GIF.
181
+ # Specific WEBP params like 'method' and 'quality' control this.
182
+ # We can remove 'optimize' if it causes issues or is ignored for WEBP.
183
+ # For now, let's keep it, Pillow might handle it or ignore it.
184
+
185
+ main_frame.save(new_filename, **save_kwargs)
186
+ finally:
187
+ if main_frame:
188
+ main_frame.close()
189
+ for frame in other_frames:
190
+ frame.close()
191
+
192
+ if args.open:
193
+ _open_image(new_filename)
@@ -26,7 +26,7 @@ from fotolab import save_image
26
26
  log = logging.getLogger(__name__)
27
27
 
28
28
 
29
- def build_subparser(subparsers) -> None:
29
+ def build_subparser(subparsers: argparse._SubParsersAction) -> None:
30
30
  """Build the subparser."""
31
31
  border_parser = subparsers.add_parser("border", help="add border to image")
32
32
 
@@ -150,17 +150,16 @@ def run(args: argparse.Namespace) -> None:
150
150
 
151
151
  def get_border(
152
152
  args: argparse.Namespace,
153
- ) -> Union[Tuple[int, int, int, int], int]:
153
+ ) -> Tuple[int, int, int, int]:
154
154
  """Calculate the border dimensions.
155
155
 
156
156
  Args:
157
157
  args (argparse.Namespace): Command line arguments
158
158
 
159
159
  Returns:
160
- Union[Tuple[int, int, int, int], int]: Border dimensions in pixels.
161
- If individual widths are specified, returns a tuple of (left, top,
162
- right, bottom) widths. Otherwise, returns a uniform width for all
163
- sides.
160
+ Tuple[int, int, int, int]: Border dimensions in pixels as (left, top,
161
+ right, bottom) widths. If individual widths are not specified,
162
+ a uniform width is returned for all sides.
164
163
  """
165
164
  if any(
166
165
  [
@@ -176,4 +175,5 @@ def get_border(
176
175
  args.width_right,
177
176
  args.width_bottom,
178
177
  )
179
- return args.width
178
+ # If no individual widths are specified, use the general width for all sides
179
+ return (args.width, args.width, args.width, args.width)
@@ -31,7 +31,9 @@ def _validate_cutoff(value: str) -> float:
31
31
  try:
32
32
  f_value = float(value)
33
33
  except ValueError as e:
34
- raise argparse.ArgumentTypeError(f"invalid float value: '{value}'") from e
34
+ raise argparse.ArgumentTypeError(
35
+ f"invalid float value: '{value}'"
36
+ ) from e
35
37
  if not 0 <= f_value <= 50:
36
38
  raise argparse.ArgumentTypeError(
37
39
  f"cutoff value {f_value} must be between 0 and 50"
@@ -129,6 +129,21 @@ def build_subparser(subparsers: argparse._SubParsersAction) -> None:
129
129
  metavar="OUTLINE_COLOR",
130
130
  )
131
131
 
132
+ watermark_parser.add_argument(
133
+ "-a",
134
+ "--alpha",
135
+ dest="alpha",
136
+ type=int,
137
+ default=128,
138
+ choices=range(0, 256),
139
+ metavar="ALPHA_VALUE",
140
+ help=(
141
+ "set the transparency of the watermark text (0-255, "
142
+ "where 0 is fully transparent and 255 is fully opaque; "
143
+ "default: '%(default)s')"
144
+ ),
145
+ )
146
+
132
147
  watermark_parser.add_argument(
133
148
  "--camera",
134
149
  default=False,
@@ -176,7 +191,15 @@ def run(args: argparse.Namespace) -> None:
176
191
  log.debug(args)
177
192
 
178
193
  for image_filename in args.image_filenames:
179
- image: Image.Image = Image.open(image_filename)
194
+ try:
195
+ image: Image.Image = Image.open(image_filename)
196
+ except FileNotFoundError:
197
+ log.error("Image file not found: %s", image_filename)
198
+ continue
199
+ except Exception as e:
200
+ log.error("Could not open image %s: %s", image_filename, e)
201
+ continue
202
+
180
203
  if image.format == "GIF":
181
204
  watermark_gif_image(image, image_filename, args)
182
205
  else:
@@ -202,7 +225,7 @@ def watermark_gif_image(
202
225
  frames: list[Image.Image] = []
203
226
  for frame in ImageSequence.Iterator(original_image):
204
227
  watermarked_frame: Image.Image = watermark_image(
205
- args, frame.convert("RGBA")
228
+ args, frame.convert("RGBA"), args.alpha
206
229
  )
207
230
  frames.append(watermarked_frame)
208
231
 
@@ -241,11 +264,11 @@ def watermark_non_gif_image(
241
264
  Returns:
242
265
  Image.Image: The watermarked image
243
266
  """
244
- return watermark_image(args, original_image)
267
+ return watermark_image(args, original_image, args.alpha)
245
268
 
246
269
 
247
270
  def watermark_image(
248
- args: argparse.Namespace, original_image: Image.Image
271
+ args: argparse.Namespace, original_image: Image.Image, alpha: int
249
272
  ) -> Image.Image:
250
273
  """Watermark an image."""
251
274
  watermarked_image: Image.Image = original_image.copy()
@@ -268,13 +291,21 @@ def watermark_image(
268
291
  calc_padding(original_image, args),
269
292
  )
270
293
 
294
+ try:
295
+ font_fill_color = ImageColor.getrgb(args.font_color)
296
+ stroke_fill_color = ImageColor.getrgb(args.outline_color)
297
+ except ValueError:
298
+ log.error("Invalid font or outline color specified. Using defaults.")
299
+ font_fill_color = ImageColor.getrgb("white")
300
+ stroke_fill_color = ImageColor.getrgb("black")
301
+
271
302
  draw.text(
272
303
  (position_x, position_y),
273
304
  text,
274
305
  font=font,
275
- fill=(*ImageColor.getrgb(args.font_color), 128),
306
+ fill=(*font_fill_color, alpha),
276
307
  stroke_width=calc_font_outline_width(original_image, args),
277
- stroke_fill=(*ImageColor.getrgb(args.outline_color), 128),
308
+ stroke_fill=(*stroke_fill_color, alpha),
278
309
  )
279
310
  return watermarked_image
280
311
 
@@ -179,6 +179,7 @@ def release(session: nox.Session) -> None:
179
179
  session.run(
180
180
  "git",
181
181
  "commit",
182
+ "--no-verify",
182
183
  "-am",
183
184
  f"Bump {after_version} release",
184
185
  external=True,
@@ -27,6 +27,17 @@ def fixture_csv_file(tmpdir):
27
27
  return csv_file
28
28
 
29
29
 
30
+ @pytest.fixture(autouse=True, name="image_file")
31
+ def fixture_image_file(tmpdir):
32
+ def image_file(filename):
33
+ src = Path(FIXTURE_PATH, filename)
34
+ des = Path(tmpdir, src.name)
35
+ copyfile(src, des)
36
+ return des
37
+
38
+ return image_file
39
+
40
+
30
41
  @pytest.fixture(autouse=True, name="cli_runner")
31
42
  def fixture_cli_runner(tmpdir):
32
43
  def cli_runner(*args, **kwargs):
@@ -0,0 +1,7 @@
1
+ # pylint: disable=C0114,C0116
2
+
3
+ def test_animate_subcommand(cli_runner, image_file):
4
+ img_path1 = image_file("sample.png")
5
+ img_path2 = image_file("sample.png")
6
+ ret = cli_runner('animate', str(img_path1), str(img_path2))
7
+ assert ret.returncode == 0
@@ -0,0 +1,7 @@
1
+ # pylint: disable=C0114,C0116
2
+
3
+ def test_auto_subcommand(cli_runner, image_file):
4
+ """Test auto subcommand."""
5
+ img_path = image_file("sample.png")
6
+ ret = cli_runner("auto", str(img_path))
7
+ assert ret.returncode == 0
@@ -0,0 +1,6 @@
1
+ # pylint: disable=C0114,C0116
2
+
3
+ def test_border_subcommand(cli_runner, image_file):
4
+ img_path = image_file("sample.png")
5
+ ret = cli_runner('border', str(img_path), '--width', '10')
6
+ assert ret.returncode == 0
@@ -0,0 +1,6 @@
1
+ # pylint: disable=C0114,C0116
2
+
3
+ def test_contrast_subcommand(cli_runner, image_file):
4
+ img_path = image_file("sample.png")
5
+ ret = cli_runner('contrast', str(img_path))
6
+ assert ret.returncode == 0
@@ -0,0 +1,23 @@
1
+ # pylint: disable=C0114,C0116
2
+
3
+ import platform
4
+ import sys
5
+
6
+ from fotolab import __version__
7
+
8
+
9
+ def test_env_output(cli_runner):
10
+ ret = cli_runner("env")
11
+
12
+ actual_sys_version = sys.version.replace("\n", "")
13
+ actual_platform = platform.platform()
14
+
15
+ expected_output = (
16
+ f"fotolab: {__version__}\n"
17
+ f"python: {actual_sys_version}\n"
18
+ f"platform: {actual_platform}\n"
19
+ )
20
+
21
+ assert ret.stdout == expected_output
22
+ assert ret.stderr == ""
23
+ assert ret.returncode == 0
@@ -0,0 +1,6 @@
1
+ # pylint: disable=C0114,C0116
2
+
3
+ def test_halftone_subcommand(cli_runner, image_file):
4
+ img_path = image_file("sample.png")
5
+ ret = cli_runner('halftone', str(img_path))
6
+ assert ret.returncode == 0
@@ -0,0 +1,6 @@
1
+ # pylint: disable=C0114,C0116
2
+
3
+ def test_info_subcommand(cli_runner, image_file):
4
+ img_path = image_file("sample.png")
5
+ ret = cli_runner('info', str(img_path))
6
+ assert ret.returncode == 0
@@ -0,0 +1,6 @@
1
+ # pylint: disable=C0114,C0116
2
+
3
+ def test_montage_subcommand(cli_runner, image_file):
4
+ img_path = image_file("sample.png")
5
+ ret = cli_runner('montage', str(img_path), str(img_path))
6
+ assert ret.returncode == 0
@@ -0,0 +1,6 @@
1
+ # pylint: disable=C0114,C0116
2
+
3
+ def test_resize_subcommand(cli_runner, image_file):
4
+ img_path = image_file("sample.png")
5
+ ret = cli_runner('resize', str(img_path), '--width', '200')
6
+ assert ret.returncode == 0
@@ -0,0 +1,6 @@
1
+ # pylint: disable=C0114,C0116
2
+
3
+ def test_rotate_subcommand(cli_runner, image_file):
4
+ img_path = image_file("sample.png")
5
+ ret = cli_runner('rotate', str(img_path))
6
+ assert ret.returncode == 0
@@ -0,0 +1,6 @@
1
+ # pylint: disable=C0114,C0116
2
+
3
+ def test_sharpen_subcommand(cli_runner, image_file):
4
+ img_path = image_file("sample.png")
5
+ ret = cli_runner('sharpen', str(img_path))
6
+ assert ret.returncode == 0
@@ -0,0 +1,6 @@
1
+ # pylint: disable=C0114,C0116
2
+
3
+ def test_watermark_subcommand(cli_runner, image_file):
4
+ img_path = image_file("sample.png")
5
+ ret = cli_runner('watermark', str(img_path), '--text', 'Test')
6
+ assert ret.returncode == 0
@@ -1,131 +0,0 @@
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
- """Animate subcommand."""
17
-
18
- import argparse
19
- import logging
20
- from pathlib import Path
21
-
22
- from PIL import Image
23
-
24
- from fotolab import _open_image
25
-
26
- log = logging.getLogger(__name__)
27
-
28
-
29
- def build_subparser(subparsers) -> None:
30
- """Build the subparser."""
31
- animate_parser = subparsers.add_parser("animate", help="animate an image")
32
-
33
- animate_parser.set_defaults(func=run)
34
-
35
- animate_parser.add_argument(
36
- dest="image_filenames",
37
- help="set the image filenames",
38
- nargs="+",
39
- type=str,
40
- default=None,
41
- metavar="IMAGE_FILENAMES",
42
- )
43
-
44
- animate_parser.add_argument(
45
- "-f",
46
- "--format",
47
- dest="format",
48
- type=str,
49
- choices=["gif", "webp"],
50
- default="gif",
51
- help="set the image format (default: '%(default)s')",
52
- metavar="FORMAT",
53
- )
54
-
55
- animate_parser.add_argument(
56
- "-d",
57
- "--duration",
58
- dest="duration",
59
- type=int,
60
- default=2500,
61
- help="set the duration in milliseconds (default: '%(default)s')",
62
- metavar="DURATION",
63
- )
64
-
65
- animate_parser.add_argument(
66
- "-l",
67
- "--loop",
68
- dest="loop",
69
- type=int,
70
- default=0,
71
- help="set the loop cycle (default: '%(default)s')",
72
- metavar="LOOP",
73
- )
74
-
75
- animate_parser.add_argument(
76
- "-op",
77
- "--open",
78
- default=False,
79
- action="store_true",
80
- dest="open",
81
- help="open the image using default program (default: '%(default)s')",
82
- )
83
-
84
- animate_parser.add_argument(
85
- "-od",
86
- "--output-dir",
87
- dest="output_dir",
88
- default="output",
89
- help="set default output folder (default: '%(default)s')",
90
- )
91
-
92
-
93
- def run(args: argparse.Namespace) -> None:
94
- """Run animate subcommand.
95
-
96
- Args:
97
- args (argparse.Namespace): Config from command line arguments
98
-
99
- Returns:
100
- None
101
- """
102
- log.debug(args)
103
-
104
- first_image = args.image_filenames[0]
105
- animated_image = Image.open(first_image)
106
-
107
- append_images = []
108
- for image_filename in args.image_filenames[1:]:
109
- append_images.append(Image.open(image_filename))
110
-
111
- image_file = Path(first_image)
112
- new_filename = Path(
113
- args.output_dir,
114
- image_file.with_name(f"animate_{image_file.stem}.{args.format}"),
115
- )
116
- new_filename.parent.mkdir(parents=True, exist_ok=True)
117
-
118
- log.info("animate image: %s", new_filename)
119
-
120
- animated_image.save(
121
- new_filename,
122
- format=args.format,
123
- append_images=append_images,
124
- save_all=True,
125
- duration=args.duration,
126
- loop=args.loop,
127
- optimize=True,
128
- )
129
-
130
- if args.open:
131
- _open_image(new_filename)
@@ -1,10 +0,0 @@
1
- # pylint: disable=C0114,C0116
2
-
3
- from fotolab import __version__
4
-
5
-
6
- def test_env_output(cli_runner):
7
- ret = cli_runner("env")
8
- assert f"fotolab: {__version__}" in ret.stdout
9
- assert "python: " in ret.stdout
10
- assert "platform: " in ret.stdout
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes