From 8712cb89b228ce0316c08e1a77339cae6f395113 Mon Sep 17 00:00:00 2001 From: Mathias Scheider Date: Wed, 30 Sep 2026 22:59:09 +0200 Subject: [PATCH] basic photo functionalities --- src/photools3/photo.py | 29 +++-- test/test_photo.py | 276 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 297 insertions(+), 8 deletions(-) create mode 100644 test/test_photo.py diff --git a/src/photools3/photo.py b/src/photools3/photo.py index 5522267..1fe3874 100644 --- a/src/photools3/photo.py +++ b/src/photools3/photo.py @@ -1,15 +1,28 @@ +import base64 +from io import BytesIO from pathlib import Path +from PIL import Image + class Photo: - def __init__(self): ... + def __init__(self, path: Path): + self.path = path + self.image = Image.open(path) - @classmethod - def from_file(cls, path: Path) -> Photo: - # TODO - return cls() - - def resize(self) -> Photo: - # TODO + def resize(self, image_max_size=1024) -> Photo: + """Limiting max dimensions while maintaining aspect ratio""" + self.image.thumbnail((image_max_size, image_max_size), Image.Resampling.LANCZOS) return self + + def to_rgb(self): + if self.image.mode != "RGB": + self.image = self.image.convert("RGB") + return self + + def get_encoded(self): + buffer = BytesIO() + self.image.save(buffer, format="JPEG", quality=85) + image_data = base64.b64encode(buffer.getvalue()).decode("utf-8") + return image_data diff --git a/test/test_photo.py b/test/test_photo.py new file mode 100644 index 0000000..0c924fe --- /dev/null +++ b/test/test_photo.py @@ -0,0 +1,276 @@ +import base64 +from io import BytesIO + +import pytest +from PIL import Image + +from photools3.photo import Photo +from photools3.settings import DNG_EXAMPLES_DIR + +PATHS_DNG_EXAMPLES = [ + DNG_EXAMPLES_DIR / "example_dng_01.dng", + DNG_EXAMPLES_DIR / "example_dng_02.dng", + DNG_EXAMPLES_DIR / "example_dng_03.dng", + DNG_EXAMPLES_DIR / "example_dng_04.dng", + DNG_EXAMPLES_DIR / "example_dng_05.dng", +] + +# (PIL mode) -> file format used to persist the test image +MODE_TO_FORMAT = { + "RGB": "PNG", + "L": "PNG", + "P": "PNG", + "RGBA": "PNG", + "LA": "PNG", + "CMYK": "TIFF", + "1": "PNG", + "I": "TIFF", + "F": "TIFF", +} +ALL_MODES = list(MODE_TO_FORMAT) +JPEG_COMPATIBLE_MODES = ["RGB", "L", "CMYK", "1"] +JPEG_INCOMPATIBLE_MODES = ["P", "RGBA", "LA", "I", "F"] + + +def _make_image_file(tmp_path, mode: str, size=(64, 48)): + """Create an image in the given mode, save it to disk and return its path.""" + fmt = MODE_TO_FORMAT[mode] + img = Image.new(mode, size) + path = tmp_path / f"test_{mode}.{fmt.lower()}" + img.save(path, format=fmt) + return path + + +def test_load_photo(): + photo = Photo(PATHS_DNG_EXAMPLES[0]) + assert photo + assert photo.path == PATHS_DNG_EXAMPLES[0] + assert isinstance(photo.image, Image.Image) + + +def test_resize_method_exists(): + """Test that resize method exists and returns a Photo instance""" + photo = Photo(PATHS_DNG_EXAMPLES[0]) + resized_photo = photo.resize() + assert isinstance(resized_photo, Photo) + assert resized_photo is photo # Should return self for chaining + + +def test_resize_with_custom_max_size(): + """Test resize with a custom maximum size""" + photo = Photo(PATHS_DNG_EXAMPLES[0]) + original_width, original_height = photo.image.size + assert original_width > 100 or original_height > 100 # Assume it's larger + + resized_photo = photo.resize(image_max_size=50) + + new_width, new_height = resized_photo.image.size + max_dim = max(new_width, new_height) + assert max_dim <= 50, f"Max dimension {max_dim} exceeds limit of 50" + + +def test_resize_maintains_aspect_ratio(): + """Test that resize maintains the original aspect ratio""" + photo = Photo(PATHS_DNG_EXAMPLES[0]) + original_width, original_height = photo.image.size + + resized_photo = photo.resize(image_max_size=100) + new_width, new_height = resized_photo.image.size + + original_ratio = original_width / original_height + resized_ratio = new_width / new_height + + assert ( + abs(original_ratio - resized_ratio) < 0.01 + ), f"Aspect ratio changed: {original_ratio} -> {resized_ratio}" + + +def test_resize_image_thumbnail_called(): + """Test that the image was actually resized using thumbnail""" + photo = Photo(PATHS_DNG_EXAMPLES[0]) + original_size = photo.image.size + + # Resize to a smaller dimension + resized_photo = photo.resize(image_max_size=10) + + new_size = resized_photo.image.size + assert ( + new_size != original_size + ), "Image should be resized when max_size is smaller than original" + + +def test_resize_chaining(): + """Test that resize returns self for method chaining""" + photo = Photo(PATHS_DNG_EXAMPLES[0]) + + # Should be able to chain multiple operations + result = photo.resize().resize(image_max_size=50) + assert result is photo + + +def test_resize_default_parameter(): + """Test resize with default parameter (no arguments)""" + photo = Photo(PATHS_DNG_EXAMPLES[0]) + resized_photo = photo.resize() + + # Should return a valid Photo instance + assert isinstance(resized_photo, Photo) + assert resized_photo.path == photo.path + + +# --- to_rgb() --- + + +@pytest.mark.parametrize("mode", ALL_MODES) +def test_to_rgb_converts_to_rgb_mode(tmp_path, mode): + """to_rgb() must leave the image in RGB mode regardless of the initial mode""" + path = _make_image_file(tmp_path, mode) + photo = Photo(path) + result = photo.to_rgb() + assert result.image.mode == "RGB" + + +@pytest.mark.parametrize("mode", ALL_MODES) +def test_to_rgb_returns_self_for_chaining(tmp_path, mode): + """to_rgb() returns self so it can be chained""" + path = _make_image_file(tmp_path, mode) + photo = Photo(path) + assert photo.to_rgb() is photo + + +def test_to_rgb_keeps_rgb_image_unmodified(tmp_path): + """RGB images are not re-converted: the same image object is kept""" + path = _make_image_file(tmp_path, "RGB") + photo = Photo(path) + original_image = photo.image + photo.to_rgb() + assert photo.image is original_image + + +@pytest.mark.parametrize("mode", ["L", "P", "RGBA", "LA", "CMYK", "1", "I", "F"]) +def test_to_rgb_replaces_non_rgb_image(tmp_path, mode): + """Non-RGB images are converted: a new image object is assigned""" + path = _make_image_file(tmp_path, mode) + photo = Photo(path) + original_image = photo.image + photo.to_rgb() + assert photo.image is not original_image + assert photo.image.mode == "RGB" + + +@pytest.mark.parametrize("mode", ["RGBA", "LA"]) +def test_to_rgb_drops_alpha_channel(tmp_path, mode): + """Converting alpha-bearing modes to RGB drops the alpha band""" + path = _make_image_file(tmp_path, mode) + photo = Photo(path) + assert "A" in photo.image.getbands() + photo.to_rgb() + assert photo.image.getbands() == ("R", "G", "B") + + +def test_to_rgb_on_dng_examples(tmp_path): + """DNG examples are already RGB; to_rgb() is a safe no-op on every file""" + for path in PATHS_DNG_EXAMPLES: + photo = Photo(path) + original_image = photo.image + assert photo.to_rgb() is photo + assert photo.image is original_image + assert photo.image.mode == "RGB" + + +# --- get_encoded() --- + + +@pytest.mark.parametrize("mode", JPEG_COMPATIBLE_MODES) +def test_get_encoded_returns_valid_base64(tmp_path, mode): + """get_encoded() returns a non-empty, decodable base64 string""" + path = _make_image_file(tmp_path, mode) + photo = Photo(path) + encoded = photo.get_encoded() + assert isinstance(encoded, str) + assert len(encoded) > 0 + raw = base64.b64decode(encoded, validate=True) + assert len(raw) > 0 + + +@pytest.mark.parametrize("mode", JPEG_COMPATIBLE_MODES) +def test_get_encoded_produces_openable_jpeg(tmp_path, mode): + """The decoded payload must be a valid JPEG with the original dimensions""" + path = _make_image_file(tmp_path, mode) + photo = Photo(path) + original_size = photo.image.size + encoded = photo.get_encoded() + img = Image.open(BytesIO(base64.b64decode(encoded))) + assert img.format == "JPEG" + assert img.size == original_size + + +def test_get_encoded_starts_with_jpeg_magic_bytes(tmp_path): + """JPEG files start with the FFD8FF magic bytes""" + path = _make_image_file(tmp_path, "RGB") + photo = Photo(path) + raw = base64.b64decode(photo.get_encoded()) + assert raw[:3] == b"\xff\xd8\xff" + + +def test_get_encoded_reencodes_instead_of_passing_raw_file_bytes(tmp_path): + """The output must be a fresh JPEG encoding, not the source file bytes""" + path = _make_image_file(tmp_path, "RGB") + photo = Photo(path) + raw = base64.b64decode(photo.get_encoded()) + source_bytes = path.read_bytes() + assert raw != source_bytes + assert source_bytes[:4] == b"\x89PNG" # source is a PNG... + assert raw[:3] == b"\xff\xd8\xff" # ...but the encoding is a JPEG + + +def test_get_encoded_is_deterministic(tmp_path): + """Encoding the same image twice yields identical base64""" + path = _make_image_file(tmp_path, "RGB") + assert Photo(path).get_encoded() == Photo(path).get_encoded() + + +@pytest.mark.parametrize("mode", JPEG_INCOMPATIBLE_MODES) +def test_get_encoded_raises_for_non_jpeg_modes(tmp_path, mode): + """Modes JPEG cannot store (palette, alpha, integer/float) must raise OSError""" + path = _make_image_file(tmp_path, mode) + photo = Photo(path) + with pytest.raises(OSError): + photo.get_encoded() + + +@pytest.mark.parametrize("mode", JPEG_INCOMPATIBLE_MODES) +def test_to_rgb_then_get_encoded_works_around_unsupported_modes(tmp_path, mode): + """Chaining to_rgb().get_encoded() fixes modes that JPEG cannot store""" + path = _make_image_file(tmp_path, mode) + photo = Photo(path) + with pytest.raises(OSError): + photo.get_encoded() + encoded = photo.to_rgb().get_encoded() + assert isinstance(encoded, str) + img = Image.open(BytesIO(base64.b64decode(encoded))) + assert img.format == "JPEG" + + +@pytest.mark.parametrize("path", PATHS_DNG_EXAMPLES) +def test_get_encoded_works_on_all_dng_examples(path): + """Every DNG example (all different files) encodes to a valid JPEG""" + photo = Photo(path) + encoded = photo.get_encoded() + assert isinstance(encoded, str) + raw = base64.b64decode(encoded, validate=True) + assert raw[:3] == b"\xff\xd8\xff" + img = Image.open(BytesIO(raw)) + assert img.format == "JPEG" + assert img.size == photo.image.size + + +def test_full_chain_resize_to_rgb_get_encoded(tmp_path): + """The intended workflow: resize -> to_rgb -> get_encoded""" + path = _make_image_file(tmp_path, "RGBA") + photo = Photo(path) + photo.resize(32).to_rgb() + assert photo.image.mode == "RGB" + img = Image.open(BytesIO(base64.b64decode(photo.get_encoded()))) + assert img.format == "JPEG" + assert max(img.size) <= 32