diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 97f2505..c7fdafa 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -287,3 +287,176 @@ jobs: with: name: slimg-kotlin-${{ env.VERSION }} path: bindings/kotlin/build/libs/*.jar + + # ───────────────────────────────────────────── + # Build Python wheels for all platforms + # ───────────────────────────────────────────── + build-python-wheels: + name: Build Python wheel (${{ matrix.target }}) + runs-on: ${{ matrix.runner }} + env: + SYSTEM_DEPS_DAV1D_BUILD_INTERNAL: always + strategy: + fail-fast: false + matrix: + include: + - { target: aarch64-apple-darwin, runner: macos-latest } + - { target: x86_64-apple-darwin, runner: macos-13 } + - { target: x86_64-unknown-linux-gnu, runner: ubuntu-latest } + - { target: aarch64-unknown-linux-gnu, runner: ubuntu-24.04-arm } + - { target: x86_64-pc-windows-msvc, runner: windows-latest } + steps: + - uses: actions/checkout@v4 + + - name: Install system dependencies (macOS) + if: runner.os == 'macOS' + run: brew install nasm meson ninja + + - name: Install system dependencies (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y nasm meson ninja-build + + - name: Setup MSVC environment (Windows) + if: runner.os == 'Windows' + uses: ilammy/msvc-dev-cmd@v1 + + - name: Add NASM to PATH (Windows) + if: runner.os == 'Windows' + shell: bash + run: echo "C:/Program Files/NASM" >> $GITHUB_PATH + + - name: Install build tools (Windows) + if: runner.os == 'Windows' + shell: bash + run: | + choco install nasm ninja pkgconfiglite -y + pip install meson + + - name: Force MSVC compiler for meson (Windows) + if: runner.os == 'Windows' + shell: bash + run: | + echo "CC=cl" >> $GITHUB_ENV + echo "CXX=cl" >> $GITHUB_ENV + + - name: Build wheel + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + args: --release --out dist + working-directory: bindings/python + + - uses: actions/upload-artifact@v4 + with: + name: python-wheels-${{ matrix.target }} + path: bindings/python/dist/*.whl + + # ───────────────────────────────────────────── + # Build Python source distribution + # ───────────────────────────────────────────── + build-python-sdist: + name: Build Python sdist + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist + working-directory: bindings/python + + - uses: actions/upload-artifact@v4 + with: + name: python-wheels-sdist + path: bindings/python/dist/*.tar.gz + + # ───────────────────────────────────────────── + # Verify Python version matches release tag + # ───────────────────────────────────────────── + verify-python-version: + name: Verify Python package version + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check version consistency + shell: bash + run: | + RELEASE_VERSION="${VERSION#v}" + PKG_VERSION=$(python3 -c " + import tomllib + with open('bindings/python/pyproject.toml', 'rb') as f: + print(tomllib.load(f)['project']['version']) + ") + if [ "$RELEASE_VERSION" != "$PKG_VERSION" ]; then + echo "::error::Version mismatch: release tag=$RELEASE_VERSION, pyproject.toml=$PKG_VERSION" + exit 1 + fi + echo "Version verified: $RELEASE_VERSION" + + # ───────────────────────────────────────────── + # Smoke test built Python wheels + # ───────────────────────────────────────────── + smoke-test-python: + name: Smoke test Python wheel (${{ matrix.os }}) + needs: [build-python-wheels] + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - uses: actions/download-artifact@v4 + with: + pattern: python-wheels-* + path: dist/ + merge-multiple: true + + - name: Install and smoke test + shell: bash + run: | + pip install dist/*.whl + python -c " + import slimg + print('Format:', slimg.Format.PNG) + print('Extension:', slimg.Format.PNG.extension) + print('Can encode:', slimg.Format.JPEG.can_encode) + # Create a small test image and round-trip it + data = bytes(4 * 4 * 4) # 4x4 RGBA black + img = slimg.Image(4, 4, data) + result = slimg.convert(img, 'png') + assert len(result.data) > 0, 'PNG encode failed' + print('Smoke test passed!') + " + + # ───────────────────────────────────────────── + # Publish Python package to PyPI + # ───────────────────────────────────────────── + publish-python: + name: Publish to PyPI + needs: [build-python-wheels, build-python-sdist, verify-python-version, smoke-test-python] + runs-on: ubuntu-latest + permissions: + id-token: write + environment: pypi + steps: + - uses: actions/download-artifact@v4 + with: + pattern: python-wheels-* + path: dist/ + merge-multiple: true + + - uses: actions/download-artifact@v4 + with: + name: python-wheels-sdist + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/ diff --git a/.github/workflows/python-bindings.yml b/.github/workflows/python-bindings.yml new file mode 100644 index 0000000..a620c62 --- /dev/null +++ b/.github/workflows/python-bindings.yml @@ -0,0 +1,84 @@ +name: Python Bindings + +on: + push: + branches: [main] + paths: + - 'crates/slimg-ffi/**' + - 'crates/slimg-core/**' + - 'bindings/python/**' + - '.github/workflows/python-bindings.yml' + pull_request: + paths: + - 'crates/slimg-ffi/**' + - 'crates/slimg-core/**' + - 'bindings/python/**' + - '.github/workflows/python-bindings.yml' + +jobs: + build-and-test: + runs-on: ${{ matrix.os }} + env: + SYSTEM_DEPS_DAV1D_BUILD_INTERNAL: always + strategy: + matrix: + os: [macos-latest, ubuntu-latest, windows-latest] + python-version: ['3.9', '3.13'] + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install system dependencies (macOS) + if: runner.os == 'macOS' + run: brew install nasm meson ninja + + - name: Install system dependencies (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y nasm meson ninja-build + + - name: Setup MSVC environment (Windows) + if: runner.os == 'Windows' + uses: ilammy/msvc-dev-cmd@v1 + + - name: Add NASM to PATH (Windows) + if: runner.os == 'Windows' + shell: bash + run: echo "C:/Program Files/NASM" >> $GITHUB_PATH + + - name: Install build tools (Windows) + if: runner.os == 'Windows' + shell: bash + run: | + choco install nasm ninja pkgconfiglite -y + pip install meson + + - name: Force MSVC compiler for meson (Windows) + if: runner.os == 'Windows' + shell: bash + run: | + echo "CC=cl" >> $GITHUB_ENV + echo "CXX=cl" >> $GITHUB_ENV + + - name: Install maturin and pytest + run: pip install maturin pytest + + - name: Build wheel + working-directory: bindings/python + run: maturin build --out dist + + - name: Install wheel + working-directory: bindings/python + shell: bash + run: pip install dist/*.whl + + - name: Run tests + working-directory: bindings/python + run: pytest tests/ -v diff --git a/Cargo.lock b/Cargo.lock index 457d942..a8f3a05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1777,7 +1777,7 @@ checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "slimg" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "clap", @@ -1790,7 +1790,7 @@ dependencies = [ [[package]] name = "slimg-core" -version = "0.3.0" +version = "0.3.1" dependencies = [ "criterion", "image", @@ -1806,7 +1806,7 @@ dependencies = [ [[package]] name = "slimg-ffi" -version = "0.3.0" +version = "0.3.1" dependencies = [ "slimg-core", "thiserror", diff --git a/bindings/kotlin/README.md b/bindings/kotlin/README.md index 768895a..d5d4dcc 100644 --- a/bindings/kotlin/README.md +++ b/bindings/kotlin/README.md @@ -10,7 +10,7 @@ Supports macOS (Apple Silicon, Intel), Linux (x86_64, ARM64), and Windows (x86_6 ```kotlin dependencies { - implementation("io.clroot.slimg:slimg-kotlin:0.1.2") + implementation("io.clroot.slimg:slimg-kotlin:0.3.1") } ``` @@ -18,7 +18,7 @@ dependencies { ```groovy dependencies { - implementation 'io.clroot.slimg:slimg-kotlin:0.1.2' + implementation 'io.clroot.slimg:slimg-kotlin:0.3.1' } ``` @@ -28,7 +28,7 @@ dependencies { io.clroot.slimg slimg-kotlin - 0.1.2 + 0.3.1 ``` @@ -71,6 +71,9 @@ val resized = convert(result.image, PipelineOptions( | `decode(data: ByteArray)` | Decode image from bytes | | `decodeFile(path: String)` | Decode image from file path | | `convert(image, options)` | Convert image to a different format | +| `crop(image, mode)` | Crop an image by region or aspect ratio | +| `extend(image, mode, fill)` | Extend (pad) image canvas | +| `resize(image, mode)` | Resize an image | | `optimize(data: ByteArray, quality: UByte)` | Re-encode to reduce file size | | `outputPath(input, format, output?)` | Generate output file path | | `formatExtension(format)` | Get file extension for a format | @@ -84,11 +87,14 @@ val resized = convert(result.image, PipelineOptions( |------|-------------| | `Format` | `JPEG`, `PNG`, `WEB_P`, `AVIF`, `JXL`, `QOI` | | `ResizeMode` | `Width`, `Height`, `Exact`, `Fit`, `Scale` | -| `PipelineOptions` | `format`, `quality`, `resize` | +| `CropMode` | `Region`, `AspectRatio` | +| `ExtendMode` | `AspectRatio`, `Size` | +| `FillColor` | `Transparent`, `Solid(r, g, b, a)` | +| `PipelineOptions` | `format`, `quality`, `resize`, `crop`, `extend`, `fillColor` | | `PipelineResult` | `data` (ByteArray), `format` | | `DecodeResult` | `image` (ImageData), `format` | | `ImageData` | `width`, `height`, `data` (raw pixels) | -| `SlimgException` | Error with subclasses: `Decode`, `Encode`, `Resize`, `Io`, `Image` | +| `SlimgException` | Error with subclasses: `UnsupportedFormat`, `UnknownFormat`, `EncodingNotSupported`, `Decode`, `Encode`, `Resize`, `Crop`, `Extend`, `Io`, `Image` | ## Supported Platforms diff --git a/bindings/kotlin/gradle.properties b/bindings/kotlin/gradle.properties index 60b1735..45471e0 100644 --- a/bindings/kotlin/gradle.properties +++ b/bindings/kotlin/gradle.properties @@ -1,3 +1,3 @@ group=io.clroot.slimg -version=0.3.0 +version=0.3.1 kotlin.code.style=official diff --git a/bindings/kotlin/src/main/kotlin/io/clroot/slimg/slimg_ffi.kt b/bindings/kotlin/src/main/kotlin/io/clroot/slimg/slimg_ffi.kt index ccc97b5..eb24ab9 100644 --- a/bindings/kotlin/src/main/kotlin/io/clroot/slimg/slimg_ffi.kt +++ b/bindings/kotlin/src/main/kotlin/io/clroot/slimg/slimg_ffi.kt @@ -657,6 +657,8 @@ internal object IntegrityCheckingUniffiLib { ): Short external fun uniffi_slimg_ffi_checksum_func_output_path( ): Short + external fun uniffi_slimg_ffi_checksum_func_resize( + ): Short external fun ffi_slimg_ffi_uniffi_contract_version( ): Int @@ -692,6 +694,8 @@ internal object UniffiLib { ): RustBuffer.ByValue external fun uniffi_slimg_ffi_fn_func_output_path(`input`: RustBuffer.ByValue,`format`: RustBuffer.ByValue,`output`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue + external fun uniffi_slimg_ffi_fn_func_resize(`image`: RustBuffer.ByValue,`mode`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue external fun ffi_slimg_ffi_rustbuffer_alloc(`size`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun ffi_slimg_ffi_rustbuffer_from_bytes(`bytes`: ForeignBytes.ByValue,uniffi_out_err: UniffiRustCallStatus, @@ -844,6 +848,9 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_slimg_ffi_checksum_func_output_path() != 60410.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_slimg_ffi_checksum_func_resize() != 48866.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } } /** @@ -2442,4 +2449,18 @@ public object FfiConverterOptionalTypeResizeMode: FfiConverterRustBuffer + UniffiLib.uniffi_slimg_ffi_fn_func_resize( + + FfiConverterTypeImageData.lower(`image`),FfiConverterTypeResizeMode.lower(`mode`),_status) +} + ) + } + + diff --git a/bindings/kotlin/src/test/kotlin/io/clroot/slimg/SlimgTest.kt b/bindings/kotlin/src/test/kotlin/io/clroot/slimg/SlimgTest.kt index 89d7b4e..4257326 100644 --- a/bindings/kotlin/src/test/kotlin/io/clroot/slimg/SlimgTest.kt +++ b/bindings/kotlin/src/test/kotlin/io/clroot/slimg/SlimgTest.kt @@ -234,6 +234,55 @@ class SlimgTest { } } + // ── Resize Tests ──────────────────────────────────── + + @Test + fun `resize by width preserves aspect ratio`() { + val image = createTestImage(10u, 8u) + val result = resize(image, ResizeMode.Width(5u)) + assertEquals(5u, result.width) + assertEquals(4u, result.height) // 10:8 → 5:4 + } + + @Test + fun `resize by height preserves aspect ratio`() { + val image = createTestImage(10u, 8u) + val result = resize(image, ResizeMode.Height(4u)) + assertEquals(5u, result.width) + assertEquals(4u, result.height) + } + + @Test + fun `resize exact allows non-proportional`() { + val image = createTestImage(10u, 8u) + val result = resize(image, ResizeMode.Exact(20u, 20u)) + assertEquals(20u, result.width) + assertEquals(20u, result.height) + } + + @Test + fun `resize fit stays within bounds`() { + val image = createTestImage(10u, 8u) + val result = resize(image, ResizeMode.Fit(5u, 5u)) + assertTrue(result.width <= 5u) + assertTrue(result.height <= 5u) + } + + @Test + fun `resize scale doubles dimensions`() { + val image = createTestImage(10u, 8u) + val result = resize(image, ResizeMode.Scale(2.0)) + assertEquals(20u, result.width) + assertEquals(16u, result.height) + } + + @Test + fun `resize produces valid RGBA data`() { + val image = createTestImage(10u, 8u) + val result = resize(image, ResizeMode.Width(5u)) + assertEquals(result.width.toInt() * result.height.toInt() * 4, result.data.size) + } + // ── Convert (Pipeline) Tests ──────────────────────── @Test diff --git a/bindings/python/.gitignore b/bindings/python/.gitignore new file mode 100644 index 0000000..30e2150 --- /dev/null +++ b/bindings/python/.gitignore @@ -0,0 +1,9 @@ +.venv/ +__pycache__/ +*.pyc +*.so +*.dylib +*.pyd + +# maturin develop installs the native module here +slimg/_lowlevel/ diff --git a/bindings/python/README.md b/bindings/python/README.md new file mode 100644 index 0000000..1b595c0 --- /dev/null +++ b/bindings/python/README.md @@ -0,0 +1,102 @@ +# slimg + +Python bindings for the [slimg](https://github.com/clroot/slimg) image optimization library. + +Supports macOS (Apple Silicon, Intel), Linux (x86_64, ARM64), and Windows (x86_64) -- native extensions are bundled in pre-built wheels. + +## Installation + +``` +pip install slimg +``` + +## Usage + +```python +import slimg + +# Open an image file +image = slimg.open("photo.jpg") +print(f"{image.width}x{image.height} {image.format}") + +# Convert to WebP +result = slimg.convert(image, format="webp", quality=80) +result.save("photo.webp") + +# Optimize in the same format +result = slimg.optimize_file("photo.jpg", quality=75) +result.save("optimized.jpg") + +# Resize by width (preserves aspect ratio) +resized = slimg.resize(image, width=800) +result = slimg.convert(resized, format="png") +result.save("thumbnail.png") + +# Crop to aspect ratio (centre-anchored) +cropped = slimg.crop(image, aspect_ratio=(16, 9)) + +# Crop by pixel region +cropped = slimg.crop(image, region=(100, 50, 800, 600)) + +# Extend (pad) to aspect ratio with a fill colour +extended = slimg.extend(image, aspect_ratio=(1, 1), fill=(255, 255, 255)) + +# Extend with transparent padding (default) +extended = slimg.extend(image, aspect_ratio=(1, 1)) +``` + +## Supported Formats + +| Format | Decode | Encode | Notes | +|----------|--------|--------|-------| +| JPEG | Yes | Yes | MozJPEG encoder | +| PNG | Yes | Yes | OxiPNG + Zopfli compression | +| WebP | Yes | Yes | Lossy encoding via libwebp | +| AVIF | Yes | Yes | ravif encoder; dav1d decoder | +| QOI | Yes | Yes | Lossless, fast encode/decode | +| JPEG XL | Yes | No | Decode-only | + +## API Reference + +### Functions + +| Function | Description | +|----------|-------------| +| `open(path)` | Decode an image file from disk | +| `decode(data)` | Decode image bytes (auto-detects format) | +| `convert(image, format, quality=80)` | Encode image in a target format | +| `resize(image, *, width/height/exact/fit/scale)` | Resize an image | +| `crop(image, *, region/aspect_ratio)` | Crop an image | +| `extend(image, *, aspect_ratio/size, fill)` | Pad an image canvas | +| `optimize(data, quality=80)` | Re-encode bytes to reduce file size | +| `optimize_file(path, quality=80)` | Read a file and re-encode | + +### Types + +| Type | Description | +|------|-------------| +| `Format` | `JPEG`, `PNG`, `WEBP`, `AVIF`, `JXL`, `QOI` | +| `Image` | Decoded image with `width`, `height`, `data`, `format` | +| `Result` | Encoded output with `data`, `format`, and `save(path)` | +| `Resize` | Factory: `width`, `height`, `exact`, `fit`, `scale` | +| `Crop` | Factory: `region`, `aspect_ratio` | +| `Extend` | Factory: `aspect_ratio`, `size` | +| `SlimgError` | Error with subclasses: `UnsupportedFormat`, `UnknownFormat`, `EncodingNotSupported`, `Decode`, `Encode`, `Resize`, `Crop`, `Extend`, `Io`, `Image` | + +## Supported Platforms + +| Platform | Architecture | Status | +|----------|-------------|--------| +| macOS | Apple Silicon (aarch64) | Supported | +| macOS | Intel (x86_64) | Supported | +| Linux | x86_64 | Supported | +| Linux | ARM64 (aarch64) | Supported | +| Windows | x86_64 | Supported | + +## Requirements + +- Python 3.9+ + +## License + +MIT OR Apache-2.0 diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml new file mode 100644 index 0000000..f881480 --- /dev/null +++ b/bindings/python/pyproject.toml @@ -0,0 +1,38 @@ +[build-system] +requires = ["maturin>=1.0,<2.0"] +build-backend = "maturin" + +[project] +name = "slimg" +version = "0.3.1" +requires-python = ">=3.9" +description = "Fast image optimization library powered by Rust" +readme = "README.md" +license = { text = "MIT OR Apache-2.0" } +keywords = ["image", "optimization", "compression", "webp", "avif", "jpeg", "png"] +classifiers = [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: MIT License", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Multimedia :: Graphics", + "Topic :: Multimedia :: Graphics :: Graphics Conversion", +] + +[project.urls] +Homepage = "https://github.com/clroot/slimg" +Repository = "https://github.com/clroot/slimg" +Issues = "https://github.com/clroot/slimg/issues" + +[tool.maturin] +manifest-path = "../../crates/slimg-ffi/Cargo.toml" +bindings = "uniffi" +python-source = "." +module-name = "slimg._lowlevel" diff --git a/bindings/python/slimg/__init__.py b/bindings/python/slimg/__init__.py new file mode 100644 index 0000000..098ddc1 --- /dev/null +++ b/bindings/python/slimg/__init__.py @@ -0,0 +1,37 @@ +"""slimg - Fast image optimization library powered by Rust.""" + +from slimg._types import ( + Format, + Image, + Result, + Resize, + Crop, + Extend, + SlimgError, + open, + decode, + convert, + crop_image as crop, + extend_image as extend, + resize_image as resize, + optimize, + optimize_file, +) + +__all__ = [ + "Format", + "Image", + "Result", + "Resize", + "Crop", + "Extend", + "SlimgError", + "open", + "decode", + "convert", + "crop", + "extend", + "resize", + "optimize", + "optimize_file", +] diff --git a/bindings/python/slimg/_types.py b/bindings/python/slimg/_types.py new file mode 100644 index 0000000..8f2c958 --- /dev/null +++ b/bindings/python/slimg/_types.py @@ -0,0 +1,498 @@ +"""Pythonic wrapper types and functions for slimg. + +This module imports from the UniFFI-generated ``_lowlevel`` package and +re-exports a cleaner, more Pythonic public API. +""" + +from __future__ import annotations + +import builtins +from enum import Enum +from typing import Optional, Tuple, Union + +from slimg import _lowlevel + +# Keep a reference to Python's built-in ``open`` since this module +# defines its own ``open`` function that shadows the builtin. +_builtin_open = builtins.open + +# --------------------------------------------------------------------------- +# Error re-export +# --------------------------------------------------------------------------- + +# ``_lowlevel.SlimgError`` is the *namespace* class generated by UniFFI. +# The actual base exception class that all error variants inherit from is +# stored as ``_UniffiTempSlimgError`` internally. However, the namespace +# class is what users import to access subclasses like +# ``SlimgError.Decode``, etc. Fortunately the lowlevel module re-attaches +# the subclasses onto the temp class, so ``isinstance(e, SlimgError)`` +# still works when we expose the temp (base) class. +# +# We expose *both*: the base exception (for isinstance checks / catching) +# and the namespace (for accessing specific variant subclasses). Since the +# lowlevel module sets up the aliases, exposing the namespace class is +# sufficient -- ``except SlimgError:`` works because the variants inherit +# from _UniffiTempSlimgError which IS the SlimgError namespace before it +# gets overwritten. In practice, the lowlevel wildcard import already set +# this up correctly. We just need the reference. +SlimgError = _lowlevel.SlimgError # noqa: N816 + + +# --------------------------------------------------------------------------- +# Format enum +# --------------------------------------------------------------------------- + +class Format(Enum): + """Image format enumeration with utility methods.""" + + JPEG = "jpeg" + PNG = "png" + WEBP = "webp" + AVIF = "avif" + JXL = "jxl" + QOI = "qoi" + + @property + def extension(self) -> str: + """Canonical file extension (e.g. ``'jpg'`` for JPEG).""" + return _lowlevel.format_extension(self._to_lowlevel()) + + @property + def can_encode(self) -> bool: + """Whether slimg can encode images in this format.""" + return _lowlevel.format_can_encode(self._to_lowlevel()) + + @classmethod + def from_path(cls, path: str) -> Optional[Format]: + """Detect format from a file path's extension. + + Returns ``None`` if the extension is unrecognised. + """ + result = _lowlevel.format_from_extension(path) + return cls._from_lowlevel(result) if result is not None else None + + @classmethod + def from_bytes(cls, data: bytes) -> Optional[Format]: + """Detect format from the magic bytes at the start of *data*. + + Returns ``None`` if the bytes don't match a known format. + """ + result = _lowlevel.format_from_magic_bytes(data) + return cls._from_lowlevel(result) if result is not None else None + + # -- internal helpers ---------------------------------------------------- + + def _to_lowlevel(self) -> _lowlevel.Format: + return _FORMAT_TO_LL[self] + + @classmethod + def _from_lowlevel(cls, ll_fmt: _lowlevel.Format) -> Format: + return _FORMAT_FROM_LL[ll_fmt] + + @classmethod + def _resolve(cls, fmt: Union[Format, str]) -> Format: + """Accept a ``Format`` enum member or a string and return ``Format``. + + Strings are matched case-insensitively; ``'jpg'`` is accepted as an + alias for JPEG. + """ + if isinstance(fmt, cls): + return fmt + if isinstance(fmt, str): + normalised = fmt.lower().strip() + name_map = { + "jpeg": cls.JPEG, + "jpg": cls.JPEG, + "png": cls.PNG, + "webp": cls.WEBP, + "avif": cls.AVIF, + "jxl": cls.JXL, + "qoi": cls.QOI, + } + result = name_map.get(normalised) + if result is None: + raise ValueError(f"Unknown format: {fmt}") + return result + raise TypeError(f"Expected Format or str, got {type(fmt)}") + + +# Bidirectional mapping between the Pythonic enum and the lowlevel enum. +_FORMAT_TO_LL = { + Format.JPEG: _lowlevel.Format.JPEG, + Format.PNG: _lowlevel.Format.PNG, + Format.WEBP: _lowlevel.Format.WEB_P, + Format.AVIF: _lowlevel.Format.AVIF, + Format.JXL: _lowlevel.Format.JXL, + Format.QOI: _lowlevel.Format.QOI, +} +_FORMAT_FROM_LL = {v: k for k, v in _FORMAT_TO_LL.items()} + + +# --------------------------------------------------------------------------- +# Image +# --------------------------------------------------------------------------- + +class Image: + """Decoded image with RGBA pixel data.""" + + def __init__( + self, + width: int, + height: int, + data: bytes, + format: Optional[Format] = None, + ): + expected = width * height * 4 + if len(data) != expected: + raise ValueError( + f"Buffer size mismatch: expected {expected} bytes " + f"({width}x{height} RGBA), got {len(data)}" + ) + self._width = width + self._height = height + self._data = data + self._format = format + + @property + def width(self) -> int: + return self._width + + @property + def height(self) -> int: + return self._height + + @property + def data(self) -> bytes: + return self._data + + @property + def format(self) -> Optional[Format]: + return self._format + + # -- internal helpers ---------------------------------------------------- + + def _to_lowlevel(self) -> _lowlevel.ImageData: + return _lowlevel.ImageData( + width=self._width, height=self._height, data=self._data, + ) + + @classmethod + def _from_lowlevel( + cls, img: _lowlevel.ImageData, fmt: Optional[Format] = None, + ) -> Image: + return cls(width=img.width, height=img.height, data=img.data, format=fmt) + + @classmethod + def _from_raw(cls, width: int, height: int, data: bytes) -> Image: + """Create an Image from raw RGBA bytes (used by test fixtures).""" + return cls(width=width, height=height, data=data) + + +# --------------------------------------------------------------------------- +# Result +# --------------------------------------------------------------------------- + +class Result: + """Result of an encoding / optimisation operation.""" + + def __init__(self, data: bytes, format: Format): + self._data = data + self._format = format + + @property + def data(self) -> bytes: + return self._data + + @property + def format(self) -> Format: + return self._format + + def save(self, path: str) -> None: + """Write the encoded bytes to *path*.""" + with _builtin_open(path, "wb") as f: + f.write(self._data) + + +# --------------------------------------------------------------------------- +# Namespace helpers — Resize, Crop, Extend +# --------------------------------------------------------------------------- + +class Resize: + """Factory for lowlevel ``ResizeMode`` variants.""" + + @staticmethod + def width(value: int): + """Resize to *value* pixels wide, preserving aspect ratio.""" + return _lowlevel.ResizeMode.WIDTH(value=value) + + @staticmethod + def height(value: int): + """Resize to *value* pixels tall, preserving aspect ratio.""" + return _lowlevel.ResizeMode.HEIGHT(value=value) + + @staticmethod + def exact(width: int, height: int): + """Resize to exact *width* x *height* (may distort).""" + return _lowlevel.ResizeMode.EXACT(width=width, height=height) + + @staticmethod + def fit(max_width: int, max_height: int): + """Fit within *max_width* x *max_height*, preserving aspect ratio.""" + return _lowlevel.ResizeMode.FIT(max_width=max_width, max_height=max_height) + + @staticmethod + def scale(factor: float): + """Scale by *factor* (e.g. 0.5 = half size, 2.0 = double).""" + return _lowlevel.ResizeMode.SCALE(factor=factor) + + +class Crop: + """Factory for lowlevel ``CropMode`` variants.""" + + @staticmethod + def region(x: int, y: int, width: int, height: int): + """Extract a specific pixel region.""" + return _lowlevel.CropMode.REGION(x=x, y=y, width=width, height=height) + + @staticmethod + def aspect_ratio(width: int, height: int): + """Centre-crop to the given aspect ratio.""" + return _lowlevel.CropMode.ASPECT_RATIO(width=width, height=height) + + +class Extend: + """Factory for lowlevel ``ExtendMode`` variants.""" + + @staticmethod + def aspect_ratio(width: int, height: int): + """Extend (pad) canvas to fit the given aspect ratio.""" + return _lowlevel.ExtendMode.ASPECT_RATIO(width=width, height=height) + + @staticmethod + def size(width: int, height: int): + """Extend (pad) canvas to an exact pixel *width* x *height*.""" + return _lowlevel.ExtendMode.SIZE(width=width, height=height) + + +# --------------------------------------------------------------------------- +# Fill colour helper +# --------------------------------------------------------------------------- + +def _validate_channel(value: int, name: str) -> int: + """Ensure a colour channel value is in 0-255.""" + if not isinstance(value, int) or not (0 <= value <= 255): + raise ValueError(f"{name} must be an integer in 0-255, got {value!r}") + return value + + +def _validate_quality(quality: int) -> int: + """Ensure quality is in 0-100.""" + if not isinstance(quality, int) or not (0 <= quality <= 100): + raise ValueError(f"quality must be an integer in 0-100, got {quality!r}") + return quality + + +def _resolve_fill( + fill: Union[None, str, Tuple[int, int, int], Tuple[int, int, int, int]], +) -> _lowlevel.FillColor: + """Convert a user-friendly fill specification to a lowlevel ``FillColor``.""" + if fill is None or fill == "transparent": + return _lowlevel.FillColor.TRANSPARENT() + if isinstance(fill, tuple): + if len(fill) == 3: + r, g, b = fill + _validate_channel(r, "r") + _validate_channel(g, "g") + _validate_channel(b, "b") + return _lowlevel.FillColor.SOLID(r=r, g=g, b=b, a=255) + if len(fill) == 4: + r, g, b, a = fill + _validate_channel(r, "r") + _validate_channel(g, "g") + _validate_channel(b, "b") + _validate_channel(a, "a") + return _lowlevel.FillColor.SOLID(r=r, g=g, b=b, a=a) + raise ValueError( + f"Invalid fill: {fill!r}. " + "Use 'transparent', (r, g, b), or (r, g, b, a)." + ) + + +# --------------------------------------------------------------------------- +# Public functions +# --------------------------------------------------------------------------- + +def open(path: str) -> Image: + """Read an image file from disk and decode it. + + This shadows Python's built-in ``open``; the built-in is still + available via ``builtins.open``. + """ + result = _lowlevel.decode_file(path) + fmt = Format._from_lowlevel(result.format) + return Image._from_lowlevel(result.image, fmt) + + +def decode(data: bytes) -> Image: + """Decode image bytes (format is auto-detected from magic bytes).""" + result = _lowlevel.decode(data) + fmt = Format._from_lowlevel(result.format) + return Image._from_lowlevel(result.image, fmt) + + +def convert( + image: Image, + format: Union[Format, str], + quality: int = 80, + *, + resize=None, + crop=None, + extend=None, + fill=None, +) -> Result: + """Encode *image* in the target *format*, optionally applying + crop / extend / resize in a single pipeline. + + *format* may be a ``Format`` enum member or a string such as + ``'png'``, ``'webp'``, ``'jpg'``, etc. + + *resize*, *crop*, *extend* accept values returned by the + ``Resize``, ``Crop``, ``Extend`` helper classes respectively. + + *fill* accepts ``'transparent'``, ``(r, g, b)``, or + ``(r, g, b, a)``. Defaults to transparent when *extend* is set. + """ + _validate_quality(quality) + fmt = Format._resolve(format) + fill_color = None + if fill is not None or extend is not None: + fill_color = _resolve_fill(fill) + + opts = _lowlevel.PipelineOptions( + format=fmt._to_lowlevel(), + quality=quality, + resize=resize, + crop=crop, + extend=extend, + fill_color=fill_color, + ) + result = _lowlevel.convert(image._to_lowlevel(), opts) + return Result(data=result.data, format=Format._from_lowlevel(result.format)) + + +def crop_image( + image: Image, + *, + region: Optional[Tuple[int, int, int, int]] = None, + aspect_ratio: Optional[Tuple[int, int]] = None, +) -> Image: + """Crop *image*. + + Provide exactly one of: + - ``region=(x, y, width, height)`` + - ``aspect_ratio=(width, height)`` + """ + if region is not None and aspect_ratio is not None: + raise ValueError("Specify either region or aspect_ratio, not both") + if region is not None: + x, y, w, h = region + mode = _lowlevel.CropMode.REGION(x=x, y=y, width=w, height=h) + elif aspect_ratio is not None: + w, h = aspect_ratio + mode = _lowlevel.CropMode.ASPECT_RATIO(width=w, height=h) + else: + raise ValueError("Specify region or aspect_ratio") + result = _lowlevel.crop(image._to_lowlevel(), mode) + return Image._from_lowlevel(result, image.format) + + +def extend_image( + image: Image, + *, + aspect_ratio: Optional[Tuple[int, int]] = None, + size: Optional[Tuple[int, int]] = None, + fill: Union[str, Tuple[int, int, int], Tuple[int, int, int, int]] = "transparent", +) -> Image: + """Extend (pad) *image*. + + Provide exactly one of: + - ``aspect_ratio=(width, height)`` + - ``size=(width, height)`` + """ + if aspect_ratio is not None and size is not None: + raise ValueError("Specify either aspect_ratio or size, not both") + if aspect_ratio is not None: + w, h = aspect_ratio + mode = _lowlevel.ExtendMode.ASPECT_RATIO(width=w, height=h) + elif size is not None: + w, h = size + mode = _lowlevel.ExtendMode.SIZE(width=w, height=h) + else: + raise ValueError("Specify aspect_ratio or size") + fill_color = _resolve_fill(fill) + result = _lowlevel.extend(image._to_lowlevel(), mode, fill_color) + return Image._from_lowlevel(result, image.format) + + +def resize_image( + image: Image, + *, + width: Optional[int] = None, + height: Optional[int] = None, + exact: Optional[Tuple[int, int]] = None, + fit: Optional[Tuple[int, int]] = None, + scale: Optional[float] = None, +) -> Image: + """Resize *image*. + + Provide exactly one keyword argument to specify the resize mode. + """ + modes = [ + (k, v) + for k, v in [ + ("width", width), + ("height", height), + ("exact", exact), + ("fit", fit), + ("scale", scale), + ] + if v is not None + ] + if len(modes) != 1: + raise ValueError("Specify exactly one of: width, height, exact, fit, scale") + + name, value = modes[0] + if name == "width": + resize_mode = _lowlevel.ResizeMode.WIDTH(value=value) + elif name == "height": + resize_mode = _lowlevel.ResizeMode.HEIGHT(value=value) + elif name == "exact": + resize_mode = _lowlevel.ResizeMode.EXACT(width=value[0], height=value[1]) + elif name == "fit": + resize_mode = _lowlevel.ResizeMode.FIT(max_width=value[0], max_height=value[1]) + elif name == "scale": + resize_mode = _lowlevel.ResizeMode.SCALE(factor=value) + + result = _lowlevel.resize(image._to_lowlevel(), resize_mode) + return Image._from_lowlevel(result, image.format) + + +def optimize(data: bytes, quality: int = 80) -> Result: + """Re-encode *data* at the given *quality* in the same format.""" + _validate_quality(quality) + result = _lowlevel.optimize(data, quality) + return Result(data=result.data, format=Format._from_lowlevel(result.format)) + + +def optimize_file(path: str, quality: int = 80) -> Result: + """Read a file from *path* and re-encode at the given *quality*. + + Raises ``SlimgError`` if the file cannot be read or optimised. + """ + _validate_quality(quality) + try: + with _builtin_open(path, "rb") as f: + data = f.read() + except OSError as exc: + raise _lowlevel.SlimgError.Io(str(exc)) from exc + return optimize(data, quality) diff --git a/bindings/python/slimg/py.typed b/bindings/python/slimg/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/bindings/python/tests/conftest.py b/bindings/python/tests/conftest.py new file mode 100644 index 0000000..8b0560a --- /dev/null +++ b/bindings/python/tests/conftest.py @@ -0,0 +1,34 @@ +import pytest +import slimg + + +def create_test_image(width: int, height: int) -> slimg.Image: + """Create a test RGBA image. R=row, G=col, B=0xFF, A=0xFF.""" + data = bytearray(width * height * 4) + for row in range(height): + for col in range(width): + offset = (row * width + col) * 4 + data[offset] = row & 0xFF + data[offset + 1] = col & 0xFF + data[offset + 2] = 0xFF + data[offset + 3] = 0xFF + return slimg.Image(width=width, height=height, data=bytes(data)) + + +def pixel_at(image: slimg.Image, col: int, row: int) -> tuple: + """Get RGBA pixel value at (col, row).""" + offset = (row * image.width + col) * 4 + d = image.data + return (d[offset], d[offset + 1], d[offset + 2], d[offset + 3]) + + +@pytest.fixture +def sample_image(): + """10x8 test image.""" + return create_test_image(10, 8) + + +@pytest.fixture +def sample_image_100(): + """100x100 test image.""" + return create_test_image(100, 100) diff --git a/bindings/python/tests/test_convert.py b/bindings/python/tests/test_convert.py new file mode 100644 index 0000000..5d0c09a --- /dev/null +++ b/bindings/python/tests/test_convert.py @@ -0,0 +1,106 @@ +import os +import tempfile + +import pytest +import slimg +from conftest import create_test_image + + +class TestConvert: + def test_to_png(self, sample_image): + result = slimg.convert(sample_image, format="png", quality=80) + assert result.format == slimg.Format.PNG + assert len(result.data) > 0 + # PNG magic bytes + assert result.data[0] == 0x89 + assert result.data[1] == 0x50 + + def test_to_webp(self, sample_image): + result = slimg.convert(sample_image, format="webp", quality=75) + assert result.format == slimg.Format.WEBP + # RIFF magic + assert result.data[:4] == b"RIFF" + + def test_to_jpeg(self, sample_image): + result = slimg.convert(sample_image, format="jpeg", quality=80) + assert result.format == slimg.Format.JPEG + # JPEG magic bytes + assert result.data[0] == 0xFF + assert result.data[1] == 0xD8 + + def test_with_resize(self, sample_image): + result = slimg.convert( + sample_image, + format="png", + quality=80, + resize=slimg.Resize.width(5), + ) + decoded = slimg.decode(result.data) + assert decoded.width == 5 + assert decoded.height == 4 # aspect ratio preserved (10x8 -> 5x4) + + def test_format_string_case_insensitive(self, sample_image): + result = slimg.convert(sample_image, format="PNG", quality=80) + assert result.format == slimg.Format.PNG + + def test_format_enum_accepted(self, sample_image): + result = slimg.convert(sample_image, format=slimg.Format.PNG, quality=80) + assert result.format == slimg.Format.PNG + + def test_jxl_encode_raises(self, sample_image): + with pytest.raises(slimg.SlimgError): + slimg.convert(sample_image, format="jxl", quality=80) + + def test_full_pipeline_crop_extend(self, sample_image_100): + result = slimg.convert( + sample_image_100, + format="png", + quality=80, + crop=slimg.Crop.aspect_ratio(16, 9), + extend=slimg.Extend.aspect_ratio(1, 1), + fill=(255, 255, 255), + ) + decoded = slimg.decode(result.data) + assert decoded.width == decoded.height # square after extend + + +class TestConvertValidation: + def test_quality_too_high(self, sample_image): + with pytest.raises(ValueError, match="quality"): + slimg.convert(sample_image, format="png", quality=101) + + def test_quality_negative(self, sample_image): + with pytest.raises(ValueError, match="quality"): + slimg.convert(sample_image, format="png", quality=-1) + + def test_quality_not_int(self, sample_image): + with pytest.raises(ValueError, match="quality"): + slimg.convert(sample_image, format="png", quality=80.5) + + def test_unknown_format_string(self, sample_image): + with pytest.raises(ValueError, match="Unknown format"): + slimg.convert(sample_image, format="bmp", quality=80) + + +class TestResultSave: + def test_save_to_file(self, sample_image): + result = slimg.convert(sample_image, format="png", quality=80) + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: + path = f.name + try: + result.save(path) + assert os.path.getsize(path) == len(result.data) + finally: + os.unlink(path) + + def test_save_reads_back(self, sample_image): + result = slimg.convert(sample_image, format="png", quality=80) + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: + path = f.name + try: + result.save(path) + with open(path, "rb") as f: + saved = f.read() + assert saved == result.data + finally: + os.unlink(path) diff --git a/bindings/python/tests/test_decode.py b/bindings/python/tests/test_decode.py new file mode 100644 index 0000000..03d8fbb --- /dev/null +++ b/bindings/python/tests/test_decode.py @@ -0,0 +1,71 @@ +import pytest +import slimg +from conftest import create_test_image + + +class TestDecode: + def test_decode_png_bytes(self, sample_image): + # Encode to PNG first, then decode + result = slimg.convert(sample_image, format="png", quality=100) + image = slimg.decode(result.data) + assert image.width == 10 + assert image.height == 8 + assert image.format == slimg.Format.PNG + + def test_decode_image_data_is_rgba(self, sample_image): + result = slimg.convert(sample_image, format="png", quality=100) + image = slimg.decode(result.data) + assert len(image.data) == image.width * image.height * 4 + + def test_decode_invalid_data_raises(self): + with pytest.raises(slimg.SlimgError): + slimg.decode(b"\x00\x00\x00\x00") + + +class TestOpen: + def test_open_png_file(self, sample_image, tmp_path): + # Write a PNG to disk, then open it + result = slimg.convert(sample_image, format="png", quality=100) + path = str(tmp_path / "test.png") + result.save(path) + image = slimg.open(path) + assert image.width == 10 + assert image.height == 8 + assert image.format == slimg.Format.PNG + + def test_open_nonexistent_raises(self): + with pytest.raises(slimg.SlimgError): + slimg.open("/nonexistent/path/image.png") + + +class TestImage: + def test_image_properties(self, sample_image): + assert sample_image.width == 10 + assert sample_image.height == 8 + assert isinstance(sample_image.data, bytes) + + def test_image_data_length(self, sample_image): + assert len(sample_image.data) == 10 * 8 * 4 + + def test_image_format_default_none(self): + img = create_test_image(2, 2) + assert img.format is None + + def test_from_raw(self): + data = bytes([0] * 4 * 3 * 2) + img = slimg.Image._from_raw(3, 2, data) + assert img.width == 3 + assert img.height == 2 + assert img.format is None + + def test_buffer_size_mismatch_raises(self): + with pytest.raises(ValueError, match="Buffer size mismatch"): + slimg.Image(width=4, height=4, data=bytes(10)) + + def test_buffer_too_large_raises(self): + with pytest.raises(ValueError, match="Buffer size mismatch"): + slimg.Image(width=2, height=2, data=bytes(100)) + + def test_empty_buffer_raises(self): + with pytest.raises(ValueError, match="Buffer size mismatch"): + slimg.Image(width=1, height=1, data=b"") diff --git a/bindings/python/tests/test_format.py b/bindings/python/tests/test_format.py new file mode 100644 index 0000000..5141780 --- /dev/null +++ b/bindings/python/tests/test_format.py @@ -0,0 +1,94 @@ +import pytest +import slimg + + +class TestFormatExtension: + def test_jpeg(self): + assert slimg.Format.JPEG.extension == "jpg" + + def test_png(self): + assert slimg.Format.PNG.extension == "png" + + def test_webp(self): + assert slimg.Format.WEBP.extension == "webp" + + def test_avif(self): + assert slimg.Format.AVIF.extension == "avif" + + def test_jxl(self): + assert slimg.Format.JXL.extension == "jxl" + + def test_qoi(self): + assert slimg.Format.QOI.extension == "qoi" + + +class TestFormatCanEncode: + def test_jpeg_can_encode(self): + assert slimg.Format.JPEG.can_encode is True + + def test_png_can_encode(self): + assert slimg.Format.PNG.can_encode is True + + def test_webp_can_encode(self): + assert slimg.Format.WEBP.can_encode is True + + def test_jxl_cannot_encode(self): + assert slimg.Format.JXL.can_encode is False + + +class TestFormatFromPath: + def test_jpg(self): + assert slimg.Format.from_path("photo.jpg") == slimg.Format.JPEG + + def test_jpeg(self): + assert slimg.Format.from_path("photo.jpeg") == slimg.Format.JPEG + + def test_webp(self): + assert slimg.Format.from_path("image.webp") == slimg.Format.WEBP + + def test_png(self): + assert slimg.Format.from_path("image.png") == slimg.Format.PNG + + def test_avif(self): + assert slimg.Format.from_path("image.avif") == slimg.Format.AVIF + + def test_unknown_returns_none(self): + assert slimg.Format.from_path("file.bmp") is None + + def test_no_extension_returns_none(self): + assert slimg.Format.from_path("noext") is None + + +class TestFormatFromBytes: + def test_jpeg_magic(self): + header = bytes([0xFF, 0xD8, 0xFF, 0xE0]) + assert slimg.Format.from_bytes(header) == slimg.Format.JPEG + + def test_png_magic(self): + header = bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) + assert slimg.Format.from_bytes(header) == slimg.Format.PNG + + def test_unknown_returns_none(self): + assert slimg.Format.from_bytes(bytes([0x00, 0x00, 0x00, 0x00])) is None + + +class TestFormatResolve: + def test_string_lowercase(self): + assert slimg.Format._resolve("png") == slimg.Format.PNG + + def test_string_uppercase(self): + assert slimg.Format._resolve("PNG") == slimg.Format.PNG + + def test_string_jpg_alias(self): + assert slimg.Format._resolve("jpg") == slimg.Format.JPEG + + def test_enum_passthrough(self): + assert slimg.Format._resolve(slimg.Format.WEBP) == slimg.Format.WEBP + + def test_unknown_string_raises(self): + with pytest.raises(ValueError, match="Unknown format"): + slimg.Format._resolve("bmp") + + def test_wrong_type_raises(self): + with pytest.raises(TypeError): + slimg.Format._resolve(123) diff --git a/bindings/python/tests/test_operations.py b/bindings/python/tests/test_operations.py new file mode 100644 index 0000000..8213f63 --- /dev/null +++ b/bindings/python/tests/test_operations.py @@ -0,0 +1,147 @@ +import pytest +import slimg +from conftest import create_test_image, pixel_at + + +class TestCrop: + def test_region(self, sample_image): + cropped = slimg.crop(sample_image, region=(2, 1, 5, 4)) + assert cropped.width == 5 + assert cropped.height == 4 + + def test_region_preserves_pixels(self, sample_image): + cropped = slimg.crop(sample_image, region=(2, 1, 3, 2)) + pixel = pixel_at(cropped, 0, 0) + assert pixel[0] == 1 # R = row 1 + assert pixel[1] == 2 # G = col 2 + + def test_aspect_ratio_square(self, sample_image): + cropped = slimg.crop(sample_image, aspect_ratio=(1, 1)) + assert cropped.width == cropped.height + + def test_aspect_ratio_16_9(self, sample_image_100): + cropped = slimg.crop(sample_image_100, aspect_ratio=(16, 9)) + ratio = cropped.width / cropped.height + assert 1.7 < ratio < 1.8 + + def test_out_of_bounds_raises(self, sample_image): + with pytest.raises(slimg.SlimgError): + slimg.crop(sample_image, region=(8, 0, 5, 4)) + + def test_both_args_raises(self, sample_image): + with pytest.raises(ValueError, match="either region or aspect_ratio"): + slimg.crop(sample_image, region=(0, 0, 5, 4), aspect_ratio=(1, 1)) + + def test_no_args_raises(self, sample_image): + with pytest.raises(ValueError, match="region or aspect_ratio"): + slimg.crop(sample_image) + + +class TestExtend: + def test_aspect_ratio_square_from_landscape(self, sample_image): + extended = slimg.extend(sample_image, aspect_ratio=(1, 1), fill="transparent") + assert extended.width == 10 + assert extended.height == 10 + + def test_aspect_ratio_square_from_portrait(self): + img = create_test_image(6, 10) + extended = slimg.extend(img, aspect_ratio=(1, 1), fill="transparent") + assert extended.width == 10 + assert extended.height == 10 + + def test_solid_fill(self): + img = create_test_image(4, 4) + extended = slimg.extend(img, size=(6, 6), fill=(255, 0, 0)) + pixel = pixel_at(extended, 0, 0) + assert pixel == (255, 0, 0, 255) + + def test_transparent_fill(self): + img = create_test_image(4, 4) + extended = slimg.extend(img, size=(6, 6), fill="transparent") + pixel = pixel_at(extended, 0, 0) + assert pixel == (0, 0, 0, 0) + + def test_preserves_original(self): + img = create_test_image(4, 4) + extended = slimg.extend(img, size=(6, 6), fill="transparent") + # 4x4 centered in 6x6 -> offset (1,1) + pixel = pixel_at(extended, 1, 1) + assert pixel == (0, 0, 0xFF, 0xFF) + + def test_noop_when_matching(self): + img = create_test_image(10, 10) + extended = slimg.extend(img, aspect_ratio=(1, 1), fill="transparent") + assert extended.data == img.data + + def test_smaller_size_raises(self, sample_image): + with pytest.raises(slimg.SlimgError): + slimg.extend(sample_image, size=(5, 8), fill="transparent") + + def test_rgba_fill(self): + img = create_test_image(4, 4) + extended = slimg.extend(img, size=(6, 6), fill=(128, 64, 32, 200)) + pixel = pixel_at(extended, 0, 0) + assert pixel == (128, 64, 32, 200) + + def test_both_args_raises(self, sample_image): + with pytest.raises(ValueError, match="either aspect_ratio or size"): + slimg.extend( + sample_image, + aspect_ratio=(1, 1), + size=(20, 20), + fill="transparent", + ) + + def test_no_args_raises(self, sample_image): + with pytest.raises(ValueError, match="aspect_ratio or size"): + slimg.extend(sample_image, fill="transparent") + + def test_fill_channel_out_of_range(self, sample_image): + with pytest.raises(ValueError, match="0-255"): + slimg.extend(sample_image, size=(20, 20), fill=(256, 0, 0)) + + def test_fill_channel_negative(self, sample_image): + with pytest.raises(ValueError, match="0-255"): + slimg.extend(sample_image, size=(20, 20), fill=(-1, 0, 0)) + + def test_fill_invalid_type(self, sample_image): + with pytest.raises(ValueError, match="Invalid fill"): + slimg.extend(sample_image, size=(20, 20), fill="red") + + def test_fill_wrong_tuple_length(self, sample_image): + with pytest.raises(ValueError, match="Invalid fill"): + slimg.extend(sample_image, size=(20, 20), fill=(255, 0)) + + +class TestResize: + def test_width(self, sample_image): + resized = slimg.resize(sample_image, width=5) + assert resized.width == 5 + assert resized.height == 4 # 10x8 -> 5x4 + + def test_height(self, sample_image): + resized = slimg.resize(sample_image, height=4) + assert resized.height == 4 + + def test_exact(self, sample_image): + resized = slimg.resize(sample_image, exact=(20, 20)) + assert resized.width == 20 + assert resized.height == 20 + + def test_fit(self, sample_image): + resized = slimg.resize(sample_image, fit=(5, 5)) + assert resized.width <= 5 + assert resized.height <= 5 + + def test_scale(self, sample_image): + resized = slimg.resize(sample_image, scale=2.0) + assert resized.width == 20 + assert resized.height == 16 + + def test_no_mode_raises(self, sample_image): + with pytest.raises(ValueError, match="exactly one"): + slimg.resize(sample_image) + + def test_multiple_modes_raises(self, sample_image): + with pytest.raises(ValueError, match="exactly one"): + slimg.resize(sample_image, width=5, height=4) diff --git a/bindings/python/tests/test_optimize.py b/bindings/python/tests/test_optimize.py new file mode 100644 index 0000000..be1d6b9 --- /dev/null +++ b/bindings/python/tests/test_optimize.py @@ -0,0 +1,68 @@ +import os +import tempfile + +import pytest +import slimg +from conftest import create_test_image + + +class TestOptimize: + def test_optimize_png_bytes(self, sample_image): + encoded = slimg.convert(sample_image, format="png", quality=80) + result = slimg.optimize(encoded.data, quality=60) + assert result.format == slimg.Format.PNG + assert len(result.data) > 0 + + def test_optimize_preserves_format(self, sample_image): + encoded = slimg.convert(sample_image, format="webp", quality=80) + result = slimg.optimize(encoded.data, quality=60) + assert result.format == slimg.Format.WEBP + + def test_optimize_invalid_data_raises(self): + with pytest.raises(slimg.SlimgError): + slimg.optimize(b"\x00\x00\x00\x00", quality=80) + + +class TestOptimizeFile: + def test_optimize_file(self, sample_image): + encoded = slimg.convert(sample_image, format="png", quality=100) + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: + f.write(encoded.data) + path = f.name + try: + result = slimg.optimize_file(path, quality=60) + assert result.format == slimg.Format.PNG + assert len(result.data) > 0 + finally: + os.unlink(path) + + def test_optimize_file_webp(self, sample_image): + encoded = slimg.convert(sample_image, format="webp", quality=100) + with tempfile.NamedTemporaryFile(suffix=".webp", delete=False) as f: + f.write(encoded.data) + path = f.name + try: + result = slimg.optimize_file(path, quality=60) + assert result.format == slimg.Format.WEBP + finally: + os.unlink(path) + + def test_optimize_file_nonexistent_raises(self): + with pytest.raises(slimg.SlimgError): + slimg.optimize_file("/nonexistent/path/image.png", quality=80) + + +class TestValidation: + def test_optimize_quality_too_high(self, sample_image): + encoded = slimg.convert(sample_image, format="png", quality=80) + with pytest.raises(ValueError, match="quality"): + slimg.optimize(encoded.data, quality=101) + + def test_optimize_quality_negative(self, sample_image): + encoded = slimg.convert(sample_image, format="png", quality=80) + with pytest.raises(ValueError, match="quality"): + slimg.optimize(encoded.data, quality=-1) + + def test_optimize_file_quality_too_high(self): + with pytest.raises(ValueError, match="quality"): + slimg.optimize_file("/any/path", quality=200) diff --git a/cli/Cargo.toml b/cli/Cargo.toml index a9b4baf..d29ba7a 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "slimg" -version = "0.3.0" +version = "0.3.1" edition = "2024" license = "MIT OR Apache-2.0" description = "Image optimization CLI — convert, compress, and resize images using MozJPEG, OxiPNG, WebP, AVIF, and QOI" @@ -11,7 +11,7 @@ keywords = ["image", "optimization", "cli", "compression", "webp"] categories = ["multimedia::images", "command-line-utilities"] [dependencies] -slimg-core = { version = "0.3.0", path = "../crates/slimg-core" } +slimg-core = { version = "0.3.1", path = "../crates/slimg-core" } clap = { version = "4.5", features = ["derive"] } clap_complete = "4.5" anyhow = "1" diff --git a/crates/slimg-core/Cargo.toml b/crates/slimg-core/Cargo.toml index 9c3acfe..965293e 100644 --- a/crates/slimg-core/Cargo.toml +++ b/crates/slimg-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "slimg-core" -version = "0.3.0" +version = "0.3.1" edition = "2024" license = "MIT OR Apache-2.0" description = "Image optimization library — encode, decode, convert, resize with MozJPEG, OxiPNG, WebP, AVIF, and QOI" diff --git a/crates/slimg-ffi/Cargo.toml b/crates/slimg-ffi/Cargo.toml index 065dcbe..6e8ac14 100644 --- a/crates/slimg-ffi/Cargo.toml +++ b/crates/slimg-ffi/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "slimg-ffi" -version = "0.3.0" +version = "0.3.1" edition = "2024" license = "MIT OR Apache-2.0" description = "UniFFI bindings for slimg-core image optimization library" diff --git a/crates/slimg-ffi/src/lib.rs b/crates/slimg-ffi/src/lib.rs index 76373bc..3d19105 100644 --- a/crates/slimg-ffi/src/lib.rs +++ b/crates/slimg-ffi/src/lib.rs @@ -325,6 +325,13 @@ fn extend(image: &ImageData, mode: &ExtendMode, fill: &FillColor) -> Result Result { + let result = slimg_core::resize::resize(&image.to_core(), &mode.to_core())?; + Ok(ImageData::from_core(result)) +} + /// Decode the data and re-encode in the same format at the given quality. #[uniffi::export] fn optimize(data: Vec, quality: u8) -> Result { diff --git a/crates/slimg-ffi/uniffi.toml b/crates/slimg-ffi/uniffi.toml index 4fe1299..1d59f5c 100644 --- a/crates/slimg-ffi/uniffi.toml +++ b/crates/slimg-ffi/uniffi.toml @@ -2,3 +2,6 @@ package_name = "io.clroot.slimg" cdylib_name = "slimg_ffi" generate_immutable_records = true + +[bindings.python] +cdylib_name = "slimg_ffi" diff --git a/docs/plans/2026-02-19-python-bindings-design.md b/docs/plans/2026-02-19-python-bindings-design.md new file mode 100644 index 0000000..f5bbe8c --- /dev/null +++ b/docs/plans/2026-02-19-python-bindings-design.md @@ -0,0 +1,202 @@ +# Python Bindings Design + +## Overview + +slimg에 Python 바인딩을 추가한다. 기존 UniFFI 기반 FFI 레이어(`slimg-ffi`)를 재사용하고, maturin으로 wheel을 빌드하여 PyPI에 배포한다. + +- **패키지명**: `slimg` (`pip install slimg`) +- **Python 지원 범위**: 3.9 ~ 3.13 +- **플랫폼**: macOS(arm64, x86_64), Linux(x86_64, aarch64), Windows(x86_64) +- **API 스타일**: UniFFI 자동생성 + Pythonic 래퍼 + +## Project Structure + +``` +bindings/python/ +├── pyproject.toml # maturin 빌드 설정 (bindings = "uniffi") +├── slimg/ +│ ├── __init__.py # Pythonic public API +│ ├── _types.py # type alias, Enum re-export +│ └── py.typed # PEP 561 type stub marker +├── tests/ +│ ├── conftest.py # 테스트 픽스처 (샘플 이미지 등) +│ └── test_slimg.py # pytest 기반 테스트 +└── README.md +``` + +## Build System + +maturin `bindings = "uniffi"` 모드로 기존 `slimg-ffi` 크레이트를 직접 빌드한다. + +```toml +# bindings/python/pyproject.toml +[build-system] +requires = ["maturin>=1.0,<2.0"] +build-backend = "maturin" + +[project] +name = "slimg" +requires-python = ">=3.9" +description = "Fast image optimization library powered by Rust" +license = { text = "MIT OR Apache-2.0" } +classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", +] + +[tool.maturin] +manifest-path = "../../crates/slimg-ffi/Cargo.toml" +bindings = "uniffi" +python-source = "." +module-name = "slimg._lowlevel" +``` + +### uniffi.toml 변경 + +기존 Kotlin 설정에 Python 설정을 추가한다: + +```toml +[bindings.kotlin] +package_name = "io.clroot.slimg" +cdylib_name = "slimg_ffi" +generate_immutable_records = true + +[bindings.python] +cdylib_name = "slimg_ffi" +``` + +## Pythonic Wrapper API + +UniFFI 자동생성 코드(`_lowlevel`)를 내부 모듈로 격리하고, `__init__.py`에서 Pythonic한 API를 제공한다. + +### Public API + +```python +import slimg + +# -- Decoding -- +image = slimg.open("photo.jpg") # 파일에서 디코딩 +image = slimg.decode(raw_bytes) # bytes에서 디코딩 + +image.width # u32 +image.height # u32 +image.format # slimg.Format.JPEG +image.data # bytes (RGBA) + +# -- Convert -- +result = slimg.convert(image, format="webp", quality=80) +result = slimg.convert( + image, + format="avif", + quality=60, + resize=slimg.Resize.fit(1920, 1080), +) + +result.data # bytes (인코딩된 이미지) +result.format # slimg.Format.WEBP +result.save("output.webp") + +# -- Image operations -- +cropped = slimg.crop(image, region=(10, 20, 100, 80)) +cropped = slimg.crop(image, aspect_ratio=(16, 9)) + +extended = slimg.extend(image, aspect_ratio=(1, 1), fill="transparent") +extended = slimg.extend(image, size=(1920, 1080), fill=(255, 255, 255)) + +resized = slimg.resize(image, width=800) +resized = slimg.resize(image, scale=0.5) +resized = slimg.resize(image, fit=(1920, 1080)) + +# -- Optimize (re-encode) -- +result = slimg.optimize(raw_bytes, quality=80) +result = slimg.optimize_file("photo.jpg", quality=80) + +# -- Format utilities -- +slimg.Format.JPEG.extension # "jpg" +slimg.Format.JPEG.can_encode # True +slimg.Format.JXL.can_encode # False +slimg.Format.from_path("a.webp") # Format.WEBP +``` + +### Wrapper Design Principles + +| Principle | Application | +|-----------|-------------| +| String to Enum | `format="webp"` -> `Format.WEB_P` | +| Remove None defaults | keyword args only, no `None, None, None` | +| Tuple to Object | `region=(10, 20, 100, 80)` -> `CropMode.Region(...)` | +| Convenience methods | `result.save()`, `slimg.open()`, `slimg.optimize_file()` | +| Error mapping | `SlimgError` hierarchy preserved as Python exceptions | + +## CI/CD + +### 1. python-bindings.yml (PR/push build & test) + +```yaml +name: Python Bindings +on: + push: + branches: [main] + paths: + - 'crates/slimg-ffi/**' + - 'crates/slimg-core/**' + - 'bindings/python/**' + - '.github/workflows/python-bindings.yml' + pull_request: + paths: # same paths + +jobs: + build-and-test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [macos-latest, ubuntu-latest, windows-latest] + python-version: ['3.9', '3.13'] + steps: + - checkout, rust toolchain, python setup + - system dependencies (same as kotlin workflow) + - pip install maturin pytest + - maturin develop (working-directory: bindings/python) + - pytest tests/ (working-directory: bindings/python) +``` + +### 2. publish.yml additions (PyPI deployment) + +Added jobs alongside existing crates.io, Homebrew, Maven Central publishing: + +``` +build-python-wheels: + - 5 platform matrix (same targets as Kotlin) + - Uses PyO3/maturin-action@v1 + - Outputs: platform-specific .whl files + +build-python-sdist: + - Source distribution (.tar.gz) + +publish-python: + - needs: [build-python-wheels, build-python-sdist] + - Uses PyPI Trusted Publisher (OIDC, no API token needed) + - pypa/gh-action-pypi-publish@release/v1 +``` + +### PyPI Setup Requirements + +| Item | Detail | +|------|--------| +| PyPI account | Claim `slimg` package name on pypi.org | +| Trusted Publisher | PyPI -> Publishing -> `clroot/slimg` repo, `publish.yml` workflow | +| GitHub environment | Repo Settings -> Environments -> create `pypi` | +| Test deployment | First release on test.pypi.org, then production PyPI | + +## Testing + +pytest-based tests covering the same scope as Kotlin tests (`SlimgTest.kt`): + +- **TestFormat**: extension, can_encode, from_path, from_bytes +- **TestDecode**: open file, decode bytes, dimensions/RGBA validation +- **TestConvert**: format conversion, pipeline with resize +- **TestCrop**: region crop, aspect ratio crop +- **TestExtend**: aspect ratio extend, size extend, fill colors +- **TestResize**: width, height, exact, fit, scale modes +- **TestOptimize**: bytes optimization, file optimization +- **TestError**: invalid data, unsupported encoding (JXL) diff --git a/docs/plans/2026-02-19-python-bindings-plan.md b/docs/plans/2026-02-19-python-bindings-plan.md new file mode 100644 index 0000000..06e7237 --- /dev/null +++ b/docs/plans/2026-02-19-python-bindings-plan.md @@ -0,0 +1,904 @@ +# Python Bindings Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** slimg에 Python 바인딩을 추가하고, PyPI 배포를 기존 CI/CD 워크플로우에 통합한다. + +**Architecture:** UniFFI 자동생성 Python 코드를 `_lowlevel` 내부 모듈로 격리하고, `__init__.py`에서 Pythonic 래퍼를 제공. maturin으로 빌드하여 플랫폼별 wheel을 생성. + +**Tech Stack:** Rust (slimg-ffi), UniFFI 0.31, maturin, pytest, GitHub Actions, PyPI Trusted Publisher + +**Design doc:** `docs/plans/2026-02-19-python-bindings-design.md` + +--- + +### Task 1: Project scaffold & maturin build verification + +**Files:** +- Create: `bindings/python/pyproject.toml` +- Create: `bindings/python/slimg/__init__.py` (minimal stub) +- Create: `bindings/python/slimg/py.typed` +- Modify: `crates/slimg-ffi/uniffi.toml` + +**Step 1: Update uniffi.toml to add Python config** + +```toml +# crates/slimg-ffi/uniffi.toml +[bindings.kotlin] +package_name = "io.clroot.slimg" +cdylib_name = "slimg_ffi" +generate_immutable_records = true + +[bindings.python] +cdylib_name = "slimg_ffi" +``` + +**Step 2: Create pyproject.toml** + +```toml +# bindings/python/pyproject.toml +[build-system] +requires = ["maturin>=1.0,<2.0"] +build-backend = "maturin" + +[project] +name = "slimg" +version = "0.3.0" +requires-python = ">=3.9" +description = "Fast image optimization library powered by Rust" +license = { text = "MIT OR Apache-2.0" } +keywords = ["image", "optimization", "compression", "webp", "avif", "jpeg", "png"] +classifiers = [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: MIT License", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Multimedia :: Graphics", + "Topic :: Multimedia :: Graphics :: Graphics Conversion", +] + +[project.urls] +Homepage = "https://github.com/clroot/slimg" +Repository = "https://github.com/clroot/slimg" +Issues = "https://github.com/clroot/slimg/issues" + +[tool.maturin] +manifest-path = "../../crates/slimg-ffi/Cargo.toml" +bindings = "uniffi" +python-source = "." +module-name = "slimg._lowlevel" +``` + +**Step 3: Create minimal slimg/__init__.py stub** + +```python +# bindings/python/slimg/__init__.py +"""slimg - Fast image optimization library powered by Rust.""" + +from slimg._lowlevel import * # noqa: F401, F403 +``` + +**Step 4: Create py.typed marker** + +``` +# bindings/python/slimg/py.typed +# (empty file — PEP 561 marker) +``` + +**Step 5: Verify maturin develop works** + +```bash +cd bindings/python +pip install maturin +maturin develop +``` + +Expected: Build succeeds, `import slimg` works in Python. + +**Step 6: Verify UniFFI-generated functions are accessible** + +```bash +python -c "from slimg._lowlevel import format_extension; print(format_extension('Jpeg'))" +``` + +Expected: Prints `jpg` (or similar — note the exact enum variant name will depend on UniFFI's Python codegen, adjust accordingly). + +**Step 7: Inspect the generated Python module to understand UniFFI's naming conventions** + +```bash +python -c "import slimg._lowlevel; print(dir(slimg._lowlevel))" +``` + +Record the output — this determines the exact import names and enum variant conventions for the Pythonic wrapper. The wrapper implementation in subsequent tasks will reference these names. + +**Step 8: Commit** + +```bash +git add bindings/python/pyproject.toml bindings/python/slimg/__init__.py bindings/python/slimg/py.typed crates/slimg-ffi/uniffi.toml +git commit -m "feat(python): scaffold Python bindings with maturin + uniffi" +``` + +--- + +### Task 2: Pythonic wrapper — Format enum & utilities + +**Files:** +- Create: `bindings/python/slimg/_types.py` +- Modify: `bindings/python/slimg/__init__.py` +- Create: `bindings/python/tests/conftest.py` +- Create: `bindings/python/tests/test_format.py` + +**Step 1: Write failing tests for Format** + +```python +# bindings/python/tests/test_format.py +import slimg + + +class TestFormatExtension: + def test_jpeg(self): + assert slimg.Format.JPEG.extension == "jpg" + + def test_png(self): + assert slimg.Format.PNG.extension == "png" + + def test_webp(self): + assert slimg.Format.WEBP.extension == "webp" + + def test_avif(self): + assert slimg.Format.AVIF.extension == "avif" + + def test_jxl(self): + assert slimg.Format.JXL.extension == "jxl" + + def test_qoi(self): + assert slimg.Format.QOI.extension == "qoi" + + +class TestFormatCanEncode: + def test_jpeg_can_encode(self): + assert slimg.Format.JPEG.can_encode is True + + def test_jxl_cannot_encode(self): + assert slimg.Format.JXL.can_encode is False + + +class TestFormatFromPath: + def test_jpg(self): + assert slimg.Format.from_path("photo.jpg") == slimg.Format.JPEG + + def test_jpeg(self): + assert slimg.Format.from_path("photo.jpeg") == slimg.Format.JPEG + + def test_webp(self): + assert slimg.Format.from_path("image.webp") == slimg.Format.WEBP + + def test_unknown_returns_none(self): + assert slimg.Format.from_path("file.bmp") is None + + def test_no_extension_returns_none(self): + assert slimg.Format.from_path("noext") is None + + +class TestFormatFromBytes: + def test_jpeg_magic(self): + header = bytes([0xFF, 0xD8, 0xFF, 0xE0]) + assert slimg.Format.from_bytes(header) == slimg.Format.JPEG + + def test_png_magic(self): + header = bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) + assert slimg.Format.from_bytes(header) == slimg.Format.PNG + + def test_unknown_returns_none(self): + assert slimg.Format.from_bytes(bytes([0x00, 0x00, 0x00, 0x00])) is None +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd bindings/python +pytest tests/test_format.py -v +``` + +Expected: FAIL — `slimg.Format` does not have `extension`, `can_encode`, `from_path`, `from_bytes`. + +**Step 3: Implement Format wrapper and _types.py** + +Implement `bindings/python/slimg/_types.py` with the `Format` enum that wraps UniFFI-generated types. Use the naming conventions discovered in Task 1, Step 7. + +The wrapper `Format` enum should: +- Expose `JPEG`, `PNG`, `WEBP`, `AVIF`, `JXL`, `QOI` as class-level attributes +- Provide `extension` property (calls `_lowlevel.format_extension`) +- Provide `can_encode` property (calls `_lowlevel.format_can_encode`) +- Provide `from_path(path)` classmethod (calls `_lowlevel.format_from_extension`) +- Provide `from_bytes(data)` classmethod (calls `_lowlevel.format_from_magic_bytes`) +- Support equality comparison with other Format instances + +Update `__init__.py` to import and expose `Format` from `_types`. + +**Step 4: Run tests to verify they pass** + +```bash +cd bindings/python +pytest tests/test_format.py -v +``` + +Expected: All PASS. + +**Step 5: Commit** + +```bash +git add bindings/python/slimg/_types.py bindings/python/slimg/__init__.py bindings/python/tests/ +git commit -m "feat(python): add Format enum with Pythonic wrapper" +``` + +--- + +### Task 3: Pythonic wrapper — Image (decode/open) & conftest + +**Files:** +- Modify: `bindings/python/slimg/__init__.py` +- Modify: `bindings/python/slimg/_types.py` +- Create: `bindings/python/tests/conftest.py` +- Create: `bindings/python/tests/test_decode.py` + +**Step 1: Create conftest.py with test helpers** + +```python +# bindings/python/tests/conftest.py +import pytest +import slimg + + +def create_test_image(width: int, height: int) -> slimg.Image: + """Create a test RGBA image. R=row, G=col, B=0xFF, A=0xFF.""" + data = bytearray(width * height * 4) + for row in range(height): + for col in range(width): + offset = (row * width + col) * 4 + data[offset] = row & 0xFF + data[offset + 1] = col & 0xFF + data[offset + 2] = 0xFF + data[offset + 3] = 0xFF + return slimg.Image._from_raw(width, height, bytes(data)) + + +def pixel_at(image: slimg.Image, col: int, row: int) -> tuple[int, int, int, int]: + """Get RGBA pixel value at (col, row).""" + offset = (row * image.width + col) * 4 + d = image.data + return (d[offset], d[offset + 1], d[offset + 2], d[offset + 3]) + + +@pytest.fixture +def sample_image(): + """10x8 test image.""" + return create_test_image(10, 8) + + +@pytest.fixture +def sample_image_100(): + """100x100 test image.""" + return create_test_image(100, 100) +``` + +**Step 2: Write failing tests for decode** + +```python +# bindings/python/tests/test_decode.py +import pytest +import slimg +from conftest import create_test_image, pixel_at + + +class TestDecode: + def test_decode_png_bytes(self, sample_image): + # Encode to PNG first, then decode + result = slimg.convert(sample_image, format="png", quality=100) + image = slimg.decode(result.data) + assert image.width == 10 + assert image.height == 8 + assert image.format == slimg.Format.PNG + + def test_decode_image_data_is_rgba(self, sample_image): + result = slimg.convert(sample_image, format="png", quality=100) + image = slimg.decode(result.data) + assert len(image.data) == image.width * image.height * 4 + + def test_decode_invalid_data_raises(self): + with pytest.raises(slimg.SlimgError): + slimg.decode(b"\x00\x00\x00\x00") + + +class TestImage: + def test_image_properties(self, sample_image): + assert sample_image.width == 10 + assert sample_image.height == 8 + assert isinstance(sample_image.data, bytes) +``` + +**Step 3: Run tests to verify they fail** + +```bash +cd bindings/python +pytest tests/test_decode.py -v +``` + +Expected: FAIL — `slimg.Image`, `slimg.decode`, `slimg.convert` not yet implemented. + +**Step 4: Implement Image class and decode/open functions** + +Add to `_types.py`: +- `Image` class wrapping UniFFI's `ImageData`, with `width`, `height`, `data`, `format` properties +- `Image._from_raw(width, height, data)` internal constructor (for test fixtures) +- `decode(data: bytes) -> Image` function +- `open(path: str) -> Image` function (calls `_lowlevel.decode_file`) +- `SlimgError` exception re-export from `_lowlevel` + +Update `__init__.py` to expose `Image`, `decode`, `open`, `SlimgError`. + +**Step 5: Run tests to verify they pass** + +```bash +cd bindings/python +pytest tests/test_decode.py -v +``` + +Expected: All PASS. + +**Step 6: Commit** + +```bash +git add bindings/python/ +git commit -m "feat(python): add Image class, decode, and open functions" +``` + +--- + +### Task 4: Pythonic wrapper — convert & Result.save() + +**Files:** +- Modify: `bindings/python/slimg/_types.py` +- Modify: `bindings/python/slimg/__init__.py` +- Create: `bindings/python/tests/test_convert.py` + +**Step 1: Write failing tests** + +```python +# bindings/python/tests/test_convert.py +import os +import tempfile +import slimg +from conftest import create_test_image + + +class TestConvert: + def test_to_png(self, sample_image): + result = slimg.convert(sample_image, format="png", quality=80) + assert result.format == slimg.Format.PNG + assert len(result.data) > 0 + # PNG magic bytes + assert result.data[0] == 0x89 + assert result.data[1] == 0x50 + + def test_to_webp(self, sample_image): + result = slimg.convert(sample_image, format="webp", quality=75) + assert result.format == slimg.Format.WEBP + # RIFF magic + assert result.data[:4] == b"RIFF" + + def test_with_resize(self, sample_image): + result = slimg.convert( + sample_image, format="png", quality=80, + resize=slimg.Resize.width(5), + ) + decoded = slimg.decode(result.data) + assert decoded.width == 5 + assert decoded.height == 4 # aspect ratio preserved (10x8 -> 5x4) + + def test_format_string_case_insensitive(self, sample_image): + result = slimg.convert(sample_image, format="PNG", quality=80) + assert result.format == slimg.Format.PNG + + def test_format_enum_accepted(self, sample_image): + result = slimg.convert(sample_image, format=slimg.Format.PNG, quality=80) + assert result.format == slimg.Format.PNG + + def test_jxl_encode_raises(self, sample_image): + with pytest.raises(slimg.SlimgError): + slimg.convert(sample_image, format="jxl", quality=80) + + def test_full_pipeline_crop_extend(self, sample_image_100): + result = slimg.convert( + sample_image_100, + format="png", + quality=80, + crop=slimg.Crop.aspect_ratio(16, 9), + extend=slimg.Extend.aspect_ratio(1, 1), + fill=(255, 255, 255), + ) + decoded = slimg.decode(result.data) + assert decoded.width == decoded.height # square after extend + + +class TestResultSave: + def test_save_to_file(self, sample_image): + result = slimg.convert(sample_image, format="png", quality=80) + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: + path = f.name + try: + result.save(path) + assert os.path.getsize(path) == len(result.data) + finally: + os.unlink(path) +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd bindings/python +pytest tests/test_convert.py -v +``` + +Expected: FAIL — `slimg.convert`, `slimg.Resize`, `slimg.Crop`, `slimg.Extend`, `Result.save` not implemented. + +**Step 3: Implement convert, Resize, Crop, Extend helpers, and Result class** + +Add to `_types.py`: +- `Result` class with `data`, `format` properties and `save(path)` method +- `Resize` namespace class with static methods: `width(v)`, `height(v)`, `exact(w, h)`, `fit(w, h)`, `scale(f)` +- `Crop` namespace class: `region(x, y, w, h)`, `aspect_ratio(w, h)` +- `Extend` namespace class: `aspect_ratio(w, h)`, `size(w, h)` +- `convert(image, format, quality, *, resize=None, crop=None, extend=None, fill=None)` function + - `format` accepts string or `Format` enum + - Converts string to UniFFI enum internally + - `fill` accepts `"transparent"` or `(r, g, b)` or `(r, g, b, a)` tuple + +Update `__init__.py` to expose all new types. + +**Step 4: Run tests to verify they pass** + +```bash +cd bindings/python +pytest tests/test_convert.py -v +``` + +Expected: All PASS. + +**Step 5: Commit** + +```bash +git add bindings/python/ +git commit -m "feat(python): add convert pipeline with Resize, Crop, Extend helpers" +``` + +--- + +### Task 5: Pythonic wrapper — crop, extend, resize standalone functions + +**Files:** +- Modify: `bindings/python/slimg/_types.py` +- Modify: `bindings/python/slimg/__init__.py` +- Create: `bindings/python/tests/test_operations.py` + +**Step 1: Write failing tests** + +```python +# bindings/python/tests/test_operations.py +import slimg +from conftest import create_test_image, pixel_at + + +class TestCrop: + def test_region(self, sample_image): + cropped = slimg.crop(sample_image, region=(2, 1, 5, 4)) + assert cropped.width == 5 + assert cropped.height == 4 + + def test_region_preserves_pixels(self, sample_image): + cropped = slimg.crop(sample_image, region=(2, 1, 3, 2)) + pixel = pixel_at(cropped, 0, 0) + assert pixel[0] == 1 # R = row 1 + assert pixel[1] == 2 # G = col 2 + + def test_aspect_ratio_square(self, sample_image): + cropped = slimg.crop(sample_image, aspect_ratio=(1, 1)) + assert cropped.width == cropped.height + + def test_aspect_ratio_16_9(self, sample_image_100): + cropped = slimg.crop(sample_image_100, aspect_ratio=(16, 9)) + ratio = cropped.width / cropped.height + assert 1.7 < ratio < 1.8 + + def test_out_of_bounds_raises(self, sample_image): + with pytest.raises(slimg.SlimgError): + slimg.crop(sample_image, region=(8, 0, 5, 4)) + + +class TestExtend: + def test_aspect_ratio_square_from_landscape(self, sample_image): + extended = slimg.extend(sample_image, aspect_ratio=(1, 1), fill="transparent") + assert extended.width == 10 + assert extended.height == 10 + + def test_aspect_ratio_square_from_portrait(self): + img = create_test_image(6, 10) + extended = slimg.extend(img, aspect_ratio=(1, 1), fill="transparent") + assert extended.width == 10 + assert extended.height == 10 + + def test_solid_fill(self): + img = create_test_image(4, 4) + extended = slimg.extend(img, size=(6, 6), fill=(255, 0, 0)) + pixel = pixel_at(extended, 0, 0) + assert pixel == (255, 0, 0, 255) + + def test_transparent_fill(self): + img = create_test_image(4, 4) + extended = slimg.extend(img, size=(6, 6), fill="transparent") + pixel = pixel_at(extended, 0, 0) + assert pixel == (0, 0, 0, 0) + + def test_preserves_original(self): + img = create_test_image(4, 4) + extended = slimg.extend(img, size=(6, 6), fill="transparent") + # 4x4 centered in 6x6 -> offset (1,1) + pixel = pixel_at(extended, 1, 1) + assert pixel == (0, 0, 0xFF, 0xFF) + + def test_noop_when_matching(self): + img = create_test_image(10, 10) + extended = slimg.extend(img, aspect_ratio=(1, 1), fill="transparent") + assert extended.data == img.data + + def test_smaller_size_raises(self, sample_image): + with pytest.raises(slimg.SlimgError): + slimg.extend(sample_image, size=(5, 8), fill="transparent") + + +class TestResize: + def test_width(self, sample_image): + resized = slimg.resize(sample_image, width=5) + assert resized.width == 5 + assert resized.height == 4 # 10x8 -> 5x4 + + def test_height(self, sample_image): + resized = slimg.resize(sample_image, height=4) + assert resized.height == 4 + + def test_exact(self, sample_image): + resized = slimg.resize(sample_image, exact=(20, 20)) + assert resized.width == 20 + assert resized.height == 20 + + def test_fit(self, sample_image): + resized = slimg.resize(sample_image, fit=(5, 5)) + assert resized.width <= 5 + assert resized.height <= 5 + + def test_scale(self, sample_image): + resized = slimg.resize(sample_image, scale=2.0) + assert resized.width == 20 + assert resized.height == 16 +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd bindings/python +pytest tests/test_operations.py -v +``` + +Expected: FAIL — `slimg.crop`, `slimg.extend`, `slimg.resize` not implemented as standalone functions. + +**Step 3: Implement standalone crop, extend, resize functions** + +Add to `_types.py`: +- `crop(image, *, region=None, aspect_ratio=None) -> Image` + - Exactly one of `region` or `aspect_ratio` must be provided + - `region=(x, y, w, h)` tuple -> `CropMode.Region` + - `aspect_ratio=(w, h)` tuple -> `CropMode.AspectRatio` +- `extend(image, *, aspect_ratio=None, size=None, fill) -> Image` + - Exactly one of `aspect_ratio` or `size` must be provided + - `fill` accepts `"transparent"`, `(r, g, b)`, or `(r, g, b, a)` +- `resize(image, *, width=None, height=None, exact=None, fit=None, scale=None) -> Image` + - Exactly one resize mode must be provided + +Update `__init__.py` to expose `crop`, `extend`, `resize`. + +**Step 4: Run tests to verify they pass** + +```bash +cd bindings/python +pytest tests/test_operations.py -v +``` + +Expected: All PASS. + +**Step 5: Commit** + +```bash +git add bindings/python/ +git commit -m "feat(python): add standalone crop, extend, resize functions" +``` + +--- + +### Task 6: Pythonic wrapper — optimize & optimize_file + +**Files:** +- Modify: `bindings/python/slimg/_types.py` +- Modify: `bindings/python/slimg/__init__.py` +- Create: `bindings/python/tests/test_optimize.py` + +**Step 1: Write failing tests** + +```python +# bindings/python/tests/test_optimize.py +import os +import tempfile +import slimg +from conftest import create_test_image + + +class TestOptimize: + def test_optimize_png_bytes(self, sample_image): + encoded = slimg.convert(sample_image, format="png", quality=80) + result = slimg.optimize(encoded.data, quality=60) + assert result.format == slimg.Format.PNG + assert len(result.data) > 0 + + def test_optimize_preserves_format(self, sample_image): + encoded = slimg.convert(sample_image, format="webp", quality=80) + result = slimg.optimize(encoded.data, quality=60) + assert result.format == slimg.Format.WEBP + + def test_optimize_invalid_data_raises(self): + with pytest.raises(slimg.SlimgError): + slimg.optimize(b"\x00\x00\x00\x00", quality=80) + + +class TestOptimizeFile: + def test_optimize_file(self, sample_image): + encoded = slimg.convert(sample_image, format="png", quality=100) + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: + f.write(encoded.data) + path = f.name + try: + result = slimg.optimize_file(path, quality=60) + assert result.format == slimg.Format.PNG + assert len(result.data) > 0 + finally: + os.unlink(path) +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd bindings/python +pytest tests/test_optimize.py -v +``` + +Expected: FAIL — `slimg.optimize`, `slimg.optimize_file` not implemented. + +**Step 3: Implement optimize and optimize_file** + +Add to `_types.py`: +- `optimize(data: bytes, quality: int) -> Result` +- `optimize_file(path: str, quality: int) -> Result` — reads file, calls optimize + +Update `__init__.py` to expose both. + +**Step 4: Run tests to verify they pass** + +```bash +cd bindings/python +pytest tests/test_optimize.py -v +``` + +Expected: All PASS. + +**Step 5: Commit** + +```bash +git add bindings/python/ +git commit -m "feat(python): add optimize and optimize_file functions" +``` + +--- + +### Task 7: Run full test suite & verify public API completeness + +**Step 1: Run all tests together** + +```bash +cd bindings/python +pytest tests/ -v +``` + +Expected: All PASS. + +**Step 2: Verify public API surface** + +```bash +python -c " +import slimg +expected = ['Format', 'Image', 'Result', 'Resize', 'Crop', 'Extend', 'SlimgError', + 'open', 'decode', 'convert', 'crop', 'extend', 'resize', 'optimize', 'optimize_file'] +for name in expected: + assert hasattr(slimg, name), f'Missing: {name}' +print('All public API members present') +" +``` + +Expected: `All public API members present`. + +**Step 3: Verify __all__ is defined in __init__.py** + +Check that `__init__.py` defines `__all__` listing the public API to prevent leaking internal names. + +**Step 4: Commit if any fixes were needed** + +```bash +git add bindings/python/ +git commit -m "fix(python): ensure complete public API surface" +``` + +--- + +### Task 8: CI — python-bindings.yml + +**Files:** +- Create: `.github/workflows/python-bindings.yml` + +**Step 1: Create workflow file** + +Reference the existing `kotlin-bindings.yml` for system dependency installation patterns. The Python workflow should: + +- Trigger on push to main and PR, filtered by paths: `crates/slimg-ffi/**`, `crates/slimg-core/**`, `bindings/python/**`, `.github/workflows/python-bindings.yml` +- Matrix: `os: [macos-latest, ubuntu-latest, windows-latest]` × `python-version: ['3.9', '3.13']` +- Steps: + 1. `actions/checkout@v4` + 2. `dtolnay/rust-toolchain@stable` + 3. `actions/setup-python@v5` with matrix python-version + 4. System dependencies — copy exact steps from `kotlin-bindings.yml` (nasm, meson, ninja for each OS, MSVC setup for Windows, `SYSTEM_DEPS_DAV1D_BUILD_INTERNAL=always`) + 5. `pip install maturin pytest` + 6. `maturin develop` (working-directory: `bindings/python`) + 7. `pytest tests/ -v` (working-directory: `bindings/python`) + +**Step 2: Commit** + +```bash +git add .github/workflows/python-bindings.yml +git commit -m "ci: add Python bindings build and test workflow" +``` + +--- + +### Task 9: CI — publish.yml PyPI integration + +**Files:** +- Modify: `.github/workflows/publish.yml` + +**Step 1: Add build-python-wheels job** + +Add after the existing `publish-kotlin` job. Use matrix matching the Kotlin native build targets: + +```yaml +build-python-wheels: + name: Build Python wheel (${{ matrix.target }}) + runs-on: ${{ matrix.runner }} + env: + SYSTEM_DEPS_DAV1D_BUILD_INTERNAL: always + strategy: + fail-fast: false + matrix: + include: + - { target: aarch64-apple-darwin, runner: macos-latest } + - { target: x86_64-apple-darwin, runner: macos-13 } + - { target: x86_64-unknown-linux-gnu, runner: ubuntu-latest } + - { target: aarch64-unknown-linux-gnu, runner: ubuntu-24.04-arm } + - { target: x86_64-pc-windows-msvc, runner: windows-latest } + steps: + - uses: actions/checkout@v4 + # System dependencies — same pattern as build-kotlin-native job + # (nasm, meson, ninja, MSVC setup, dav1d flag) + - name: Build wheel + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + args: --release --out dist + working-directory: bindings/python + - uses: actions/upload-artifact@v4 + with: + name: python-wheels-${{ matrix.target }} + path: bindings/python/dist/*.whl +``` + +**Step 2: Add build-python-sdist job** + +```yaml +build-python-sdist: + name: Build Python sdist + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist + working-directory: bindings/python + - uses: actions/upload-artifact@v4 + with: + name: python-wheels-sdist + path: bindings/python/dist/*.tar.gz +``` + +**Step 3: Add publish-python job** + +```yaml +publish-python: + name: Publish to PyPI + needs: [build-python-wheels, build-python-sdist] + runs-on: ubuntu-latest + permissions: + id-token: write + environment: pypi + steps: + - uses: actions/download-artifact@v4 + with: + pattern: python-wheels-* + path: dist/ + merge-multiple: true + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/ +``` + +**Step 4: Commit** + +```bash +git add .github/workflows/publish.yml +git commit -m "ci: add Python wheel build and PyPI publish to release workflow" +``` + +--- + +### Task 10: README for Python bindings + +**Files:** +- Create: `bindings/python/README.md` + +**Step 1: Write README** + +Write a README covering: +- Installation: `pip install slimg` +- Quick start examples (decode, convert, crop, resize, extend, optimize) +- Supported formats table (same as main README) +- Supported platforms +- Link to main repository README + +Use the same style/structure as `bindings/kotlin/README.md`. + +**Step 2: Update pyproject.toml to use README** + +Add to `[project]` section: +```toml +readme = "README.md" +``` + +**Step 3: Commit** + +```bash +git add bindings/python/README.md bindings/python/pyproject.toml +git commit -m "docs: add Python bindings README" +```