From a0243ee8b93fc43a859d84d5387da517d9f8a3c5 Mon Sep 17 00:00:00 2001 From: tateisu Date: Fri, 5 May 2023 12:12:49 +0900 Subject: [PATCH] =?UTF-8?q?=E7=B5=B5=E6=96=87=E5=AD=97=E3=83=87=E3=82=B3?= =?UTF-8?q?=E3=83=BC=E3=83=89=E5=85=88=E3=81=AE=E3=83=93=E3=83=83=E3=83=88?= =?UTF-8?q?=E3=83=9E=E3=83=83=E3=83=97=E3=81=AE=E5=A4=A7=E3=81=8D=E3=81=95?= =?UTF-8?q?=E3=82=92=E5=85=A5=E5=8A=9B=E3=83=87=E3=83=BC=E3=82=BF=E3=81=AE?= =?UTF-8?q?=E3=82=A2=E3=82=B9=E3=83=9A=E3=82=AF=E3=83=88=E6=AF=94=E3=81=AB?= =?UTF-8?q?=E5=90=88=E3=82=8F=E3=81=9B=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/jp/juggler/apng/ApngFrames.kt | 84 ++++++++++++------- .../juggler/subwaytooter/TestBitmapSample.kt | 70 ++++++++++++++++ .../java/jp/juggler/subwaytooter/ActText.kt | 6 +- .../subwaytooter/util/CustomEmojiCache.kt | 76 ++++++++--------- .../java/jp/juggler/apng/sample/ActList.kt | 2 +- .../java/jp/juggler/apng/sample/ActViewer.kt | 2 +- 6 files changed, 164 insertions(+), 76 deletions(-) create mode 100644 app/src/androidTest/java/jp/juggler/subwaytooter/TestBitmapSample.kt diff --git a/apng_android/src/main/java/jp/juggler/apng/ApngFrames.kt b/apng_android/src/main/java/jp/juggler/apng/ApngFrames.kt index 188d5528..b8874b5d 100644 --- a/apng_android/src/main/java/jp/juggler/apng/ApngFrames.kt +++ b/apng_android/src/main/java/jp/juggler/apng/ApngFrames.kt @@ -6,9 +6,11 @@ import jp.juggler.util.data.encodeUTF8 import java.io.InputStream import kotlin.math.max import kotlin.math.min +import kotlin.math.round +import kotlin.math.sqrt class ApngFrames private constructor( - private val pixelSizeMax: Int = 0, + private val pixelSizeMax: Float = 0f, private val debug: Boolean = false, ) : ApngDecoderCallback, MyGifDecoderCallback { @@ -29,43 +31,64 @@ class ApngFrames private constructor( color = 0 } + // return w,h + fun scaleEmojiSize( + srcW: Float, + srcH: Float, + maxSize: Float, + ): Pair = when { + // 入力サイズの情報がない + srcW <= 0f || srcH <= 0f -> Pair(maxSize, maxSize) + else -> { + val sqMax = maxSize * maxSize + val sqOriginal = srcW * srcH + val aspect = srcW / srcH + when { + // 既に十分小さい + sqOriginal <= sqMax -> Pair(srcW, srcH) + // アスペクト比に応じたスケーリング + else -> Pair( + sqrt(sqMax * aspect), + sqrt(sqMax / aspect), + ) + } + } + } + private fun createBlankBitmap(w: Int, h: Int) = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) - private fun scale(max: Int, num: Int, den: Int) = - (max.toFloat() * num.toFloat() / den.toFloat() + 0.5f).toInt() - private fun scaleBitmap( - sizeMax: Int, + sizeMax: Float, src: Bitmap, recycleSrc: Boolean = true, // true: ownership of "src" will be moved or recycled. ): Bitmap { - + if (sizeMax <= 0) { + return when { + recycleSrc -> src + else -> src.copy(Bitmap.Config.ARGB_8888, false) + } + } val wSrc = src.width val hSrc = src.height - if (sizeMax <= 0 || (wSrc <= sizeMax && hSrc <= sizeMax)) { - return if (recycleSrc) { - src - } else { - src.copy(Bitmap.Config.ARGB_8888, false) + val (wDst, hDst) = scaleEmojiSize( + wSrc.toFloat(), + hSrc.toFloat(), + sizeMax, + ) + val wDstInt = max(1, round(wDst).toInt()) + val hDstInt = max(1, round(hDst).toInt()) + if (wSrc <= wDstInt && hSrc <= hDstInt ) { + return when { + recycleSrc -> src + else -> src.copy(Bitmap.Config.ARGB_8888, false) } } - val wDst: Int - val hDst: Int - if (wSrc >= hSrc) { - wDst = sizeMax - hDst = max(1, scale(sizeMax, hSrc, wSrc)) - } else { - hDst = sizeMax - wDst = max(1, scale(sizeMax, wSrc, hSrc)) - } - //Log.v(TAG,"scaleBitmap: $wSrc,$hSrc => $wDst,$hDst") - - val b2 = createBlankBitmap(wDst, hDst) + val b2 = createBlankBitmap(wDstInt, hDstInt) val canvas = Canvas(b2) val rectSrc = Rect(0, 0, wSrc, hSrc) - val rectDst = Rect(0, 0, wDst, hDst) + val rectDst = Rect(0, 0, wDstInt, hDstInt) canvas.drawBitmap(src, rectSrc, rectDst, sPaintDontBlend) if (recycleSrc) src.recycle() @@ -83,12 +106,12 @@ class ApngFrames private constructor( Bitmap.Config.ARGB_8888 ) - private fun toAndroidBitmap(src: ApngBitmap, sizeMax: Int) = + private fun toAndroidBitmap(src: ApngBitmap, sizeMax: Float) = scaleBitmap(sizeMax, toAndroidBitmap(src)) private fun parseApng( inStream: InputStream, - pixelSizeMax: Int, + pixelSizeMax: Float, debug: Boolean = false, ): ApngFrames { val result = ApngFrames(pixelSizeMax, debug) @@ -105,7 +128,7 @@ class ApngFrames private constructor( private fun parseWebP( inStream: InputStream, - pixelSizeMax: Int, + pixelSizeMax: Float, debug: Boolean = false, ): ApngFrames { val result = ApngFrames(pixelSizeMax, debug) @@ -122,7 +145,7 @@ class ApngFrames private constructor( private fun parseGif( inStream: InputStream, - pixelSizeMax: Int, + pixelSizeMax: Float, debug: Boolean = false, ): ApngFrames { val result = ApngFrames(pixelSizeMax, debug) @@ -166,7 +189,7 @@ class ApngFrames private constructor( } fun parse( - pixelSizeMax: Int, + pixelSizeMax: Float, debug: Boolean = false, opener: () -> InputStream?, ): ApngFrames? { @@ -284,7 +307,7 @@ class ApngFrames private constructor( val animationControl = this.animationControl val frames = this.frames - if (animationControl == null || frames == null || frames.isEmpty()) { + if (animationControl == null || frames.isNullOrEmpty()) { // ここは通らないはず… result.bitmap = null result.delay = Long.MAX_VALUE @@ -409,6 +432,7 @@ class ApngFrames private constructor( frameControl.width, frameControl.height ) + else -> null } diff --git a/app/src/androidTest/java/jp/juggler/subwaytooter/TestBitmapSample.kt b/app/src/androidTest/java/jp/juggler/subwaytooter/TestBitmapSample.kt new file mode 100644 index 00000000..0ffd4215 --- /dev/null +++ b/app/src/androidTest/java/jp/juggler/subwaytooter/TestBitmapSample.kt @@ -0,0 +1,70 @@ +package jp.juggler.subwaytooter + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.test.ext.junit.runners.AndroidJUnit4 +import jp.juggler.util.data.* +import org.junit.Assert.fail +import org.junit.Test +import org.junit.runner.RunWith +import java.io.ByteArrayOutputStream + +@RunWith(AndroidJUnit4::class) +class TestBitmapSample { + + /** + * BitmapFactory.Options.inSampleSize の取り扱いの確認 + */ + @Test + fun test() { + val srcSize = 1024 + + val baSrc = run { + val bitmap = Bitmap.createBitmap(srcSize, srcSize, Bitmap.Config.ARGB_8888) + try { + ByteArrayOutputStream().use { outStream -> + bitmap.compress(Bitmap.CompressFormat.JPEG, 70, outStream) + outStream.toByteArray() + } + } finally { + bitmap.recycle() + } + } + + for (n in 0..32) { + val options = BitmapFactory.Options().apply { + inScaled = false + outWidth = 0 + outHeight = 0 + inJustDecodeBounds = false + inSampleSize = n + } + val expectedSizeA: Int + val expectedSizeB: Int + when (n) { + // ドキュメントには "If set to a value > 1" とあり、1以下の値はリサイズに影響しない + 0, 1 -> { + expectedSizeA = srcSize + expectedSizeB = srcSize + } + // 2以上の場合、ドキュメントには + // "Note: the decoder uses a final value based on powers of 2, any other value will be rounded down to the nearest power of 2." + // とあるが、実際に試すと端末により width = srcSize/n となることがある。 + else -> { + expectedSizeA = srcSize.div(n.takeHighestOneBit()) + expectedSizeB = srcSize.div(n) + } + } + val bitmap = BitmapFactory.decodeByteArray(baSrc, 0, baSrc.size, options)!! + try { + when (bitmap.width) { + expectedSizeA -> Unit + expectedSizeB -> Unit + else -> fail("inSampleSize=$n, srcSize=$srcSize, resultWidth=${bitmap.width}") + } + } finally { + bitmap.recycle() + } + } + } +} diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActText.kt b/app/src/main/java/jp/juggler/subwaytooter/ActText.kt index b2eeafaa..82fc685b 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActText.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActText.kt @@ -72,7 +72,7 @@ class ActText : AppCompatActivity() { } } - class SearchHilightSpan(color: Int) : BackgroundColorSpan(color) + class SearchResultSpan(color: Int) : BackgroundColorSpan(color) private var account: SavedAccount? = null @@ -360,7 +360,7 @@ class ActText : AppCompatActivity() { private fun searchHighlight(newPos: Int?) { views.etText.text?.let { e -> - for (span in e.getSpans(0, e.length, SearchHilightSpan::class.java)) { + for (span in e.getSpans(0, e.length, SearchResultSpan::class.java)) { try { e.removeSpan(span) } catch (ignored: Throwable) { @@ -372,7 +372,7 @@ class ActText : AppCompatActivity() { else -> R.attr.colorButtonBgCw } e.setSpan( - SearchHilightSpan(attrColor(attrId)), + SearchResultSpan(attrColor(attrId)), pos, pos + searchKeywordLength, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, diff --git a/app/src/main/java/jp/juggler/subwaytooter/util/CustomEmojiCache.kt b/app/src/main/java/jp/juggler/subwaytooter/util/CustomEmojiCache.kt index 145ebf6a..9aa40dc3 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/util/CustomEmojiCache.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/util/CustomEmojiCache.kt @@ -9,21 +9,23 @@ import android.os.Handler import android.os.SystemClock import com.caverock.androidsvg.SVG import jp.juggler.apng.ApngFrames +import jp.juggler.apng.ApngFrames.Companion.scaleEmojiSize import jp.juggler.subwaytooter.App1 import jp.juggler.subwaytooter.pref.PrefS import jp.juggler.subwaytooter.table.EmojiCacheDbOpenHelper import jp.juggler.subwaytooter.table.daoImageAspect import jp.juggler.util.coroutine.EmptyScope -import jp.juggler.util.data.* -import jp.juggler.util.log.* +import jp.juggler.util.data.clip +import jp.juggler.util.log.LogCategory import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch import java.io.ByteArrayInputStream import java.lang.ref.WeakReference -import java.util.* +import java.util.LinkedList import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit import kotlin.math.ceil +import kotlin.math.max class CustomEmojiCache( val context: Context, @@ -248,7 +250,7 @@ class CustomEmojiCache( ts = elapsedTime val frames = try { - data?.let { decodeAPNG(it, request.url) } + data?.let { decodeImage(it, request.url) } } catch (ex: Throwable) { log.e(ex, "decode failed.") null @@ -327,13 +329,13 @@ class CustomEmojiCache( } } - private fun decodeAPNG(data: ByteArray, url: String): ApngFrames? { + private fun decodeImage(data: ByteArray, url: String): ApngFrames? { val errors = ArrayList() - val maxSize = PrefS.spEmojiPixels.toInt().clip(16, 1024) + val maxSize = PrefS.spEmojiPixels.toInt().clip(16, 1024).toFloat() try { - // APNGをデコード AWebPも + // APNG,AWebP,AGIF val x = ApngFrames.parse(maxSize) { ByteArrayInputStream(data) } if (x != null) return x error("ApngFrames.parse returns null.") @@ -354,7 +356,7 @@ class CustomEmojiCache( // SVGのロードを試みる try { - val b = decodeSVG(url, data, maxSize.toFloat()) + val b = decodeSVG(url, data, maxSize) if (b != null) return ApngFrames(b) error("decodeSVG returns null.") } catch (ex: Throwable) { @@ -373,22 +375,27 @@ class CustomEmojiCache( private fun decodeBitmap( data: ByteArray, - @Suppress("SameParameterValue") pixelMax: Int, + maxPixels: Float, ): Bitmap? { options.inJustDecodeBounds = true options.inScaled = false options.outWidth = 0 options.outHeight = 0 BitmapFactory.decodeByteArray(data, 0, data.size, options) - var w = options.outWidth - var h = options.outHeight - if (w <= 0 || h <= 0) error("decodeBitmap: can't decode bounds.") + var srcW = options.outWidth + var srcH = options.outHeight + if (srcW <= 0 || srcH <= 0) error("decodeBitmap: can't decode bounds.") + val (preferW, preferH) = scaleEmojiSize( + srcW.toFloat(), + srcH.toFloat(), + maxPixels + ) var bits = 0 - while (w > pixelMax || h > pixelMax) { + while (srcW > preferW || srcW > preferH) { ++bits - w = w shr 1 - h = h shr 1 + srcW = srcW shr 1 + srcH = srcH shr 1 } options.inJustDecodeBounds = false options.inSampleSize = 1 shl bits @@ -398,33 +405,20 @@ class CustomEmojiCache( private fun decodeSVG( url: String, data: ByteArray, - @Suppress("SameParameterValue") pixelMax: Float, + maxSize: Float, ): Bitmap? { try { val svg = SVG.getFromInputStream(ByteArrayInputStream(data)) - // the width in pixels, or -1 if there is no width available. - // the height in pixels, or -1 if there is no height available. - val srcW = svg.documentWidth - val srcH = svg.documentHeight - val aspect = if (srcW <= 0f || srcH <= 0f) { - // widthやheightの情報がない - 1f - } else { - srcW / srcH - } - - val dstW: Float - val dstH: Float - if (aspect >= 1f) { - dstW = pixelMax - dstH = pixelMax / aspect - } else { - dstH = pixelMax - dstW = pixelMax * aspect - } - val wCeil = ceil(dstW) - val hCeil = ceil(dstH) + val (wDst, hDst) = scaleEmojiSize( + // the width in pixels, or -1 if there is no width available. + svg.documentWidth, + // the height in pixels, or -1 if there is no height available. + svg.documentHeight, + maxSize + ) + val wCeil = max(1f, ceil(wDst)) + val hCeil = max(1f, ceil(hDst)) // Create a Bitmap to render our SVG to val b = Bitmap.createBitmap(wCeil.toInt(), hCeil.toInt(), Bitmap.Config.ARGB_8888) @@ -433,10 +427,10 @@ class CustomEmojiCache( svg.renderToCanvas( canvas, - if (aspect >= 1f) { - RectF(0f, hCeil - dstH, dstW, dstH) // 後半はw,hを指定する + if (wDst >= hDst) { + RectF(0f, hCeil - hDst, wDst, hDst) // 後半はw,hを指定する } else { - RectF(wCeil - dstW, 0f, dstW, dstH) // 後半はw,hを指定する + RectF(wCeil - wDst, 0f, wDst, hDst) // 後半はw,hを指定する } ) return b diff --git a/sample_apng/src/main/java/jp/juggler/apng/sample/ActList.kt b/sample_apng/src/main/java/jp/juggler/apng/sample/ActList.kt index fc027f14..eef69844 100644 --- a/sample_apng/src/main/java/jp/juggler/apng/sample/ActList.kt +++ b/sample_apng/src/main/java/jp/juggler/apng/sample/ActList.kt @@ -181,7 +181,7 @@ class ActList : AppCompatActivity(), CoroutineScope { val job = async(AppDispatchers.IO) { try { - ApngFrames.parse(128) { resources?.openRawResource(resId) } + ApngFrames.parse(128f) { resources?.openRawResource(resId) } } catch (ex: Throwable) { ex.printStackTrace() null diff --git a/sample_apng/src/main/java/jp/juggler/apng/sample/ActViewer.kt b/sample_apng/src/main/java/jp/juggler/apng/sample/ActViewer.kt index b9cb1a58..83e2ffe1 100644 --- a/sample_apng/src/main/java/jp/juggler/apng/sample/ActViewer.kt +++ b/sample_apng/src/main/java/jp/juggler/apng/sample/ActViewer.kt @@ -62,7 +62,7 @@ class ActViewer : AsyncActivity() { apngFrames = withContext(AppDispatchers.IO) { try { ApngFrames.parse( - 1024, + 1024f, debug = true ) { resources?.openRawResource(resId) } } catch (ex: Throwable) {