Skip to content

Commit 2d2745b

Browse files
committed
feat: add support for compressing images
1 parent 93d2559 commit 2d2745b

6 files changed

Lines changed: 260 additions & 8 deletions

File tree

README.md

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ A Kotlin Multiplatform image selection library for Android, iOS and Desktop.
1010
## Features
1111
* Selecting images on Android, iOS and Desktop
1212
* Setting aspect ratios
13+
* Compressing images by quality or toward a target file size
1314

14-
## Supported Platform
15+
## Supported Platform
1516

1617
| Platform | Supported |
1718
|:---------|:----------|
@@ -20,7 +21,7 @@ A Kotlin Multiplatform image selection library for Android, iOS and Desktop.
2021
| Desktop | ✔️ |
2122
| Web | ❌️ |
2223

23-
## 🚀 Installation
24+
## Installation
2425
See the releases section of this repository for the latest version.
2526

2627
To your `build.gradle` under `commonMain.dependencies` add:
@@ -68,6 +69,24 @@ class AppViewModel : ViewModel() {
6869
}
6970
```
7071

72+
You can then compress the selected image when needed:
73+
74+
```kotlin
75+
val compressed = image.value?.compress(
76+
ImageCompressionOptions(
77+
quality = 88
78+
)
79+
)
80+
```
81+
82+
Or target a maximum size, for example 5 MB:
83+
84+
```kotlin
85+
val underFiveMb = image.value?.compressToMaxBytes(
86+
maxBytes = 5L * 1024L * 1024L
87+
)
88+
```
89+
7190
Example Composable:
7291
```kotlin
7392
@Composable
@@ -96,9 +115,9 @@ fun App(viewModel: AppViewModel = viewModel { AppViewModel() }) {
96115
}
97116
```
98117

99-
## 📄 License
118+
## License
100119
MIT LICENSE. See [LICENSE](./LICENSE) for details.
101120

102-
## 🙌 Contributing
121+
## Contributing
103122
Pull requests and feature requests are welcome!
104-
If you encounter any issues, feel free to open an issue.
123+
If you encounter any issues, feel free to open an issue.

imageselector/src/androidMain/kotlin/com/wannaverse/imageselector/ByteArrayExtension.kt

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,35 @@ actual fun ImageBitmap.toByteArray(): ByteArray {
1616
val stream = ByteArrayOutputStream()
1717
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
1818
return stream.toByteArray()
19-
}
19+
}
20+
21+
actual fun ByteArray.compressImage(options: ImageCompressionOptions): ByteArray {
22+
val bitmap = BitmapFactory.decodeByteArray(this, 0, size) ?: return this
23+
24+
val format = when (options.format) {
25+
ImageCompressionFormat.JPEG -> Bitmap.CompressFormat.JPEG
26+
ImageCompressionFormat.PNG -> Bitmap.CompressFormat.PNG
27+
}
28+
29+
var quality = options.quality
30+
var compressed = bitmap.encode(format, quality)
31+
32+
if (format == Bitmap.CompressFormat.JPEG) {
33+
val maxBytes = options.maxBytes
34+
while (maxBytes != null && compressed.size > maxBytes && quality > options.minQuality) {
35+
quality = maxOf(options.minQuality, quality - options.qualityStep)
36+
compressed = bitmap.encode(format, quality)
37+
if (quality == options.minQuality) {
38+
break
39+
}
40+
}
41+
}
42+
43+
return compressed
44+
}
45+
46+
private fun Bitmap.encode(format: Bitmap.CompressFormat, quality: Int): ByteArray {
47+
val stream = ByteArrayOutputStream()
48+
compress(format, quality, stream)
49+
return stream.toByteArray()
50+
}

imageselector/src/commonMain/kotlin/com/wannaverse/imageselector/ByteArrayExtension.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,12 @@ expect fun ByteArray.toImageBitmap(): ImageBitmap
1515
* This is an `expect` function; its actual implementation is platform-specific.
1616
*/
1717
expect fun ImageBitmap.toByteArray(): ByteArray
18+
19+
/**
20+
* Compresses this [ByteArray] if it contains a supported image.
21+
*
22+
* If compression cannot be applied, the original bytes are returned unchanged.
23+
*/
24+
expect fun ByteArray.compressImage(
25+
options: ImageCompressionOptions = ImageCompressionOptions()
26+
): ByteArray
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package com.wannaverse.imageselector
2+
3+
/**
4+
* Output format used during compression.
5+
*/
6+
enum class ImageCompressionFormat {
7+
JPEG,
8+
PNG
9+
}
10+
11+
/**
12+
* Options that control image compression.
13+
*
14+
* JPEG is the default because it gives predictable file-size savings for photos.
15+
* PNG is supported as a lossless option, but size targeting becomes best-effort.
16+
*/
17+
data class ImageCompressionOptions(
18+
val format: ImageCompressionFormat = ImageCompressionFormat.JPEG,
19+
val quality: Int = 90,
20+
val maxBytes: Long? = null,
21+
val minQuality: Int = 55,
22+
val qualityStep: Int = 5
23+
) {
24+
init {
25+
require(quality in 0..100) { "quality must be between 0 and 100." }
26+
require(minQuality in 0..100) { "minQuality must be between 0 and 100." }
27+
require(minQuality <= quality) { "minQuality must be less than or equal to quality." }
28+
require(qualityStep > 0) { "qualityStep must be greater than 0." }
29+
require(maxBytes == null || maxBytes > 0) { "maxBytes must be greater than 0 when provided." }
30+
}
31+
}
32+
33+
/**
34+
* Returns a compressed copy of this [ImageData].
35+
*/
36+
fun ImageData.compress(
37+
options: ImageCompressionOptions = ImageCompressionOptions()
38+
): ImageData = copy(
39+
bytes = bytes?.compressImage(options)
40+
)
41+
42+
/**
43+
* Compresses this [ByteArray] toward the provided maximum size.
44+
*/
45+
fun ByteArray.compressToMaxBytes(
46+
maxBytes: Long,
47+
format: ImageCompressionFormat = ImageCompressionFormat.JPEG,
48+
quality: Int = 90,
49+
minQuality: Int = 55,
50+
qualityStep: Int = 5
51+
): ByteArray = compressImage(
52+
ImageCompressionOptions(
53+
format = format,
54+
quality = quality,
55+
maxBytes = maxBytes,
56+
minQuality = minQuality,
57+
qualityStep = qualityStep
58+
)
59+
)
60+
61+
/**
62+
* Compresses this [ImageData] toward the provided maximum size.
63+
*/
64+
fun ImageData.compressToMaxBytes(
65+
maxBytes: Long,
66+
format: ImageCompressionFormat = ImageCompressionFormat.JPEG,
67+
quality: Int = 90,
68+
minQuality: Int = 55,
69+
qualityStep: Int = 5
70+
): ImageData = compress(
71+
ImageCompressionOptions(
72+
format = format,
73+
quality = quality,
74+
maxBytes = maxBytes,
75+
minQuality = minQuality,
76+
qualityStep = qualityStep
77+
)
78+
)

imageselector/src/iosMain/kotlin/com/wannaverse/imageselector/ByteArrayExtension.kt

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,17 @@ package com.wannaverse.imageselector
33
import androidx.compose.ui.graphics.ImageBitmap
44
import androidx.compose.ui.graphics.asSkiaBitmap
55
import androidx.compose.ui.graphics.toComposeImageBitmap
6+
import kotlinx.cinterop.ExperimentalForeignApi
7+
import kotlinx.cinterop.addressOf
8+
import kotlinx.cinterop.usePinned
69
import org.jetbrains.skia.Data
710
import org.jetbrains.skia.EncodedImageFormat
811
import org.jetbrains.skia.Image
12+
import platform.Foundation.NSData
13+
import platform.UIKit.UIImage
14+
import platform.UIKit.UIImageJPEGRepresentation
15+
import platform.UIKit.UIImagePNGRepresentation
16+
import platform.posix.memcpy
917

1018
actual fun ByteArray.toImageBitmap(): ImageBitmap {
1119
return Image
@@ -16,4 +24,45 @@ actual fun ByteArray.toImageBitmap(): ImageBitmap {
1624
actual fun ImageBitmap.toByteArray(): ByteArray {
1725
val skiaImage = Image.makeFromBitmap(this.asSkiaBitmap())
1826
return skiaImage.encodeToData(EncodedImageFormat.PNG, 100)?.bytes ?: ByteArray(0)
19-
}
27+
}
28+
29+
actual fun ByteArray.compressImage(options: ImageCompressionOptions): ByteArray {
30+
val image = UIImage(data = toNSData()) ?: return this
31+
32+
var quality = options.quality
33+
var compressed = image.encode(options.format, quality) ?: return this
34+
35+
if (options.format == ImageCompressionFormat.JPEG) {
36+
val maxBytes = options.maxBytes
37+
while (maxBytes != null && compressed.length.toLong() > maxBytes && quality > options.minQuality) {
38+
quality = maxOf(options.minQuality, quality - options.qualityStep)
39+
compressed = image.encode(options.format, quality) ?: break
40+
if (quality == options.minQuality) {
41+
break
42+
}
43+
}
44+
}
45+
46+
return compressed.toByteArray()
47+
}
48+
49+
private fun UIImage.encode(format: ImageCompressionFormat, quality: Int): NSData? {
50+
return when (format) {
51+
ImageCompressionFormat.JPEG -> UIImageJPEGRepresentation(this, quality.toDouble() / 100.0)
52+
ImageCompressionFormat.PNG -> UIImagePNGRepresentation(this)
53+
}
54+
}
55+
56+
@OptIn(ExperimentalForeignApi::class)
57+
private fun ByteArray.toNSData(): NSData = usePinned {
58+
NSData.dataWithBytes(bytes = it.addressOf(0), length = size.toULong())
59+
}
60+
61+
@OptIn(ExperimentalForeignApi::class)
62+
private fun NSData.toByteArray(): ByteArray {
63+
val bytes = ByteArray(length.toInt())
64+
bytes.usePinned {
65+
memcpy(it.addressOf(0), this.bytes, length)
66+
}
67+
return bytes
68+
}

imageselector/src/jvmMain/kotlin/com/wannaverse/imageselector/ByteArrayExtension.kt

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,15 @@ package com.wannaverse.imageselector
33
import androidx.compose.ui.graphics.ImageBitmap
44
import androidx.compose.ui.graphics.toAwtImage
55
import androidx.compose.ui.graphics.toComposeImageBitmap
6+
import java.awt.Color
7+
import java.awt.RenderingHints
68
import java.awt.image.BufferedImage
79
import java.io.ByteArrayInputStream
810
import java.io.ByteArrayOutputStream
911
import javax.imageio.ImageIO
12+
import javax.imageio.ImageWriteParam
13+
import javax.imageio.ImageWriter
14+
import javax.imageio.stream.MemoryCacheImageOutputStream
1015

1116
actual fun ByteArray.toImageBitmap(): ImageBitmap {
1217
val inputStream = ByteArrayInputStream(this)
@@ -19,4 +24,65 @@ actual fun ImageBitmap.toByteArray(): ByteArray {
1924
val outputStream = ByteArrayOutputStream()
2025
ImageIO.write(bufferedImage, "png", outputStream)
2126
return outputStream.toByteArray()
22-
}
27+
}
28+
29+
actual fun ByteArray.compressImage(options: ImageCompressionOptions): ByteArray {
30+
val sourceImage = ImageIO.read(ByteArrayInputStream(this)) ?: return this
31+
32+
var quality = options.quality
33+
var compressed = sourceImage.encode(options.format, quality) ?: return this
34+
35+
if (options.format == ImageCompressionFormat.JPEG) {
36+
val maxBytes = options.maxBytes
37+
while (maxBytes != null && compressed.size > maxBytes && quality > options.minQuality) {
38+
quality = maxOf(options.minQuality, quality - options.qualityStep)
39+
compressed = sourceImage.encode(options.format, quality) ?: break
40+
if (quality == options.minQuality) {
41+
break
42+
}
43+
}
44+
}
45+
46+
return compressed
47+
}
48+
49+
private fun BufferedImage.encode(format: ImageCompressionFormat, quality: Int): ByteArray? {
50+
return when (format) {
51+
ImageCompressionFormat.PNG -> ByteArrayOutputStream().use { output ->
52+
ImageIO.write(this, "png", output)
53+
output.toByteArray()
54+
}
55+
ImageCompressionFormat.JPEG -> encodeJpeg(quality)
56+
}
57+
}
58+
59+
private fun BufferedImage.encodeJpeg(quality: Int): ByteArray? {
60+
val writer = ImageIO.getImageWritersByFormatName("jpeg").asSequence().firstOrNull() ?: return null
61+
return ByteArrayOutputStream().use { output ->
62+
MemoryCacheImageOutputStream(output).use { imageOutput ->
63+
writer.output = imageOutput
64+
writer.write(null, javax.imageio.IIOImage(asJpegCompatibleImage(), null, null), writer.jpegWriteParam(quality))
65+
writer.dispose()
66+
}
67+
output.toByteArray()
68+
}
69+
}
70+
71+
private fun BufferedImage.asJpegCompatibleImage(): BufferedImage {
72+
if (type == BufferedImage.TYPE_INT_RGB) return this
73+
74+
val converted = BufferedImage(width, height, BufferedImage.TYPE_INT_RGB)
75+
val graphics = converted.createGraphics()
76+
graphics.color = Color.WHITE
77+
graphics.fillRect(0, 0, width, height)
78+
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR)
79+
graphics.drawImage(this, 0, 0, null)
80+
graphics.dispose()
81+
return converted
82+
}
83+
84+
private fun ImageWriter.jpegWriteParam(quality: Int): ImageWriteParam =
85+
defaultWriteParam.apply {
86+
compressionMode = ImageWriteParam.MODE_EXPLICIT
87+
compressionQuality = quality.coerceIn(0, 100) / 100f
88+
}

0 commit comments

Comments
 (0)