diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 55e6736..c25adf4 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -11,6 +11,6 @@ name: Optimize Images entry: optimize-images language: python - types_or: [jpeg] - description: Resize and compress JPEG images to reduce repository size. + types_or: [jpeg, png] + description: Resize and compress JPEG and PNG images to reduce repository size. stages: [pre-commit, pre-merge-commit, pre-push, manual] diff --git a/README.md b/README.md index bf4d40a..6dc849f 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Two hooks for OpenSCAD-based 3D printing projects: | Hook | Description | |------|-------------| | `flatten-validate` | Runs `scadm flatten --all` and fails if flattened files have uncommitted changes | -| `optimize-images` | Resizes oversized JPEGs, compresses them, and strips EXIF data (privacy) | +| `optimize-images` | Resizes oversized JPEGs/PNGs, compresses them, and strips metadata (privacy) | ## 🔧 Usage @@ -44,7 +44,7 @@ Flattens OpenSCAD files via [scadm](https://pypi.org/project/scadm/) and validat ### `optimize-images` -Resizes JPEGs exceeding max width, compresses to target quality, and strips EXIF metadata (removes GPS coordinates from real-life photos). +Resizes JPEGs and PNGs exceeding max width, compresses them, and strips metadata. EXIF data (including GPS coordinates) is removed from JPEGs; text metadata chunks are removed from PNGs. ```yaml - id: optimize-images @@ -56,7 +56,7 @@ Resizes JPEGs exceeding max width, compresses to target quality, and strips EXIF | Arg | Default | Description | |-----|---------|-------------| | `--max-width` | `1920` | Maximum image width in pixels | -| `--quality` | `85` | JPEG compression quality (1-95) | +| `--quality` | `85` | JPEG compression quality (1-95, ignored for PNG) | Both hooks **fail when files are modified**, printing instructions to `git add` the changes. Re-run `git commit` after staging. diff --git a/src/pre_commit_hooks/optimize_images.py b/src/pre_commit_hooks/optimize_images.py index 8f0f3c2..50d9b8a 100644 --- a/src/pre_commit_hooks/optimize_images.py +++ b/src/pre_commit_hooks/optimize_images.py @@ -1,4 +1,4 @@ -"""Pre-commit hook: resize and compress JPEG images.""" +"""Pre-commit hook: resize and compress JPEG and PNG images.""" import argparse import shlex @@ -8,24 +8,32 @@ def _optimize_file(filepath, max_width, quality): - """Resize, compress, and strip EXIF from a single JPEG file. + """Resize, compress, and strip metadata from a single image file. + + Supports JPEG and PNG formats. JPEGs are compressed with the given quality + and have EXIF data stripped. PNGs are saved with Pillow's optimize flag + and have text metadata stripped. Args: - filepath: Path to the JPEG file. + filepath: Path to the image file. max_width: Maximum allowed width in pixels. - quality: JPEG compression quality (1-95). + quality: JPEG compression quality (1-95). Ignored for PNG. Returns: True if the file was modified, False otherwise. """ with Image.open(filepath) as img: - # Apply EXIF orientation before checking size + img_format = img.format # "JPEG" or "PNG" + if img_format not in ("JPEG", "PNG"): + msg = f"Unsupported format '{img_format}' for {filepath}" + raise ValueError(msg) + has_png_text = img_format == "PNG" and bool(getattr(img, "text", None)) img = ImageOps.exif_transpose(img) width, height = img.size needs_resize = width > max_width has_exif = bool(img.info.get("exif")) - if not needs_resize and not has_exif: + if not needs_resize and not has_exif and not has_png_text: return False if needs_resize: @@ -34,17 +42,28 @@ def _optimize_file(filepath, max_width, quality): new_size = (max_width, new_height) img = img.resize(new_size, Image.LANCZOS) - if img.mode in ("RGBA", "P"): - img = img.convert("RGB") - img.save(filepath, "JPEG", quality=quality, optimize=True) + if img_format == "PNG": + # Strip metadata by creating a clean image without materializing + # the pixel stream as a Python list. + clean = Image.new(img.mode, img.size) + if img.mode == "P": + palette = img.getpalette() + if palette is not None: + clean.putpalette(palette) + clean.frombytes(img.tobytes()) + clean.save(filepath, "PNG", optimize=True) + else: + if img.mode in ("RGBA", "P"): + img = img.convert("RGB") + img.save(filepath, "JPEG", quality=quality, optimize=True) return True def main(): """Entry point for the optimize-images pre-commit hook.""" - parser = argparse.ArgumentParser(description="Resize and compress JPEG images.") - parser.add_argument("filenames", nargs="*", help="JPEG files to check.") + parser = argparse.ArgumentParser(description="Resize and compress images.") + parser.add_argument("filenames", nargs="*", help="Image files to check.") parser.add_argument( "--max-width", type=int, diff --git a/tests/test_optimize_images.py b/tests/test_optimize_images.py index 4e73646..827c0e7 100644 --- a/tests/test_optimize_images.py +++ b/tests/test_optimize_images.py @@ -3,6 +3,7 @@ from unittest import mock from PIL import Image +from PIL.PngImagePlugin import PngInfo from pre_commit_hooks.optimize_images import main @@ -27,6 +28,23 @@ def _create_jpeg(path, width, height, exif=False): img.save(str(path), "JPEG", quality=95) +def _create_png(path, width, height, text_meta=False): + """Create a test PNG file. + + Args: + path: File path to write. + width: Image width in pixels. + height: Image height in pixels. + text_meta: If True, embed text metadata chunks. + """ + img = Image.new("RGBA", (width, height), color="blue") + pnginfo = PngInfo() + if text_meta: + pnginfo.add_text("Software", "TestSuite") + pnginfo.add_text("Comment", "test metadata") + img.save(str(path), "PNG", pnginfo=pnginfo) + + class TestOptimizeImages: """Tests for the optimize-images hook.""" @@ -130,3 +148,116 @@ def test_error_causes_failure(self, tmp_path): result = main() assert result == 1 + + # --- PNG tests --- + + def test_large_png_resized(self, tmp_path): + """PNGs wider than max-width are resized.""" + img_path = tmp_path / "large.png" + _create_png(img_path, 3000, 2000) + + with mock.patch("sys.argv", ["optimize-images", "--max-width=1920", str(img_path)]): + result = main() + + assert result == 1 + with Image.open(img_path) as img: + assert img.size[0] == 1920 + assert img.size[1] == 1280 + + def test_small_png_untouched(self, tmp_path): + """Small PNGs without metadata are not modified.""" + img_path = tmp_path / "small.png" + _create_png(img_path, 800, 600) + original_size = img_path.stat().st_size + + with mock.patch("sys.argv", ["optimize-images", "--max-width=1920", str(img_path)]): + result = main() + + assert result == 0 + assert img_path.stat().st_size == original_size + + def test_png_metadata_stripped(self, tmp_path): + """Text metadata is stripped from PNGs.""" + img_path = tmp_path / "meta.png" + _create_png(img_path, 800, 600, text_meta=True) + + with mock.patch("sys.argv", ["optimize-images", "--max-width=1920", str(img_path)]): + result = main() + + assert result == 1 + with Image.open(img_path) as img: + assert not getattr(img, "text", None) + + def test_png_preserves_transparency(self, tmp_path): + """PNGs retain their alpha channel after optimization.""" + img_path = tmp_path / "alpha.png" + img = Image.new("RGBA", (3000, 2000), color=(0, 0, 255, 128)) + img.save(str(img_path), "PNG") + + with mock.patch("sys.argv", ["optimize-images", "--max-width=1920", str(img_path)]): + result = main() + + assert result == 1 + with Image.open(img_path) as img: + assert img.mode == "RGBA" + assert img.size[0] == 1920 + + def test_mixed_jpeg_and_png(self, tmp_path): + """Both JPEG and PNG files are handled in a single run.""" + jpg_path = tmp_path / "big.jpg" + png_path = tmp_path / "big.png" + _create_jpeg(jpg_path, 4000, 3000) + _create_png(png_path, 4000, 3000) + + with mock.patch( + "sys.argv", + ["optimize-images", "--max-width=1920", str(jpg_path), str(png_path)], + ): + result = main() + + assert result == 1 + with Image.open(jpg_path) as img: + assert img.size[0] == 1920 + with Image.open(png_path) as img: + assert img.size[0] == 1920 + + def test_paletted_png_resized(self, tmp_path): + """Paletted PNGs are resized and palette is preserved.""" + img_path = tmp_path / "palette.png" + img = Image.new("P", (3000, 2000)) + img.putpalette([i % 256 for i in range(768)]) + img.save(str(img_path), "PNG") + + with mock.patch("sys.argv", ["optimize-images", "--max-width=1920", str(img_path)]): + result = main() + + assert result == 1 + with Image.open(img_path) as img: + assert img.mode == "P" + assert img.size[0] == 1920 + assert img.getpalette() is not None + + def test_paletted_png_with_transparency(self, tmp_path): + """Paletted PNGs with transparency are handled correctly.""" + img_path = tmp_path / "palette_alpha.png" + img = Image.new("RGBA", (3000, 2000), color=(255, 0, 0, 128)) + img = img.convert("P") + img.save(str(img_path), "PNG") + + with mock.patch("sys.argv", ["optimize-images", "--max-width=1920", str(img_path)]): + result = main() + + assert result == 1 + with Image.open(img_path) as img: + assert img.size[0] == 1920 + + def test_unsupported_format_raises(self, tmp_path): + """Unsupported image formats cause a failure.""" + img_path = tmp_path / "image.bmp" + img = Image.new("RGB", (800, 600), color="green") + img.save(str(img_path), "BMP") + + with mock.patch("sys.argv", ["optimize-images", str(img_path)]): + result = main() + + assert result == 1