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"
+```