image-edit-prep 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Leanna Yode
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.5
2
+ Name: image-edit-prep
3
+ Version: 0.1.0
4
+ Summary: Prepare local photos for editing and create before-and-after comparison sheets.
5
+ Project-URL: Editing workflow example, https://ediroo.com
6
+ Author: Leanna Yode
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: comparison,image,photo,resize,webp
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Topic :: Multimedia :: Graphics :: Graphics Conversion
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: pillow>=11.3
18
+ Description-Content-Type: text/markdown
19
+
20
+ # image-edit-prep
21
+
22
+ A small local CLI and Python library for preparing photos before editing and
23
+ reviewing the results afterwards. It corrects EXIF orientation, fits photos
24
+ inside a size limit, exports JPEG/PNG/WebP, and creates labelled Before/After
25
+ comparison sheets.
26
+
27
+ Images stay on your computer. No account, API key, or network connection is used
28
+ by this package.
29
+
30
+ ## Install
31
+
32
+ Requires Python 3.10 or later.
33
+
34
+ ```sh
35
+ pip install image-edit-prep
36
+ ```
37
+
38
+ ## Quick start
39
+
40
+ ```sh
41
+ image-edit-prep inspect original.jpg
42
+ image-edit-prep prepare original.jpg ready.webp --max-width 1600 --max-height 1600
43
+ image-edit-prep compare ready.webp edited.png comparison.png
44
+ ```
45
+
46
+ Every successful command prints a JSON object with the output path, dimensions,
47
+ and file size. Errors go to stderr and return exit status 1. Parent output
48
+ directories must already exist. Existing outputs require `--overwrite`; input
49
+ files cannot be overwritten, even with that flag.
50
+
51
+ Preparation keeps the full image and its aspect ratio, never crops or upscales,
52
+ and applies EXIF orientation before resizing. PNG and WebP preserve transparency;
53
+ JPEG flattens transparent pixels onto white. `--quality 85` controls JPEG/WebP
54
+ quality; PNG uses lossless encoding. Re-encoding removes EXIF, XMP, and textual
55
+ comments. RGB ICC color profiles are retained in prepared files. This is not an
56
+ anonymization tool: information visible in the pixels remains visible.
57
+
58
+ ## Example: a browser photo-editing workflow
59
+
60
+ To replace a background or refine an object with text instructions, you can use
61
+ [Ediroo, an online AI picture editor](https://ediroo.com). Prepare a convenient
62
+ upload size locally, upload that copy manually in the browser, and download the
63
+ edited result. Then create a comparison sheet to review both versions together:
64
+
65
+ ```sh
66
+ image-edit-prep prepare product-original.jpg product-ready.png --max-width 1800
67
+ # Edit product-ready.png in your browser and save the result as product-edited.png.
68
+ image-edit-prep compare product-ready.png product-edited.png review.png \
69
+ --panel-width 800 --panel-height 800
70
+ ```
71
+
72
+ Both panels fit the full image without cropping or enlargement. A neutral
73
+ background makes transparent areas visible. Tagged RGB images are converted to
74
+ sRGB for the comparison canvas; untagged images are assumed to be sRGB.
75
+
76
+ This independent utility does not call Ediroo, upload files, or require its
77
+ service. You can use the same workflow with any image editor.
78
+
79
+ ## Python API
80
+
81
+ ```python
82
+ from image_edit_prep import inspect_image, prepare, compare
83
+
84
+ facts = inspect_image("photo.jpg")
85
+ result = prepare("photo.jpg", "ready.webp", max_width=1600, max_height=1600)
86
+ sheet = compare("ready.webp", "edited.png", "review.png", panel_width=800)
87
+ print(facts, result, sheet)
88
+ ```
89
+
90
+ The API raises `ValueError` for unsupported options and image modes,
91
+ `FileExistsError` for existing outputs, and Pillow or filesystem exceptions for
92
+ unreadable images or paths.
93
+
94
+ ## Supported images and limits
95
+
96
+ - Single-frame JPEG, PNG, and WebP inputs; JPEG, PNG, and WebP outputs.
97
+ - RGB, RGBA, palette, and 8-bit grayscale inputs. Convert CMYK, LAB, and high-bit
98
+ depth sources to sRGB in a color-managed editor first. Grayscale profiles are
99
+ not retained after RGB conversion.
100
+ - Maximum preparation dimensions: 8192 per side. Comparison panels: 4096 per
101
+ side. Pillow's built-in large-image safeguards remain enabled.
102
+ - Animated images are rejected rather than silently losing frames.
103
+ - Metadata is not copied except for compatible RGB ICC profiles. Changing
104
+ formats or using lossy compression can change pixels.
105
+
106
+ ## Development and source
107
+
108
+ The PyPI source distribution includes the complete source, tests, and MIT
109
+ license. Download and unpack the `.tar.gz` from the project's Files page, then:
110
+
111
+ ```sh
112
+ python -m pip install -e .
113
+ python -m unittest discover -s tests -v
114
+ python -m pip install build twine
115
+ python -m build
116
+ python -m twine check dist/*
117
+ ```
118
+
119
+ Image decoding and encoding are provided by
120
+ [Pillow](https://pillow.readthedocs.io/en/stable/). Contributions can be shared
121
+ as patches against the source distribution.
122
+
123
+ ## License
124
+
125
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,106 @@
1
+ # image-edit-prep
2
+
3
+ A small local CLI and Python library for preparing photos before editing and
4
+ reviewing the results afterwards. It corrects EXIF orientation, fits photos
5
+ inside a size limit, exports JPEG/PNG/WebP, and creates labelled Before/After
6
+ comparison sheets.
7
+
8
+ Images stay on your computer. No account, API key, or network connection is used
9
+ by this package.
10
+
11
+ ## Install
12
+
13
+ Requires Python 3.10 or later.
14
+
15
+ ```sh
16
+ pip install image-edit-prep
17
+ ```
18
+
19
+ ## Quick start
20
+
21
+ ```sh
22
+ image-edit-prep inspect original.jpg
23
+ image-edit-prep prepare original.jpg ready.webp --max-width 1600 --max-height 1600
24
+ image-edit-prep compare ready.webp edited.png comparison.png
25
+ ```
26
+
27
+ Every successful command prints a JSON object with the output path, dimensions,
28
+ and file size. Errors go to stderr and return exit status 1. Parent output
29
+ directories must already exist. Existing outputs require `--overwrite`; input
30
+ files cannot be overwritten, even with that flag.
31
+
32
+ Preparation keeps the full image and its aspect ratio, never crops or upscales,
33
+ and applies EXIF orientation before resizing. PNG and WebP preserve transparency;
34
+ JPEG flattens transparent pixels onto white. `--quality 85` controls JPEG/WebP
35
+ quality; PNG uses lossless encoding. Re-encoding removes EXIF, XMP, and textual
36
+ comments. RGB ICC color profiles are retained in prepared files. This is not an
37
+ anonymization tool: information visible in the pixels remains visible.
38
+
39
+ ## Example: a browser photo-editing workflow
40
+
41
+ To replace a background or refine an object with text instructions, you can use
42
+ [Ediroo, an online AI picture editor](https://ediroo.com). Prepare a convenient
43
+ upload size locally, upload that copy manually in the browser, and download the
44
+ edited result. Then create a comparison sheet to review both versions together:
45
+
46
+ ```sh
47
+ image-edit-prep prepare product-original.jpg product-ready.png --max-width 1800
48
+ # Edit product-ready.png in your browser and save the result as product-edited.png.
49
+ image-edit-prep compare product-ready.png product-edited.png review.png \
50
+ --panel-width 800 --panel-height 800
51
+ ```
52
+
53
+ Both panels fit the full image without cropping or enlargement. A neutral
54
+ background makes transparent areas visible. Tagged RGB images are converted to
55
+ sRGB for the comparison canvas; untagged images are assumed to be sRGB.
56
+
57
+ This independent utility does not call Ediroo, upload files, or require its
58
+ service. You can use the same workflow with any image editor.
59
+
60
+ ## Python API
61
+
62
+ ```python
63
+ from image_edit_prep import inspect_image, prepare, compare
64
+
65
+ facts = inspect_image("photo.jpg")
66
+ result = prepare("photo.jpg", "ready.webp", max_width=1600, max_height=1600)
67
+ sheet = compare("ready.webp", "edited.png", "review.png", panel_width=800)
68
+ print(facts, result, sheet)
69
+ ```
70
+
71
+ The API raises `ValueError` for unsupported options and image modes,
72
+ `FileExistsError` for existing outputs, and Pillow or filesystem exceptions for
73
+ unreadable images or paths.
74
+
75
+ ## Supported images and limits
76
+
77
+ - Single-frame JPEG, PNG, and WebP inputs; JPEG, PNG, and WebP outputs.
78
+ - RGB, RGBA, palette, and 8-bit grayscale inputs. Convert CMYK, LAB, and high-bit
79
+ depth sources to sRGB in a color-managed editor first. Grayscale profiles are
80
+ not retained after RGB conversion.
81
+ - Maximum preparation dimensions: 8192 per side. Comparison panels: 4096 per
82
+ side. Pillow's built-in large-image safeguards remain enabled.
83
+ - Animated images are rejected rather than silently losing frames.
84
+ - Metadata is not copied except for compatible RGB ICC profiles. Changing
85
+ formats or using lossy compression can change pixels.
86
+
87
+ ## Development and source
88
+
89
+ The PyPI source distribution includes the complete source, tests, and MIT
90
+ license. Download and unpack the `.tar.gz` from the project's Files page, then:
91
+
92
+ ```sh
93
+ python -m pip install -e .
94
+ python -m unittest discover -s tests -v
95
+ python -m pip install build twine
96
+ python -m build
97
+ python -m twine check dist/*
98
+ ```
99
+
100
+ Image decoding and encoding are provided by
101
+ [Pillow](https://pillow.readthedocs.io/en/stable/). Contributions can be shared
102
+ as patches against the source distribution.
103
+
104
+ ## License
105
+
106
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "image-edit-prep"
7
+ version = "0.1.0"
8
+ description = "Prepare local photos for editing and create before-and-after comparison sheets."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{name = "Leanna Yode"}]
14
+ dependencies = ["Pillow>=11.3"]
15
+ keywords = ["image", "photo", "resize", "webp", "comparison"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "Topic :: Multimedia :: Graphics :: Graphics Conversion",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3 :: Only",
23
+ ]
24
+
25
+ [project.scripts]
26
+ image-edit-prep = "image_edit_prep.cli:main"
27
+
28
+ [project.urls]
29
+ "Editing workflow example" = "https://ediroo.com"
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["src/image_edit_prep"]
33
+
34
+ [tool.hatch.build.targets.sdist]
35
+ include = ["/src", "/tests", "/README.md", "/LICENSE", "/pyproject.toml"]
@@ -0,0 +1,6 @@
1
+ """Local photo preparation and comparison tools."""
2
+
3
+ from .core import compare, inspect_image, prepare
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["compare", "inspect_image", "prepare"]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,41 @@
1
+ """Command-line interface."""
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+
7
+ from PIL.Image import DecompressionBombError
8
+
9
+ from . import __version__
10
+ from .core import compare, inspect_image, prepare
11
+
12
+
13
+ def main(argv: list[str] | None = None) -> int:
14
+ parser = argparse.ArgumentParser(description="Prepare local photos and comparison sheets.")
15
+ parser.add_argument("--version", action="version", version=__version__)
16
+ commands = parser.add_subparsers(dest="command", required=True)
17
+ inspect_parser = commands.add_parser("inspect", help="Show oriented dimensions and file size")
18
+ inspect_parser.add_argument("source")
19
+ prep = commands.add_parser("prepare", help="Orient, resize, and export a photo")
20
+ prep.add_argument("source")
21
+ prep.add_argument("output")
22
+ prep.add_argument("--max-width", type=int, default=2048)
23
+ prep.add_argument("--max-height", type=int, default=2048)
24
+ comparison = commands.add_parser("compare", help="Create a Before/After comparison sheet")
25
+ comparison.add_argument("before")
26
+ comparison.add_argument("after")
27
+ comparison.add_argument("output")
28
+ comparison.add_argument("--panel-width", type=int, default=640)
29
+ comparison.add_argument("--panel-height", type=int, default=640)
30
+ for command in (prep, comparison):
31
+ command.add_argument("--quality", type=int, default=85, help="JPEG/WebP quality, 1–100")
32
+ command.add_argument("--overwrite", action="store_true", help="Replace an existing output")
33
+ values = vars(parser.parse_args(argv))
34
+ command = values.pop("command")
35
+ try:
36
+ function = {"inspect": inspect_image, "prepare": prepare, "compare": compare}[command]
37
+ print(json.dumps(function(**values), ensure_ascii=False))
38
+ return 0
39
+ except (OSError, ValueError, DecompressionBombError) as error:
40
+ print(f"image-edit-prep: {error}", file=sys.stderr)
41
+ return 1
@@ -0,0 +1,123 @@
1
+ """Small Pillow-based operations with no network access."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ import tempfile
6
+
7
+ from PIL import Image, ImageDraw, ImageOps
8
+
9
+ _FORMATS = {".jpg": "JPEG", ".jpeg": "JPEG", ".png": "PNG", ".webp": "WEBP"}
10
+ _MODES = {"1", "L", "LA", "P", "RGB", "RGBA"}
11
+
12
+
13
+ def _limit(value: int, name: str, maximum: int = 8192) -> None:
14
+ if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= maximum:
15
+ raise ValueError(f"{name} must be an integer from 1 to {maximum}")
16
+
17
+
18
+ def _load(path: str | Path) -> Image.Image:
19
+ with Image.open(path) as source:
20
+ if source.format not in {"JPEG", "PNG", "WEBP"}:
21
+ raise ValueError("Input must be JPEG, PNG, or WebP")
22
+ if getattr(source, "n_frames", 1) != 1:
23
+ raise ValueError("Animated or multi-frame images are not supported")
24
+ if source.mode not in _MODES:
25
+ raise ValueError(f"Unsupported image mode {source.mode}; convert to sRGB first")
26
+ source.load()
27
+ oriented = ImageOps.exif_transpose(source)
28
+ alpha = "A" in oriented.getbands() or "transparency" in oriented.info
29
+ result = oriented.convert("RGBA" if alpha else "RGB")
30
+ profile = source.info.get("icc_profile")
31
+ # A grayscale profile is incompatible after conversion to RGB.
32
+ result.info.clear()
33
+ if profile and source.mode in {"RGB", "RGBA"}:
34
+ result.info["icc_profile"] = profile
35
+ return result
36
+
37
+
38
+ def _save(image: Image.Image, output: str | Path, sources: list[str | Path],
39
+ quality: int, overwrite: bool) -> dict:
40
+ target = Path(output)
41
+ if target.resolve() in {Path(path).resolve() for path in sources}:
42
+ raise ValueError("Output must be different from every input")
43
+ fmt = _FORMATS.get(target.suffix.lower())
44
+ if fmt is None:
45
+ raise ValueError("Output extension must be .jpg, .jpeg, .png, or .webp")
46
+ _limit(quality, "quality", 100)
47
+ if target.exists() and not overwrite:
48
+ raise FileExistsError(f"Output already exists: {target}")
49
+ if fmt == "JPEG" and image.mode == "RGBA":
50
+ canvas = Image.new("RGB", image.size, "white")
51
+ canvas.paste(image, mask=image.getchannel("A"))
52
+ canvas.info.update(image.info)
53
+ image = canvas
54
+ options = {}
55
+ if fmt in {"JPEG", "WEBP"}:
56
+ options["quality"] = quality
57
+ if fmt == "JPEG":
58
+ options.update(optimize=True, progressive=True)
59
+ if image.info.get("icc_profile"):
60
+ options["icc_profile"] = image.info["icc_profile"]
61
+ fd, temporary = tempfile.mkstemp(prefix=".image-edit-prep-", dir=target.parent)
62
+ os.close(fd)
63
+ try:
64
+ image.save(temporary, format=fmt, **options)
65
+ if overwrite:
66
+ os.replace(temporary, target)
67
+ else:
68
+ # Atomic exclusive publication also protects against competing writers.
69
+ os.link(temporary, target)
70
+ finally:
71
+ Path(temporary).unlink(missing_ok=True)
72
+ return {"path": str(target), "width": image.width, "height": image.height,
73
+ "format": fmt, "bytes": target.stat().st_size}
74
+
75
+
76
+ def inspect_image(source: str | Path) -> dict:
77
+ """Return basic file facts and dimensions after EXIF orientation."""
78
+ image = _load(source)
79
+ return {"path": str(source), "width": image.width, "height": image.height,
80
+ "mode": image.mode, "bytes": Path(source).stat().st_size,
81
+ "has_alpha": image.mode == "RGBA"}
82
+
83
+
84
+ def prepare(source: str | Path, output: str | Path, *, max_width: int = 2048,
85
+ max_height: int = 2048, quality: int = 85, overwrite: bool = False) -> dict:
86
+ """Orient and fit a photo inside a box, without cropping or upscaling."""
87
+ _limit(max_width, "max_width")
88
+ _limit(max_height, "max_height")
89
+ image = _load(source)
90
+ image.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
91
+ return _save(image, output, [source], quality, overwrite)
92
+
93
+
94
+ def compare(before: str | Path, after: str | Path, output: str | Path, *,
95
+ panel_width: int = 640, panel_height: int = 640,
96
+ quality: int = 85, overwrite: bool = False) -> dict:
97
+ """Create a labelled side-by-side sheet with equal panels and no cropping.
98
+
99
+ ICC-tagged RGB photos are converted to sRGB for a common comparison canvas.
100
+ Untagged photos are assumed to be sRGB.
101
+ """
102
+ _limit(panel_width, "panel_width", 4096)
103
+ _limit(panel_height, "panel_height", 4096)
104
+ canvas = Image.new("RGB", (panel_width * 2, panel_height + 32), "#eeeeee")
105
+ draw = ImageDraw.Draw(canvas)
106
+ for index, (path, label) in enumerate(((before, "Before"), (after, "After"))):
107
+ image = _load(path)
108
+ if image.info.get("icc_profile"):
109
+ from io import BytesIO
110
+ from PIL import ImageCms
111
+
112
+ alpha = image.getchannel("A") if image.mode == "RGBA" else None
113
+ image = ImageCms.profileToProfile(
114
+ image.convert("RGB"), ImageCms.ImageCmsProfile(BytesIO(image.info["icc_profile"])),
115
+ ImageCms.createProfile("sRGB"), outputMode="RGB")
116
+ if alpha is not None:
117
+ image.putalpha(alpha)
118
+ image.thumbnail((panel_width, panel_height), Image.Resampling.LANCZOS)
119
+ x = index * panel_width + (panel_width - image.width) // 2
120
+ y = 32 + (panel_height - image.height) // 2
121
+ canvas.paste(image, (x, y), image.getchannel("A") if image.mode == "RGBA" else None)
122
+ draw.text((index * panel_width + 10, 9), label, fill="#222222")
123
+ return _save(canvas, output, [before, after], quality, overwrite)
@@ -0,0 +1,129 @@
1
+ import contextlib
2
+ import io
3
+ import json
4
+ from pathlib import Path
5
+ import tempfile
6
+ import unittest
7
+
8
+ from PIL import Image, ImageCms, PngImagePlugin
9
+
10
+ from image_edit_prep import compare, inspect_image, prepare
11
+ from image_edit_prep.cli import main
12
+
13
+
14
+ class ImageTests(unittest.TestCase):
15
+ def setUp(self):
16
+ self.directory = tempfile.TemporaryDirectory()
17
+ self.addCleanup(self.directory.cleanup)
18
+ self.root = Path(self.directory.name)
19
+ self.source = self.root / "source.png"
20
+ Image.new("RGB", (120, 60), "red").save(self.source)
21
+
22
+ def test_resize_keeps_aspect_and_does_not_upscale(self):
23
+ for size, expected in ((40, (40, 20)), (400, (120, 60))):
24
+ output = self.root / f"{size}.webp"
25
+ result = prepare(self.source, output, max_width=size, max_height=size)
26
+ self.assertEqual((result["width"], result["height"]), expected)
27
+ with Image.open(output) as image:
28
+ self.assertEqual(image.size, expected)
29
+ self.assertEqual(image.format, "WEBP")
30
+
31
+ def test_orientation_applied_and_exif_removed(self):
32
+ source = self.root / "rotated.jpg"
33
+ exif = Image.Exif()
34
+ exif[274] = 6
35
+ exif[315] = "test author"
36
+ Image.new("RGB", (120, 60), "blue").save(source, exif=exif)
37
+ self.assertEqual(inspect_image(source)["width"], 60)
38
+ output = self.root / "oriented.png"
39
+ prepare(source, output)
40
+ with Image.open(output) as image:
41
+ self.assertEqual(image.size, (60, 120))
42
+ self.assertFalse(image.getexif())
43
+
44
+ def test_transparency_and_white_jpeg_background(self):
45
+ Image.new("RGBA", (20, 10), (255, 0, 0, 0)).save(self.source)
46
+ output = self.root / "transparent.webp"
47
+ prepare(self.source, output)
48
+ with Image.open(output) as image:
49
+ self.assertEqual(image.getpixel((0, 0))[3], 0)
50
+ output = self.root / "white.jpg"
51
+ prepare(self.source, output)
52
+ with Image.open(output) as image:
53
+ self.assertTrue(all(channel >= 250 for channel in image.getpixel((0, 0))))
54
+
55
+ def test_text_removed_and_icc_retained(self):
56
+ info = PngImagePlugin.PngInfo()
57
+ info.add_text("Comment", "private comment")
58
+ profile = ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes()
59
+ Image.new("RGBA", (20, 10), "red").save(self.source, pnginfo=info, icc_profile=profile)
60
+ for suffix in ("png", "webp", "jpg"):
61
+ output = self.root / f"clean.{suffix}"
62
+ prepare(self.source, output)
63
+ with Image.open(output) as image:
64
+ self.assertNotIn("Comment", image.info)
65
+ self.assertEqual(image.info["icc_profile"], profile)
66
+
67
+ def test_comparison_layout_and_alpha(self):
68
+ after = self.root / "after.png"
69
+ Image.new("RGBA", (30, 60), (0, 0, 255, 0)).save(after)
70
+ output = self.root / "sheet.png"
71
+ result = compare(self.source, after, output, panel_width=100, panel_height=80)
72
+ self.assertEqual((result["width"], result["height"]), (200, 112))
73
+ with Image.open(output) as image:
74
+ self.assertEqual(image.getpixel((50, 72)), (255, 0, 0))
75
+ self.assertEqual(image.getpixel((150, 72)), (238, 238, 238))
76
+
77
+ def test_comparison_accepts_tagged_input(self):
78
+ profile = ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes()
79
+ Image.new("RGB", (10, 10), "red").save(self.source, icc_profile=profile)
80
+ output = self.root / "profile-sheet.png"
81
+ compare(self.source, self.source, output, panel_width=20, panel_height=20)
82
+ with Image.open(output) as image:
83
+ self.assertEqual(image.getpixel((10, 42)), (255, 0, 0))
84
+
85
+ def test_outputs_are_protected(self):
86
+ original = self.source.read_bytes()
87
+ with self.assertRaises(ValueError):
88
+ prepare(self.source, self.source, overwrite=True)
89
+ output = self.root / "exists.png"
90
+ output.write_bytes(b"keep me")
91
+ with self.assertRaises(FileExistsError):
92
+ prepare(self.source, output)
93
+ self.assertEqual(output.read_bytes(), b"keep me")
94
+ prepare(self.source, output, overwrite=True)
95
+ self.assertEqual(self.source.read_bytes(), original)
96
+ self.assertFalse(list(self.root.glob(".image-edit-prep-*")))
97
+
98
+ def test_invalid_images_and_options(self):
99
+ with self.assertRaises(ValueError):
100
+ prepare(self.source, self.root / "bad.gif")
101
+ for kwargs in ({"max_width": 0}, {"max_height": 9000}, {"quality": 101}):
102
+ with self.assertRaises(ValueError):
103
+ prepare(self.source, self.root / "bad.png", **kwargs)
104
+ cmyk = self.root / "cmyk.jpg"
105
+ Image.new("CMYK", (10, 10)).save(cmyk)
106
+ with self.assertRaisesRegex(ValueError, "sRGB"):
107
+ inspect_image(cmyk)
108
+
109
+ def test_animation_rejected(self):
110
+ animated = self.root / "animated.png"
111
+ first = Image.new("RGB", (20, 20), "red")
112
+ first.save(animated, save_all=True, append_images=[Image.new("RGB", (20, 20), "blue")])
113
+ with self.assertRaisesRegex(ValueError, "multi-frame"):
114
+ prepare(animated, self.root / "flat.png")
115
+
116
+ def test_cli_json_and_readable_errors(self):
117
+ stdout, stderr = io.StringIO(), io.StringIO()
118
+ with contextlib.redirect_stdout(stdout):
119
+ code = main(["inspect", str(self.source)])
120
+ self.assertEqual(code, 0)
121
+ self.assertEqual(json.loads(stdout.getvalue())["width"], 120)
122
+ with contextlib.redirect_stderr(stderr):
123
+ code = main(["prepare", str(self.source), str(self.root / "bad.png"), "--quality", "0"])
124
+ self.assertEqual(code, 1)
125
+ self.assertIn("quality", stderr.getvalue())
126
+
127
+
128
+ if __name__ == "__main__":
129
+ unittest.main()