From 3a4ff3c8dc577a1b640638260c5573b3852442ee Mon Sep 17 00:00:00 2001 From: aga Date: Fri, 27 Mar 2026 12:28:58 +0100 Subject: [PATCH 1/3] Extract resize helper into image optimisation module and add tests --- src/photo_tools/image/optimisation.py | 9 +++++++++ src/photo_tools/optimise.py | 9 +-------- tests/image/test_optimisation.py | 19 +++++++++++++++++++ 3 files changed, 29 insertions(+), 8 deletions(-) create mode 100644 src/photo_tools/image/optimisation.py create mode 100644 tests/image/test_optimisation.py diff --git a/src/photo_tools/image/optimisation.py b/src/photo_tools/image/optimisation.py new file mode 100644 index 0000000..b952965 --- /dev/null +++ b/src/photo_tools/image/optimisation.py @@ -0,0 +1,9 @@ +from PIL import Image + + +def resize_to_max_width(img: Image.Image, max_width: int) -> Image.Image: + if img.width <= max_width: + return img + + new_height = int(img.height * (max_width / img.width)) + return img.resize((max_width, new_height), Image.Resampling.LANCZOS) diff --git a/src/photo_tools/optimise.py b/src/photo_tools/optimise.py index 982c9d1..5378f20 100644 --- a/src/photo_tools/optimise.py +++ b/src/photo_tools/optimise.py @@ -5,6 +5,7 @@ from PIL import Image from photo_tools.core.validation import validate_input_dir +from photo_tools.image.optimisation import resize_to_max_width logger = logging.getLogger(__name__) @@ -58,14 +59,6 @@ def optimise(input_dir: str, dry_run: bool = False) -> None: ) -def resize_to_max_width(img: Image.Image, max_width: int) -> Image.Image: - if img.width <= max_width: - return img - - new_height = int(img.height * (max_width / img.width)) - return img.resize((max_width, new_height), Image.Resampling.LANCZOS) - - def find_best_jpeg_bytes(img: Image.Image) -> tuple[bytes, int]: low = MIN_QUALITY high = MAX_QUALITY diff --git a/tests/image/test_optimisation.py b/tests/image/test_optimisation.py new file mode 100644 index 0000000..2677924 --- /dev/null +++ b/tests/image/test_optimisation.py @@ -0,0 +1,19 @@ +from PIL import Image + +from photo_tools.image.optimisation import resize_to_max_width + + +def test_does_not_resize_when_width_is_within_limit(): + img = Image.new("RGB", (1200, 800)) + + result = resize_to_max_width(img, max_width=2500) + + assert result.size == img.size + + +def test_resizes_image_with_simple_ratio(): + img = Image.new("RGB", (4000, 2000)) # 2:1 ratio + + result = resize_to_max_width(img, max_width=2000) + + assert result.size == (2000, 1000) From a8837ae1fa34442a0680023aa9cf1601d4e56e74 Mon Sep 17 00:00:00 2001 From: aga Date: Fri, 27 Mar 2026 12:43:37 +0100 Subject: [PATCH 2/3] Extract image optimisation helpers into dedicated module --- src/photo_tools/image/optimisation.py | 53 +++++++++++++++++++++++++++ src/photo_tools/optimise.py | 40 +------------------- 2 files changed, 55 insertions(+), 38 deletions(-) diff --git a/src/photo_tools/image/optimisation.py b/src/photo_tools/image/optimisation.py index b952965..0417dd6 100644 --- a/src/photo_tools/image/optimisation.py +++ b/src/photo_tools/image/optimisation.py @@ -1,3 +1,5 @@ +from io import BytesIO + from PIL import Image @@ -7,3 +9,54 @@ def resize_to_max_width(img: Image.Image, max_width: int) -> Image.Image: new_height = int(img.height * (max_width / img.width)) return img.resize((max_width, new_height), Image.Resampling.LANCZOS) + + +# Binary search to find the highest JPEG quality that produces a file +# within the maximum allowed size. Assumes file size increases with quality. +def optimise_jpeg( + img: Image.Image, + max_file_size_bytes: int, + min_quality: int = 70, + max_quality: int = 100, +) -> tuple[bytes, int]: + """ + Binary search to find the highest JPEG quality that produces a file + within the maximum allowed size. + """ + + low = min_quality + high = max_quality + + best_bytes: bytes | None = None + best_quality = min_quality + + while low <= high: + quality = (low + high) // 2 + jpeg_bytes = encode_jpeg(img, quality) + + if len(jpeg_bytes) <= max_file_size_bytes: + best_bytes = jpeg_bytes + best_quality = quality + low = quality + 1 + else: + high = quality - 1 + + if best_bytes is None: + jpeg_bytes = encode_jpeg(img, min_quality) + return jpeg_bytes, min_quality + + return best_bytes, best_quality + + +# Encode a PIL Image into JPEG bytes in memory using the given quality. +# This is used to evaluate the resulting file size without writing to disk. +# `quality` controls compression (higher = better quality, larger size). +def encode_jpeg(img: Image.Image, quality: int) -> bytes: + buffer = BytesIO() + img.save( + buffer, + format="JPEG", + quality=quality, + optimize=True, + ) + return buffer.getvalue() diff --git a/src/photo_tools/optimise.py b/src/photo_tools/optimise.py index 5378f20..9cd621f 100644 --- a/src/photo_tools/optimise.py +++ b/src/photo_tools/optimise.py @@ -1,11 +1,10 @@ import logging -from io import BytesIO from pathlib import Path from PIL import Image from photo_tools.core.validation import validate_input_dir -from photo_tools.image.optimisation import resize_to_max_width +from photo_tools.image.optimisation import optimise_jpeg, resize_to_max_width logger = logging.getLogger(__name__) @@ -40,7 +39,7 @@ def optimise(input_dir: str, dry_run: bool = False) -> None: with Image.open(file_path) as original_img: img = original_img.convert("RGB") resized_img = resize_to_max_width(img, MAX_WIDTH) - jpeg_bytes, quality = find_best_jpeg_bytes(resized_img) + jpeg_bytes, quality = optimise_jpeg(resized_img, MAX_FILE_SIZE_BYTES) size_kb = len(jpeg_bytes) // 1024 @@ -57,38 +56,3 @@ def optimise(input_dir: str, dry_run: bool = False) -> None: f"Optimised {file_path.name} → {output_path.name} " f"(quality={quality}, size={size_kb} KB)" ) - - -def find_best_jpeg_bytes(img: Image.Image) -> tuple[bytes, int]: - low = MIN_QUALITY - high = MAX_QUALITY - - best_bytes: bytes | None = None - best_quality = MIN_QUALITY - - while low <= high: - quality = (low + high) // 2 - jpeg_bytes = render_jpeg_bytes(img, quality) - - if len(jpeg_bytes) <= MAX_FILE_SIZE_BYTES: - best_bytes = jpeg_bytes - best_quality = quality - low = quality + 1 - else: - high = quality - 1 - - if best_bytes is None: - return render_jpeg_bytes(img, MIN_QUALITY), MIN_QUALITY - - return best_bytes, best_quality - - -def render_jpeg_bytes(img: Image.Image, quality: int) -> bytes: - buffer = BytesIO() - img.save( - buffer, - format="JPEG", - quality=quality, - optimize=True, - ) - return buffer.getvalue() From 1db8d97814ca001ad127fa90980dd31eb9df0bfd Mon Sep 17 00:00:00 2001 From: aga Date: Fri, 27 Mar 2026 12:55:56 +0100 Subject: [PATCH 3/3] Add integration tests for optimise command and cover core behaviours --- tests/test_optimise.py | 72 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 tests/test_optimise.py diff --git a/tests/test_optimise.py b/tests/test_optimise.py new file mode 100644 index 0000000..ef89e25 --- /dev/null +++ b/tests/test_optimise.py @@ -0,0 +1,72 @@ +from PIL import Image + +from photo_tools.optimise import MAX_WIDTH, optimise + + +def test_dry_run_does_not_create_output(tmp_path): + input_dir = tmp_path / "input" + input_dir.mkdir() + + img_path = input_dir / "photo.jpg" + Image.new("RGB", (1000, 1000)).save(img_path) + + optimise(str(input_dir), dry_run=True) + + assert img_path.exists() + assert not (input_dir / "lq_photo.jpg").exists() + + +def test_creates_optimised_file(tmp_path): + input_dir = tmp_path / "input" + input_dir.mkdir() + + img_path = input_dir / "photo.jpg" + Image.new("RGB", (1000, 1000)).save(img_path) + + optimise(str(input_dir), dry_run=False) + + output = input_dir / "lq_photo.jpg" + + assert img_path.exists() + assert output.exists() + + +def test_skips_unsupported_files(tmp_path): + input_dir = tmp_path / "input" + input_dir.mkdir() + + txt_file = input_dir / "notes.txt" + txt_file.write_text("hello") + + optimise(str(input_dir), dry_run=False) + + assert txt_file.exists() + assert not (input_dir / "lq_notes.txt").exists() + + +def test_skips_already_optimised_files(tmp_path): + input_dir = tmp_path / "input" + input_dir.mkdir() + + img_path = input_dir / "lq_photo.jpg" + Image.new("RGB", (1000, 1000)).save(img_path) + + optimise(str(input_dir), dry_run=False) + + assert img_path.exists() + assert not (input_dir / "lq_lq_photo.jpg").exists() + + +def test_output_image_width_is_capped(tmp_path): + input_dir = tmp_path / "input" + input_dir.mkdir() + + img_path = input_dir / "photo.jpg" + Image.new("RGB", (4000, 2000)).save(img_path) + + optimise(str(input_dir), dry_run=False) + + output = input_dir / "lq_photo.jpg" + + with Image.open(output) as img: + assert img.width <= MAX_WIDTH