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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 26 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ A Kotlin Multiplatform image selection library for Android, iOS and Desktop.
## Features
* Selecting images on Android, iOS and Desktop
* Setting aspect ratios
* Compressing images by quality or toward a target file size

## Supported Platform
## Supported Platform

| Platform | Supported |
|:---------|:----------|
Expand All @@ -20,7 +21,7 @@ A Kotlin Multiplatform image selection library for Android, iOS and Desktop.
| Desktop | ✔️ |
| Web | ❌️ |

## 🚀 Installation
## Installation
See the releases section of this repository for the latest version.

To your `build.gradle` under `commonMain.dependencies` add:
Expand Down Expand Up @@ -68,6 +69,26 @@ class AppViewModel : ViewModel() {
}
```

You can then compress the selected image when needed:

```kotlin
val compressed = image.value?.compress(
ImageCompressionOptions(
quality = 88
)
)
```

Or target a maximum size, for example 5 MB:

```kotlin
val underFiveMb = image.value?.compressToMaxBytes(
maxBytes = 5L * 1024L * 1024L
)
```

> **Note:** `maxBytes` is only enforced for JPEG images. PNG is a lossless format with no quality parameter, so the `maxBytes` constraint is ignored when compressing to PNG, the image will be returned at full size.

Example Composable:
```kotlin
@Composable
Expand Down Expand Up @@ -96,9 +117,9 @@ fun App(viewModel: AppViewModel = viewModel { AppViewModel() }) {
}
```

## 📄 License
## License
MIT LICENSE. See [LICENSE](./LICENSE) for details.

## 🙌 Contributing
## Contributing
Pull requests and feature requests are welcome!
If you encounter any issues, feel free to open an issue.
If you encounter any issues, feel free to open an issue.
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,35 @@ actual fun ImageBitmap.toByteArray(): ByteArray {
val stream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
return stream.toByteArray()
}
}

actual fun ByteArray.compressImage(options: ImageCompressionOptions): ByteArray {
val bitmap = BitmapFactory.decodeByteArray(this, 0, size) ?: return this

val format = when (options.format) {
ImageCompressionFormat.JPEG -> Bitmap.CompressFormat.JPEG
ImageCompressionFormat.PNG -> Bitmap.CompressFormat.PNG
}

var quality = options.quality
var compressed = bitmap.encode(format, quality)

if (format == Bitmap.CompressFormat.JPEG) {
val maxBytes = options.maxBytes
while (maxBytes != null && compressed.size > maxBytes && quality > options.minQuality) {
quality = maxOf(options.minQuality, quality - options.qualityStep)
compressed = bitmap.encode(format, quality)
if (quality == options.minQuality) {
break
}
}
}

return compressed
}

private fun Bitmap.encode(format: Bitmap.CompressFormat, quality: Int): ByteArray {
val stream = ByteArrayOutputStream()
compress(format, quality, stream)
return stream.toByteArray()
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,12 @@ expect fun ByteArray.toImageBitmap(): ImageBitmap
* This is an `expect` function; its actual implementation is platform-specific.
*/
expect fun ImageBitmap.toByteArray(): ByteArray

/**
* Compresses this [ByteArray] if it contains a supported image.
*
* If compression cannot be applied, the original bytes are returned unchanged.
*/
expect fun ByteArray.compressImage(
options: ImageCompressionOptions = ImageCompressionOptions()
): ByteArray
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package com.wannaverse.imageselector

/**
* Output format used during compression.
*/
enum class ImageCompressionFormat {
JPEG,
PNG
}

/**
* Options that control image compression.
*
* JPEG is the default because it gives predictable file-size savings for photos.
* PNG is supported as a lossless option, but size targeting becomes best-effort.
*/
data class ImageCompressionOptions(
val format: ImageCompressionFormat = ImageCompressionFormat.JPEG,
val quality: Int = 90,
val maxBytes: Long? = null,
val minQuality: Int = 55,
val qualityStep: Int = 5
) {
init {
require(quality in 0..100) { "quality must be between 0 and 100." }
require(minQuality in 0..100) { "minQuality must be between 0 and 100." }
require(minQuality <= quality) { "minQuality must be less than or equal to quality." }
require(qualityStep > 0) { "qualityStep must be greater than 0." }
require(maxBytes == null || maxBytes > 0) { "maxBytes must be greater than 0 when provided." }
}
}

/**
* Returns a compressed copy of this [ImageData].
*/
fun ImageData.compress(
options: ImageCompressionOptions = ImageCompressionOptions()
): ImageData = copy(
bytes = bytes?.compressImage(options)
)

/**
* Compresses this [ByteArray] toward the provided maximum size.
*/
fun ByteArray.compressToMaxBytes(
maxBytes: Long,
format: ImageCompressionFormat = ImageCompressionFormat.JPEG,
quality: Int = 90,
minQuality: Int = 55,
qualityStep: Int = 5
): ByteArray = compressImage(
ImageCompressionOptions(
format = format,
quality = quality,
maxBytes = maxBytes,
minQuality = minQuality,
qualityStep = qualityStep
)
)

/**
* Compresses this [ImageData] toward the provided maximum size.
*/
fun ImageData.compressToMaxBytes(
maxBytes: Long,
format: ImageCompressionFormat = ImageCompressionFormat.JPEG,
quality: Int = 90,
minQuality: Int = 55,
qualityStep: Int = 5
): ImageData = compress(
ImageCompressionOptions(
format = format,
quality = quality,
maxBytes = maxBytes,
minQuality = minQuality,
qualityStep = qualityStep
)
)
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,19 @@ package com.wannaverse.imageselector
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asSkiaBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.usePinned
import org.jetbrains.skia.Data
import org.jetbrains.skia.EncodedImageFormat
import org.jetbrains.skia.Image
import platform.Foundation.NSData
import platform.Foundation.NSMutableData
import platform.Foundation.appendBytes
import platform.UIKit.UIImage
import platform.UIKit.UIImageJPEGRepresentation
import platform.UIKit.UIImagePNGRepresentation
import platform.posix.memcpy

actual fun ByteArray.toImageBitmap(): ImageBitmap {
return Image
Expand All @@ -16,4 +26,48 @@ actual fun ByteArray.toImageBitmap(): ImageBitmap {
actual fun ImageBitmap.toByteArray(): ByteArray {
val skiaImage = Image.makeFromBitmap(this.asSkiaBitmap())
return skiaImage.encodeToData(EncodedImageFormat.PNG, 100)?.bytes ?: ByteArray(0)
}
}

actual fun ByteArray.compressImage(options: ImageCompressionOptions): ByteArray {
val image = UIImage(data = toNSData()) ?: return this

var quality = options.quality
var compressed = image.encode(options.format, quality) ?: return this

if (options.format == ImageCompressionFormat.JPEG) {
val maxBytes = options.maxBytes
while (maxBytes != null && compressed.length.toLong() > maxBytes && quality > options.minQuality) {
quality = maxOf(options.minQuality, quality - options.qualityStep)
compressed = image.encode(options.format, quality) ?: break
if (quality == options.minQuality) {
break
}
}
}

return compressed.toByteArray()
}

private fun UIImage.encode(format: ImageCompressionFormat, quality: Int): NSData? {
return when (format) {
ImageCompressionFormat.JPEG -> UIImageJPEGRepresentation(this, quality.toDouble() / 100.0)
ImageCompressionFormat.PNG -> UIImagePNGRepresentation(this)
}
}

@OptIn(ExperimentalForeignApi::class)
private fun ByteArray.toNSData(): NSData {
val data = NSMutableData()
if (isEmpty()) return data
usePinned { data.appendBytes(it.addressOf(0), size.toULong()) }
return data
}

@OptIn(ExperimentalForeignApi::class)
private fun NSData.toByteArray(): ByteArray {
val bytes = ByteArray(length.toInt())
bytes.usePinned {
memcpy(it.addressOf(0), this.bytes, length)
}
return bytes
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import platform.darwin.NSObject
import platform.posix.memcpy
import kotlin.coroutines.resume

private var activePickerDelegate: NSObject? = null
private var activePicker: UIImagePickerController? = null

actual suspend fun selectImage(): ImageData? = suspendCancellableCoroutine { continuation ->
val picker = UIImagePickerController().apply {
sourceType =
Expand All @@ -34,31 +37,44 @@ actual suspend fun selectImage(): ImageData? = suspendCancellableCoroutine { con
val bytes = data?.toByteArray()

picker.dismissViewControllerAnimated(true) {
continuation.resume(
ImageData(
bytes = bytes
)
)
clearActivePickerReferences()
continuation.resume(bytes?.let { ImageData(bytes = it) })
}
}

override fun imagePickerControllerDidCancel(picker: UIImagePickerController) {
picker.dismissViewControllerAnimated(true) {
clearActivePickerReferences()
continuation.resume(null)
}
}
}

picker.delegate = delegate
activePicker = picker
activePickerDelegate = delegate

val rootController = UIApplication.sharedApplication.keyWindow?.rootViewController
rootController?.presentViewController(picker, true, null)
if (rootController == null) {
clearActivePickerReferences()
continuation.resume(null)
return@suspendCancellableCoroutine
}

rootController.presentViewController(picker, true, null)

continuation.invokeOnCancellation {
picker.dismissViewControllerAnimated(true, null)
clearActivePickerReferences()
}
}

private fun clearActivePickerReferences() {
activePicker?.delegate = null
activePicker = null
activePickerDelegate = null
}

private fun UIImage.toJpegData(quality: Double = 1.0): NSData? {
return UIImageJPEGRepresentation(this, quality)
}
Expand All @@ -71,4 +87,4 @@ private fun NSData.toByteArray(): ByteArray {
memcpy(rawPtr, this@toByteArray.bytes, this@toByteArray.length)
}
return bytes
}
}
Loading
Loading