Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand Down
41 changes: 30 additions & 11 deletions src/pre_commit_hooks/optimize_images.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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))
Comment thread
kellervater marked this conversation as resolved.
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:
Expand All @@ -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)
Comment thread
kellervater marked this conversation as resolved.
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,
Expand Down
131 changes: 131 additions & 0 deletions tests/test_optimize_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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."""

Expand Down Expand Up @@ -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."""
Comment thread
kellervater marked this conversation as resolved.
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
Loading