tachiyomi/core/src/main/java/tachiyomi/core/util/system/ImageUtil.kt

579 lines
21 KiB
Kotlin

package tachiyomi.core.util.system
import android.content.Context
import android.content.res.Configuration
import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder
import android.graphics.Color
import android.graphics.Matrix
import android.graphics.Rect
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable
import android.os.Build
import android.webkit.MimeTypeMap
import androidx.annotation.ColorInt
import androidx.core.graphics.alpha
import androidx.core.graphics.applyCanvas
import androidx.core.graphics.blue
import androidx.core.graphics.createBitmap
import androidx.core.graphics.get
import androidx.core.graphics.green
import androidx.core.graphics.red
import com.hippo.unifile.UniFile
import logcat.LogPriority
import tachiyomi.decoder.Format
import tachiyomi.decoder.ImageDecoder
import java.io.BufferedInputStream
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.net.URLConnection
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
object ImageUtil {
fun isImage(name: String, openStream: (() -> InputStream)? = null): Boolean {
val contentType = try {
URLConnection.guessContentTypeFromName(name)
} catch (e: Exception) {
null
} ?: openStream?.let { findImageType(it)?.mime }
return contentType?.startsWith("image/") ?: false
}
fun findImageType(openStream: () -> InputStream): ImageType? {
return openStream().use { findImageType(it) }
}
fun findImageType(stream: InputStream): ImageType? {
return try {
when (getImageType(stream)?.format) {
Format.Avif -> ImageType.AVIF
Format.Gif -> ImageType.GIF
Format.Heif -> ImageType.HEIF
Format.Jpeg -> ImageType.JPEG
Format.Jxl -> ImageType.JXL
Format.Png -> ImageType.PNG
Format.Webp -> ImageType.WEBP
else -> null
}
} catch (e: Exception) {
null
}
}
fun getExtensionFromMimeType(mime: String?): String {
return MimeTypeMap.getSingleton().getExtensionFromMimeType(mime)
?: SUPPLEMENTARY_MIMETYPE_MAPPING[mime]
?: "jpg"
}
fun isAnimatedAndSupported(stream: InputStream): Boolean {
try {
val type = getImageType(stream) ?: return false
return when (type.format) {
Format.Gif -> true
// Coil supports animated WebP on Android 9.0+
// https://coil-kt.github.io/coil/getting_started/#supported-image-formats
Format.Webp -> type.isAnimated && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
else -> false
}
} catch (e: Exception) {
/* Do Nothing */
}
return false
}
private fun getImageType(stream: InputStream): tachiyomi.decoder.ImageType? {
val bytes = ByteArray(32)
val length = if (stream.markSupported()) {
stream.mark(bytes.size)
stream.read(bytes, 0, bytes.size).also { stream.reset() }
} else {
stream.read(bytes, 0, bytes.size)
}
if (length == -1) {
return null
}
return ImageDecoder.findType(bytes)
}
enum class ImageType(val mime: String, val extension: String) {
AVIF("image/avif", "avif"),
GIF("image/gif", "gif"),
HEIF("image/heif", "heif"),
JPEG("image/jpeg", "jpg"),
JXL("image/jxl", "jxl"),
PNG("image/png", "png"),
WEBP("image/webp", "webp"),
}
/**
* Check whether the image is wide (which we consider a double-page spread).
*
* @return true if the width is greater than the height
*/
fun isWideImage(imageStream: BufferedInputStream): Boolean {
val options = extractImageOptions(imageStream)
return options.outWidth > options.outHeight
}
/**
* Extract the 'side' part from imageStream and return it as InputStream.
*/
fun splitInHalf(imageStream: InputStream, side: Side): InputStream {
val imageBytes = imageStream.readBytes()
val imageBitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
val height = imageBitmap.height
val width = imageBitmap.width
val singlePage = Rect(0, 0, width / 2, height)
val half = createBitmap(width / 2, height)
val part = when (side) {
Side.RIGHT -> Rect(width - width / 2, 0, width, height)
Side.LEFT -> Rect(0, 0, width / 2, height)
}
half.applyCanvas {
drawBitmap(imageBitmap, part, singlePage, null)
}
val output = ByteArrayOutputStream()
half.compress(Bitmap.CompressFormat.JPEG, 100, output)
return ByteArrayInputStream(output.toByteArray())
}
fun rotateImage(imageStream: InputStream, degrees: Float): InputStream {
val imageBytes = imageStream.readBytes()
val imageBitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
val rotated = rotateBitMap(imageBitmap, degrees)
val output = ByteArrayOutputStream()
rotated.compress(Bitmap.CompressFormat.JPEG, 100, output)
return ByteArrayInputStream(output.toByteArray())
}
private fun rotateBitMap(bitmap: Bitmap, degrees: Float): Bitmap {
val matrix = Matrix().apply { postRotate(degrees) }
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
/**
* Split the image into left and right parts, then merge them into a new image.
*/
fun splitAndMerge(imageStream: InputStream, upperSide: Side): InputStream {
val imageBytes = imageStream.readBytes()
val imageBitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
val height = imageBitmap.height
val width = imageBitmap.width
val result = createBitmap(width / 2, height * 2)
result.applyCanvas {
// right -> upper
val rightPart = when (upperSide) {
Side.RIGHT -> Rect(width - width / 2, 0, width, height)
Side.LEFT -> Rect(0, 0, width / 2, height)
}
val upperPart = Rect(0, 0, width / 2, height)
drawBitmap(imageBitmap, rightPart, upperPart, null)
// left -> bottom
val leftPart = when (upperSide) {
Side.LEFT -> Rect(width - width / 2, 0, width, height)
Side.RIGHT -> Rect(0, 0, width / 2, height)
}
val bottomPart = Rect(0, height, width / 2, height * 2)
drawBitmap(imageBitmap, leftPart, bottomPart, null)
}
val output = ByteArrayOutputStream()
result.compress(Bitmap.CompressFormat.JPEG, 100, output)
return ByteArrayInputStream(output.toByteArray())
}
enum class Side {
RIGHT,
LEFT,
}
/**
* Check whether the image is considered a tall image.
*
* @return true if the height:width ratio is greater than 3.
*/
private fun isTallImage(imageStream: InputStream): Boolean {
val options = extractImageOptions(imageStream, resetAfterExtraction = false)
return (options.outHeight / options.outWidth) > 3
}
/**
* Splits tall images to improve performance of reader
*/
fun splitTallImage(tmpDir: UniFile, imageFile: UniFile, filenamePrefix: String): Boolean {
if (isAnimatedAndSupported(imageFile.openInputStream()) || !isTallImage(imageFile.openInputStream())) {
return true
}
val bitmapRegionDecoder = getBitmapRegionDecoder(imageFile.openInputStream())
if (bitmapRegionDecoder == null) {
logcat { "Failed to create new instance of BitmapRegionDecoder" }
return false
}
val options = extractImageOptions(imageFile.openInputStream(), resetAfterExtraction = false).apply {
inJustDecodeBounds = false
}
val splitDataList = options.splitData
return try {
splitDataList.forEach { splitData ->
val splitImageName = splitImageName(filenamePrefix, splitData.index)
// Remove pre-existing split if exists (this split shouldn't exist under normal circumstances)
tmpDir.findFile(splitImageName)?.delete()
val splitFile = tmpDir.createFile(splitImageName)
val region = Rect(0, splitData.topOffset, splitData.splitWidth, splitData.bottomOffset)
splitFile.openOutputStream().use { outputStream ->
val splitBitmap = bitmapRegionDecoder.decodeRegion(region, options)
splitBitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
splitBitmap.recycle()
}
logcat {
"Success: Split #${splitData.index + 1} with topOffset=${splitData.topOffset} " +
"height=${splitData.splitHeight} bottomOffset=${splitData.bottomOffset}"
}
}
imageFile.delete()
true
} catch (e: Exception) {
// Image splits were not successfully saved so delete them and keep the original image
splitDataList
.map { splitImageName(filenamePrefix, it.index) }
.forEach { tmpDir.findFile(it)?.delete() }
logcat(LogPriority.ERROR, e)
false
} finally {
bitmapRegionDecoder.recycle()
}
}
private fun splitImageName(filenamePrefix: String, index: Int) = "${filenamePrefix}__${"%03d".format(
index + 1,
)}.jpg"
private val BitmapFactory.Options.splitData
get(): List<SplitData> {
val imageHeight = outHeight
val imageWidth = outWidth
// -1 so it doesn't try to split when imageHeight = optimalImageHeight
val partCount = (imageHeight - 1) / optimalImageHeight + 1
val optimalSplitHeight = imageHeight / partCount
logcat {
"Generating SplitData for image (height: $imageHeight): " +
"$partCount parts @ ${optimalSplitHeight}px height per part"
}
return buildList {
val range = 0..<partCount
for (index in range) {
// Only continue if the list is empty or there is image remaining
if (isNotEmpty() && imageHeight <= last().bottomOffset) break
val topOffset = index * optimalSplitHeight
var splitHeight = min(optimalSplitHeight, imageHeight - topOffset)
if (index == range.last) {
val remainingHeight = imageHeight - (topOffset + splitHeight)
splitHeight += remainingHeight
}
add(SplitData(index, topOffset, splitHeight, imageWidth))
}
}
}
data class SplitData(
val index: Int,
val topOffset: Int,
val splitHeight: Int,
val splitWidth: Int,
) {
val bottomOffset = topOffset + splitHeight
}
/**
* Algorithm for determining what background to accompany a comic/manga page
*/
fun chooseBackground(context: Context, imageStream: InputStream): Drawable {
val decoder = ImageDecoder.newInstance(imageStream)
val image = decoder?.decode()
decoder?.recycle()
val whiteColor = Color.WHITE
if (image == null) return ColorDrawable(whiteColor)
if (image.width < 50 || image.height < 50) {
return ColorDrawable(whiteColor)
}
val top = 5
val bot = image.height - 5
val left = (image.width * 0.0275).toInt()
val right = image.width - left
val midX = image.width / 2
val midY = image.height / 2
val offsetX = (image.width * 0.01).toInt()
val leftOffsetX = left - offsetX
val rightOffsetX = right + offsetX
val topLeftPixel = image[left, top]
val topRightPixel = image[right, top]
val midLeftPixel = image[left, midY]
val midRightPixel = image[right, midY]
val topCenterPixel = image[midX, top]
val botLeftPixel = image[left, bot]
val bottomCenterPixel = image[midX, bot]
val botRightPixel = image[right, bot]
val topLeftIsDark = topLeftPixel.isDark()
val topRightIsDark = topRightPixel.isDark()
val midLeftIsDark = midLeftPixel.isDark()
val midRightIsDark = midRightPixel.isDark()
val topMidIsDark = topCenterPixel.isDark()
val botLeftIsDark = botLeftPixel.isDark()
val botRightIsDark = botRightPixel.isDark()
var darkBG =
(topLeftIsDark && (botLeftIsDark || botRightIsDark || topRightIsDark || midLeftIsDark || topMidIsDark)) ||
(topRightIsDark && (botRightIsDark || botLeftIsDark || midRightIsDark || topMidIsDark))
val topAndBotPixels =
listOf(topLeftPixel, topCenterPixel, topRightPixel, botRightPixel, bottomCenterPixel, botLeftPixel)
val isNotWhiteAndCloseTo = topAndBotPixels.mapIndexed { index, color ->
val other = topAndBotPixels[(index + 1) % topAndBotPixels.size]
!color.isWhite() && color.isCloseTo(other)
}
if (isNotWhiteAndCloseTo.all { it }) {
return ColorDrawable(topLeftPixel)
}
val cornerPixels = listOf(topLeftPixel, topRightPixel, botLeftPixel, botRightPixel)
val numberOfWhiteCorners = cornerPixels.map { cornerPixel -> cornerPixel.isWhite() }
.filter { it }
.size
if (numberOfWhiteCorners > 2) {
darkBG = false
}
var blackColor = when {
topLeftIsDark -> topLeftPixel
topRightIsDark -> topRightPixel
botLeftIsDark -> botLeftPixel
botRightIsDark -> botRightPixel
else -> whiteColor
}
var overallWhitePixels = 0
var overallBlackPixels = 0
var topBlackStreak = 0
var topWhiteStreak = 0
var botBlackStreak = 0
var botWhiteStreak = 0
outer@ for (x in intArrayOf(left, right, leftOffsetX, rightOffsetX)) {
var whitePixelsStreak = 0
var whitePixels = 0
var blackPixelsStreak = 0
var blackPixels = 0
var blackStreak = false
var whiteStreak = false
val notOffset = x == left || x == right
inner@ for ((index, y) in (0..<image.height step image.height / 25).withIndex()) {
val pixel = image[x, y]
val pixelOff = image[x + (if (x < image.width / 2) -offsetX else offsetX), y]
if (pixel.isWhite()) {
whitePixelsStreak++
whitePixels++
if (notOffset) {
overallWhitePixels++
}
if (whitePixelsStreak > 14) {
whiteStreak = true
}
if (whitePixelsStreak > 6 && whitePixelsStreak >= index - 1) {
topWhiteStreak = whitePixelsStreak
}
} else {
whitePixelsStreak = 0
if (pixel.isDark() && pixelOff.isDark()) {
blackPixels++
if (notOffset) {
overallBlackPixels++
}
blackPixelsStreak++
if (blackPixelsStreak >= 14) {
blackStreak = true
}
continue@inner
}
}
if (blackPixelsStreak > 6 && blackPixelsStreak >= index - 1) {
topBlackStreak = blackPixelsStreak
}
blackPixelsStreak = 0
}
if (blackPixelsStreak > 6) {
botBlackStreak = blackPixelsStreak
} else if (whitePixelsStreak > 6) {
botWhiteStreak = whitePixelsStreak
}
when {
blackPixels > 22 -> {
if (x == right || x == rightOffsetX) {
blackColor = when {
topRightIsDark -> topRightPixel
botRightIsDark -> botRightPixel
else -> blackColor
}
}
darkBG = true
overallWhitePixels = 0
break@outer
}
blackStreak -> {
darkBG = true
if (x == right || x == rightOffsetX) {
blackColor = when {
topRightIsDark -> topRightPixel
botRightIsDark -> botRightPixel
else -> blackColor
}
}
if (blackPixels > 18) {
overallWhitePixels = 0
break@outer
}
}
whiteStreak || whitePixels > 22 -> darkBG = false
}
}
val topIsBlackStreak = topBlackStreak > topWhiteStreak
val bottomIsBlackStreak = botBlackStreak > botWhiteStreak
if (overallWhitePixels > 9 && overallWhitePixels > overallBlackPixels) {
darkBG = false
}
if (topIsBlackStreak && bottomIsBlackStreak) {
darkBG = true
}
val isLandscape = context.resources.configuration?.orientation == Configuration.ORIENTATION_LANDSCAPE
if (isLandscape) {
return when {
darkBG -> ColorDrawable(blackColor)
else -> ColorDrawable(whiteColor)
}
}
val botCornersIsWhite = botLeftPixel.isWhite() && botRightPixel.isWhite()
val topCornersIsWhite = topLeftPixel.isWhite() && topRightPixel.isWhite()
val topCornersIsDark = topLeftIsDark && topRightIsDark
val botCornersIsDark = botLeftIsDark && botRightIsDark
val topOffsetCornersIsDark = image[leftOffsetX, top].isDark() && image[rightOffsetX, top].isDark()
val botOffsetCornersIsDark = image[leftOffsetX, bot].isDark() && image[rightOffsetX, bot].isDark()
val gradient = when {
darkBG && botCornersIsWhite -> {
intArrayOf(blackColor, blackColor, whiteColor, whiteColor)
}
darkBG && topCornersIsWhite -> {
intArrayOf(whiteColor, whiteColor, blackColor, blackColor)
}
darkBG -> {
return ColorDrawable(blackColor)
}
topIsBlackStreak || (
topCornersIsDark && topOffsetCornersIsDark &&
(topMidIsDark || overallBlackPixels > 9)
) -> {
intArrayOf(blackColor, blackColor, whiteColor, whiteColor)
}
bottomIsBlackStreak || (
botCornersIsDark && botOffsetCornersIsDark &&
(bottomCenterPixel.isDark() || overallBlackPixels > 9)
) -> {
intArrayOf(whiteColor, whiteColor, blackColor, blackColor)
}
else -> {
return ColorDrawable(whiteColor)
}
}
return GradientDrawable(
GradientDrawable.Orientation.TOP_BOTTOM,
gradient,
)
}
private fun @receiver:ColorInt Int.isDark(): Boolean =
red < 40 && blue < 40 && green < 40 && alpha > 200
private fun @receiver:ColorInt Int.isCloseTo(other: Int): Boolean =
abs(red - other.red) < 30 && abs(green - other.green) < 30 && abs(blue - other.blue) < 30
private fun @receiver:ColorInt Int.isWhite(): Boolean =
red + blue + green > 740
/**
* Used to check an image's dimensions without loading it in the memory.
*/
private fun extractImageOptions(
imageStream: InputStream,
resetAfterExtraction: Boolean = true,
): BitmapFactory.Options {
imageStream.mark(imageStream.available() + 1)
val imageBytes = imageStream.readBytes()
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size, options)
if (resetAfterExtraction) imageStream.reset()
return options
}
private fun getBitmapRegionDecoder(imageStream: InputStream): BitmapRegionDecoder? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
BitmapRegionDecoder.newInstance(imageStream)
} else {
@Suppress("DEPRECATION")
BitmapRegionDecoder.newInstance(imageStream, false)
}
}
private val optimalImageHeight = getDisplayMaxHeightInPx * 2
// Android doesn't include some mappings
private val SUPPLEMENTARY_MIMETYPE_MAPPING = mapOf(
// https://issuetracker.google.com/issues/182703810
"image/jxl" to "jxl",
)
}
val getDisplayMaxHeightInPx: Int
get() = Resources.getSystem().displayMetrics.let { max(it.heightPixels, it.widthPixels) }