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

This commit is contained in:
HapeLee
2026-07-01 01:02:56 +08:00
parent 2a3cc12da5
commit 1397b0a334
3 changed files with 185 additions and 17 deletions
@@ -6,6 +6,7 @@ import android.view.View
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageView import android.widget.ImageView
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -165,14 +166,14 @@ fun ReadBookRouteScreen(
} }
val readStyleImagePicker = rememberLauncherForActivityResult( val readStyleImagePicker = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument() ActivityResultContracts.PickVisualMedia()
) { uri -> ) { uri ->
uri?.let { viewModel.onIntent(ReadBookIntent.ReadStyleImageSelected(it)) } uri?.let { viewModel.onIntent(ReadBookIntent.ReadStyleImageSelected(it)) }
} }
var pendingReadStyleImageIsNight by remember { mutableStateOf(false) } var pendingReadStyleImageIsNight by remember { mutableStateOf(false) }
val readStyleImagePickerForMode = rememberLauncherForActivityResult( val readStyleImagePickerForMode = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument() ActivityResultContracts.PickVisualMedia()
) { uri -> ) { uri ->
uri?.let { uri?.let {
viewModel.onIntent(ReadBookIntent.ReadStyleImageSelectedForMode(it, pendingReadStyleImageIsNight)) viewModel.onIntent(ReadBookIntent.ReadStyleImageSelectedForMode(it, pendingReadStyleImageIsNight))
@@ -367,11 +368,15 @@ fun ReadBookRouteScreen(
booksDirPicker.launch(null) booksDirPicker.launch(null)
} }
is ReadBookEffect.OpenReadStyleImagePicker -> { is ReadBookEffect.OpenReadStyleImagePicker -> {
readStyleImagePicker.launch(arrayOf("image/*")) readStyleImagePicker.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
} }
is ReadBookEffect.OpenReadStyleImagePickerForMode -> { is ReadBookEffect.OpenReadStyleImagePickerForMode -> {
pendingReadStyleImageIsNight = effect.isNight pendingReadStyleImageIsNight = effect.isNight
readStyleImagePickerForMode.launch(arrayOf("image/*")) readStyleImagePickerForMode.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
} }
is ReadBookEffect.OpenReadStyleImport -> { is ReadBookEffect.OpenReadStyleImport -> {
readStyleImportPicker.launch( readStyleImportPicker.launch(
@@ -527,9 +527,12 @@ data class TextLine(
val sh = rectHeight * scale val sh = rectHeight * scale
val dx = startX + (rectWidth - sw) / 2f val dx = startX + (rectWidth - sw) / 2f
val dy = top + (rectHeight - sh) / 2f val dy = top + (rectHeight - sh) / 2f
val destination = android.graphics.RectF(dx, dy, dx + sw, dy + sh)
canvas.save() canvas.save()
canvas.clipRect(startX, top, endX, bottom) 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() canvas.restore()
} }
2 -> { 2 -> {
@@ -565,6 +568,124 @@ data class TextLine(
PaintPool.recycle(paint) 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 { fun checkFastDraw(): Boolean {
if (!ReadConfig.optimizeRender || exceed || !onlyTextColumn || textPage.isMsgPage) { if (!ReadConfig.optimizeRender || exceed || !onlyTextColumn || textPage.isMsgPage) {
return false return false
@@ -664,25 +785,26 @@ data class TextLine(
private fun loadBgBitmap(path: String): Bitmap? { private fun loadBgBitmap(path: String): Bitmap? {
return try { return try {
val ctx = appCtx val ctx = appCtx
val sampleBitmap = !isRawNinePatchPath(path)
if (path.startsWith("assets://")) { if (path.startsWith("assets://")) {
val assetPath = path.removePrefix("assets://") val assetPath = path.removePrefix("assets://")
ctx.assets.open(assetPath).use { input -> ctx.assets.open(assetPath).use { input ->
decodeSampledBitmap(input) decodeSampledBitmap(input, sampleBitmap)
} }
} else if (path.startsWith("content://")) { } else if (path.startsWith("content://")) {
val uri = android.net.Uri.parse(path) val uri = android.net.Uri.parse(path)
ctx.contentResolver.openInputStream(uri)?.use { input -> ctx.contentResolver.openInputStream(uri)?.use { input ->
decodeSampledBitmap(input) decodeSampledBitmap(input, sampleBitmap)
} }
} else { } else {
val file = java.io.File(path) val file = java.io.File(path)
if (file.exists()) { if (file.exists()) {
decodeSampledBitmapFile(path) decodeSampledBitmapFile(path, sampleBitmap)
} else { } else {
val assetPath = if (path.startsWith("bg/")) path else "bg/$path" val assetPath = if (path.startsWith("bg/")) path else "bg/$path"
runCatching { runCatching {
ctx.assets.open(assetPath).use { input -> ctx.assets.open(assetPath).use { input ->
decodeSampledBitmap(input) decodeSampledBitmap(input, sampleBitmap)
} }
}.getOrNull() }.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 buffered = if (input.markSupported()) input else java.io.BufferedInputStream(input)
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
buffered.mark(buffered.available()) buffered.mark(buffered.available())
BitmapFactory.decodeStream(buffered, null, options) BitmapFactory.decodeStream(buffered, null, options)
options.inSampleSize = calculateInSampleSize(options, bgSampleWidth, bgSampleHeight) options.inSampleSize = if (sampleBitmap) {
calculateInSampleSize(options, bgSampleWidth, bgSampleHeight)
} else {
1
}
options.inJustDecodeBounds = false options.inJustDecodeBounds = false
buffered.reset() buffered.reset()
return BitmapFactory.decodeStream(buffered, null, options) 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 } val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, options) BitmapFactory.decodeFile(path, options)
options.inSampleSize = calculateInSampleSize(options, bgSampleWidth, bgSampleHeight) options.inSampleSize = if (sampleBitmap) {
calculateInSampleSize(options, bgSampleWidth, bgSampleHeight)
} else {
1
}
options.inJustDecodeBounds = false options.inJustDecodeBounds = false
return BitmapFactory.decodeFile(path, options) return BitmapFactory.decodeFile(path, options)
} }
private fun isRawNinePatchPath(path: String): Boolean {
return path.substringBefore('?').substringBefore('#').endsWith(".9.png", ignoreCase = true)
}
private fun calculateInSampleSize( private fun calculateInSampleSize(
options: BitmapFactory.Options, options: BitmapFactory.Options,
reqWidth: Int, reqWidth: Int,
@@ -2,7 +2,9 @@ package io.legado.app.ui.book.read.sheet
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -135,14 +137,34 @@ fun HighlightRuleEditSheet(
// Validation // Validation
var patternError by remember(show, rule) { mutableStateOf<String?>(null) } 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( val imagePicker = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent() ActivityResultContracts.PickVisualMedia()
) { uri: Uri? -> ) { uri: Uri? ->
if (uri != null) { if (uri != null) {
val dir = File(appCtx.filesDir, "bg_images") val dir = File(appCtx.filesDir, "bg_images")
if (!dir.exists()) dir.mkdirs() 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 -> context.contentResolver.openInputStream(uri)?.use { input ->
target.outputStream().use { output -> target.outputStream().use { output ->
input.copyTo(output) input.copyTo(output)
@@ -362,7 +384,11 @@ fun HighlightRuleEditSheet(
TinyClickableSettingItem( TinyClickableSettingItem(
title = stringResource(R.string.highlight_bg_image), title = stringResource(R.string.highlight_bg_image),
description = bgImage.ifBlank { null }?.let { File(it).name }, description = bgImage.ifBlank { null }?.let { File(it).name },
onClick = { imagePicker.launch("image/*") }, onClick = {
imagePicker.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
},
) )
} }
if (hasBgImage && bgImage.isNotBlank()) { if (hasBgImage && bgImage.isNotBlank()) {