Просмотр исходного кода

fix: 使用图片选择器并支持九宫格高亮背景

HapeLee 4 месяцев назад
Родитель
Сommit
1397b0a334

+ 9 - 4
app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt

@@ -6,6 +6,7 @@ import android.view.View
 import android.widget.FrameLayout
 import android.widget.ImageView
 import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.PickVisualMediaRequest
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxSize
@@ -165,14 +166,14 @@ fun ReadBookRouteScreen(
     }
 
     val readStyleImagePicker = rememberLauncherForActivityResult(
-        ActivityResultContracts.OpenDocument()
+        ActivityResultContracts.PickVisualMedia()
     ) { uri ->
         uri?.let { viewModel.onIntent(ReadBookIntent.ReadStyleImageSelected(it)) }
     }
 
     var pendingReadStyleImageIsNight by remember { mutableStateOf(false) }
     val readStyleImagePickerForMode = rememberLauncherForActivityResult(
-        ActivityResultContracts.OpenDocument()
+        ActivityResultContracts.PickVisualMedia()
     ) { uri ->
         uri?.let {
             viewModel.onIntent(ReadBookIntent.ReadStyleImageSelectedForMode(it, pendingReadStyleImageIsNight))
@@ -367,11 +368,15 @@ fun ReadBookRouteScreen(
                                 booksDirPicker.launch(null)
                             }
                             is ReadBookEffect.OpenReadStyleImagePicker -> {
-                                readStyleImagePicker.launch(arrayOf("image/*"))
+                                readStyleImagePicker.launch(
+                                    PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
+                                )
                             }
                             is ReadBookEffect.OpenReadStyleImagePickerForMode -> {
                                 pendingReadStyleImageIsNight = effect.isNight
-                                readStyleImagePickerForMode.launch(arrayOf("image/*"))
+                                readStyleImagePickerForMode.launch(
+                                    PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
+                                )
                             }
                             is ReadBookEffect.OpenReadStyleImport -> {
                                 readStyleImportPicker.launch(

+ 146 - 9
app/src/main/java/io/legado/app/ui/book/read/page/entities/TextLine.kt

@@ -527,9 +527,12 @@ data class TextLine(
                 val sh = rectHeight * scale
                 val dx = startX + (rectWidth - sw) / 2f
                 val dy = top + (rectHeight - sh) / 2f
+                val destination = android.graphics.RectF(dx, dy, dx + sw, dy + sh)
                 canvas.save()
                 canvas.clipRect(startX, top, endX, bottom)
-                canvas.drawBitmap(bitmap, null, android.graphics.RectF(dx, dy, dx + sw, dy + sh), paint)
+                if (!drawNinePatchBitmap(canvas, bitmap, destination, paint, bgImage)) {
+                    canvas.drawBitmap(bitmap, null, destination, paint)
+                }
                 canvas.restore()
             }
             2 -> {
@@ -565,6 +568,124 @@ data class TextLine(
         PaintPool.recycle(paint)
     }
 
+    private fun drawNinePatchBitmap(
+        canvas: Canvas,
+        bitmap: Bitmap,
+        destination: android.graphics.RectF,
+        paint: Paint,
+        path: String,
+    ): Boolean {
+        bitmap.ninePatchChunk?.takeIf(android.graphics.NinePatch::isNinePatchChunk)?.let { chunk ->
+            android.graphics.NinePatch(bitmap, chunk, null).draw(
+                canvas,
+                android.graphics.Rect(
+                    destination.left.toInt(),
+                    destination.top.toInt(),
+                    destination.right.toInt(),
+                    destination.bottom.toInt(),
+                ),
+                paint,
+            )
+            return true
+        }
+        if (!isRawNinePatchPath(path) || bitmap.width < 3 || bitmap.height < 3) {
+            return false
+        }
+
+        val horizontalStretch = findNinePatchStretchRange(bitmap, horizontal = true) ?: return false
+        val verticalStretch = findNinePatchStretchRange(bitmap, horizontal = false) ?: return false
+        val sourceX = intArrayOf(
+            1,
+            horizontalStretch.first,
+            horizontalStretch.last + 1,
+            bitmap.width - 1,
+        )
+        val sourceY = intArrayOf(
+            1,
+            verticalStretch.first,
+            verticalStretch.last + 1,
+            bitmap.height - 1,
+        )
+
+        var leftWidth = (sourceX[1] - sourceX[0]).toFloat()
+        var rightWidth = (sourceX[3] - sourceX[2]).toFloat()
+        val fixedWidth = leftWidth + rightWidth
+        if (fixedWidth > destination.width() && fixedWidth > 0f) {
+            val ratio = destination.width() / fixedWidth
+            leftWidth *= ratio
+            rightWidth *= ratio
+        }
+
+        var topHeight = (sourceY[1] - sourceY[0]).toFloat()
+        var bottomHeight = (sourceY[3] - sourceY[2]).toFloat()
+        val fixedHeight = topHeight + bottomHeight
+        if (fixedHeight > destination.height() && fixedHeight > 0f) {
+            val ratio = destination.height() / fixedHeight
+            topHeight *= ratio
+            bottomHeight *= ratio
+        }
+
+        val destinationX = floatArrayOf(
+            destination.left,
+            destination.left + leftWidth,
+            destination.right - rightWidth,
+            destination.right,
+        )
+        val destinationY = floatArrayOf(
+            destination.top,
+            destination.top + topHeight,
+            destination.bottom - bottomHeight,
+            destination.bottom,
+        )
+
+        for (row in 0..2) {
+            for (column in 0..2) {
+                if (sourceX[column] == sourceX[column + 1] ||
+                    sourceY[row] == sourceY[row + 1] ||
+                    destinationX[column] == destinationX[column + 1] ||
+                    destinationY[row] == destinationY[row + 1]
+                ) {
+                    continue
+                }
+                canvas.drawBitmap(
+                    bitmap,
+                    android.graphics.Rect(
+                        sourceX[column],
+                        sourceY[row],
+                        sourceX[column + 1],
+                        sourceY[row + 1],
+                    ),
+                    android.graphics.RectF(
+                        destinationX[column],
+                        destinationY[row],
+                        destinationX[column + 1],
+                        destinationY[row + 1],
+                    ),
+                    paint,
+                )
+            }
+        }
+        return true
+    }
+
+    private fun findNinePatchStretchRange(bitmap: Bitmap, horizontal: Boolean): IntRange? {
+        val limit = if (horizontal) bitmap.width - 2 else bitmap.height - 2
+        var first = -1
+        var last = -1
+        for (position in 1..limit) {
+            val color = if (horizontal) bitmap.getPixel(position, 0) else bitmap.getPixel(0, position)
+            val isMarker = android.graphics.Color.alpha(color) == 0xFF &&
+                android.graphics.Color.red(color) == 0 &&
+                android.graphics.Color.green(color) == 0 &&
+                android.graphics.Color.blue(color) == 0
+            if (isMarker) {
+                if (first < 0) first = position
+                last = position
+            }
+        }
+        return if (first >= 0) first..last else null
+    }
+
     fun checkFastDraw(): Boolean {
         if (!ReadConfig.optimizeRender || exceed || !onlyTextColumn || textPage.isMsgPage) {
             return false
@@ -664,25 +785,26 @@ data class TextLine(
         private fun loadBgBitmap(path: String): Bitmap? {
             return try {
                 val ctx = appCtx
+                val sampleBitmap = !isRawNinePatchPath(path)
                 if (path.startsWith("assets://")) {
                     val assetPath = path.removePrefix("assets://")
                     ctx.assets.open(assetPath).use { input ->
-                        decodeSampledBitmap(input)
+                        decodeSampledBitmap(input, sampleBitmap)
                     }
                 } else if (path.startsWith("content://")) {
                     val uri = android.net.Uri.parse(path)
                     ctx.contentResolver.openInputStream(uri)?.use { input ->
-                        decodeSampledBitmap(input)
+                        decodeSampledBitmap(input, sampleBitmap)
                     }
                 } else {
                     val file = java.io.File(path)
                     if (file.exists()) {
-                        decodeSampledBitmapFile(path)
+                        decodeSampledBitmapFile(path, sampleBitmap)
                     } else {
                         val assetPath = if (path.startsWith("bg/")) path else "bg/$path"
                         runCatching {
                             ctx.assets.open(assetPath).use { input ->
-                                decodeSampledBitmap(input)
+                                decodeSampledBitmap(input, sampleBitmap)
                             }
                         }.getOrNull()
                     }
@@ -692,25 +814,40 @@ data class TextLine(
             }
         }
 
-        private fun decodeSampledBitmap(input: java.io.InputStream): Bitmap? {
+        private fun decodeSampledBitmap(
+            input: java.io.InputStream,
+            sampleBitmap: Boolean,
+        ): Bitmap? {
             val buffered = if (input.markSupported()) input else java.io.BufferedInputStream(input)
             val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
             buffered.mark(buffered.available())
             BitmapFactory.decodeStream(buffered, null, options)
-            options.inSampleSize = calculateInSampleSize(options, bgSampleWidth, bgSampleHeight)
+            options.inSampleSize = if (sampleBitmap) {
+                calculateInSampleSize(options, bgSampleWidth, bgSampleHeight)
+            } else {
+                1
+            }
             options.inJustDecodeBounds = false
             buffered.reset()
             return BitmapFactory.decodeStream(buffered, null, options)
         }
 
-        private fun decodeSampledBitmapFile(path: String): Bitmap? {
+        private fun decodeSampledBitmapFile(path: String, sampleBitmap: Boolean): Bitmap? {
             val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
             BitmapFactory.decodeFile(path, options)
-            options.inSampleSize = calculateInSampleSize(options, bgSampleWidth, bgSampleHeight)
+            options.inSampleSize = if (sampleBitmap) {
+                calculateInSampleSize(options, bgSampleWidth, bgSampleHeight)
+            } else {
+                1
+            }
             options.inJustDecodeBounds = false
             return BitmapFactory.decodeFile(path, options)
         }
 
+        private fun isRawNinePatchPath(path: String): Boolean {
+            return path.substringBefore('?').substringBefore('#').endsWith(".9.png", ignoreCase = true)
+        }
+
         private fun calculateInSampleSize(
             options: BitmapFactory.Options,
             reqWidth: Int,

+ 30 - 4
app/src/main/java/io/legado/app/ui/book/read/sheet/HighlightRuleEditSheet.kt

@@ -2,7 +2,9 @@ package io.legado.app.ui.book.read.sheet
 
 import android.content.Intent
 import android.net.Uri
+import android.provider.OpenableColumns
 import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.PickVisualMediaRequest
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.compose.foundation.Canvas
 import androidx.compose.foundation.layout.Arrangement
@@ -135,14 +137,34 @@ fun HighlightRuleEditSheet(
     // Validation
     var patternError by remember(show, rule) { mutableStateOf<String?>(null) }
 
-    // SAF image picker
+    // System photo picker, with the contract's built-in fallback on older devices.
     val imagePicker = rememberLauncherForActivityResult(
-        ActivityResultContracts.GetContent()
+        ActivityResultContracts.PickVisualMedia()
     ) { uri: Uri? ->
         if (uri != null) {
             val dir = File(appCtx.filesDir, "bg_images")
             if (!dir.exists()) dir.mkdirs()
-            val target = File(dir, "bg_${System.currentTimeMillis()}.jpg")
+            val displayName = context.contentResolver.query(
+                uri,
+                arrayOf(OpenableColumns.DISPLAY_NAME),
+                null,
+                null,
+                null,
+            )?.use { cursor ->
+                if (cursor.moveToFirst()) {
+                    cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
+                        .takeIf { it >= 0 }
+                        ?.let(cursor::getString)
+                } else {
+                    null
+                }
+            }
+            val suffix = when {
+                displayName?.endsWith(".9.png", ignoreCase = true) == true -> ".9.png"
+                displayName?.substringAfterLast('.', "").isNullOrBlank() -> ".img"
+                else -> ".${displayName.substringAfterLast('.')}"
+            }
+            val target = File(dir, "bg_${System.currentTimeMillis()}$suffix")
             context.contentResolver.openInputStream(uri)?.use { input ->
                 target.outputStream().use { output ->
                     input.copyTo(output)
@@ -362,7 +384,11 @@ fun HighlightRuleEditSheet(
                 TinyClickableSettingItem(
                     title = stringResource(R.string.highlight_bg_image),
                     description = bgImage.ifBlank { null }?.let { File(it).name },
-                    onClick = { imagePicker.launch("image/*") },
+                    onClick = {
+                        imagePicker.launch(
+                            PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
+                        )
+                    },
                 )
             }
             if (hasBgImage && bgImage.isNotBlank()) {