fotolab 0.32.0__tar.gz → 0.33.1__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 (41) hide show
  1. {fotolab-0.32.0/src/fotolab.egg-info → fotolab-0.33.1}/PKG-INFO +1 -1
  2. {fotolab-0.32.0 → fotolab-0.33.1}/pyproject.toml +1 -1
  3. {fotolab-0.32.0 → fotolab-0.33.1}/src/fotolab/__init__.py +35 -26
  4. {fotolab-0.32.0 → fotolab-0.33.1}/src/fotolab/subcommands/animate.py +29 -14
  5. {fotolab-0.32.0 → fotolab-0.33.1}/src/fotolab/subcommands/auto.py +30 -4
  6. {fotolab-0.32.0 → fotolab-0.33.1}/src/fotolab/subcommands/border.py +4 -3
  7. {fotolab-0.32.0 → fotolab-0.33.1}/src/fotolab/subcommands/contrast.py +2 -1
  8. {fotolab-0.32.0 → fotolab-0.33.1}/src/fotolab/subcommands/halftone.py +6 -2
  9. {fotolab-0.32.0 → fotolab-0.33.1}/src/fotolab/subcommands/montage.py +6 -4
  10. {fotolab-0.32.0 → fotolab-0.33.1}/src/fotolab/subcommands/resize.py +18 -17
  11. {fotolab-0.32.0 → fotolab-0.33.1}/src/fotolab/subcommands/rotate.py +9 -1
  12. {fotolab-0.32.0 → fotolab-0.33.1}/src/fotolab/subcommands/sharpen.py +7 -2
  13. {fotolab-0.32.0 → fotolab-0.33.1}/src/fotolab/subcommands/watermark.py +3 -1
  14. {fotolab-0.32.0 → fotolab-0.33.1/src/fotolab.egg-info}/PKG-INFO +1 -1
  15. {fotolab-0.32.0 → fotolab-0.33.1}/LICENSE.md +0 -0
  16. {fotolab-0.32.0 → fotolab-0.33.1}/README.md +0 -0
  17. {fotolab-0.32.0 → fotolab-0.33.1}/setup.cfg +0 -0
  18. {fotolab-0.32.0 → fotolab-0.33.1}/src/fotolab/__main__.py +0 -0
  19. {fotolab-0.32.0 → fotolab-0.33.1}/src/fotolab/cli.py +0 -0
  20. {fotolab-0.32.0 → fotolab-0.33.1}/src/fotolab/subcommands/__init__.py +0 -0
  21. {fotolab-0.32.0 → fotolab-0.33.1}/src/fotolab/subcommands/env.py +0 -0
  22. {fotolab-0.32.0 → fotolab-0.33.1}/src/fotolab/subcommands/info.py +0 -0
  23. {fotolab-0.32.0 → fotolab-0.33.1}/src/fotolab.egg-info/SOURCES.txt +0 -0
  24. {fotolab-0.32.0 → fotolab-0.33.1}/src/fotolab.egg-info/dependency_links.txt +0 -0
  25. {fotolab-0.32.0 → fotolab-0.33.1}/src/fotolab.egg-info/entry_points.txt +0 -0
  26. {fotolab-0.32.0 → fotolab-0.33.1}/src/fotolab.egg-info/requires.txt +0 -0
  27. {fotolab-0.32.0 → fotolab-0.33.1}/src/fotolab.egg-info/top_level.txt +0 -0
  28. {fotolab-0.32.0 → fotolab-0.33.1}/tests/test_animate_subcommand.py +0 -0
  29. {fotolab-0.32.0 → fotolab-0.33.1}/tests/test_auto_subcommand.py +0 -0
  30. {fotolab-0.32.0 → fotolab-0.33.1}/tests/test_border_subcommand.py +0 -0
  31. {fotolab-0.32.0 → fotolab-0.33.1}/tests/test_contrast_subcommand.py +0 -0
  32. {fotolab-0.32.0 → fotolab-0.33.1}/tests/test_env_subcommand.py +0 -0
  33. {fotolab-0.32.0 → fotolab-0.33.1}/tests/test_halftone_subcommand.py +0 -0
  34. {fotolab-0.32.0 → fotolab-0.33.1}/tests/test_help_flag.py +0 -0
  35. {fotolab-0.32.0 → fotolab-0.33.1}/tests/test_info_subcommand.py +0 -0
  36. {fotolab-0.32.0 → fotolab-0.33.1}/tests/test_montage_subcommand.py +0 -0
  37. {fotolab-0.32.0 → fotolab-0.33.1}/tests/test_quiet_flag.py +0 -0
  38. {fotolab-0.32.0 → fotolab-0.33.1}/tests/test_resize_subcommand.py +0 -0
  39. {fotolab-0.32.0 → fotolab-0.33.1}/tests/test_rotate_subcommand.py +0 -0
  40. {fotolab-0.32.0 → fotolab-0.33.1}/tests/test_sharpen_subcommand.py +0 -0
  41. {fotolab-0.32.0 → fotolab-0.33.1}/tests/test_watermark_subcommand.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fotolab
3
- Version: 0.32.0
3
+ Version: 0.33.1
4
4
  Author-email: Kian-Meng Ang <kianmeng@cpan.org>
5
5
  License-Expression: AGPL-3.0-or-later
6
6
  Project-URL: Changelog, https://github.com/kianmeng/fotolab/blob/master/CHANGELOG.md
@@ -7,7 +7,7 @@ where = ["src"]
7
7
 
8
8
  [project]
9
9
  name = "fotolab"
10
- version = "0.32.0"
10
+ version = "0.33.1"
11
11
  authors = [{name = "Kian-Meng Ang", email = "kianmeng@cpan.org"}]
12
12
  requires-python = ">=3.9"
13
13
  readme = "README.md"
@@ -31,30 +31,45 @@ log = logging.getLogger(__name__)
31
31
 
32
32
  def save_gif_image(
33
33
  args: argparse.Namespace,
34
- image_filename: str,
34
+ image_filepath: Path,
35
35
  original_image: Image.Image,
36
36
  after_image: Image.Image,
37
37
  subcommand: str,
38
38
  ) -> None:
39
39
  """Save the original and after image."""
40
- image_file = Path(image_filename)
41
- new_filename = Path(
42
- args.output_dir,
43
- image_file.with_name(f"{subcommand}_gif_{image_file.stem}.gif"),
44
- )
45
- new_filename.parent.mkdir(parents=True, exist_ok=True)
46
-
47
- log.info("%s gif image: %s", subcommand, new_filename)
48
- original_image.save(
49
- new_filename,
50
- format="gif",
51
- append_images=[after_image],
52
- save_all=True,
53
- duration=2500,
54
- loop=0,
55
- optimize=True,
40
+ gif_kwargs = {
41
+ "format": "gif",
42
+ "append_images": [after_image],
43
+ "save_all": True,
44
+ "duration": 2500,
45
+ "loop": 0,
46
+ "optimize": True,
47
+ }
48
+ _save_image_with_options(
49
+ args, original_image, image_filepath, subcommand, **gif_kwargs
56
50
  )
57
51
 
52
+
53
+ def _save_image_with_options(
54
+ args: argparse.Namespace,
55
+ image: Image.Image,
56
+ output_filepath: Path,
57
+ subcommand: str,
58
+ **kwargs,
59
+ ) -> None:
60
+ """Save image with additional options and handle opening.
61
+
62
+ Args:
63
+ args (argparse.Namespace): Config from command line arguments.
64
+ image (Image.Image): The image to save.
65
+ output_filepath (Path): The path to save the image to.
66
+ subcommand (str): The name of the subcommand.
67
+ **kwargs: Additional keyword arguments for Image.save().
68
+ """
69
+ new_filename = _get_output_filename(args, output_filepath, subcommand)
70
+ log.info("%s image: %s", subcommand, new_filename.resolve())
71
+ image.save(new_filename, **kwargs)
72
+
58
73
  if args.open:
59
74
  _open_image(new_filename)
60
75
 
@@ -62,7 +77,7 @@ def save_gif_image(
62
77
  def save_image(
63
78
  args: argparse.Namespace,
64
79
  new_image: Image.Image,
65
- output_filename: str,
80
+ output_filepath: Path,
66
81
  subcommand: str,
67
82
  ) -> None:
68
83
  """Save image after image operation.
@@ -70,13 +85,7 @@ def save_image(
70
85
  Returns:
71
86
  None
72
87
  """
73
- image_file = Path(output_filename)
74
- new_filename = _get_output_filename(args, image_file, subcommand)
75
- log.info("%s image: %s", subcommand, new_filename.resolve())
76
- new_image.save(new_filename)
77
-
78
- if args.open:
79
- _open_image(new_filename)
88
+ _save_image_with_options(args, new_image, output_filepath, subcommand)
80
89
 
81
90
 
82
91
  def _get_output_filename(
@@ -91,7 +100,7 @@ def _get_output_filename(
91
100
  return output_dir / image_file.with_name(f"{subcommand}_{image_file.name}")
92
101
 
93
102
 
94
- def _open_image(filename):
103
+ def _open_image(filename: Path):
95
104
  """Open generated image using default program."""
96
105
  try:
97
106
  if sys.platform == "linux":
@@ -125,7 +125,8 @@ def build_subparser(subparsers) -> None:
125
125
  default=4,
126
126
  choices=range(0, 7),
127
127
  help=(
128
- "set WEBP encoding method (0=fast, 6=slow/best, default: '%(default)s')"
128
+ "set WEBP encoding method "
129
+ "(0=fast, 6=slow/best, default: '%(default)s')"
129
130
  ),
130
131
  metavar="METHOD",
131
132
  )
@@ -138,6 +139,14 @@ def build_subparser(subparsers) -> None:
138
139
  help="set default output folder (default: '%(default)s')",
139
140
  )
140
141
 
142
+ animate_parser.add_argument(
143
+ "-of",
144
+ "--output-filename",
145
+ dest="output_filename",
146
+ default=None,
147
+ help="set output filename (default: '%(default)s')",
148
+ )
149
+
141
150
 
142
151
  def run(args: argparse.Namespace) -> None:
143
152
  """Run animate subcommand.
@@ -150,21 +159,27 @@ def run(args: argparse.Namespace) -> None:
150
159
  """
151
160
  log.debug(args)
152
161
 
153
- first_image_filepath = args.image_filenames[0]
162
+ image_filepaths = [Path(f) for f in args.image_filenames]
163
+ first_image_filepath = image_filepaths[0]
154
164
  other_frames = []
155
165
 
156
166
  with ExitStack() as stack:
157
167
  main_frame = stack.enter_context(Image.open(first_image_filepath))
158
168
 
159
- for image_filename in args.image_filenames[1:]:
160
- img = stack.enter_context(Image.open(image_filename))
169
+ for image_filepath in image_filepaths[1:]:
170
+ img = stack.enter_context(Image.open(image_filepath))
161
171
  other_frames.append(img)
162
172
 
163
- image_file = Path(first_image_filepath)
164
- new_filename = Path(
165
- args.output_dir,
166
- image_file.with_name(f"animate_{image_file.stem}.{args.format}"),
167
- )
173
+ if args.output_filename:
174
+ new_filename = Path(args.output_dir, args.output_filename)
175
+ else:
176
+ image_file = Path(first_image_filepath)
177
+ new_filename = Path(
178
+ args.output_dir,
179
+ image_file.with_name(
180
+ f"animate_{image_file.stem}.{args.format}"
181
+ ),
182
+ )
168
183
  new_filename.parent.mkdir(parents=True, exist_ok=True)
169
184
 
170
185
  log.info("animate image: %s", new_filename)
@@ -175,14 +190,14 @@ def run(args: argparse.Namespace) -> None:
175
190
  "save_all": True,
176
191
  "duration": args.duration,
177
192
  "loop": args.loop,
178
- "optimize": True,
179
193
  }
180
194
 
181
195
  # Pillow's WEBP save doesn't use a general 'optimize' like GIF.
182
- # Specific WEBP params like 'method' and 'quality' control this. We
183
- # can remove 'optimize' if it causes issues or is ignored for WEBP.
184
- # For now, let's keep it, Pillow might handle it or ignore it.
185
- if args.format == "webp":
196
+ # Specific WEBP params like 'method' and 'quality' control this.
197
+ # 'optimize' is removed for WEBP to avoid confusion.
198
+ if args.format == "gif":
199
+ save_kwargs["optimize"] = True
200
+ elif args.format == "webp":
186
201
  save_kwargs["quality"] = args.webp_quality
187
202
  save_kwargs["lossless"] = args.webp_lossless
188
203
  save_kwargs["method"] = args.webp_method
@@ -18,6 +18,7 @@
18
18
  import argparse
19
19
  import logging
20
20
 
21
+ import fotolab.subcommands.animate
21
22
  import fotolab.subcommands.contrast
22
23
  import fotolab.subcommands.resize
23
24
  import fotolab.subcommands.sharpen
@@ -45,9 +46,19 @@ def build_subparser(subparsers) -> None:
45
46
 
46
47
  auto_parser.add_argument(
47
48
  "-t",
48
- "--text",
49
- dest="text",
50
- help="set the watermark text (default: '%(default)s')",
49
+ "--title",
50
+ dest="title",
51
+ help="set the tile (default: '%(default)s')",
52
+ type=str,
53
+ default=None,
54
+ metavar="TITLE",
55
+ )
56
+
57
+ auto_parser.add_argument(
58
+ "-w",
59
+ "--watermark",
60
+ dest="watermark",
61
+ help="set the watermark (default: '%(default)s')",
51
62
  type=str,
52
63
  default="kianmeng.org",
53
64
  metavar="WATERMARK_TEXT",
@@ -63,6 +74,10 @@ def run(args: argparse.Namespace) -> None:
63
74
  Returns:
64
75
  None
65
76
  """
77
+ text = args.watermark
78
+ if args.title and args.watermark:
79
+ text = f"{args.title}\n{args.watermark}"
80
+
66
81
  extra_args = {
67
82
  "width": 600,
68
83
  "height": 277,
@@ -70,7 +85,7 @@ def run(args: argparse.Namespace) -> None:
70
85
  "radius": 1,
71
86
  "percent": 100,
72
87
  "threshold": 2,
73
- "text": f"{args.text if args.text else 'kianmeng.org'}",
88
+ "text": text,
74
89
  "position": "bottom-left",
75
90
  "font_size": 12,
76
91
  "font_color": "white",
@@ -98,3 +113,14 @@ def run(args: argparse.Namespace) -> None:
98
113
  fotolab.subcommands.contrast.run(combined_args)
99
114
  fotolab.subcommands.sharpen.run(combined_args)
100
115
  fotolab.subcommands.watermark.run(combined_args)
116
+
117
+ if len(args.image_filenames) > 1:
118
+ output_filename = (
119
+ args.title.lower().replace(",", "").replace(" ", "_") + ".gif"
120
+ )
121
+ combined_args.output_dir = "output"
122
+ combined_args.format = "gif"
123
+ combined_args.duration = 2500
124
+ combined_args.loop = 0
125
+ combined_args.output_filename = output_filename
126
+ fotolab.subcommands.animate.run(combined_args)
@@ -17,6 +17,7 @@
17
17
 
18
18
  import argparse
19
19
  import logging
20
+ from pathlib import Path
20
21
  from typing import Tuple
21
22
 
22
23
  from PIL import Image, ImageColor, ImageOps
@@ -134,8 +135,8 @@ def run(args: argparse.Namespace) -> None:
134
135
  """
135
136
  log.debug(args)
136
137
 
137
- for image_filename in args.image_filenames:
138
- original_image = Image.open(image_filename)
138
+ for image_filepath in [Path(f) for f in args.image_filenames]:
139
+ original_image = Image.open(image_filepath)
139
140
  border = get_border(args)
140
141
  bordered_image = ImageOps.expand(
141
142
  original_image,
@@ -143,7 +144,7 @@ def run(args: argparse.Namespace) -> None:
143
144
  fill=ImageColor.getrgb(args.color),
144
145
  )
145
146
 
146
- save_image(args, bordered_image, image_filename, "border")
147
+ save_image(args, bordered_image, image_filepath, "border")
147
148
 
148
149
 
149
150
  def get_border(
@@ -17,6 +17,7 @@
17
17
 
18
18
  import argparse
19
19
  import logging
20
+ from pathlib import Path
20
21
 
21
22
  from PIL import Image, ImageOps
22
23
 
@@ -107,4 +108,4 @@ def run(args: argparse.Namespace) -> None:
107
108
  original_image, cutoff=args.cutoff
108
109
  )
109
110
 
110
- save_image(args, contrast_image, image_filename, "contrast")
111
+ save_image(args, contrast_image, Path(image_filename), "contrast")
@@ -18,6 +18,7 @@
18
18
  import argparse
19
19
  import logging
20
20
  import math
21
+ from pathlib import Path
21
22
  from typing import NamedTuple, Union
22
23
 
23
24
  from PIL import Image, ImageDraw
@@ -84,7 +85,9 @@ def build_subparser(subparsers) -> None:
84
85
  dest="cells",
85
86
  type=int,
86
87
  default=50,
87
- help="set number of cells across the image width (default: %(default)s)",
88
+ help=(
89
+ "set number of cells across the image width (default: %(default)s)"
90
+ ),
88
91
  )
89
92
 
90
93
  halftone_parser.add_argument(
@@ -108,7 +111,8 @@ def run(args: argparse.Namespace) -> None:
108
111
  """
109
112
  log.debug(args)
110
113
 
111
- for image_filename in args.image_filenames:
114
+ for image_filename_str in args.image_filenames:
115
+ image_filename = Path(image_filename_str)
112
116
  original_image = Image.open(image_filename)
113
117
  halftone_image = create_halftone_image(
114
118
  original_image, args.cells, args.grayscale
@@ -17,6 +17,7 @@
17
17
 
18
18
  import argparse
19
19
  import logging
20
+ from pathlib import Path
20
21
 
21
22
  from PIL import Image
22
23
 
@@ -37,7 +38,7 @@ def build_subparser(subparsers) -> None:
37
38
  dest="image_filenames",
38
39
  help="set the image filenames",
39
40
  nargs="+",
40
- type=argparse.FileType("r"),
41
+ type=str,
41
42
  default=None,
42
43
  metavar="IMAGE_FILENAMES",
43
44
  )
@@ -71,8 +72,9 @@ def run(args: argparse.Namespace) -> None:
71
72
  """
72
73
  log.debug(args)
73
74
  images = []
74
- for image_filename in args.image_filenames:
75
- images.append(Image.open(image_filename.name))
75
+ for image_filename_str in args.image_filenames:
76
+ image_filename = Path(image_filename_str)
77
+ images.append(Image.open(image_filename))
76
78
 
77
79
  if len(images) < 2:
78
80
  raise ValueError("at least two images is required for montage")
@@ -87,5 +89,5 @@ def run(args: argparse.Namespace) -> None:
87
89
  montaged_image.paste(image, (x_offset, 0))
88
90
  x_offset += image.width
89
91
 
90
- output_image_filename = args.image_filenames[0].name
92
+ output_image_filename = Path(args.image_filenames[0])
91
93
  save_image(args, montaged_image, output_image_filename, "montage")
@@ -19,6 +19,7 @@ import argparse
19
19
  import logging
20
20
  import math
21
21
  import sys
22
+ from pathlib import Path
22
23
 
23
24
  from PIL import Image, ImageColor
24
25
 
@@ -136,13 +137,13 @@ def run(args: argparse.Namespace) -> None:
136
137
  """
137
138
  log.debug(args)
138
139
 
139
- for image_filename in args.image_filenames:
140
- original_image = Image.open(image_filename)
140
+ for image_filepath in [Path(f) for f in args.image_filenames]:
141
+ original_image = Image.open(image_filepath)
141
142
  if args.canvas:
142
143
  resized_image = _resize_image_onto_canvas(original_image, args)
143
144
  else:
144
145
  resized_image = _resize_image(original_image, args)
145
- save_image(args, resized_image, image_filename, "resize")
146
+ save_image(args, resized_image, image_filepath, "resize")
146
147
 
147
148
 
148
149
  def _resize_image_onto_canvas(original_image, args):
@@ -167,26 +168,26 @@ def _resize_image(original_image, args):
167
168
 
168
169
 
169
170
  def _calc_new_image_dimension(image, args) -> tuple:
170
- new_width = args.width
171
- new_height = args.height
172
-
173
171
  old_width, old_height = image.size
174
172
  log.debug("old image dimension: %d x %d", old_width, old_height)
175
173
 
176
- if args.width != DEFAULT_WIDTH:
177
- aspect_ratio = old_height / old_width
178
- log.debug("aspect ratio: %f", aspect_ratio)
174
+ new_width = args.width
175
+ new_height = args.height
179
176
 
180
- new_height = math.ceil(args.width * aspect_ratio)
181
- log.debug("new height: %d", new_height)
177
+ original_aspect_ratio = old_width / old_height
182
178
 
183
- if args.height != DEFAULT_HEIGHT:
184
- aspect_ratio = old_width / old_height
185
- log.debug("aspect ratio: %f", aspect_ratio)
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)
186
187
 
187
- new_width = math.floor(args.height * aspect_ratio)
188
- log.debug("new width: %d", new_width)
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
189
191
 
190
192
  log.debug("new image dimension: %d x %d", new_width, new_height)
191
-
192
193
  return (new_width, new_height)
@@ -17,6 +17,7 @@
17
17
 
18
18
  import argparse
19
19
  import logging
20
+ from pathlib import Path
20
21
 
21
22
  from PIL import Image
22
23
 
@@ -85,9 +86,16 @@ def run(args: argparse.Namespace) -> None:
85
86
  log.debug(args)
86
87
 
87
88
  rotation = -args.rotation if args.clockwise else args.rotation
88
- for image_filename in args.image_filenames:
89
+ log.debug(f"Rotation angle: {rotation} degrees")
90
+
91
+ for image_filename_str in args.image_filenames:
92
+ image_filename = Path(image_filename_str)
93
+ log.debug(f"Processing image: {image_filename}")
89
94
  original_image = Image.open(image_filename)
95
+ log.debug(f"Original image size: {original_image.size}")
90
96
  rotated_image = original_image.rotate(
91
97
  rotation, expand=True, resample=Image.Resampling.BICUBIC
92
98
  )
99
+ log.debug(f"Rotated image size: {rotated_image.size}")
93
100
  save_image(args, rotated_image, image_filename, "rotate")
101
+ log.debug(f"Image saved: {image_filename}")
@@ -17,6 +17,7 @@
17
17
 
18
18
  import argparse
19
19
  import logging
20
+ from pathlib import Path
20
21
 
21
22
  from PIL import Image, ImageFilter
22
23
 
@@ -123,7 +124,11 @@ def run(args: argparse.Namespace) -> None:
123
124
  )
124
125
  if args.before_after:
125
126
  save_gif_image(
126
- args, image_filename, original_image, sharpen_image, "sharpen"
127
+ args,
128
+ Path(image_filename),
129
+ original_image,
130
+ sharpen_image,
131
+ "sharpen",
127
132
  )
128
133
  else:
129
- save_image(args, sharpen_image, image_filename, "sharpen")
134
+ save_image(args, sharpen_image, Path(image_filename), "sharpen")
@@ -206,7 +206,9 @@ def run(args: argparse.Namespace) -> None:
206
206
  watermarked_image: Image.Image = watermark_non_gif_image(
207
207
  image, args
208
208
  )
209
- save_image(args, watermarked_image, image_filename, "watermark")
209
+ save_image(
210
+ args, watermarked_image, Path(image_filename), "watermark"
211
+ )
210
212
 
211
213
 
212
214
  def watermark_gif_image(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fotolab
3
- Version: 0.32.0
3
+ Version: 0.33.1
4
4
  Author-email: Kian-Meng Ang <kianmeng@cpan.org>
5
5
  License-Expression: AGPL-3.0-or-later
6
6
  Project-URL: Changelog, https://github.com/kianmeng/fotolab/blob/master/CHANGELOG.md
File without changes
File without changes
File without changes
File without changes