imagex 0.2.0__tar.gz → 0.2.2__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.
- {imagex-0.2.0 → imagex-0.2.2}/PKG-INFO +40 -6
- {imagex-0.2.0 → imagex-0.2.2}/README.md +3 -2
- imagex-0.2.2/imagex/__init__.py +1 -0
- {imagex-0.2.0 → imagex-0.2.2}/imagex/cli.py +20 -0
- {imagex-0.2.0 → imagex-0.2.2}/imagex/features/add_noise.py +6 -1
- {imagex-0.2.0 → imagex-0.2.2}/imagex/features/compress.py +20 -13
- {imagex-0.2.0 → imagex-0.2.2}/imagex/features/convert.py +15 -10
- imagex-0.2.2/imagex/features/flip.py +38 -0
- {imagex-0.2.0 → imagex-0.2.2}/imagex/features/resize.py +6 -1
- {imagex-0.2.0 → imagex-0.2.2}/imagex/features/rotate.py +6 -1
- {imagex-0.2.0 → imagex-0.2.2}/imagex/features/watermark.py +8 -2
- {imagex-0.2.0 → imagex-0.2.2}/imagex.egg-info/PKG-INFO +40 -6
- {imagex-0.2.0 → imagex-0.2.2}/imagex.egg-info/SOURCES.txt +2 -0
- {imagex-0.2.0 → imagex-0.2.2}/pyproject.toml +29 -5
- imagex-0.2.2/tests/test_flip.py +72 -0
- imagex-0.2.0/imagex/__init__.py +0 -1
- {imagex-0.2.0 → imagex-0.2.2}/LICENSE +0 -0
- {imagex-0.2.0 → imagex-0.2.2}/imagex/__main__.py +0 -0
- {imagex-0.2.0 → imagex-0.2.2}/imagex/config.py +0 -0
- {imagex-0.2.0 → imagex-0.2.2}/imagex/features/__init__.py +0 -0
- {imagex-0.2.0 → imagex-0.2.2}/imagex/features/remove_metadata.py +0 -0
- {imagex-0.2.0 → imagex-0.2.2}/imagex/features/rename_batch.py +0 -0
- {imagex-0.2.0 → imagex-0.2.2}/imagex/utils/__init__.py +0 -0
- {imagex-0.2.0 → imagex-0.2.2}/imagex/utils/file_ops.py +0 -0
- {imagex-0.2.0 → imagex-0.2.2}/imagex/utils/progress.py +0 -0
- {imagex-0.2.0 → imagex-0.2.2}/imagex.egg-info/dependency_links.txt +0 -0
- {imagex-0.2.0 → imagex-0.2.2}/imagex.egg-info/entry_points.txt +0 -0
- {imagex-0.2.0 → imagex-0.2.2}/imagex.egg-info/requires.txt +0 -0
- {imagex-0.2.0 → imagex-0.2.2}/imagex.egg-info/top_level.txt +0 -0
- {imagex-0.2.0 → imagex-0.2.2}/setup.cfg +0 -0
- {imagex-0.2.0 → imagex-0.2.2}/tests/test_add_noise.py +0 -0
- {imagex-0.2.0 → imagex-0.2.2}/tests/test_cli.py +0 -0
- {imagex-0.2.0 → imagex-0.2.2}/tests/test_compress.py +0 -0
- {imagex-0.2.0 → imagex-0.2.2}/tests/test_convert.py +0 -0
- {imagex-0.2.0 → imagex-0.2.2}/tests/test_file_ops.py +0 -0
- {imagex-0.2.0 → imagex-0.2.2}/tests/test_remove_metadata.py +0 -0
- {imagex-0.2.0 → imagex-0.2.2}/tests/test_rename_batch.py +0 -0
- {imagex-0.2.0 → imagex-0.2.2}/tests/test_resize.py +0 -0
- {imagex-0.2.0 → imagex-0.2.2}/tests/test_rotate.py +0 -0
- {imagex-0.2.0 → imagex-0.2.2}/tests/test_watermark.py +0 -0
|
@@ -1,16 +1,49 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: imagex
|
|
3
|
-
Version: 0.2.
|
|
4
|
-
Summary:
|
|
5
|
-
Author: kushal1o1
|
|
6
|
-
License: MIT
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: All-in-one image processing CLI — resize, convert, watermark, compress, and more
|
|
5
|
+
Author-email: kushal1o1 <kushal1o1@users.noreply.github.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Kushal Baral
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/kushal1o1/ImageX
|
|
29
|
+
Project-URL: Repository, https://github.com/kushal1o1/ImageX
|
|
30
|
+
Project-URL: Documentation, https://github.com/kushal1o1/ImageX/blob/main/OPERATIONS.md
|
|
31
|
+
Project-URL: Changelog, https://github.com/kushal1o1/ImageX/releases
|
|
32
|
+
Project-URL: Issues, https://github.com/kushal1o1/ImageX/issues
|
|
33
|
+
Keywords: image-processing,cli,pillow,resize,convert,watermark,compress,metadata,batch,rename
|
|
7
34
|
Classifier: Development Status :: 3 - Alpha
|
|
8
35
|
Classifier: Environment :: Console
|
|
36
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
37
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
38
|
+
Classifier: Natural Language :: English
|
|
9
39
|
Classifier: Operating System :: OS Independent
|
|
10
40
|
Classifier: Programming Language :: Python :: 3
|
|
11
41
|
Classifier: Programming Language :: Python :: 3.10
|
|
12
42
|
Classifier: Programming Language :: Python :: 3.11
|
|
13
43
|
Classifier: Programming Language :: Python :: 3.12
|
|
44
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
45
|
+
Classifier: Topic :: Multimedia :: Graphics :: Graphics Conversion
|
|
46
|
+
Classifier: Topic :: Utilities
|
|
14
47
|
Requires-Python: >=3.10
|
|
15
48
|
Description-Content-Type: text/markdown
|
|
16
49
|
License-File: LICENSE
|
|
@@ -48,6 +81,7 @@ Navigate to any folder with images and run `imagex`.
|
|
|
48
81
|
| Feature | Description |
|
|
49
82
|
|---|---|
|
|
50
83
|
| Rotate | Rotate 90° Left, 90° Right, or 180° |
|
|
84
|
+
| Flip | Mirror horizontally or vertically |
|
|
51
85
|
| Remove Metadata | Strip EXIF/XMP/IPTC (incl. AI generation markers) |
|
|
52
86
|
| Convert Format | JPG ↔ PNG ↔ WEBP ↔ TIFF ↔ BMP ↔ GIF ↔ HEIC |
|
|
53
87
|
| Compress / Optimize | Reduce file size with quality slider |
|
|
@@ -56,7 +90,7 @@ Navigate to any folder with images and run `imagex`.
|
|
|
56
90
|
| Add Noise | Gaussian or salt & pepper (bypass AI detection) |
|
|
57
91
|
| Watermark | Add text/image or remove existing |
|
|
58
92
|
|
|
59
|
-
Full details in [OPERATIONS.md](OPERATIONS.md).
|
|
93
|
+
Full details in [OPERATIONS.md](https://github.com/kushal1o1/ImageX/blob/main/OPERATIONS.md).
|
|
60
94
|
|
|
61
95
|
## Install
|
|
62
96
|
|
|
@@ -72,7 +106,7 @@ Then run `imagex` from any folder.
|
|
|
72
106
|
|
|
73
107
|
## Adding Features
|
|
74
108
|
|
|
75
|
-
See [CONTRIBUTION.md](CONTRIBUTION.md) for the contribution guide.
|
|
109
|
+
See [CONTRIBUTION.md](https://github.com/kushal1o1/ImageX/blob/main/CONTRIBUTION.md) for the contribution guide.
|
|
76
110
|
|
|
77
111
|
## License
|
|
78
112
|
|
|
@@ -21,6 +21,7 @@ Navigate to any folder with images and run `imagex`.
|
|
|
21
21
|
| Feature | Description |
|
|
22
22
|
|---|---|
|
|
23
23
|
| Rotate | Rotate 90° Left, 90° Right, or 180° |
|
|
24
|
+
| Flip | Mirror horizontally or vertically |
|
|
24
25
|
| Remove Metadata | Strip EXIF/XMP/IPTC (incl. AI generation markers) |
|
|
25
26
|
| Convert Format | JPG ↔ PNG ↔ WEBP ↔ TIFF ↔ BMP ↔ GIF ↔ HEIC |
|
|
26
27
|
| Compress / Optimize | Reduce file size with quality slider |
|
|
@@ -29,7 +30,7 @@ Navigate to any folder with images and run `imagex`.
|
|
|
29
30
|
| Add Noise | Gaussian or salt & pepper (bypass AI detection) |
|
|
30
31
|
| Watermark | Add text/image or remove existing |
|
|
31
32
|
|
|
32
|
-
Full details in [OPERATIONS.md](OPERATIONS.md).
|
|
33
|
+
Full details in [OPERATIONS.md](https://github.com/kushal1o1/ImageX/blob/main/OPERATIONS.md).
|
|
33
34
|
|
|
34
35
|
## Install
|
|
35
36
|
|
|
@@ -45,7 +46,7 @@ Then run `imagex` from any folder.
|
|
|
45
46
|
|
|
46
47
|
## Adding Features
|
|
47
48
|
|
|
48
|
-
See [CONTRIBUTION.md](CONTRIBUTION.md) for the contribution guide.
|
|
49
|
+
See [CONTRIBUTION.md](https://github.com/kushal1o1/ImageX/blob/main/CONTRIBUTION.md) for the contribution guide.
|
|
49
50
|
|
|
50
51
|
## License
|
|
51
52
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.2"
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import argparse
|
|
2
|
+
import json
|
|
2
3
|
import sys
|
|
4
|
+
import urllib.request
|
|
3
5
|
from pathlib import Path
|
|
4
6
|
from typing import Optional
|
|
5
7
|
|
|
@@ -163,6 +165,23 @@ def select_files(all_files: list[Path]) -> Optional[list[Path]]:
|
|
|
163
165
|
return [Path(f) for f in selected]
|
|
164
166
|
|
|
165
167
|
|
|
168
|
+
PYPI_URL = "https://pypi.org/pypi/imagex/json"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _check_version():
|
|
172
|
+
try:
|
|
173
|
+
req = urllib.request.Request(PYPI_URL, headers={"Accept": "application/json"})
|
|
174
|
+
resp = urllib.request.urlopen(req, timeout=2)
|
|
175
|
+
latest = json.loads(resp.read())["info"]["version"]
|
|
176
|
+
if latest != __version__:
|
|
177
|
+
console.print(
|
|
178
|
+
f" [dim] New version available :) : {latest} → "
|
|
179
|
+
f"pip install --upgrade imagex[/dim]"
|
|
180
|
+
)
|
|
181
|
+
except Exception:
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
|
|
166
185
|
def main():
|
|
167
186
|
_parse_args()
|
|
168
187
|
|
|
@@ -214,6 +233,7 @@ def main():
|
|
|
214
233
|
)
|
|
215
234
|
|
|
216
235
|
console.print("\n[bold green]✓ All done![/bold green]")
|
|
236
|
+
_check_version()
|
|
217
237
|
|
|
218
238
|
except KeyboardInterrupt:
|
|
219
239
|
console.print("\n[yellow]Interrupted. Exiting.[/yellow]")
|
|
@@ -44,7 +44,12 @@ def run(file: Path, output_path: Path, args: Optional[dict[str, Any]] = None) ->
|
|
|
44
44
|
else:
|
|
45
45
|
_add_salt_pepper(img, intensity)
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
kw = {"format": img.format or "JPEG"}
|
|
48
|
+
if exif := img.info.get("exif"):
|
|
49
|
+
kw["exif"] = exif
|
|
50
|
+
if icc := img.info.get("icc_profile"):
|
|
51
|
+
kw["icc_profile"] = icc
|
|
52
|
+
img.save(str(output_path), **kw)
|
|
48
53
|
return True
|
|
49
54
|
|
|
50
55
|
|
|
@@ -28,17 +28,22 @@ def run(file: Path, output_path: Path, args: Optional[dict[str, Any]] = None) ->
|
|
|
28
28
|
|
|
29
29
|
quality = args.get("quality", 80)
|
|
30
30
|
img = Image.open(file)
|
|
31
|
+
meta = {}
|
|
32
|
+
if exif := img.info.get("exif"):
|
|
33
|
+
meta["exif"] = exif
|
|
34
|
+
if icc := img.info.get("icc_profile"):
|
|
35
|
+
meta["icc_profile"] = icc
|
|
31
36
|
fmt = img.format
|
|
32
37
|
original_size = file.stat().st_size
|
|
33
38
|
|
|
34
39
|
if fmt == "JPEG":
|
|
35
|
-
_compress_jpeg(img, output_path, quality)
|
|
40
|
+
_compress_jpeg(img, output_path, quality, meta)
|
|
36
41
|
elif fmt == "PNG":
|
|
37
|
-
_compress_png(img, output_path, quality)
|
|
42
|
+
_compress_png(img, output_path, quality, meta)
|
|
38
43
|
elif fmt == "WEBP":
|
|
39
|
-
_compress_webp(img, output_path, quality)
|
|
44
|
+
_compress_webp(img, output_path, quality, meta)
|
|
40
45
|
else:
|
|
41
|
-
_compress_other(img, output_path, fmt, quality)
|
|
46
|
+
_compress_other(img, output_path, fmt, quality, meta)
|
|
42
47
|
|
|
43
48
|
new_size = output_path.stat().st_size
|
|
44
49
|
saved = original_size - new_size
|
|
@@ -51,17 +56,17 @@ def run(file: Path, output_path: Path, args: Optional[dict[str, Any]] = None) ->
|
|
|
51
56
|
return True
|
|
52
57
|
|
|
53
58
|
|
|
54
|
-
def _compress_jpeg(img: Image.Image, output_path: Path, quality: int):
|
|
59
|
+
def _compress_jpeg(img: Image.Image, output_path: Path, quality: int, meta: dict):
|
|
55
60
|
if img.mode == "RGBA":
|
|
56
61
|
bg = Image.new("RGB", img.size, (255, 255, 255))
|
|
57
62
|
bg.paste(img, mask=img.split()[3])
|
|
58
63
|
img = bg
|
|
59
64
|
elif img.mode != "RGB":
|
|
60
65
|
img = img.convert("RGB")
|
|
61
|
-
img.save(str(output_path), format="JPEG", quality=quality, optimize=True)
|
|
66
|
+
img.save(str(output_path), format="JPEG", quality=quality, optimize=True, **meta)
|
|
62
67
|
|
|
63
68
|
|
|
64
|
-
def _compress_png(img: Image.Image, output_path: Path, quality: int):
|
|
69
|
+
def _compress_png(img: Image.Image, output_path: Path, quality: int, meta: dict):
|
|
65
70
|
if quality < 50:
|
|
66
71
|
if img.mode == "RGBA":
|
|
67
72
|
bg = Image.new("RGB", img.size, (255, 255, 255))
|
|
@@ -71,17 +76,19 @@ def _compress_png(img: Image.Image, output_path: Path, quality: int):
|
|
|
71
76
|
img = img.convert("RGB")
|
|
72
77
|
colors = max(quality * 2, 16)
|
|
73
78
|
img = img.quantize(colors=colors, method=Image.Quantize.MEDIANCUT)
|
|
74
|
-
img.save(str(output_path), format="PNG", optimize=True)
|
|
79
|
+
img.save(str(output_path), format="PNG", optimize=True, **meta)
|
|
75
80
|
else:
|
|
76
|
-
img.save(str(output_path), format="PNG", optimize=True)
|
|
81
|
+
img.save(str(output_path), format="PNG", optimize=True, **meta)
|
|
77
82
|
|
|
78
83
|
|
|
79
|
-
def _compress_webp(img: Image.Image, output_path: Path, quality: int):
|
|
80
|
-
img.save(str(output_path), format="WEBP", quality=quality)
|
|
84
|
+
def _compress_webp(img: Image.Image, output_path: Path, quality: int, meta: dict):
|
|
85
|
+
img.save(str(output_path), format="WEBP", quality=quality, **meta)
|
|
81
86
|
|
|
82
87
|
|
|
83
|
-
def _compress_other(
|
|
84
|
-
|
|
88
|
+
def _compress_other(
|
|
89
|
+
img: Image.Image, output_path: Path, fmt: str, quality: int, meta: dict,
|
|
90
|
+
):
|
|
91
|
+
params = {"format": fmt, **meta}
|
|
85
92
|
if fmt == "GIF":
|
|
86
93
|
params["save_all"] = True
|
|
87
94
|
img.save(str(output_path), **params)
|
|
@@ -48,16 +48,21 @@ def run(file: Path, output_path: Path, args: Optional[dict[str, Any]] = None) ->
|
|
|
48
48
|
target_fmt = args["target_format"]
|
|
49
49
|
|
|
50
50
|
img = Image.open(file)
|
|
51
|
+
meta = {}
|
|
52
|
+
if exif := img.info.get("exif"):
|
|
53
|
+
meta["exif"] = exif
|
|
54
|
+
if icc := img.info.get("icc_profile"):
|
|
55
|
+
meta["icc_profile"] = icc
|
|
51
56
|
actual_output = output_path.with_suffix(args["target_ext"])
|
|
52
57
|
|
|
53
58
|
if target_fmt == "JPEG":
|
|
54
|
-
_save_as_jpeg(img, actual_output)
|
|
59
|
+
_save_as_jpeg(img, actual_output, meta)
|
|
55
60
|
elif target_fmt == "PNG":
|
|
56
|
-
_save_as_png(img, actual_output)
|
|
61
|
+
_save_as_png(img, actual_output, meta)
|
|
57
62
|
elif target_fmt == "WEBP":
|
|
58
|
-
_save_as_webp(img, actual_output)
|
|
63
|
+
_save_as_webp(img, actual_output, meta)
|
|
59
64
|
elif target_fmt == "TIFF":
|
|
60
|
-
img.save(str(actual_output), format="TIFF")
|
|
65
|
+
img.save(str(actual_output), format="TIFF", **meta)
|
|
61
66
|
elif target_fmt == "BMP":
|
|
62
67
|
img.save(str(actual_output), format="BMP")
|
|
63
68
|
elif target_fmt == "GIF":
|
|
@@ -68,22 +73,22 @@ def run(file: Path, output_path: Path, args: Optional[dict[str, Any]] = None) ->
|
|
|
68
73
|
return True
|
|
69
74
|
|
|
70
75
|
|
|
71
|
-
def _save_as_jpeg(img: Image.Image, output_path: Path):
|
|
76
|
+
def _save_as_jpeg(img: Image.Image, output_path: Path, meta: dict):
|
|
72
77
|
if img.mode == "RGBA":
|
|
73
78
|
bg = Image.new("RGB", img.size, (255, 255, 255))
|
|
74
79
|
bg.paste(img, mask=img.split()[3])
|
|
75
80
|
img = bg
|
|
76
81
|
elif img.mode != "RGB":
|
|
77
82
|
img = img.convert("RGB")
|
|
78
|
-
img.save(str(output_path), format="JPEG")
|
|
83
|
+
img.save(str(output_path), format="JPEG", **meta)
|
|
79
84
|
|
|
80
85
|
|
|
81
|
-
def _save_as_png(img: Image.Image, output_path: Path):
|
|
82
|
-
img.save(str(output_path), format="PNG")
|
|
86
|
+
def _save_as_png(img: Image.Image, output_path: Path, meta: dict):
|
|
87
|
+
img.save(str(output_path), format="PNG", **meta)
|
|
83
88
|
|
|
84
89
|
|
|
85
|
-
def _save_as_webp(img: Image.Image, output_path: Path):
|
|
86
|
-
img.save(str(output_path), format="WEBP")
|
|
90
|
+
def _save_as_webp(img: Image.Image, output_path: Path, meta: dict):
|
|
91
|
+
img.save(str(output_path), format="WEBP", **meta)
|
|
87
92
|
|
|
88
93
|
|
|
89
94
|
def _save_as_heic(img: Image.Image, output_path: Path):
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
import questionary
|
|
5
|
+
from PIL import Image
|
|
6
|
+
|
|
7
|
+
NAME = "Flip"
|
|
8
|
+
DESCRIPTION = "Mirror images horizontally or vertically"
|
|
9
|
+
|
|
10
|
+
OPTIONS = {
|
|
11
|
+
"Horizontal (left ↔ right)": Image.FLIP_LEFT_RIGHT,
|
|
12
|
+
"Vertical (top ↔ bottom)": Image.FLIP_TOP_BOTTOM,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def ask_args(files: list[Path]) -> dict[str, Any]:
|
|
17
|
+
direction = questionary.select(
|
|
18
|
+
"Flip direction:",
|
|
19
|
+
choices=list(OPTIONS.keys()),
|
|
20
|
+
).ask()
|
|
21
|
+
|
|
22
|
+
return {"method": OPTIONS[direction]}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run(file: Path, output_path: Path, args: Optional[dict[str, Any]] = None) -> bool:
|
|
26
|
+
if args is None:
|
|
27
|
+
msg = "args required for flip"
|
|
28
|
+
raise ValueError(msg)
|
|
29
|
+
|
|
30
|
+
img = Image.open(file)
|
|
31
|
+
flipped = img.transpose(args["method"])
|
|
32
|
+
kw = {"format": img.format or "JPEG"}
|
|
33
|
+
if exif := img.info.get("exif"):
|
|
34
|
+
kw["exif"] = exif
|
|
35
|
+
if icc := img.info.get("icc_profile"):
|
|
36
|
+
kw["icc_profile"] = icc
|
|
37
|
+
flipped.save(str(output_path), **kw)
|
|
38
|
+
return True
|
|
@@ -78,7 +78,12 @@ def run(file: Path, output_path: Path, args: Optional[dict[str, Any]] = None) ->
|
|
|
78
78
|
raise ValueError(msg)
|
|
79
79
|
|
|
80
80
|
resized = img.resize((new_w, new_h), Image.LANCZOS)
|
|
81
|
-
|
|
81
|
+
kw = {"format": img.format or "JPEG"}
|
|
82
|
+
if exif := img.info.get("exif"):
|
|
83
|
+
kw["exif"] = exif
|
|
84
|
+
if icc := img.info.get("icc_profile"):
|
|
85
|
+
kw["icc_profile"] = icc
|
|
86
|
+
resized.save(str(output_path), **kw)
|
|
82
87
|
|
|
83
88
|
return True
|
|
84
89
|
|
|
@@ -30,5 +30,10 @@ def run(file: Path, output_path: Path, args: Optional[dict[str, Any]] = None) ->
|
|
|
30
30
|
|
|
31
31
|
img = Image.open(file)
|
|
32
32
|
rotated = img.transpose(args["method"])
|
|
33
|
-
|
|
33
|
+
kw = {"format": img.format or "JPEG"}
|
|
34
|
+
if exif := img.info.get("exif"):
|
|
35
|
+
kw["exif"] = exif
|
|
36
|
+
if icc := img.info.get("icc_profile"):
|
|
37
|
+
kw["icc_profile"] = icc
|
|
38
|
+
rotated.save(str(output_path), **kw)
|
|
34
39
|
return True
|
|
@@ -123,7 +123,13 @@ def run(file: Path, output_path: Path, args: Optional[dict[str, Any]] = None) ->
|
|
|
123
123
|
msg = "args required for watermark"
|
|
124
124
|
raise ValueError(msg)
|
|
125
125
|
|
|
126
|
-
|
|
126
|
+
src = Image.open(file)
|
|
127
|
+
meta = {}
|
|
128
|
+
if exif := src.info.get("exif"):
|
|
129
|
+
meta["exif"] = exif
|
|
130
|
+
if icc := src.info.get("icc_profile"):
|
|
131
|
+
meta["icc_profile"] = icc
|
|
132
|
+
img = src.convert("RGBA")
|
|
127
133
|
action = args["action"]
|
|
128
134
|
|
|
129
135
|
if action == "add":
|
|
@@ -135,7 +141,7 @@ def run(file: Path, output_path: Path, args: Optional[dict[str, Any]] = None) ->
|
|
|
135
141
|
raise ValueError(msg)
|
|
136
142
|
|
|
137
143
|
out = img.convert("RGB") if img.mode == "RGBA" else img
|
|
138
|
-
out.save(str(output_path))
|
|
144
|
+
out.save(str(output_path), **meta)
|
|
139
145
|
return True
|
|
140
146
|
|
|
141
147
|
|
|
@@ -1,16 +1,49 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: imagex
|
|
3
|
-
Version: 0.2.
|
|
4
|
-
Summary:
|
|
5
|
-
Author: kushal1o1
|
|
6
|
-
License: MIT
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: All-in-one image processing CLI — resize, convert, watermark, compress, and more
|
|
5
|
+
Author-email: kushal1o1 <kushal1o1@users.noreply.github.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Kushal Baral
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/kushal1o1/ImageX
|
|
29
|
+
Project-URL: Repository, https://github.com/kushal1o1/ImageX
|
|
30
|
+
Project-URL: Documentation, https://github.com/kushal1o1/ImageX/blob/main/OPERATIONS.md
|
|
31
|
+
Project-URL: Changelog, https://github.com/kushal1o1/ImageX/releases
|
|
32
|
+
Project-URL: Issues, https://github.com/kushal1o1/ImageX/issues
|
|
33
|
+
Keywords: image-processing,cli,pillow,resize,convert,watermark,compress,metadata,batch,rename
|
|
7
34
|
Classifier: Development Status :: 3 - Alpha
|
|
8
35
|
Classifier: Environment :: Console
|
|
36
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
37
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
38
|
+
Classifier: Natural Language :: English
|
|
9
39
|
Classifier: Operating System :: OS Independent
|
|
10
40
|
Classifier: Programming Language :: Python :: 3
|
|
11
41
|
Classifier: Programming Language :: Python :: 3.10
|
|
12
42
|
Classifier: Programming Language :: Python :: 3.11
|
|
13
43
|
Classifier: Programming Language :: Python :: 3.12
|
|
44
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
45
|
+
Classifier: Topic :: Multimedia :: Graphics :: Graphics Conversion
|
|
46
|
+
Classifier: Topic :: Utilities
|
|
14
47
|
Requires-Python: >=3.10
|
|
15
48
|
Description-Content-Type: text/markdown
|
|
16
49
|
License-File: LICENSE
|
|
@@ -48,6 +81,7 @@ Navigate to any folder with images and run `imagex`.
|
|
|
48
81
|
| Feature | Description |
|
|
49
82
|
|---|---|
|
|
50
83
|
| Rotate | Rotate 90° Left, 90° Right, or 180° |
|
|
84
|
+
| Flip | Mirror horizontally or vertically |
|
|
51
85
|
| Remove Metadata | Strip EXIF/XMP/IPTC (incl. AI generation markers) |
|
|
52
86
|
| Convert Format | JPG ↔ PNG ↔ WEBP ↔ TIFF ↔ BMP ↔ GIF ↔ HEIC |
|
|
53
87
|
| Compress / Optimize | Reduce file size with quality slider |
|
|
@@ -56,7 +90,7 @@ Navigate to any folder with images and run `imagex`.
|
|
|
56
90
|
| Add Noise | Gaussian or salt & pepper (bypass AI detection) |
|
|
57
91
|
| Watermark | Add text/image or remove existing |
|
|
58
92
|
|
|
59
|
-
Full details in [OPERATIONS.md](OPERATIONS.md).
|
|
93
|
+
Full details in [OPERATIONS.md](https://github.com/kushal1o1/ImageX/blob/main/OPERATIONS.md).
|
|
60
94
|
|
|
61
95
|
## Install
|
|
62
96
|
|
|
@@ -72,7 +106,7 @@ Then run `imagex` from any folder.
|
|
|
72
106
|
|
|
73
107
|
## Adding Features
|
|
74
108
|
|
|
75
|
-
See [CONTRIBUTION.md](CONTRIBUTION.md) for the contribution guide.
|
|
109
|
+
See [CONTRIBUTION.md](https://github.com/kushal1o1/ImageX/blob/main/CONTRIBUTION.md) for the contribution guide.
|
|
76
110
|
|
|
77
111
|
## License
|
|
78
112
|
|
|
@@ -15,6 +15,7 @@ imagex/features/__init__.py
|
|
|
15
15
|
imagex/features/add_noise.py
|
|
16
16
|
imagex/features/compress.py
|
|
17
17
|
imagex/features/convert.py
|
|
18
|
+
imagex/features/flip.py
|
|
18
19
|
imagex/features/remove_metadata.py
|
|
19
20
|
imagex/features/rename_batch.py
|
|
20
21
|
imagex/features/resize.py
|
|
@@ -28,6 +29,7 @@ tests/test_cli.py
|
|
|
28
29
|
tests/test_compress.py
|
|
29
30
|
tests/test_convert.py
|
|
30
31
|
tests/test_file_ops.py
|
|
32
|
+
tests/test_flip.py
|
|
31
33
|
tests/test_remove_metadata.py
|
|
32
34
|
tests/test_rename_batch.py
|
|
33
35
|
tests/test_resize.py
|
|
@@ -4,23 +4,40 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "imagex"
|
|
7
|
-
version = "0.2.
|
|
8
|
-
description = "
|
|
7
|
+
version = "0.2.2"
|
|
8
|
+
description = "All-in-one image processing CLI — resize, convert, watermark, compress, and more"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
|
-
license = {
|
|
11
|
+
license = {file = "LICENSE"}
|
|
12
12
|
authors = [
|
|
13
|
-
{name = "kushal1o1"},
|
|
13
|
+
{name = "kushal1o1", email = "kushal1o1@users.noreply.github.com"},
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"image-processing",
|
|
17
|
+
"cli",
|
|
18
|
+
"pillow",
|
|
19
|
+
"resize",
|
|
20
|
+
"convert",
|
|
21
|
+
"watermark",
|
|
22
|
+
"compress",
|
|
23
|
+
"metadata",
|
|
24
|
+
"batch",
|
|
25
|
+
"rename",
|
|
14
26
|
]
|
|
15
27
|
classifiers = [
|
|
16
28
|
"Development Status :: 3 - Alpha",
|
|
17
29
|
"Environment :: Console",
|
|
30
|
+
"Intended Audience :: End Users/Desktop",
|
|
31
|
+
"License :: OSI Approved :: MIT License",
|
|
32
|
+
"Natural Language :: English",
|
|
18
33
|
"Operating System :: OS Independent",
|
|
19
34
|
"Programming Language :: Python :: 3",
|
|
20
|
-
|
|
21
35
|
"Programming Language :: Python :: 3.10",
|
|
22
36
|
"Programming Language :: Python :: 3.11",
|
|
23
37
|
"Programming Language :: Python :: 3.12",
|
|
38
|
+
"Topic :: Multimedia :: Graphics",
|
|
39
|
+
"Topic :: Multimedia :: Graphics :: Graphics Conversion",
|
|
40
|
+
"Topic :: Utilities",
|
|
24
41
|
]
|
|
25
42
|
dependencies = [
|
|
26
43
|
"rich>=13.0",
|
|
@@ -28,6 +45,13 @@ dependencies = [
|
|
|
28
45
|
"Pillow>=10.0",
|
|
29
46
|
]
|
|
30
47
|
|
|
48
|
+
[project.urls]
|
|
49
|
+
Homepage = "https://github.com/kushal1o1/ImageX"
|
|
50
|
+
Repository = "https://github.com/kushal1o1/ImageX"
|
|
51
|
+
Documentation = "https://github.com/kushal1o1/ImageX/blob/main/OPERATIONS.md"
|
|
52
|
+
Changelog = "https://github.com/kushal1o1/ImageX/releases"
|
|
53
|
+
Issues = "https://github.com/kushal1o1/ImageX/issues"
|
|
54
|
+
|
|
31
55
|
[project.optional-dependencies]
|
|
32
56
|
heif = [
|
|
33
57
|
"pillow-heif>=0.16",
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from PIL import Image
|
|
2
|
+
|
|
3
|
+
from imagex.features.flip import run
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _marked_image(path, size=(4, 4)):
|
|
7
|
+
"""An all-black image with a single white pixel in the top-left corner."""
|
|
8
|
+
img = Image.new("RGB", size, color="black")
|
|
9
|
+
img.putpixel((0, 0), (255, 255, 255))
|
|
10
|
+
img.save(str(path))
|
|
11
|
+
return img
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestFlipRun:
|
|
15
|
+
def test_flip_horizontal_moves_pixel_left_to_right(self, tmp_path):
|
|
16
|
+
src = tmp_path / "marked.png"
|
|
17
|
+
_marked_image(src)
|
|
18
|
+
out = tmp_path / "h.png"
|
|
19
|
+
assert run(src, out, {"method": Image.FLIP_LEFT_RIGHT})
|
|
20
|
+
|
|
21
|
+
flipped = Image.open(out)
|
|
22
|
+
# top-left white pixel mirrors to the top-right
|
|
23
|
+
assert flipped.getpixel((3, 0)) == (255, 255, 255)
|
|
24
|
+
assert flipped.getpixel((0, 0)) == (0, 0, 0)
|
|
25
|
+
|
|
26
|
+
def test_flip_vertical_moves_pixel_top_to_bottom(self, tmp_path):
|
|
27
|
+
src = tmp_path / "marked.png"
|
|
28
|
+
_marked_image(src)
|
|
29
|
+
out = tmp_path / "v.png"
|
|
30
|
+
assert run(src, out, {"method": Image.FLIP_TOP_BOTTOM})
|
|
31
|
+
|
|
32
|
+
flipped = Image.open(out)
|
|
33
|
+
# top-left white pixel mirrors to the bottom-left
|
|
34
|
+
assert flipped.getpixel((0, 3)) == (255, 255, 255)
|
|
35
|
+
assert flipped.getpixel((0, 0)) == (0, 0, 0)
|
|
36
|
+
|
|
37
|
+
def test_preserves_dimensions(self, tmp_images, tmp_path):
|
|
38
|
+
src = tmp_images / "test.jpg"
|
|
39
|
+
original = Image.open(src)
|
|
40
|
+
out = tmp_path / "out.jpg"
|
|
41
|
+
assert run(src, out, {"method": Image.FLIP_LEFT_RIGHT})
|
|
42
|
+
|
|
43
|
+
flipped = Image.open(out)
|
|
44
|
+
assert flipped.width == original.width
|
|
45
|
+
assert flipped.height == original.height
|
|
46
|
+
|
|
47
|
+
def test_double_flip_restores_original(self, tmp_path):
|
|
48
|
+
src = tmp_path / "marked.png"
|
|
49
|
+
_marked_image(src)
|
|
50
|
+
once = tmp_path / "once.png"
|
|
51
|
+
twice = tmp_path / "twice.png"
|
|
52
|
+
run(src, once, {"method": Image.FLIP_LEFT_RIGHT})
|
|
53
|
+
run(once, twice, {"method": Image.FLIP_LEFT_RIGHT})
|
|
54
|
+
|
|
55
|
+
assert Image.open(twice).getpixel((0, 0)) == (255, 255, 255)
|
|
56
|
+
|
|
57
|
+
def test_preserves_format(self, tmp_images, tmp_path):
|
|
58
|
+
src = tmp_images / "image.webp"
|
|
59
|
+
out = tmp_path / "out.webp"
|
|
60
|
+
assert run(src, out, {"method": Image.FLIP_TOP_BOTTOM})
|
|
61
|
+
|
|
62
|
+
flipped = Image.open(out)
|
|
63
|
+
assert flipped.format == "WEBP"
|
|
64
|
+
|
|
65
|
+
def test_run_without_args_raises(self, tmp_images, tmp_path):
|
|
66
|
+
src = tmp_images / "test.jpg"
|
|
67
|
+
out = tmp_path / "out.jpg"
|
|
68
|
+
try:
|
|
69
|
+
run(src, out, None)
|
|
70
|
+
assert False, "should have raised"
|
|
71
|
+
except ValueError as e:
|
|
72
|
+
assert "args required" in str(e).lower()
|
imagex-0.2.0/imagex/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.0"
|
|
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
|
|
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
|