diff --git a/Cargo.lock b/Cargo.lock
index a8f3a05..8ba773f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1777,7 +1777,7 @@ checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
[[package]]
name = "slimg"
-version = "0.3.1"
+version = "0.4.0"
dependencies = [
"anyhow",
"clap",
@@ -1790,7 +1790,7 @@ dependencies = [
[[package]]
name = "slimg-core"
-version = "0.3.1"
+version = "0.4.0"
dependencies = [
"criterion",
"image",
@@ -1806,7 +1806,7 @@ dependencies = [
[[package]]
name = "slimg-ffi"
-version = "0.3.1"
+version = "0.4.0"
dependencies = [
"slimg-core",
"thiserror",
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..021f521
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 clroot
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.ko.md b/README.ko.md
index fe8b404..ec8306e 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -123,4 +123,4 @@ result.save(Path::new("photo.webp"))?;
## 라이선스
-MIT OR Apache-2.0
+MIT
diff --git a/README.md b/README.md
index 0d5bca5..66fd514 100644
--- a/README.md
+++ b/README.md
@@ -124,4 +124,4 @@ result.save(Path::new("photo.webp"))?;
## License
-MIT OR Apache-2.0
+MIT
diff --git a/bindings/kotlin/README.md b/bindings/kotlin/README.md
index d5d4dcc..e92dca5 100644
--- a/bindings/kotlin/README.md
+++ b/bindings/kotlin/README.md
@@ -6,11 +6,13 @@ Supports macOS (Apple Silicon, Intel), Linux (x86_64, ARM64), and Windows (x86_6
## Installation
+[](https://central.sonatype.com/artifact/io.clroot.slimg/slimg-kotlin)
+
### Gradle (Kotlin DSL)
```kotlin
dependencies {
- implementation("io.clroot.slimg:slimg-kotlin:0.3.1")
+ implementation("io.clroot.slimg:slimg-kotlin:$slimgVersion")
}
```
@@ -18,7 +20,7 @@ dependencies {
```groovy
dependencies {
- implementation 'io.clroot.slimg:slimg-kotlin:0.3.1'
+ implementation 'io.clroot.slimg:slimg-kotlin:$slimgVersion'
}
```
@@ -28,58 +30,160 @@ dependencies {
io.clroot.slimg
slimg-kotlin
- 0.3.1
+ ${slimgVersion}
```
-## Usage
+## Quick Start
```kotlin
import io.clroot.slimg.*
-// Decode an image file
-val result = decodeFile("photo.jpg")
-println("${result.image.width}x${result.image.height} ${result.format}")
-
-// Convert to WebP
-val webp = convert(result.image, PipelineOptions(
- format = Format.WEB_P,
- quality = 80u,
- resize = null,
-))
-File("photo.webp").writeBytes(webp.data)
-
-// Optimize in the same format
-val fileBytes = File("photo.png").readBytes()
-val optimized = optimize(fileBytes, 80u)
-File("photo-optimized.png").writeBytes(optimized.data)
-
-// Resize and convert
-val resized = convert(result.image, PipelineOptions(
- format = Format.WEB_P,
- quality = 80u,
+// Decode → transform → encode in a single chain
+val result = SlimgImage.decodeFile("photo.jpg")
+ .resize(width = 800)
+ .crop(aspectRatio = 16 to 9)
+ .encode(Format.WEB_P, quality = 85)
+
+File("photo.webp").writeBytes(result.data)
+```
+
+## Usage
+
+### SlimgImage (Fluent API)
+
+Chain decode → transform → encode operations fluently. All parameters use `Int` — no `UInt`/`UByte` conversions needed.
+
+```kotlin
+// Decode from various sources
+val img = SlimgImage.decode(byteArray)
+val img = SlimgImage.decode(inputStream)
+val img = SlimgImage.decode(byteBuffer)
+val img = SlimgImage.decodeFile("photo.jpg")
+
+// Resize
+img.resize(width = 800) // by width (preserve aspect ratio)
+img.resize(height = 600) // by height
+img.resize(width = 800, height = 600) // exact dimensions
+img.resize(fit = 1200 to 1200) // fit within bounds
+img.resize(scale = 0.5) // scale factor
+
+// Crop
+img.crop(aspectRatio = 16 to 9) // center-anchored
+img.crop(x = 100, y = 50, width = 800, height = 600) // region
+
+// Extend (add padding)
+img.extend(1920, 1080) // to exact size
+img.extend(aspectRatio = 1 to 1) // to aspect ratio
+img.extend(1920, 1080, fill = Slimg.solidColor(255, 0, 0)) // with color
+
+// Encode
+img.encode(Format.WEB_P, quality = 85) // to specific format
+img.optimize(quality = 70) // re-encode in source format
+```
+
+#### Chaining
+
+Each operation returns a new `SlimgImage`, so you can chain freely:
+
+```kotlin
+val result = SlimgImage.decodeFile("photo.jpg")
+ .resize(fit = 1200 to 1200)
+ .crop(aspectRatio = 1 to 1)
+ .extend(aspectRatio = 16 to 9, fill = Slimg.solidColor(0, 0, 0))
+ .encode(Format.WEB_P, quality = 80)
+
+File("output.webp").writeBytes(result.data)
+```
+
+#### With InputStream / ByteBuffer
+
+Works with any byte source — file streams, network responses, WebFlux `DataBuffer`, etc.
+
+```kotlin
+// From InputStream
+FileInputStream("photo.jpg").use { stream ->
+ val result = SlimgImage.decode(stream)
+ .resize(width = 800)
+ .encode(Format.WEB_P)
+ File("photo.webp").writeBytes(result.data)
+}
+
+// From ByteBuffer (e.g. WebFlux DataBuffer)
+val result = SlimgImage.decode(dataBuffer.asByteBuffer())
+ .resize(fit = 1200 to 1200)
+ .encode(Format.WEB_P, quality = 85)
+```
+
+### Slimg Object
+
+For one-off operations without chaining:
+
+```kotlin
+// Decode
+val decoded = Slimg.decode(byteArray) // or InputStream, ByteBuffer
+val decoded = Slimg.decodeFile("photo.jpg")
+
+// Convert with simplified parameters
+val result = Slimg.convert(
+ decoded.image,
+ Format.WEB_P,
+ quality = 85, // Int, not UByte
resize = ResizeMode.Width(800u),
-))
+ crop = CropMode.AspectRatio(16u, 9u),
+)
+
+// Optimize
+val optimized = Slimg.optimize(fileBytes, quality = 70) // Int quality
+
+// Image operations
+val resized = Slimg.resize(image, ResizeMode.Width(800u))
+val cropped = Slimg.crop(image, CropMode.AspectRatio(16u, 9u))
+val extended = Slimg.extend(image, ExtendMode.Size(1920u, 1080u))
+
+// FillColor helper
+val red = Slimg.solidColor(255, 0, 0) // Int RGBA, alpha defaults to 255
```
+### Low-level Functions
+
+Direct FFI bindings are also available as top-level functions:
+
+```kotlin
+val decoded = decode(byteArray)
+val result = convert(image, PipelineOptions(Format.WEB_P, 80u.toUByte(), null, null, null, null))
+val optimized = optimize(byteArray, 80u.toUByte())
+```
+
+## API Layers
+
+| Layer | Use When | Example |
+|-------|----------|---------|
+| `SlimgImage` | Chaining multiple operations | `SlimgImage.decode(bytes).resize(width = 800).encode(Format.WEB_P)` |
+| `Slimg` object | One-off operations with `Int` params | `Slimg.optimize(bytes, quality = 80)` |
+| Top-level functions | Direct FFI access needed | `decode(bytes)`, `convert(image, options)` |
+
## API Reference
-### Functions
-
-| Function | Description |
-|----------|-------------|
-| `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 |
-| `formatCanEncode(format)` | Check if format supports encoding |
-| `formatFromExtension(path)` | Detect format from file extension |
-| `formatFromMagicBytes(data)` | Detect format from file header |
+### SlimgImage
+
+| Method | Description |
+|--------|-------------|
+| `SlimgImage.decode(ByteArray / InputStream / ByteBuffer)` | Decode from bytes |
+| `SlimgImage.decodeFile(path)` | Decode from file path |
+| `SlimgImage.from(ImageData, format?)` | Wrap existing ImageData |
+| `.resize(width?, height?)` | Resize by width/height/exact |
+| `.resize(fit: Pair)` | Fit within bounds |
+| `.resize(scale: Double)` | Scale by factor |
+| `.crop(x, y, width, height)` | Crop region |
+| `.crop(aspectRatio: Pair)` | Crop to aspect ratio |
+| `.extend(width, height, fill?)` | Extend to exact size |
+| `.extend(aspectRatio: Pair, fill?)` | Extend to aspect ratio |
+| `.encode(format, quality?)` | Encode to format |
+| `.optimize(quality?)` | Re-encode in source format |
+| `.width` / `.height` | Current dimensions |
+| `.format` | Source format (from decode) |
+| `.imageData` | Underlying ImageData |
### Types
@@ -90,11 +194,10 @@ val resized = convert(result.image, PipelineOptions(
| `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: `UnsupportedFormat`, `UnknownFormat`, `EncodingNotSupported`, `Decode`, `Encode`, `Resize`, `Crop`, `Extend`, `Io`, `Image` |
+| `ImageData` | `width`, `height`, `data` (raw RGBA pixels) |
+| `SlimgException` | `UnsupportedFormat`, `UnknownFormat`, `EncodingNotSupported`, `Decode`, `Encode`, `Resize`, `Crop`, `Extend`, `Io`, `Image` |
## Supported Platforms
@@ -113,4 +216,4 @@ val resized = convert(result.image, PipelineOptions(
## License
-MIT OR Apache-2.0
+MIT
diff --git a/bindings/kotlin/build.gradle.kts b/bindings/kotlin/build.gradle.kts
index 3fc808d..96e765b 100644
--- a/bindings/kotlin/build.gradle.kts
+++ b/bindings/kotlin/build.gradle.kts
@@ -42,7 +42,7 @@ mavenPublishing {
licenses {
license {
- name.set("MIT OR Apache-2.0")
+ name.set("MIT")
url.set("https://github.com/clroot/slimg/blob/main/LICENSE")
}
}
diff --git a/bindings/kotlin/gradle.properties b/bindings/kotlin/gradle.properties
index 45471e0..e6b14db 100644
--- a/bindings/kotlin/gradle.properties
+++ b/bindings/kotlin/gradle.properties
@@ -1,3 +1,3 @@
group=io.clroot.slimg
-version=0.3.1
+version=0.4.0
kotlin.code.style=official
diff --git a/bindings/kotlin/src/main/kotlin/io/clroot/slimg/Slimg.kt b/bindings/kotlin/src/main/kotlin/io/clroot/slimg/Slimg.kt
new file mode 100644
index 0000000..376a360
--- /dev/null
+++ b/bindings/kotlin/src/main/kotlin/io/clroot/slimg/Slimg.kt
@@ -0,0 +1,123 @@
+package io.clroot.slimg
+
+import java.io.InputStream
+import java.nio.ByteBuffer
+
+/**
+ * Ergonomic wrapper around the slimg FFI functions.
+ *
+ * Eliminates [UByte]/[UInt] boilerplate and provides sensible defaults.
+ *
+ * ```kotlin
+ * val result = Slimg.decode(imageBytes)
+ * val optimized = Slimg.optimize(imageBytes, quality = 80)
+ * val converted = Slimg.convert(result.image, Format.WEB_P, quality = 85)
+ * ```
+ */
+object Slimg {
+
+ // ── Decode ──────────────────────────────────────────
+
+ @Throws(SlimgException::class)
+ fun decode(data: ByteArray): DecodeResult =
+ io.clroot.slimg.decode(data)
+
+ @Throws(SlimgException::class)
+ fun decode(stream: InputStream): DecodeResult =
+ io.clroot.slimg.decode(stream)
+
+ @Throws(SlimgException::class)
+ fun decode(buffer: ByteBuffer): DecodeResult =
+ io.clroot.slimg.decode(buffer)
+
+ @Throws(SlimgException::class)
+ fun decodeFile(path: String): DecodeResult =
+ io.clroot.slimg.decodeFile(path)
+
+ // ── Optimize ────────────────────────────────────────
+
+ @Throws(SlimgException::class)
+ fun optimize(data: ByteArray, quality: Int = 80): PipelineResult =
+ io.clroot.slimg.optimize(data, quality.toQuality())
+
+ @Throws(SlimgException::class)
+ fun optimize(stream: InputStream, quality: Int = 80): PipelineResult =
+ io.clroot.slimg.optimize(stream, quality.toQuality())
+
+ @Throws(SlimgException::class)
+ fun optimize(buffer: ByteBuffer, quality: Int = 80): PipelineResult =
+ io.clroot.slimg.optimize(buffer, quality.toQuality())
+
+ // ── Convert ─────────────────────────────────────────
+
+ /**
+ * Encode [image] into [format] with optional transformations.
+ *
+ * ```kotlin
+ * Slimg.convert(image, Format.WEB_P, quality = 85, resize = ResizeMode.Width(800u))
+ * ```
+ */
+ @Throws(SlimgException::class)
+ fun convert(
+ image: ImageData,
+ format: Format,
+ quality: Int = 80,
+ resize: ResizeMode? = null,
+ crop: CropMode? = null,
+ extend: ExtendMode? = null,
+ fillColor: FillColor? = null,
+ ): PipelineResult = io.clroot.slimg.convert(
+ image,
+ PipelineOptions(format, quality.toQuality(), resize, crop, extend, fillColor),
+ )
+
+ // ── Image Operations ────────────────────────────────
+
+ @Throws(SlimgException::class)
+ fun crop(image: ImageData, mode: CropMode): ImageData =
+ io.clroot.slimg.crop(image, mode)
+
+ @Throws(SlimgException::class)
+ fun resize(image: ImageData, mode: ResizeMode): ImageData =
+ io.clroot.slimg.resize(image, mode)
+
+ @Throws(SlimgException::class)
+ fun extend(
+ image: ImageData,
+ mode: ExtendMode,
+ fill: FillColor = FillColor.Transparent,
+ ): ImageData = io.clroot.slimg.extend(image, mode, fill)
+
+ // ── Format Utilities ────────────────────────────────
+
+ fun formatExtension(format: Format): String =
+ io.clroot.slimg.formatExtension(format)
+
+ fun formatCanEncode(format: Format): Boolean =
+ io.clroot.slimg.formatCanEncode(format)
+
+ fun formatFromExtension(path: String): Format? =
+ io.clroot.slimg.formatFromExtension(path)
+
+ fun formatFromMagicBytes(data: ByteArray): Format? =
+ io.clroot.slimg.formatFromMagicBytes(data)
+
+ fun formatFromMagicBytes(stream: InputStream): Format? =
+ io.clroot.slimg.formatFromMagicBytes(stream)
+
+ fun formatFromMagicBytes(buffer: ByteBuffer): Format? =
+ io.clroot.slimg.formatFromMagicBytes(buffer)
+
+ fun outputPath(input: String, format: Format, output: String? = null): String =
+ io.clroot.slimg.outputPath(input, format, output)
+
+ // ── Helpers ─────────────────────────────────────────
+
+ /**
+ * Create a solid [FillColor] from integer RGBA values (0–255).
+ */
+ fun solidColor(r: Int, g: Int, b: Int, a: Int = 255): FillColor =
+ FillColor.Solid(r.toUByte(), g.toUByte(), b.toUByte(), a.toUByte())
+}
+
+private fun Int.toQuality(): UByte = coerceIn(0, 100).toUByte()
diff --git a/bindings/kotlin/src/main/kotlin/io/clroot/slimg/SlimgImage.kt b/bindings/kotlin/src/main/kotlin/io/clroot/slimg/SlimgImage.kt
new file mode 100644
index 0000000..30de7f1
--- /dev/null
+++ b/bindings/kotlin/src/main/kotlin/io/clroot/slimg/SlimgImage.kt
@@ -0,0 +1,167 @@
+package io.clroot.slimg
+
+import java.io.InputStream
+import java.nio.ByteBuffer
+
+/**
+ * Fluent wrapper around [ImageData] for chaining image operations.
+ *
+ * ```kotlin
+ * val result = SlimgImage.decode(bytes)
+ * .resize(width = 800)
+ * .crop(aspectRatio = 16 to 9)
+ * .encode(Format.WEB_P, quality = 85)
+ * ```
+ *
+ * Each transformation returns a new [SlimgImage]; the original is never mutated.
+ */
+class SlimgImage private constructor(
+ val imageData: ImageData,
+ val format: Format?,
+) {
+ val width: Int get() = imageData.width.toInt()
+ val height: Int get() = imageData.height.toInt()
+
+ // ── Factory ─────────────────────────────────────────
+
+ companion object {
+ @Throws(SlimgException::class)
+ fun decode(data: ByteArray): SlimgImage =
+ io.clroot.slimg.decode(data).toSlimgImage()
+
+ @Throws(SlimgException::class)
+ fun decode(stream: InputStream): SlimgImage =
+ io.clroot.slimg.decode(stream).toSlimgImage()
+
+ @Throws(SlimgException::class)
+ fun decode(buffer: ByteBuffer): SlimgImage =
+ io.clroot.slimg.decode(buffer).toSlimgImage()
+
+ @Throws(SlimgException::class)
+ fun decodeFile(path: String): SlimgImage =
+ io.clroot.slimg.decodeFile(path).toSlimgImage()
+
+ fun from(imageData: ImageData, format: Format? = null): SlimgImage =
+ SlimgImage(imageData, format)
+
+ private fun DecodeResult.toSlimgImage() = SlimgImage(image, format)
+ }
+
+ // ── Resize ──────────────────────────────────────────
+
+ /**
+ * Resize the image.
+ *
+ * - `resize(width = 800)` — scale to width, preserve aspect ratio
+ * - `resize(height = 600)` — scale to height, preserve aspect ratio
+ * - `resize(width = 800, height = 600)` — exact dimensions (may distort)
+ */
+ @Throws(SlimgException::class)
+ fun resize(width: Int? = null, height: Int? = null): SlimgImage {
+ val mode = when {
+ width != null && height != null -> ResizeMode.Exact(width.toUInt(), height.toUInt())
+ width != null -> ResizeMode.Width(width.toUInt())
+ height != null -> ResizeMode.Height(height.toUInt())
+ else -> throw IllegalArgumentException("At least one of width or height must be specified")
+ }
+ return transformed(io.clroot.slimg.resize(imageData, mode))
+ }
+
+ /**
+ * Fit within bounds, preserving aspect ratio.
+ *
+ * ```kotlin
+ * image.resize(fit = 1200 to 1200)
+ * ```
+ */
+ @Throws(SlimgException::class)
+ fun resize(fit: Pair): SlimgImage {
+ val mode = ResizeMode.Fit(fit.first.toUInt(), fit.second.toUInt())
+ return transformed(io.clroot.slimg.resize(imageData, mode))
+ }
+
+ /**
+ * Scale by a factor (e.g. `0.5` = half size).
+ */
+ @Throws(SlimgException::class)
+ fun resize(scale: Double): SlimgImage =
+ transformed(io.clroot.slimg.resize(imageData, ResizeMode.Scale(scale)))
+
+ // ── Crop ────────────────────────────────────────────
+
+ /**
+ * Extract a specific region.
+ */
+ @Throws(SlimgException::class)
+ fun crop(x: Int, y: Int, width: Int, height: Int): SlimgImage {
+ val mode = CropMode.Region(x.toUInt(), y.toUInt(), width.toUInt(), height.toUInt())
+ return transformed(io.clroot.slimg.crop(imageData, mode))
+ }
+
+ /**
+ * Crop to an aspect ratio (centered).
+ *
+ * ```kotlin
+ * image.crop(aspectRatio = 16 to 9)
+ * ```
+ */
+ @Throws(SlimgException::class)
+ fun crop(aspectRatio: Pair): SlimgImage {
+ val mode = CropMode.AspectRatio(aspectRatio.first.toUInt(), aspectRatio.second.toUInt())
+ return transformed(io.clroot.slimg.crop(imageData, mode))
+ }
+
+ // ── Extend ──────────────────────────────────────────
+
+ /**
+ * Extend the canvas to exact pixel dimensions (centered).
+ */
+ @Throws(SlimgException::class)
+ fun extend(
+ width: Int,
+ height: Int,
+ fill: FillColor = FillColor.Transparent,
+ ): SlimgImage {
+ val mode = ExtendMode.Size(width.toUInt(), height.toUInt())
+ return transformed(io.clroot.slimg.extend(imageData, mode, fill))
+ }
+
+ /**
+ * Extend the canvas to fit an aspect ratio (centered).
+ *
+ * ```kotlin
+ * image.extend(aspectRatio = 16 to 9, fill = Slimg.solidColor(255, 255, 255))
+ * ```
+ */
+ @Throws(SlimgException::class)
+ fun extend(
+ aspectRatio: Pair,
+ fill: FillColor = FillColor.Transparent,
+ ): SlimgImage {
+ val mode = ExtendMode.AspectRatio(aspectRatio.first.toUInt(), aspectRatio.second.toUInt())
+ return transformed(io.clroot.slimg.extend(imageData, mode, fill))
+ }
+
+ // ── Encode ──────────────────────────────────────────
+
+ /**
+ * Encode the current image into the given [format].
+ */
+ @Throws(SlimgException::class)
+ fun encode(format: Format, quality: Int = 80): PipelineResult =
+ Slimg.convert(imageData, format, quality)
+
+ /**
+ * Re-encode in the original format detected during [decode].
+ *
+ * @throws IllegalStateException if the source format is unknown.
+ */
+ @Throws(SlimgException::class)
+ fun optimize(quality: Int = 80): PipelineResult {
+ val fmt = format
+ ?: throw IllegalStateException("Source format unknown. Use encode(format, quality) instead.")
+ return encode(fmt, quality)
+ }
+
+ private fun transformed(newData: ImageData) = SlimgImage(newData, format)
+}
diff --git a/bindings/kotlin/src/main/kotlin/io/clroot/slimg/SlimgStreams.kt b/bindings/kotlin/src/main/kotlin/io/clroot/slimg/SlimgStreams.kt
new file mode 100644
index 0000000..b94b8dc
--- /dev/null
+++ b/bindings/kotlin/src/main/kotlin/io/clroot/slimg/SlimgStreams.kt
@@ -0,0 +1,70 @@
+package io.clroot.slimg
+
+import java.io.InputStream
+import java.nio.ByteBuffer
+
+// ── InputStream overloads ───────────────────────
+
+/**
+ * Decode an image from an [InputStream].
+ *
+ * Reads all bytes from the stream and delegates to [decode].
+ * The caller is responsible for closing the stream.
+ */
+@Throws(SlimgException::class)
+fun decode(stream: InputStream): DecodeResult =
+ decode(stream.readBytes())
+
+/**
+ * Re-encode an image read from an [InputStream] at the given [quality].
+ *
+ * Reads all bytes from the stream and delegates to [optimize].
+ * The caller is responsible for closing the stream.
+ */
+@Throws(SlimgException::class)
+fun optimize(stream: InputStream, quality: UByte): PipelineResult =
+ optimize(stream.readBytes(), quality)
+
+/**
+ * Detect the image format of data provided by an [InputStream] using magic bytes.
+ *
+ * Reads all bytes from the stream and delegates to [formatFromMagicBytes].
+ * The caller is responsible for closing the stream.
+ */
+fun formatFromMagicBytes(stream: InputStream): Format? =
+ formatFromMagicBytes(stream.readBytes())
+
+// ── ByteBuffer overloads ────────────────────────
+
+/**
+ * Decode an image from a [ByteBuffer].
+ *
+ * Reads the remaining bytes (position to limit) without modifying the buffer's state.
+ */
+@Throws(SlimgException::class)
+fun decode(buffer: ByteBuffer): DecodeResult =
+ decode(buffer.toByteArray())
+
+/**
+ * Re-encode an image from a [ByteBuffer] at the given [quality].
+ *
+ * Reads the remaining bytes (position to limit) without modifying the buffer's state.
+ */
+@Throws(SlimgException::class)
+fun optimize(buffer: ByteBuffer, quality: UByte): PipelineResult =
+ optimize(buffer.toByteArray(), quality)
+
+/**
+ * Detect the image format of data in a [ByteBuffer] using magic bytes.
+ *
+ * Reads the remaining bytes (position to limit) without modifying the buffer's state.
+ */
+fun formatFromMagicBytes(buffer: ByteBuffer): Format? =
+ formatFromMagicBytes(buffer.toByteArray())
+
+private fun ByteBuffer.toByteArray(): ByteArray {
+ val buf = duplicate()
+ val bytes = ByteArray(buf.remaining())
+ buf.get(bytes)
+ return bytes
+}
diff --git a/bindings/kotlin/src/test/kotlin/io/clroot/slimg/SlimgImageTest.kt b/bindings/kotlin/src/test/kotlin/io/clroot/slimg/SlimgImageTest.kt
new file mode 100644
index 0000000..fa89cee
--- /dev/null
+++ b/bindings/kotlin/src/test/kotlin/io/clroot/slimg/SlimgImageTest.kt
@@ -0,0 +1,213 @@
+package io.clroot.slimg
+
+import java.io.ByteArrayInputStream
+import java.nio.ByteBuffer
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertTrue
+
+class SlimgImageTest {
+
+ private fun testImage(width: Int = 8, height: Int = 8): ImageData {
+ val data = ByteArray(width * height * 4) { 0xFF.toByte() }
+ return ImageData(width.toUInt(), height.toUInt(), data)
+ }
+
+ private fun pngBytes(width: Int = 8, height: Int = 8): ByteArray =
+ Slimg.convert(testImage(width, height), Format.PNG).data
+
+ // ── Factory ─────────────────────────────────────────
+
+ @Test
+ fun `decode ByteArray preserves format`() {
+ val img = SlimgImage.decode(pngBytes())
+ assertEquals(Format.PNG, img.format)
+ assertEquals(8, img.width)
+ assertEquals(8, img.height)
+ }
+
+ @Test
+ fun `decode InputStream`() {
+ val img = SlimgImage.decode(ByteArrayInputStream(pngBytes()))
+ assertEquals(Format.PNG, img.format)
+ }
+
+ @Test
+ fun `decode ByteBuffer`() {
+ val img = SlimgImage.decode(ByteBuffer.wrap(pngBytes()))
+ assertEquals(Format.PNG, img.format)
+ }
+
+ @Test
+ fun `from wraps existing ImageData`() {
+ val data = testImage()
+ val img = SlimgImage.from(data, Format.WEB_P)
+ assertEquals(Format.WEB_P, img.format)
+ assertEquals(8, img.width)
+ }
+
+ @Test
+ fun `from without format`() {
+ val img = SlimgImage.from(testImage())
+ assertEquals(null, img.format)
+ }
+
+ // ── Resize ──────────────────────────────────────────
+
+ @Test
+ fun `resize by width`() {
+ val img = SlimgImage.decode(pngBytes(16, 16))
+ .resize(width = 8)
+ assertEquals(8, img.width)
+ assertEquals(8, img.height)
+ }
+
+ @Test
+ fun `resize by height`() {
+ val img = SlimgImage.decode(pngBytes(16, 16))
+ .resize(height = 8)
+ assertEquals(8, img.width)
+ assertEquals(8, img.height)
+ }
+
+ @Test
+ fun `resize exact`() {
+ val img = SlimgImage.decode(pngBytes(16, 16))
+ .resize(width = 12, height = 8)
+ assertEquals(12, img.width)
+ assertEquals(8, img.height)
+ }
+
+ @Test
+ fun `resize fit`() {
+ val img = SlimgImage.decode(pngBytes(16, 8))
+ .resize(fit = 10 to 10)
+ assertTrue(img.width <= 10)
+ assertTrue(img.height <= 10)
+ }
+
+ @Test
+ fun `resize scale`() {
+ val img = SlimgImage.decode(pngBytes(16, 16))
+ .resize(scale = 0.5)
+ assertEquals(8, img.width)
+ assertEquals(8, img.height)
+ }
+
+ @Test
+ fun `resize no args throws`() {
+ val img = SlimgImage.decode(pngBytes())
+ assertFailsWith {
+ img.resize()
+ }
+ }
+
+ // ── Crop ────────────────────────────────────────────
+
+ @Test
+ fun `crop region`() {
+ val img = SlimgImage.decode(pngBytes(16, 16))
+ .crop(x = 0, y = 0, width = 8, height = 4)
+ assertEquals(8, img.width)
+ assertEquals(4, img.height)
+ }
+
+ @Test
+ fun `crop aspect ratio`() {
+ val img = SlimgImage.decode(pngBytes(16, 8))
+ .crop(aspectRatio = 1 to 1)
+ assertEquals(img.width, img.height)
+ }
+
+ // ── Extend ──────────────────────────────────────────
+
+ @Test
+ fun `extend to size`() {
+ val img = SlimgImage.decode(pngBytes(8, 8))
+ .extend(16, 16)
+ assertEquals(16, img.width)
+ assertEquals(16, img.height)
+ }
+
+ @Test
+ fun `extend to aspect ratio`() {
+ val img = SlimgImage.decode(pngBytes(8, 8))
+ .extend(aspectRatio = 2 to 1)
+ assertEquals(2.0, img.width.toDouble() / img.height, 0.01)
+ }
+
+ @Test
+ fun `extend with solid color`() {
+ val img = SlimgImage.decode(pngBytes(4, 4))
+ .extend(8, 8, fill = Slimg.solidColor(255, 0, 0))
+ assertEquals(8, img.width)
+ }
+
+ // ── Encode ──────────────────────────────────────────
+
+ @Test
+ fun `encode to format`() {
+ val result = SlimgImage.decode(pngBytes())
+ .encode(Format.WEB_P, quality = 85)
+ assertEquals(Format.WEB_P, result.format)
+ assertTrue(result.data.isNotEmpty())
+ }
+
+ @Test
+ fun `optimize re-encodes in source format`() {
+ val result = SlimgImage.decode(pngBytes())
+ .optimize(quality = 60)
+ assertEquals(Format.PNG, result.format)
+ }
+
+ @Test
+ fun `optimize throws when format unknown`() {
+ val img = SlimgImage.from(testImage())
+ assertFailsWith {
+ img.optimize()
+ }
+ }
+
+ // ── Chaining ────────────────────────────────────────
+
+ @Test
+ fun `full pipeline chain`() {
+ val result = SlimgImage.decode(pngBytes(16, 16))
+ .resize(width = 12)
+ .crop(aspectRatio = 1 to 1)
+ .encode(Format.WEB_P, quality = 85)
+
+ assertEquals(Format.WEB_P, result.format)
+ assertTrue(result.data.isNotEmpty())
+ }
+
+ @Test
+ fun `chain preserves source format`() {
+ val img = SlimgImage.decode(pngBytes(16, 16))
+ .resize(width = 8)
+ .crop(aspectRatio = 1 to 1)
+
+ assertEquals(Format.PNG, img.format)
+ }
+
+ @Test
+ fun `chain does not mutate original`() {
+ val original = SlimgImage.decode(pngBytes(16, 16))
+ val resized = original.resize(width = 8)
+
+ assertEquals(16, original.width)
+ assertEquals(8, resized.width)
+ }
+
+ @Test
+ fun `decode stream resize extend encode`() {
+ val result = SlimgImage.decode(ByteArrayInputStream(pngBytes(10, 10)))
+ .resize(width = 8)
+ .extend(aspectRatio = 16 to 9, fill = Slimg.solidColor(0, 0, 0))
+ .encode(Format.PNG, quality = 90)
+
+ val decoded = Slimg.decode(result.data)
+ assertEquals(Format.PNG, decoded.format)
+ }
+}
diff --git a/bindings/kotlin/src/test/kotlin/io/clroot/slimg/SlimgObjectTest.kt b/bindings/kotlin/src/test/kotlin/io/clroot/slimg/SlimgObjectTest.kt
new file mode 100644
index 0000000..9df83ce
--- /dev/null
+++ b/bindings/kotlin/src/test/kotlin/io/clroot/slimg/SlimgObjectTest.kt
@@ -0,0 +1,181 @@
+package io.clroot.slimg
+
+import java.io.ByteArrayInputStream
+import java.nio.ByteBuffer
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class SlimgObjectTest {
+
+ private fun testImage(width: UInt = 8u, height: UInt = 8u): ImageData {
+ val data = ByteArray(width.toInt() * height.toInt() * 4) { 0xFF.toByte() }
+ return ImageData(width, height, data)
+ }
+
+ private fun pngBytes(image: ImageData = testImage()): ByteArray =
+ Slimg.convert(image, Format.PNG).data
+
+ // ── Decode ──────────────────────────────────────────
+
+ @Test
+ fun `decode ByteArray`() {
+ val bytes = pngBytes()
+ val result = Slimg.decode(bytes)
+ assertEquals(Format.PNG, result.format)
+ assertEquals(8u, result.image.width)
+ }
+
+ @Test
+ fun `decode InputStream`() {
+ val bytes = pngBytes()
+ val result = Slimg.decode(ByteArrayInputStream(bytes))
+ assertEquals(Format.PNG, result.format)
+ }
+
+ @Test
+ fun `decode ByteBuffer`() {
+ val bytes = pngBytes()
+ val result = Slimg.decode(ByteBuffer.wrap(bytes))
+ assertEquals(Format.PNG, result.format)
+ }
+
+ // ── Optimize ────────────────────────────────────────
+
+ @Test
+ fun `optimize with Int quality`() {
+ val bytes = pngBytes()
+ val result = Slimg.optimize(bytes, quality = 60)
+ assertEquals(Format.PNG, result.format)
+ assertTrue(result.data.isNotEmpty())
+ }
+
+ @Test
+ fun `optimize default quality`() {
+ val bytes = pngBytes()
+ val result = Slimg.optimize(bytes)
+ assertTrue(result.data.isNotEmpty())
+ }
+
+ @Test
+ fun `optimize InputStream with Int quality`() {
+ val bytes = pngBytes()
+ val result = Slimg.optimize(ByteArrayInputStream(bytes), quality = 60)
+ assertTrue(result.data.isNotEmpty())
+ }
+
+ @Test
+ fun `optimize ByteBuffer with Int quality`() {
+ val bytes = pngBytes()
+ val result = Slimg.optimize(ByteBuffer.wrap(bytes), quality = 60)
+ assertTrue(result.data.isNotEmpty())
+ }
+
+ @Test
+ fun `optimize clamps out-of-range quality`() {
+ val bytes = pngBytes()
+ // Should not throw - values clamped to 0..100
+ Slimg.optimize(bytes, quality = -10)
+ Slimg.optimize(bytes, quality = 200)
+ }
+
+ // ── Convert ─────────────────────────────────────────
+
+ @Test
+ fun `convert with defaults`() {
+ val image = testImage()
+ val result = Slimg.convert(image, Format.WEB_P)
+ assertEquals(Format.WEB_P, result.format)
+ assertTrue(result.data.isNotEmpty())
+ }
+
+ @Test
+ fun `convert with resize`() {
+ val image = testImage(16u, 16u)
+ val result = Slimg.convert(image, Format.PNG, resize = ResizeMode.Width(8u))
+ assertEquals(Format.PNG, result.format)
+ }
+
+ @Test
+ fun `convert with crop and quality`() {
+ val image = testImage(16u, 16u)
+ val result = Slimg.convert(
+ image,
+ Format.PNG,
+ quality = 90,
+ crop = CropMode.AspectRatio(1u, 1u),
+ )
+ val decoded = Slimg.decode(result.data)
+ assertEquals(decoded.image.width, decoded.image.height)
+ }
+
+ // ── Image Operations ────────────────────────────────
+
+ @Test
+ fun `crop delegates correctly`() {
+ val image = testImage(10u, 10u)
+ val cropped = Slimg.crop(image, CropMode.Region(0u, 0u, 5u, 5u))
+ assertEquals(5u, cropped.width)
+ assertEquals(5u, cropped.height)
+ }
+
+ @Test
+ fun `resize delegates correctly`() {
+ val image = testImage(10u, 10u)
+ val resized = Slimg.resize(image, ResizeMode.Width(5u))
+ assertEquals(5u, resized.width)
+ }
+
+ @Test
+ fun `extend with default fill`() {
+ val image = testImage(8u, 8u)
+ val extended = Slimg.extend(image, ExtendMode.Size(16u, 16u))
+ assertEquals(16u, extended.width)
+ assertEquals(16u, extended.height)
+ }
+
+ // ── solidColor ──────────────────────────────────────
+
+ @Test
+ fun `solidColor creates FillColor with Int values`() {
+ val fill = Slimg.solidColor(255, 128, 0)
+ assertEquals(FillColor.Solid(255u.toUByte(), 128u.toUByte(), 0u.toUByte(), 255u.toUByte()), fill)
+ }
+
+ @Test
+ fun `solidColor with custom alpha`() {
+ val fill = Slimg.solidColor(0, 0, 0, 128)
+ assertEquals(FillColor.Solid(0u.toUByte(), 0u.toUByte(), 0u.toUByte(), 128u.toUByte()), fill)
+ }
+
+ @Test
+ fun `extend with solidColor`() {
+ val image = testImage(4u, 4u)
+ val extended = Slimg.extend(
+ image,
+ ExtendMode.Size(8u, 8u),
+ Slimg.solidColor(255, 0, 0),
+ )
+ assertEquals(8u, extended.width)
+ }
+
+ // ── Format Utilities ────────────────────────────────
+
+ @Test
+ fun `formatFromExtension via wrapper`() {
+ assertEquals(Format.PNG, Slimg.formatFromExtension("image.png"))
+ assertNull(Slimg.formatFromExtension("file.xyz"))
+ }
+
+ @Test
+ fun `formatCanEncode via wrapper`() {
+ assertTrue(Slimg.formatCanEncode(Format.PNG))
+ }
+
+ @Test
+ fun `formatFromMagicBytes ByteBuffer via wrapper`() {
+ val bytes = pngBytes()
+ assertEquals(Format.PNG, Slimg.formatFromMagicBytes(ByteBuffer.wrap(bytes)))
+ }
+}
diff --git a/bindings/kotlin/src/test/kotlin/io/clroot/slimg/SlimgStreamsTest.kt b/bindings/kotlin/src/test/kotlin/io/clroot/slimg/SlimgStreamsTest.kt
new file mode 100644
index 0000000..c6e3da5
--- /dev/null
+++ b/bindings/kotlin/src/test/kotlin/io/clroot/slimg/SlimgStreamsTest.kt
@@ -0,0 +1,147 @@
+package io.clroot.slimg
+
+import java.io.ByteArrayInputStream
+import java.nio.ByteBuffer
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+import kotlin.test.assertNull
+
+class SlimgStreamsTest {
+
+ private fun createTestImage(width: UInt, height: UInt): ImageData {
+ val data = ByteArray(width.toInt() * height.toInt() * 4)
+ for (row in 0 until height.toInt()) {
+ for (col in 0 until width.toInt()) {
+ val offset = (row * width.toInt() + col) * 4
+ data[offset] = row.toByte()
+ data[offset + 1] = col.toByte()
+ data[offset + 2] = 0xFF.toByte()
+ data[offset + 3] = 0xFF.toByte()
+ }
+ }
+ return ImageData(width, height, data)
+ }
+
+ private fun encodePng(image: ImageData): ByteArray =
+ convert(image, PipelineOptions(
+ format = Format.PNG,
+ quality = 100u.toUByte(),
+ resize = null,
+ crop = null,
+ extend = null,
+ fillColor = null,
+ )).data
+
+ // ── decode(InputStream) ─────────────────────────────
+
+ @Test
+ fun `decode InputStream returns same result as decode ByteArray`() {
+ val image = createTestImage(6u, 4u)
+ val pngBytes = encodePng(image)
+
+ val fromBytes = decode(pngBytes)
+ val fromStream = decode(ByteArrayInputStream(pngBytes))
+
+ assertEquals(fromBytes.format, fromStream.format)
+ assertEquals(fromBytes.image.width, fromStream.image.width)
+ assertEquals(fromBytes.image.height, fromStream.image.height)
+ }
+
+ // ── optimize(InputStream) ───────────────────────────
+
+ @Test
+ fun `optimize InputStream returns same result as optimize ByteArray`() {
+ val image = createTestImage(8u, 8u)
+ val pngBytes = encodePng(image)
+
+ val fromBytes = optimize(pngBytes, 60u.toUByte())
+ val fromStream = optimize(ByteArrayInputStream(pngBytes), 60u.toUByte())
+
+ assertEquals(fromBytes.format, fromStream.format)
+ assertTrue(fromStream.data.isNotEmpty())
+ }
+
+ // ── formatFromMagicBytes(InputStream) ───────────────
+
+ @Test
+ fun `formatFromMagicBytes InputStream detects PNG`() {
+ val image = createTestImage(4u, 4u)
+ val pngBytes = encodePng(image)
+
+ val format = formatFromMagicBytes(ByteArrayInputStream(pngBytes))
+ assertEquals(Format.PNG, format)
+ }
+
+ @Test
+ fun `formatFromMagicBytes InputStream returns null for unknown data`() {
+ val unknownBytes = byteArrayOf(0x00, 0x01, 0x02, 0x03)
+ val format = formatFromMagicBytes(ByteArrayInputStream(unknownBytes))
+ assertNull(format)
+ }
+
+ // ── decode(ByteBuffer) ──────────────────────────────
+
+ @Test
+ fun `decode ByteBuffer returns same result as decode ByteArray`() {
+ val image = createTestImage(6u, 4u)
+ val pngBytes = encodePng(image)
+
+ val fromBytes = decode(pngBytes)
+ val fromBuffer = decode(ByteBuffer.wrap(pngBytes))
+
+ assertEquals(fromBytes.format, fromBuffer.format)
+ assertEquals(fromBytes.image.width, fromBuffer.image.width)
+ assertEquals(fromBytes.image.height, fromBuffer.image.height)
+ }
+
+ @Test
+ fun `decode ByteBuffer does not modify buffer position`() {
+ val pngBytes = encodePng(createTestImage(4u, 4u))
+ val buffer = ByteBuffer.wrap(pngBytes)
+ val positionBefore = buffer.position()
+
+ decode(buffer)
+
+ assertEquals(positionBefore, buffer.position())
+ }
+
+ @Test
+ fun `decode direct ByteBuffer works`() {
+ val pngBytes = encodePng(createTestImage(4u, 4u))
+ val directBuffer = ByteBuffer.allocateDirect(pngBytes.size)
+ directBuffer.put(pngBytes)
+ directBuffer.flip()
+
+ val result = decode(directBuffer)
+ assertEquals(Format.PNG, result.format)
+ assertEquals(4u, result.image.width)
+ }
+
+ // ── optimize(ByteBuffer) ────────────────────────────
+
+ @Test
+ fun `optimize ByteBuffer returns valid result`() {
+ val pngBytes = encodePng(createTestImage(8u, 8u))
+
+ val result = optimize(ByteBuffer.wrap(pngBytes), 60u.toUByte())
+
+ assertEquals(Format.PNG, result.format)
+ assertTrue(result.data.isNotEmpty())
+ }
+
+ // ── formatFromMagicBytes(ByteBuffer) ────────────────
+
+ @Test
+ fun `formatFromMagicBytes ByteBuffer detects PNG`() {
+ val pngBytes = encodePng(createTestImage(4u, 4u))
+ val format = formatFromMagicBytes(ByteBuffer.wrap(pngBytes))
+ assertEquals(Format.PNG, format)
+ }
+
+ @Test
+ fun `formatFromMagicBytes ByteBuffer returns null for unknown data`() {
+ val format = formatFromMagicBytes(ByteBuffer.wrap(byteArrayOf(0x00, 0x01, 0x02, 0x03)))
+ assertNull(format)
+ }
+}
diff --git a/bindings/python/README.md b/bindings/python/README.md
index 1b595c0..3cf57b8 100644
--- a/bindings/python/README.md
+++ b/bindings/python/README.md
@@ -99,4 +99,4 @@ extended = slimg.extend(image, aspect_ratio=(1, 1))
## License
-MIT OR Apache-2.0
+MIT
diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml
index f881480..1daea50 100644
--- a/bindings/python/pyproject.toml
+++ b/bindings/python/pyproject.toml
@@ -4,16 +4,15 @@ build-backend = "maturin"
[project]
name = "slimg"
-version = "0.3.1"
+version = "0.4.0"
requires-python = ">=3.9"
description = "Fast image optimization library powered by Rust"
readme = "README.md"
-license = { text = "MIT OR Apache-2.0" }
+license = { text = "MIT" }
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",
diff --git a/cli/Cargo.toml b/cli/Cargo.toml
index d29ba7a..6e67018 100644
--- a/cli/Cargo.toml
+++ b/cli/Cargo.toml
@@ -1,8 +1,8 @@
[package]
name = "slimg"
-version = "0.3.1"
+version = "0.4.0"
edition = "2024"
-license = "MIT OR Apache-2.0"
+license = "MIT"
description = "Image optimization CLI — convert, compress, and resize images using MozJPEG, OxiPNG, WebP, AVIF, and QOI"
repository = "https://github.com/clroot/slimg"
homepage = "https://github.com/clroot/slimg"
@@ -11,7 +11,7 @@ keywords = ["image", "optimization", "cli", "compression", "webp"]
categories = ["multimedia::images", "command-line-utilities"]
[dependencies]
-slimg-core = { version = "0.3.1", path = "../crates/slimg-core" }
+slimg-core = { version = "0.4.0", 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 965293e..ae5b23b 100644
--- a/crates/slimg-core/Cargo.toml
+++ b/crates/slimg-core/Cargo.toml
@@ -1,8 +1,8 @@
[package]
name = "slimg-core"
-version = "0.3.1"
+version = "0.4.0"
edition = "2024"
-license = "MIT OR Apache-2.0"
+license = "MIT"
description = "Image optimization library — encode, decode, convert, resize with MozJPEG, OxiPNG, WebP, AVIF, and QOI"
repository = "https://github.com/clroot/slimg"
homepage = "https://github.com/clroot/slimg"
diff --git a/crates/slimg-core/README.md b/crates/slimg-core/README.md
index bb761e2..0390f1b 100644
--- a/crates/slimg-core/README.md
+++ b/crates/slimg-core/README.md
@@ -59,4 +59,4 @@ For batch processing and command-line usage, see [slimg](https://crates.io/crate
## License
-MIT OR Apache-2.0
+MIT
diff --git a/crates/slimg-ffi/Cargo.toml b/crates/slimg-ffi/Cargo.toml
index 6e8ac14..101e123 100644
--- a/crates/slimg-ffi/Cargo.toml
+++ b/crates/slimg-ffi/Cargo.toml
@@ -1,8 +1,8 @@
[package]
name = "slimg-ffi"
-version = "0.3.1"
+version = "0.4.0"
edition = "2024"
-license = "MIT OR Apache-2.0"
+license = "MIT"
description = "UniFFI bindings for slimg-core image optimization library"
publish = false