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.
- image_edit_prep-0.1.0/LICENSE +21 -0
- image_edit_prep-0.1.0/PKG-INFO +125 -0
- image_edit_prep-0.1.0/README.md +106 -0
- image_edit_prep-0.1.0/pyproject.toml +35 -0
- image_edit_prep-0.1.0/src/image_edit_prep/__init__.py +6 -0
- image_edit_prep-0.1.0/src/image_edit_prep/__main__.py +3 -0
- image_edit_prep-0.1.0/src/image_edit_prep/cli.py +41 -0
- image_edit_prep-0.1.0/src/image_edit_prep/core.py +123 -0
- image_edit_prep-0.1.0/tests/test_images.py +129 -0
|
@@ -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,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()
|