579 lines
21 KiB
Kotlin
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) }
|