From d58f0847f549f8fe0ac857a4a63a6d5bff0a9bd0 Mon Sep 17 00:00:00 2001 From: T8RIN Date: Wed, 28 Aug 2024 04:17:41 +0300 Subject: [PATCH] Added ICO image conversion support --- .../core/data/image/AndroidImageCompressor.kt | 3 +- .../core/data/image/utils/SimpleCompressor.kt | 354 +++++++++++------- .../core/domain/image/model/ImageFormat.kt | 13 +- .../domain/image/model/ImageFormatGroup.kt | 12 +- .../resources/src/main/res/values/strings.xml | 1 + .../core/ui/widget/controls/IcoSizeWarning.kt | 71 ++++ .../core/ui/widget/controls/OOMWarning.kt | 4 +- .../ui/widget/controls/ResizeImageField.kt | 7 +- gradle/libs.versions.toml | 4 +- 9 files changed, 328 insertions(+), 141 deletions(-) create mode 100644 core/ui/src/main/kotlin/ru/tech/imageresizershrinker/core/ui/widget/controls/IcoSizeWarning.kt diff --git a/core/data/src/main/java/ru/tech/imageresizershrinker/core/data/image/AndroidImageCompressor.kt b/core/data/src/main/java/ru/tech/imageresizershrinker/core/data/image/AndroidImageCompressor.kt index aa8780d07..8d180d457 100644 --- a/core/data/src/main/java/ru/tech/imageresizershrinker/core/data/image/AndroidImageCompressor.kt +++ b/core/data/src/main/java/ru/tech/imageresizershrinker/core/data/image/AndroidImageCompressor.kt @@ -74,7 +74,8 @@ internal class AndroidImageCompressor @Inject constructor( SimpleCompressor .getInstance( imageFormat = imageFormat, - context = context + context = context, + imageScaler = imageScaler ) .compress( image = image.toSoftware(), diff --git a/core/data/src/main/java/ru/tech/imageresizershrinker/core/data/image/utils/SimpleCompressor.kt b/core/data/src/main/java/ru/tech/imageresizershrinker/core/data/image/utils/SimpleCompressor.kt index 897a832c9..7512af2c7 100644 --- a/core/data/src/main/java/ru/tech/imageresizershrinker/core/data/image/utils/SimpleCompressor.kt +++ b/core/data/src/main/java/ru/tech/imageresizershrinker/core/data/image/utils/SimpleCompressor.kt @@ -34,15 +34,19 @@ import com.radzivon.bartoshyk.avif.coder.AvifSpeed import com.radzivon.bartoshyk.avif.coder.HeifCoder import com.radzivon.bartoshyk.avif.coder.PreciseMode import com.t8rin.qoi_coder.QOIEncoder +import kotlinx.coroutines.coroutineScope import org.beyka.tiffbitmapfactory.CompressionScheme import org.beyka.tiffbitmapfactory.Orientation import org.beyka.tiffbitmapfactory.TiffSaver import org.beyka.tiffbitmapfactory.TiffSaver.SaveOptions +import ru.tech.imageresizershrinker.core.domain.image.ImageScaler import ru.tech.imageresizershrinker.core.domain.image.model.ImageFormat import ru.tech.imageresizershrinker.core.domain.image.model.Quality +import ru.tech.imageresizershrinker.core.domain.image.model.ResizeType import java.io.ByteArrayOutputStream import java.io.File import java.nio.ByteBuffer +import java.nio.ByteOrder internal abstract class SimpleCompressor { @@ -51,7 +55,8 @@ internal abstract class SimpleCompressor { fun getInstance( imageFormat: ImageFormat, - context: Context + context: Context, + imageScaler: ImageScaler ): SimpleCompressor = when (imageFormat) { ImageFormat.Avif.Lossless -> AvifLossless(context) ImageFormat.Avif.Lossy -> AvifLossy(context) @@ -79,6 +84,8 @@ internal abstract class SimpleCompressor { ImageFormat.Tiff -> Tiff(context) ImageFormat.Qoi -> Qoi + + ImageFormat.Ico -> Ico(imageScaler) } } @@ -88,138 +95,6 @@ internal abstract class SimpleCompressor { quality: Quality ): ByteArray - data object Bmp : SimpleCompressor() { - private const val BMP_WIDTH_OF_TIMES = 4 - private const val BYTE_PER_PIXEL = 3 - - private fun writeInt(value: Int): ByteArray { - val b = ByteArray(4) - b[0] = (value and 0x000000FF).toByte() - b[1] = (value and 0x0000FF00 shr 8).toByte() - b[2] = (value and 0x00FF0000 shr 16).toByte() - b[3] = (value and -0x1000000 shr 24).toByte() - return b - } - - private fun writeShort(value: Short): ByteArray { - val b = ByteArray(2) - b[0] = (value.toInt() and 0x00FF).toByte() - b[1] = (value.toInt() and 0xFF00 shr 8).toByte() - return b - } - - override suspend fun compress( - image: Bitmap, - quality: Quality - ): ByteArray { - //image size - val width = image.width - val height = image.height - - //image dummy data size - //reason : the amount of bytes per image row must be a multiple of 4 (requirements of bmp format) - var dummyBytesPerRow: ByteArray? = null - var hasDummy = false - val rowWidthInBytes = - BYTE_PER_PIXEL * width //source image width * number of bytes to encode one pixel. - if (rowWidthInBytes % BMP_WIDTH_OF_TIMES > 0) { - hasDummy = true - //the number of dummy bytes we need to add on each row - dummyBytesPerRow = - ByteArray(BMP_WIDTH_OF_TIMES - rowWidthInBytes % BMP_WIDTH_OF_TIMES) - //just fill an array with the dummy bytes we need to append at the end of each row - for (i in dummyBytesPerRow.indices) { - dummyBytesPerRow[i] = 0xFF.toByte() - } - } - - //an array to receive the pixels from the source image - val pixels = IntArray(width * height) - - //the number of bytes used in the file to store raw image data (excluding file headers) - val imageSize = - (rowWidthInBytes + if (hasDummy) dummyBytesPerRow!!.size else 0) * height - //file headers size - val imageDataOffset = 0x36 - - //final size of the file - val fileSize = imageSize + imageDataOffset - - //Android Bitmap Image Data - image.getPixels(pixels, 0, width, 0, 0, width, height) - - //ByteArrayOutputStream baos = new ByteArrayOutputStream(fileSize); - val buffer = ByteBuffer.allocate(fileSize) - /** - * BITMAP FILE HEADER Write Start - */ - buffer.put(0x42.toByte()) - buffer.put(0x4D.toByte()) - - //size - buffer.put(writeInt(fileSize)) - - //reserved - buffer.put(writeShort(0.toShort())) - buffer.put(writeShort(0.toShort())) - - //image data start offset - buffer.put(writeInt(imageDataOffset)) - /** BITMAP FILE HEADER Write End */ - - //******************************************* - /** BITMAP INFO HEADER Write Start */ - //size - buffer.put(writeInt(0x28)) - - //width, height - //if we add 3 dummy bytes per row : it means we add a pixel (and the image width is modified. - buffer.put(writeInt(width + if (hasDummy) (if (dummyBytesPerRow!!.size == 3) 1 else 0) else 0)) - buffer.put(writeInt(height)) - - //planes - buffer.put(writeShort(1.toShort())) - - //bit count - buffer.put(writeShort(24.toShort())) - - //bit compression - buffer.put(writeInt(0)) - - //image data size - buffer.put(writeInt(imageSize)) - - //horizontal resolution in pixels per meter - buffer.put(writeInt(0)) - - //vertical resolution in pixels per meter (unreliable) - buffer.put(writeInt(0)) - buffer.put(writeInt(0)) - buffer.put(writeInt(0)) - /** BITMAP INFO HEADER Write End */ - var row = height - var startPosition = (row - 1) * width - var endPosition = row * width - while (row > 0) { - for (i in startPosition until endPosition) { - buffer.put((pixels[i] and 0x000000FF).toByte()) - buffer.put((pixels[i] and 0x0000FF00 shr 8).toByte()) - buffer.put((pixels[i] and 0x00FF0000 shr 16).toByte()) - } - if (hasDummy) { - if (dummyBytesPerRow != null) { - buffer.put(dummyBytesPerRow) - } - } - row-- - endPosition = startPosition - startPosition -= width - } - return buffer.array() - } - - } - data object Jpg : SimpleCompressor() { override suspend fun compress( @@ -523,4 +398,217 @@ internal abstract class SimpleCompressor { } + data class Ico( + private val imageScaler: ImageScaler + ) : SimpleCompressor() { + + override suspend fun compress( + image: Bitmap, + quality: Quality + ): ByteArray = coroutineScope { + val bitmap = if (image.width > 256 || image.height > 256) { + imageScaler.scaleImage( + image = image, + width = 256, + height = 256, + resizeType = ResizeType.Flexible + ) + } else image + + val width = bitmap.width + val height = bitmap.height + + val outputStream = ByteArrayOutputStream() + val header = ByteArray(6) + val entry = ByteArray(16) + val infoHeader = ByteArray(40) + + // ICO Header + header[2] = 1 // Image type: Icon + header[4] = 1 // Number of images + + outputStream.write(header) + + // Image entry + entry[0] = if (width > 256) 0 else width.toByte() + entry[1] = if (height > 256) 0 else height.toByte() + entry[4] = 1 // Color planes + entry[6] = 32 // Bits per pixel + + val andMaskSize = ((width + 31) / 32) * 4 * height + val xorMaskSize = width * height * 4 + val imageDataSize = infoHeader.size + xorMaskSize + andMaskSize + + ByteBuffer.wrap(entry, 8, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(imageDataSize) + ByteBuffer.wrap(entry, 12, 4) + .order(ByteOrder.LITTLE_ENDIAN) + .putInt(header.size + entry.size) + + outputStream.write(entry) + + // BITMAP INFO HEADER + ByteBuffer.wrap(infoHeader).order(ByteOrder.LITTLE_ENDIAN).apply { + putInt(40) // Header size + putInt(width) // Width + putInt(height * 2) // Height (XOR + AND masks) + putShort(1) // Color planes + putShort(32) // Bits per pixel + putInt(0) // Compression (BI_RGB) + putInt(xorMaskSize + andMaskSize) // Image size + } + + outputStream.write(infoHeader) + + // XOR mask (pixel data) + for (y in height - 1 downTo 0) { + for (x in 0 until width) { + val pixel = bitmap.getPixel(x, y) + outputStream.write(pixel and 0xFF) // B + outputStream.write((pixel shr 8) and 0xFF) // G + outputStream.write((pixel shr 16) and 0xFF) // R + outputStream.write((pixel shr 24) and 0xFF) // A + } + } + + // AND mask (all 0 for no transparency mask) + outputStream.write(ByteArray(andMaskSize)) + + outputStream.toByteArray() + } + + } + + data object Bmp : SimpleCompressor() { + + private const val BMP_WIDTH_OF_TIMES = 4 + private const val BYTE_PER_PIXEL = 3 + + private fun writeInt(value: Int): ByteArray { + val b = ByteArray(4) + b[0] = (value and 0x000000FF).toByte() + b[1] = (value and 0x0000FF00 shr 8).toByte() + b[2] = (value and 0x00FF0000 shr 16).toByte() + b[3] = (value and -0x1000000 shr 24).toByte() + return b + } + + private fun writeShort(value: Short): ByteArray { + val b = ByteArray(2) + b[0] = (value.toInt() and 0x00FF).toByte() + b[1] = (value.toInt() and 0xFF00 shr 8).toByte() + return b + } + + override suspend fun compress( + image: Bitmap, + quality: Quality + ): ByteArray { + //image size + val width = image.width + val height = image.height + + //image dummy data size + //reason : the amount of bytes per image row must be a multiple of 4 (requirements of bmp format) + var dummyBytesPerRow: ByteArray? = null + var hasDummy = false + val rowWidthInBytes = + BYTE_PER_PIXEL * width //source image width * number of bytes to encode one pixel. + if (rowWidthInBytes % BMP_WIDTH_OF_TIMES > 0) { + hasDummy = true + //the number of dummy bytes we need to add on each row + dummyBytesPerRow = + ByteArray(BMP_WIDTH_OF_TIMES - rowWidthInBytes % BMP_WIDTH_OF_TIMES) + //just fill an array with the dummy bytes we need to append at the end of each row + for (i in dummyBytesPerRow.indices) { + dummyBytesPerRow[i] = 0xFF.toByte() + } + } + + //an array to receive the pixels from the source image + val pixels = IntArray(width * height) + + //the number of bytes used in the file to store raw image data (excluding file headers) + val imageSize = + (rowWidthInBytes + if (hasDummy) dummyBytesPerRow!!.size else 0) * height + //file headers size + val imageDataOffset = 0x36 + + //final size of the file + val fileSize = imageSize + imageDataOffset + + //Android Bitmap Image Data + image.getPixels(pixels, 0, width, 0, 0, width, height) + + //ByteArrayOutputStream baos = new ByteArrayOutputStream(fileSize); + val buffer = ByteBuffer.allocate(fileSize) + /** + * BITMAP FILE HEADER Write Start + */ + buffer.put(0x42.toByte()) + buffer.put(0x4D.toByte()) + + //size + buffer.put(writeInt(fileSize)) + + //reserved + buffer.put(writeShort(0.toShort())) + buffer.put(writeShort(0.toShort())) + + //image data start offset + buffer.put(writeInt(imageDataOffset)) + /** BITMAP FILE HEADER Write End */ + + //******************************************* + /** BITMAP INFO HEADER Write Start */ + //size + buffer.put(writeInt(0x28)) + + //width, height + //if we add 3 dummy bytes per row : it means we add a pixel (and the image width is modified. + buffer.put(writeInt(width + if (hasDummy) (if (dummyBytesPerRow!!.size == 3) 1 else 0) else 0)) + buffer.put(writeInt(height)) + + //planes + buffer.put(writeShort(1.toShort())) + + //bit count + buffer.put(writeShort(24.toShort())) + + //bit compression + buffer.put(writeInt(0)) + + //image data size + buffer.put(writeInt(imageSize)) + + //horizontal resolution in pixels per meter + buffer.put(writeInt(0)) + + //vertical resolution in pixels per meter (unreliable) + buffer.put(writeInt(0)) + buffer.put(writeInt(0)) + buffer.put(writeInt(0)) + /** BITMAP INFO HEADER Write End */ + var row = height + var startPosition = (row - 1) * width + var endPosition = row * width + while (row > 0) { + for (i in startPosition until endPosition) { + buffer.put((pixels[i] and 0x000000FF).toByte()) + buffer.put((pixels[i] and 0x0000FF00 shr 8).toByte()) + buffer.put((pixels[i] and 0x00FF0000 shr 16).toByte()) + } + if (hasDummy) { + if (dummyBytesPerRow != null) { + buffer.put(dummyBytesPerRow) + } + } + row-- + endPosition = startPosition + startPosition -= width + } + return buffer.array() + } + + } + } \ No newline at end of file diff --git a/core/domain/src/main/kotlin/ru/tech/imageresizershrinker/core/domain/image/model/ImageFormat.kt b/core/domain/src/main/kotlin/ru/tech/imageresizershrinker/core/domain/image/model/ImageFormat.kt index f988c643e..a486ec95a 100644 --- a/core/domain/src/main/kotlin/ru/tech/imageresizershrinker/core/domain/image/model/ImageFormat.kt +++ b/core/domain/src/main/kotlin/ru/tech/imageresizershrinker/core/domain/image/model/ImageFormat.kt @@ -254,6 +254,13 @@ sealed class ImageFormat( canChangeCompressionValue = false ) + data object Ico : ImageFormat( + title = "ICO", + extension = "ico", + mimeType = "image/x-icon", + canChangeCompressionValue = false + ) + companion object { sealed class CompressionType( open val compressionRange: IntRange = 0..100 @@ -284,6 +291,8 @@ sealed class ImageFormat( typeString.contains("avif") -> Avif.Lossless typeString.contains("heif") -> Heif.Lossless typeString.contains("heic") -> Heic.Lossless + typeString.contains("qoi") -> Qoi + typeString.contains("ico") -> Ico else -> Default } @@ -320,7 +329,9 @@ sealed class ImageFormat( Tif, Tiff, Jpeg2000.Jp2, - Jpeg2000.J2k + Jpeg2000.J2k, + Qoi, + Ico ) } } diff --git a/core/domain/src/main/kotlin/ru/tech/imageresizershrinker/core/domain/image/model/ImageFormatGroup.kt b/core/domain/src/main/kotlin/ru/tech/imageresizershrinker/core/domain/image/model/ImageFormatGroup.kt index 99624a8fd..cb75479c1 100644 --- a/core/domain/src/main/kotlin/ru/tech/imageresizershrinker/core/domain/image/model/ImageFormatGroup.kt +++ b/core/domain/src/main/kotlin/ru/tech/imageresizershrinker/core/domain/image/model/ImageFormatGroup.kt @@ -103,6 +103,13 @@ sealed class ImageFormatGroup( ) ) + data object Ico : ImageFormatGroup( + title = "ICO", + formats = listOf( + ImageFormat.Ico + ) + ) + data class Custom( override val title: String, override val formats: List @@ -110,7 +117,7 @@ sealed class ImageFormatGroup( companion object { val entries by lazy { - listOf(Jpg, Png, Webp, Avif, Heic, Jxl, Bmp, Jpeg2000, Tiff, Qoi) + listOf(Jpg, Png, Webp, Avif, Heic, Jxl, Bmp, Jpeg2000, Tiff, Qoi, Ico) } val alphaContainedEntries @@ -121,7 +128,8 @@ sealed class ImageFormatGroup( Heic, Jxl, Jpeg2000, - Qoi + Qoi, + Ico ) val highLevelFormats by lazy { diff --git a/core/resources/src/main/res/values/strings.xml b/core/resources/src/main/res/values/strings.xml index 8ee7eade7..09c0fa9d3 100644 --- a/core/resources/src/main/res/values/strings.xml +++ b/core/resources/src/main/res/values/strings.xml @@ -1347,4 +1347,5 @@ Links Preview Enables link preview retrieveing in places where you can obtain text (QRCode, OCR etc) Links + Ico Files can be saved only at maximum size of 256*256, larger values will be coerced in resulting image \ No newline at end of file diff --git a/core/ui/src/main/kotlin/ru/tech/imageresizershrinker/core/ui/widget/controls/IcoSizeWarning.kt b/core/ui/src/main/kotlin/ru/tech/imageresizershrinker/core/ui/widget/controls/IcoSizeWarning.kt new file mode 100644 index 000000000..c00848c00 --- /dev/null +++ b/core/ui/src/main/kotlin/ru/tech/imageresizershrinker/core/ui/widget/controls/IcoSizeWarning.kt @@ -0,0 +1,71 @@ +/* + * ImageToolbox is an image editor for android + * Copyright (c) 2024 T8RIN (Malik Mukhametzyanov) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * You should have received a copy of the Apache License + * along with this program. If not, see . + */ + +package ru.tech.imageresizershrinker.core.ui.widget.controls + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ru.tech.imageresizershrinker.core.resources.R +import ru.tech.imageresizershrinker.core.ui.widget.modifier.container + +@Composable +fun IcoSizeWarning(visible: Boolean) { + AnimatedVisibility( + visible = visible, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + Column( + modifier = Modifier + .padding( + top = 12.dp + ) + .container( + color = MaterialTheme.colorScheme.secondaryContainer.copy( + alpha = 0.7f + ), + resultPadding = 4.dp, + shape = RoundedCornerShape(16.dp) + ) + ) { + Text( + text = stringResource(R.string.ico_size_warning), + fontSize = 12.sp, + modifier = Modifier.padding(8.dp), + textAlign = TextAlign.Center, + fontWeight = FontWeight.SemiBold, + lineHeight = 14.sp, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.5f) + ) + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/kotlin/ru/tech/imageresizershrinker/core/ui/widget/controls/OOMWarning.kt b/core/ui/src/main/kotlin/ru/tech/imageresizershrinker/core/ui/widget/controls/OOMWarning.kt index 72c7b3a0b..5f83bc728 100644 --- a/core/ui/src/main/kotlin/ru/tech/imageresizershrinker/core/ui/widget/controls/OOMWarning.kt +++ b/core/ui/src/main/kotlin/ru/tech/imageresizershrinker/core/ui/widget/controls/OOMWarning.kt @@ -46,7 +46,9 @@ fun OOMWarning(visible: Boolean) { ) { Column( modifier = Modifier - .padding(12.dp) + .padding( + top = 12.dp + ) .container( color = MaterialTheme.colorScheme.errorContainer.copy( alpha = 0.7f diff --git a/core/ui/src/main/kotlin/ru/tech/imageresizershrinker/core/ui/widget/controls/ResizeImageField.kt b/core/ui/src/main/kotlin/ru/tech/imageresizershrinker/core/ui/widget/controls/ResizeImageField.kt index ab3a07035..c76a6d985 100644 --- a/core/ui/src/main/kotlin/ru/tech/imageresizershrinker/core/ui/widget/controls/ResizeImageField.kt +++ b/core/ui/src/main/kotlin/ru/tech/imageresizershrinker/core/ui/widget/controls/ResizeImageField.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp +import ru.tech.imageresizershrinker.core.domain.image.model.ImageFormat import ru.tech.imageresizershrinker.core.domain.image.model.ImageInfo import ru.tech.imageresizershrinker.core.domain.model.IntegerSize import ru.tech.imageresizershrinker.core.resources.R @@ -125,7 +126,11 @@ fun ResizeImageField( modifier = Modifier.weight(1f) ) } - + IcoSizeWarning( + visible = imageInfo.run { + imageFormat == ImageFormat.Ico && (width > 256 || height > 256) + } + ) OOMWarning(visible = showWarning) } } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 767565169..67938a86a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,8 +3,8 @@ androidMinSdk = "21" androidTargetSdk = "34" androidCompileSdk = "34" -versionName = "2.9.1-rc01" -versionCode = "150" +versionName = "2.9.1-rc02" +versionCode = "151" jvmTarget = "17" compose-compiler = "1.5.15"