HapeLee 1 месяц назад
Родитель
Сommit
52d2b37c97

+ 192 - 0
app/src/androidTest/java/io/legado/app/help/coil/MangaWebtoonRegionInstrumentedTest.kt

@@ -0,0 +1,192 @@
+package io.legado.app.help.coil
+
+import android.app.Application
+import android.graphics.Bitmap
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.graphics.Canvas
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
+import androidx.compose.ui.graphics.painter.BitmapPainter
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.exifinterface.media.ExifInterface
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import coil3.ImageLoader
+import coil3.decode.BitmapFactoryDecoder
+import coil3.request.ImageRequest
+import coil3.request.SuccessResult
+import coil3.size.Dimension
+import coil3.size.Scale
+import coil3.size.Size
+import io.legado.app.domain.reader.MangaImageRegion
+import io.legado.app.domain.reader.MangaImageSize
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.Closeable
+import java.io.File
+import androidx.compose.ui.geometry.Size as ComposeSize
+
+@RunWith(AndroidJUnit4::class)
+class MangaWebtoonRegionInstrumentedTest {
+    @Test
+    fun nativeJpegExifOrientationMapsDisplayedRegionsToOriginalPixels() = runBlocking {
+        val context = ApplicationProvider.getApplicationContext<Application>()
+        val file = File.createTempFile("webtoon-exif", ".jpg", context.cacheDir)
+        val original = Bitmap.createBitmap(96, 64, Bitmap.Config.ARGB_8888)
+        try {
+            val pixels = IntArray(96 * 64) { index ->
+                if (index % 96 < 48) android.graphics.Color.RED else android.graphics.Color.BLUE
+            }
+            original.setPixels(pixels, 0, 96, 0, 0, 96, 64)
+            file.outputStream().use { original.compress(Bitmap.CompressFormat.JPEG, 100, it) }
+            ExifInterface(file).apply {
+                setAttribute(
+                    ExifInterface.TAG_ORIENTATION,
+                    ExifInterface.ORIENTATION_ROTATE_90.toString()
+                )
+                saveAttributes()
+            }
+            val source = MangaAndroidRegionSource.file(file)
+            try {
+                val decoder = source.open()
+                try {
+                    assertEquals(MangaImageSize(64, 96), decoder.imageSize)
+                    val top = decoder.decodeRegion(MangaImageRegion(0, 0, 32, 32), 1)
+                    val bottom = decoder.decodeRegion(MangaImageRegion(0, 64, 32, 96), 1)
+                    try {
+                        decoder.close()
+                        assertTrue(android.graphics.Color.red(top.getPixel(16, 16)) > 240)
+                        assertTrue(android.graphics.Color.blue(bottom.getPixel(16, 16)) > 240)
+                        assertTrue(android.graphics.Color.blue(top.getPixel(16, 16)) < 10)
+                    } finally {
+                        top.recycle(); bottom.recycle()
+                    }
+                } finally {
+                    decoder.close()
+                }
+            } finally {
+                source.close()
+            }
+        } finally {
+            original.recycle(); file.delete()
+        }
+    }
+
+    @Test
+    fun fullWidthWebtoonRequestLosesHorizontalDetailOnVeryTallImage() = runBlocking {
+        val context = ApplicationProvider.getApplicationContext<Application>()
+        val file = File.createTempFile("webtoon-long", ".png", context.cacheDir)
+        val bitmap = Bitmap.createBitmap(128, 12_000, Bitmap.Config.ARGB_8888)
+        val loader = ImageLoader.Builder(ApplicationProvider.getApplicationContext<Application>())
+            .components { add(BitmapFactoryDecoder.Factory()) }.build()
+        try {
+            bitmap.eraseColor(android.graphics.Color.WHITE)
+            file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
+            val request =
+                ImageRequest.Builder(ApplicationProvider.getApplicationContext<Application>())
+                    .data(file)
+                    .size(Size(Dimension(128), Dimension.Undefined)).scale(Scale.FILL).build()
+            val result = loader.execute(request) as SuccessResult
+            // Loading has succeeded, but the default height cap has also reduced its width.
+            assertTrue(result.image.height <= 4096)
+            assertTrue(result.image.width < 64)
+        } finally {
+            loader.shutdown()
+            bitmap.recycle()
+            file.delete()
+        }
+    }
+
+    @Test
+    fun nativeLongImageRegionsPreserveStripesAndApplyEinkWithoutWholeImageDecode() = runBlocking {
+        val context = ApplicationProvider.getApplicationContext<Application>()
+        val file = File.createTempFile("webtoon-detail", ".png", context.cacheDir)
+        val original = Bitmap.createBitmap(128, 12_000, Bitmap.Config.ARGB_8888)
+        val loader =
+            ImageLoader.Builder(context).components { add(BitmapFactoryDecoder.Factory()) }.build()
+        val owner = MangaImageFileOwner()
+        try {
+            val row = IntArray(128) { if (it % 2 == 0) 0xff444444.toInt() else 0xffcccccc.toInt() }
+            repeat(12_000) { original.setPixels(row, 0, 128, 0, it, 128, 1) }
+            file.outputStream().use { original.compress(Bitmap.CompressFormat.PNG, 100, it) }
+            val result = loader.execute(
+                ImageRequest.Builder(context).data(file)
+                    .size(Size(Dimension(128), Dimension.Undefined)).scale(Scale.FILL).build()
+            ) as SuccessResult
+            // 模拟结果来自预览缓存;高清解码仍必须读取 owner 持有的原图。
+            owner.attach(file, Closeable {})
+            val cached = result.copy(
+                request = result.request.newBuilder()
+                    .data(File(context.cacheDir, "unread-webtoon-preview.png")).build()
+            )
+            val source = requireNotNull(mangaWebtoonTileSource(cached, owner, context))
+            try {
+                val decoder = source.open()
+                try {
+                    assertEquals(MangaImageSize(128, 12_000), decoder.imageSize)
+                    val imageSize = IntSize(decoder.imageSize.width, decoder.imageSize.height)
+                    val tile = mangaWebtoonTiles(
+                        imageSize, imageSize, Offset(0f, -6000f),
+                        Rect(0f, 0f, 128f, 600f)
+                    ).first()
+                    val bounds = tile.decodeBounds
+                    val painter = BitmapPainter(
+                        decoder.decodeRegion(
+                            MangaImageRegion(bounds.left, bounds.top, bounds.right, bounds.bottom),
+                            tile.sampleSize,
+                        ).asImageBitmap()
+                    )
+                    val decoded = render(painter, tile.decodeBounds.width, tile.decodeBounds.height)
+                    try {
+                        assertEquals(row[0], decoded.getPixel(0, 100))
+                        assertEquals(row[1], decoded.getPixel(1, 100))
+                        assertTrue(decoded.height <= 1026)
+                    } finally {
+                        decoded.recycle()
+                    }
+                    val eink = transformMangaWebtoonTile(
+                        painter, tile,
+                        listOf(io.legado.app.ui.book.manga.MangaEInkTransformation(128))
+                    )
+                    val filtered = render(eink, tile.decodeBounds.width, tile.decodeBounds.height)
+                    try {
+                        assertEquals(android.graphics.Color.BLACK, filtered.getPixel(0, 100))
+                        assertEquals(android.graphics.Color.WHITE, filtered.getPixel(1, 100))
+                    } finally {
+                        filtered.recycle()
+                    }
+                } finally {
+                    decoder.close()
+                }
+            } finally {
+                source.close()
+            }
+        } finally {
+            owner.close()
+            loader.shutdown()
+            original.recycle()
+            file.delete()
+        }
+    }
+
+    private fun render(
+        painter: androidx.compose.ui.graphics.painter.Painter,
+        width: Int,
+        height: Int
+    ): Bitmap {
+        val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+        CanvasDrawScope().draw(
+            Density(1f), LayoutDirection.Ltr, Canvas(bitmap.asImageBitmap()),
+            ComposeSize(width.toFloat(), height.toFloat())
+        ) {
+            with(painter) { draw(size) }
+        }
+        return bitmap
+    }
+}

+ 51 - 0
app/src/main/java/io/legado/app/domain/reader/MangaRegionDecoder.kt

@@ -0,0 +1,51 @@
+package io.legado.app.domain.reader
+
+/** Coordinates are in the displayed, EXIF-corrected original image, before sampling. */
+data class MangaImageSize(val width: Int, val height: Int)
+
+data class MangaImageRegion(val left: Int, val top: Int, val right: Int, val bottom: Int) {
+    val width: Int get() = right - left
+    val height: Int get() = bottom - top
+}
+
+/**
+ * Platform decoding boundary. The host chooses the tile representation [Tile]; no platform
+ * handle or renderer type is part of this contract. Unsupported formats fail explicitly.
+ * Calls are serialized by the implementation. close() is idempotent and waits for native work;
+ * decoded tiles remain usable after closing the decoder. Cancellation must not publish a tile.
+ * This contract currently lives in :app, not in a verified KMP module.
+ */
+interface MangaRegionDecoder<Tile> {
+    val imageSize: MangaImageSize
+    suspend fun decodeRegion(region: MangaImageRegion, sampleSize: Int): Tile
+    fun close()
+}
+
+/** EXIF's eight orientations, independent of Android and Compose. */
+internal class MangaImageOrientation(private val value: Int) {
+    private val swapsAxes get() = value in 5..8
+
+    fun displayedSize(raw: MangaImageSize): MangaImageSize =
+        if (swapsAxes) MangaImageSize(raw.height, raw.width) else raw
+
+    fun rawRegion(region: MangaImageRegion, raw: MangaImageSize): MangaImageRegion {
+        fun inverse(x: Int, y: Int): Pair<Int, Int> = when (value) {
+            2 -> raw.width - x to y
+            3 -> raw.width - x to raw.height - y
+            4 -> x to raw.height - y
+            5 -> y to x
+            6 -> y to raw.height - x
+            7 -> raw.width - y to raw.height - x
+            8 -> raw.width - y to x
+            else -> x to y
+        }
+
+        val corners = listOf(
+            inverse(region.left, region.top), inverse(region.right, region.top),
+            inverse(region.left, region.bottom), inverse(region.right, region.bottom)
+        )
+        return MangaImageRegion(
+            corners.minOf { it.first }, corners.minOf { it.second },
+            corners.maxOf { it.first }, corners.maxOf { it.second })
+    }
+}

+ 21 - 6
app/src/main/java/io/legado/app/help/coil/CoverInterceptor.kt

@@ -10,7 +10,9 @@ import coil3.request.ImageResult
 import coil3.request.SuccessResult
 import coil3.request.allowHardware
 import coil3.request.crossfade
+import coil3.request.maxBitmapSize
 import coil3.request.transformations
+import coil3.size.Size
 import io.legado.app.data.appDb
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.source.SourceHelp
@@ -57,7 +59,15 @@ class CoverInterceptor(
     }
 
     override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
-        val request = chain.request
+        // Telephoto 取消 Coil 的尺寸上限后,按宽度加载长图会生成无法绘制的整图。
+        // 整图仅作预览;分页和条漫的高清细节都由原图区域解码补足。
+        val incoming = chain.request
+        val boundedChain = if (incoming.extras[CoverExtras.Manga] == true ||
+            incoming.extras[CoverExtras.MangaFileOwner] != null
+        ) {
+            chain.withRequest(incoming.newBuilder().maxBitmapSize(Size(4096, 4096)).build())
+        } else chain
+        val request = boundedChain.request
         val data = request.data
 
         val fileOwner = request.extras[CoverExtras.MangaFileOwner]
@@ -93,7 +103,12 @@ class CoverInterceptor(
 
             var usedPreview = snapshot != null
             var result = try {
-                chain.withRequest(localRequest(snapshot?.data?.toFile() ?: file, usedPreview))
+                boundedChain.withRequest(
+                    localRequest(
+                        snapshot?.data?.toFile() ?: file,
+                        usedPreview
+                    )
+                )
                     .proceed()
             } finally {
                 if (snapshot != null) withContext(kotlinx.coroutines.NonCancellable + Dispatchers.IO) { snapshot.close() }
@@ -101,7 +116,7 @@ class CoverInterceptor(
             if (usedPreview && result is ErrorResult) {
                 withContext(Dispatchers.IO) { MangaPreviewCache.remove(previewKey) }
                 usedPreview = false
-                result = chain.withRequest(localRequest(file, false)).proceed()
+                result = boundedChain.withRequest(localRequest(file, false)).proceed()
             }
             if (webtoon && !usedPreview && result is SuccessResult && result.image is BitmapImage) {
                 MangaPreviewCache.save(previewKey, (result.image as BitmapImage).bitmap)
@@ -143,7 +158,7 @@ class CoverInterceptor(
                     val localRequest = request.newBuilder()
                         .data(file)
                         .build()
-                    return chain.withRequest(localRequest).proceed()
+                    return boundedChain.withRequest(localRequest).proceed()
                 }
             }
 
@@ -186,8 +201,8 @@ class CoverInterceptor(
                 }
                 .build()
 
-            return chain.withRequest(newRequest).proceed()
+            return boundedChain.withRequest(newRequest).proceed()
         }
-        return chain.proceed()
+        return boundedChain.proceed()
     }
 }

+ 152 - 0
app/src/main/java/io/legado/app/help/coil/MangaAndroidRegionDecoder.kt

@@ -0,0 +1,152 @@
+package io.legado.app.help.coil
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.BitmapRegionDecoder
+import android.graphics.Matrix
+import android.graphics.Rect
+import android.net.Uri
+import android.os.Build
+import androidx.exifinterface.media.ExifInterface
+import io.legado.app.domain.reader.MangaImageOrientation
+import io.legado.app.domain.reader.MangaImageRegion
+import io.legado.app.domain.reader.MangaImageSize
+import io.legado.app.domain.reader.MangaRegionDecoder
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.Closeable
+import java.io.File
+import java.io.IOException
+import java.io.InputStream
+import java.util.concurrent.atomic.AtomicBoolean
+
+/** Android-only original-image access. A source owns its lease, independently of the request. */
+internal class MangaAndroidRegionSource private constructor(
+    private val input: () -> InputStream,
+    private val lease: Closeable,
+) : Closeable {
+    private val closed = AtomicBoolean()
+
+    // The caller acquires this resource in NonCancellable and closes it in finally.
+    suspend fun open(): MangaRegionDecoder<Bitmap> = withContext(Dispatchers.IO) {
+        check(!closed.get()) { "Image source is closed" }
+        val orientation = try {
+            input().use { ExifInterface(it).getAttributeInt(ExifInterface.TAG_ORIENTATION, 1) }
+        } catch (_: IOException) {
+            1
+        }
+        val native = input().use {
+            if (Build.VERSION.SDK_INT >= 31) BitmapRegionDecoder.newInstance(it)
+            else {
+                @Suppress("DEPRECATION")
+                BitmapRegionDecoder.newInstance(it, false)
+            }
+        } ?: throw IOException("Image does not support region decoding")
+        try {
+            MangaAndroidRegionDecoder(object : MangaAndroidRegionBackend {
+                override val size = MangaImageSize(native.width, native.height)
+                override fun decode(region: MangaImageRegion, sampleSize: Int): Bitmap =
+                    native.decodeRegion(
+                        Rect(region.left, region.top, region.right, region.bottom),
+                        BitmapFactory.Options().apply {
+                            inSampleSize = sampleSize
+                            inPreferredConfig = Bitmap.Config.ARGB_8888
+                        }) ?: throw IOException("Region decoding returned no bitmap")
+
+                override fun close() = native.recycle()
+            }, orientation)
+        } catch (error: Throwable) {
+            native.recycle()
+            throw error
+        }
+    }
+
+    override fun close() {
+        if (closed.compareAndSet(false, true)) lease.close()
+    }
+
+    companion object {
+        fun file(file: File, lease: Closeable = Closeable {}) =
+            MangaAndroidRegionSource(file::inputStream, lease)
+
+        fun uri(context: Context, uri: Uri): MangaAndroidRegionSource? = when (uri.scheme) {
+            "file" -> uri.path?.let { path ->
+                if (path.startsWith("/android_asset/")) MangaAndroidRegionSource({
+                    context.assets.open(path.removePrefix("/android_asset/"))
+                }, Closeable {}) else file(File(path))
+            }
+
+            null -> uri.path?.takeIf { it.startsWith('/') }?.let { file(File(it)) }
+            "content", "android.resource" -> MangaAndroidRegionSource({
+                context.contentResolver.openInputStream(uri)
+                    ?: throw IOException("Cannot open image URI")
+            }, Closeable {})
+
+            else -> null
+        }
+    }
+}
+
+/** The native seam is injectable for lifecycle/EXIF tests without desktop JNI region decoding. */
+internal interface MangaAndroidRegionBackend : Closeable {
+    val size: MangaImageSize
+    fun decode(region: MangaImageRegion, sampleSize: Int): Bitmap
+}
+
+internal class MangaAndroidRegionDecoder(
+    private val backend: MangaAndroidRegionBackend,
+    private val orientationValue: Int,
+) : MangaRegionDecoder<Bitmap> {
+    private val orientation = MangaImageOrientation(orientationValue)
+    private val lock = Any()
+    private var closed = false
+    override val imageSize = orientation.displayedSize(backend.size)
+
+    override suspend fun decodeRegion(region: MangaImageRegion, sampleSize: Int): Bitmap =
+        withContext(Dispatchers.IO) {
+            // Cancellation cannot release the native decoder while decodeRegion is running.
+            synchronized(lock) {
+                check(!closed) { "Image decoder is closed" }
+                require(sampleSize > 0 && sampleSize.countOneBits() == 1)
+                require(
+                    region.left >= 0 && region.top >= 0 && region.right <= imageSize.width &&
+                            region.bottom <= imageSize.height && region.width > 0 && region.height > 0
+                )
+                val raw = backend.decode(orientation.rawRegion(region, backend.size), sampleSize)
+                orientMangaRegion(raw, orientationValue).also { it.prepareToDraw() }
+            }
+        }
+
+    override fun close() = synchronized(lock) {
+        if (!closed) {
+            closed = true
+            backend.close()
+        }
+    }
+}
+
+/** Reorient only the bounded tile. The returned bitmap belongs to the renderer, not the decoder. */
+internal fun orientMangaRegion(raw: Bitmap, orientation: Int): Bitmap {
+    if (orientation !in 2..8) return raw
+    val values = when (orientation) {
+        2 -> floatArrayOf(-1f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 1f)
+        3 -> floatArrayOf(-1f, 0f, 0f, 0f, -1f, 0f, 0f, 0f, 1f)
+        4 -> floatArrayOf(1f, 0f, 0f, 0f, -1f, 0f, 0f, 0f, 1f)
+        5 -> floatArrayOf(0f, 1f, 0f, 1f, 0f, 0f, 0f, 0f, 1f)
+        6 -> floatArrayOf(0f, -1f, 0f, 1f, 0f, 0f, 0f, 0f, 1f)
+        7 -> floatArrayOf(0f, -1f, 0f, -1f, 0f, 0f, 0f, 0f, 1f)
+        else -> floatArrayOf(0f, 1f, 0f, -1f, 0f, 0f, 0f, 0f, 1f)
+    }
+    try {
+        val result = Bitmap.createBitmap(
+            raw, 0, 0, raw.width, raw.height,
+            Matrix().apply { setValues(values) }, false
+        )
+        if (result !== raw) raw.recycle()
+        return result
+    } catch (error: Throwable) {
+        raw.recycle()
+        throw error
+    }
+}

+ 309 - 0
app/src/main/java/io/legado/app/help/coil/MangaWebtoonTiles.kt

@@ -0,0 +1,309 @@
+package io.legado.app.help.coil
+
+import android.net.Uri
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Canvas
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.clipRect
+import androidx.compose.ui.graphics.drawscope.translate
+import androidx.compose.ui.graphics.painter.BitmapPainter
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.IntRect
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.core.graphics.createBitmap
+import androidx.core.net.toUri
+import coil3.BitmapImage
+import coil3.request.SuccessResult
+import coil3.request.crossfadeMillis
+import coil3.toAndroidUri
+import coil3.transform.Transformation
+import io.legado.app.domain.reader.MangaImageRegion
+import io.legado.app.domain.reader.MangaRegionDecoder
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableMap
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.persistentMapOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.collections.immutable.toImmutableMap
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.withContext
+import java.io.File
+import kotlin.coroutines.cancellation.CancellationException
+import kotlin.math.ceil
+import kotlin.math.floor
+
+/** 原图区域与用于消除双线性采样接缝的边缘像素;单块解码不超过 1026 × 1026。 */
+@Stable
+internal data class MangaWebtoonTile(
+    val bounds: IntRect,
+    val decodeBounds: IntRect,
+    val sampleSize: Int,
+)
+
+/** 只选择实际视口及前后一行;长图总高度不会增加活动位图数量。 */
+internal fun mangaWebtoonTiles(
+    sourceSize: IntSize,
+    layoutSize: IntSize,
+    position: Offset,
+    viewport: Rect,
+): ImmutableList<MangaWebtoonTile> {
+    if (sourceSize.width <= 0 || sourceSize.height <= 0 ||
+        layoutSize.width <= 0 || layoutSize.height <= 0 || viewport.isEmpty
+    ) return persistentListOf()
+    val visible = viewport.translate(-position).intersect(
+        Rect(0f, 0f, layoutSize.width.toFloat(), layoutSize.height.toFloat()),
+    )
+    if (visible.isEmpty) return persistentListOf()
+    val ratioX = sourceSize.width.toDouble() / layoutSize.width
+    val ratioY = sourceSize.height.toDouble() / layoutSize.height
+    var sample = 1
+    while (sample * 2.0 <= minOf(ratioX, ratioY)) sample *= 2
+    val edge = 1024 * sample
+    val left = floor(visible.left * ratioX / edge).toInt().coerceAtLeast(0)
+    val right = ceil(visible.right * ratioX / edge).toInt()
+        .coerceAtMost(ceil(sourceSize.width.toDouble() / edge).toInt())
+    val top = (floor(visible.top * ratioY / edge).toInt() - 1).coerceAtLeast(0)
+    val bottom = (ceil(visible.bottom * ratioY / edge).toInt() + 1)
+        .coerceAtMost(ceil(sourceSize.height.toDouble() / edge).toInt())
+    return buildList {
+        // 可见块优先于前后预热块。
+        for (y in top until bottom) for (x in left until right) {
+            val bounds = IntRect(
+                x * edge, y * edge,
+                minOf((x + 1) * edge, sourceSize.width), minOf((y + 1) * edge, sourceSize.height)
+            )
+            add(
+                MangaWebtoonTile(
+                    bounds, IntRect(
+                        (bounds.left - sample).coerceAtLeast(0),
+                        (bounds.top - sample).coerceAtLeast(0),
+                        (bounds.right + sample).coerceAtMost(sourceSize.width),
+                        (bounds.bottom + sample).coerceAtMost(sourceSize.height),
+                    ), sample
+                )
+            )
+        }
+    }.sortedBy { tile ->
+        val centerY = (tile.bounds.top + tile.bounds.bottom) / 2.0
+        kotlin.math.abs(centerY - visible.center.y * ratioY)
+    }.toImmutableList()
+}
+
+/** 滑动复用重叠块,离开窗口即释放;取消不发布迟到结果,也不触发错误降级。 */
+internal suspend fun loadMangaWebtoonTiles(
+    plans: Flow<ImmutableList<MangaWebtoonTile>>,
+    decode: suspend (MangaWebtoonTile) -> Painter,
+    publish: (ImmutableMap<MangaWebtoonTile, Painter>) -> Unit,
+) {
+    val cached = mutableMapOf<MangaWebtoonTile, Painter>()
+    plans.distinctUntilChanged().collectLatest { plan ->
+        cached.keys.retainAll(plan.toSet())
+        publish(cached.toImmutableMap())
+        for (tile in plan) {
+            if (tile in cached) continue
+            val painter = decode(tile)
+            currentCoroutineContext().ensureActive()
+            cached[tile] = painter
+            publish(cached.toImmutableMap())
+        }
+    }
+}
+
+/**
+ * 条漫的高清绘制层。Coil 整图仅作为有界预览;直接画原图分块,不创建整页缩放图层。
+ * 外部 LazyColumn 继续拥有滚动/缩放,原图租约覆盖解码器的整个使用期。
+ */
+@Composable
+internal fun MangaWebtoonTiles(
+    result: SuccessResult?,
+    owner: MangaImageFileOwner,
+    layoutSize: IntSize,
+    position: Offset,
+    viewport: Rect,
+    colorFilter: ColorFilter?,
+    backgroundColor: Color,
+    transformations: ImmutableList<Transformation>,
+) {
+    val bitmap = (result?.image as? BitmapImage)?.bitmap ?: return
+    // 原尺寸已满足显示的短图沿用 AsyncImage;长图和放大后的低分辨率预览补区域细节。
+    if (layoutSize.height <= 4096 && bitmap.width >= layoutSize.width) return
+    val context = LocalContext.current
+    val currentLayout by rememberUpdatedState(layoutSize)
+    val currentPosition by rememberUpdatedState(position)
+    val currentViewport by rememberUpdatedState(viewport)
+    var sourceSize by remember(result) { mutableStateOf(IntSize.Zero) }
+    var tiles by remember(result) {
+        mutableStateOf<ImmutableMap<MangaWebtoonTile, Painter>>(persistentMapOf())
+    }
+    var hasLoadedTiles by remember(result) { mutableStateOf(false) }
+    val tileAlpha by animateFloatAsState(
+        targetValue = if (hasLoadedTiles) 1f else 0f,
+        animationSpec = tween(result.request.crossfadeMillis),
+        label = "Webtoon tile fade",
+    )
+    LaunchedEffect(result, owner, transformations) {
+        var source: MangaAndroidRegionSource? = null
+        var decoder: MangaRegionDecoder<android.graphics.Bitmap>? = null
+        try {
+            // 在线预览命中时 result.request.data 可能是预览 PNG;始终从 owner 借原图。
+            source = mangaWebtoonTileSource(result, owner, context)
+            val activeSource = source ?: return@LaunchedEffect
+            // 获取资源时先接管再响应取消,避免 suspend 返回边界丢失解码器/租约。
+            val activeDecoder = withContext(NonCancellable) {
+                activeSource.open()
+            }
+            decoder = activeDecoder
+            currentCoroutineContext().ensureActive()
+            val imageSize = IntSize(activeDecoder.imageSize.width, activeDecoder.imageSize.height)
+            sourceSize = imageSize
+            loadMangaWebtoonTiles(
+                plans = snapshotFlow {
+                    mangaWebtoonTiles(imageSize, currentLayout, currentPosition, currentViewport)
+                },
+                decode = { tile ->
+                    val bounds = tile.decodeBounds
+                    val bitmap = activeDecoder.decodeRegion(
+                        MangaImageRegion(bounds.left, bounds.top, bounds.right, bounds.bottom),
+                        tile.sampleSize,
+                    )
+                    val decoded = BitmapPainter(bitmap.asImageBitmap())
+                    // 缓存预览的结果请求已移除变换;使用原始展示请求的配置。
+                    transformMangaWebtoonTile(decoded, tile, transformations)
+                },
+                publish = {
+                    tiles = it
+                    if (it.isNotEmpty()) hasLoadedTiles = true
+                },
+            )
+        } catch (error: CancellationException) {
+            throw error
+        } catch (error: Exception) {
+            // 格式/设备区域解码失败保留已显示的预览,不重取原图。
+            owner.onRegionDecodeFailed(error)
+            tiles = persistentMapOf()
+        } finally {
+            try {
+                decoder?.close()
+            } finally {
+                source?.close()
+            }
+        }
+    }
+    Canvas(Modifier.fillMaxSize()) {
+        drawMangaWebtoonTiles(sourceSize, tiles, tileAlpha, colorFilter, backgroundColor)
+    }
+}
+
+/** 与设备 Canvas 相同的浮点边界绘制路径,允许验证缩放后的接缝和滤镜。 */
+internal fun DrawScope.drawMangaWebtoonTiles(
+    sourceSize: IntSize,
+    tiles: ImmutableMap<MangaWebtoonTile, Painter>,
+    alpha: Float = 1f,
+    colorFilter: ColorFilter? = null,
+    backgroundColor: Color = Color.Transparent,
+) {
+    if (sourceSize.width <= 0 || sourceSize.height <= 0) return
+    val scaleX = size.width / sourceSize.width
+    val scaleY = size.height / sourceSize.height
+    tiles.forEach { (tile, painter) ->
+        val bounds = tile.bounds
+        val decoded = tile.decodeBounds
+        // 相邻逻辑边界共用浮点坐标;额外边缘像素防止采样时透出底色。
+        clipRect(
+            bounds.left * scaleX,
+            bounds.top * scaleY,
+            bounds.right * scaleX,
+            bounds.bottom * scaleY
+        ) {
+            // 透明 PNG 不能在同一幅预览上叠画两遍;先用阅读背景替换该区域。
+            drawRect(backgroundColor, alpha = alpha)
+            translate(decoded.left * scaleX, decoded.top * scaleY) {
+                with(painter) {
+                    draw(
+                        Size(decoded.width * scaleX, decoded.height * scaleY),
+                        alpha = alpha, colorFilter = colorFilter
+                    )
+                }
+            }
+        }
+    }
+}
+
+/** 在线、文件、content 共用项目管理的 Android 区域解码,不依赖 Telephoto。 */
+internal fun mangaWebtoonTileSource(
+    result: SuccessResult,
+    owner: MangaImageFileOwner,
+    context: android.content.Context,
+): MangaAndroidRegionSource? {
+    owner.borrowForTiles()?.let { (file, lease) ->
+        return MangaAndroidRegionSource.file(file, lease)
+    }
+    val uri = when (val data = result.request.data) {
+        is File -> Uri.fromFile(data)
+        is Uri -> data
+        is coil3.Uri -> data.toAndroidUri()
+        is String -> data.toUri()
+        else -> return null
+    }
+    return MangaAndroidRegionSource.uri(context, uri)
+}
+
+/** 展示变换作用在原图块上;不让原色块覆盖墨水屏二值化预览。 */
+internal suspend fun transformMangaWebtoonTile(
+    painter: Painter,
+    tile: MangaWebtoonTile,
+    transformations: List<Transformation>,
+): Painter {
+    if (transformations.isEmpty()) return painter
+    return withContext(Dispatchers.Default) {
+        val width = ceil(tile.decodeBounds.width.toDouble() / tile.sampleSize).toInt()
+        val height = ceil(tile.decodeBounds.height.toDouble() / tile.sampleSize).toInt()
+        val input = createBitmap(width, height)
+        var output = input
+        try {
+            CanvasDrawScope().draw(
+                Density(1f), LayoutDirection.Ltr, Canvas(input.asImageBitmap()),
+                Size(width.toFloat(), height.toFloat())
+            ) {
+                with(painter) { draw(size) }
+            }
+            for (transformation in transformations) {
+                val previous = output
+                output = transformation.transform(previous, coil3.size.Size(width, height))
+                if (previous !== input && previous !== output) previous.recycle()
+            }
+            BitmapPainter(output.asImageBitmap())
+        } finally {
+            if (output !== input) input.recycle()
+        }
+    }
+}

+ 16 - 0
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderInteraction.kt

@@ -173,6 +173,22 @@ internal fun mangaChapterPrefetchPages(
         .thenBy { if (it.pageIndex >= anchor) 0 else 1 })
 }
 
+/** 缩放后的滚动偏移保留手势下的原图坐标;未知占位和固定高度章节项不按图片缩放。 */
+internal fun mangaWebtoonZoomAnchor(
+    visible: List<Triple<String, Int, Int>>,
+    focalY: Float,
+    knownSizes: Set<String>,
+    zoomRatio: Float,
+    panY: Float = 0f,
+): Pair<String, Int>? {
+    if (!zoomRatio.isFinite() || zoomRatio <= 0f || !focalY.isFinite() || !panY.isFinite()) return null
+    val item = visible.firstOrNull {
+        it.first in knownSizes && focalY >= it.second && focalY < it.second.toLong() + it.third
+    } ?: return null
+    val offset = (focalY - item.second) * zoomRatio - focalY - panY
+    return item.first to kotlin.math.round(offset).toInt()
+}
+
 /** 高度改变前保持视口中已有图片的位置;未知占位没有可保持的图片内容。 */
 internal fun mangaWebtoonResizeAnchor(
     visible: List<Triple<String, Int, Int>>,

+ 96 - 21
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderScreen.kt

@@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.requiredWidth
 import androidx.compose.foundation.layout.widthIn
 import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListLayoutInfo
 import androidx.compose.foundation.lazy.layout.LazyLayoutCacheWindow
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.foundation.pager.HorizontalPager
@@ -54,6 +55,7 @@ import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clipToBounds
 import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
 import androidx.compose.ui.geometry.isSpecified
 import androidx.compose.ui.graphics.Brush
 import androidx.compose.ui.graphics.Color
@@ -84,6 +86,7 @@ import androidx.lifecycle.repeatOnLifecycle
 import coil3.ImageLoader
 import coil3.compose.AsyncImage
 import coil3.request.ImageRequest
+import coil3.request.SuccessResult
 import coil3.request.allowHardware
 import coil3.request.crossfade
 import coil3.request.transformations
@@ -99,6 +102,7 @@ import io.legado.app.R
 import io.legado.app.help.coil.CoverExtras
 import io.legado.app.help.coil.MangaImageFileOwner
 import io.legado.app.help.coil.MangaPrefetchRequests
+import io.legado.app.help.coil.MangaWebtoonTiles
 import io.legado.app.help.coil.asMangaPrefetch
 import io.legado.app.help.coil.rememberMangaZoomableImageSource
 import io.legado.app.ui.book.manga.config.MangaDoublePageMode
@@ -110,6 +114,7 @@ import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.button.series.MediumOutlinedButton
 import io.legado.app.ui.widget.components.changeSource.ChangeSourceSheet
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
+import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.distinctUntilChanged
@@ -456,9 +461,11 @@ private fun WebtoonMangaList(
     val viewportSize = LocalReaderViewportSize.current
     val aspectRatios = LocalMangaAspectRatios.current
     val resizeQueue = remember(state.bookUrl) { MangaWebtoonResizeQueue() }
+    var zoomAnchorLayout by remember { mutableStateOf<LazyListLayoutInfo?>(null) }
     val applyResize: (() -> Unit) -> Unit = { resize ->
         val layout = listState.layoutInfo
-        val anchor = if (listState.isScrollInProgress || latestReaderState.scrollRequest != null ||
+        val anchor =
+            if (zoomAnchorLayout != null || listState.isScrollInProgress || latestReaderState.scrollRequest != null ||
             latestReaderState.bookUrl != state.bookUrl
         ) null else {
             val loadedKeys = layout.visibleItemsInfo.mapNotNull { info ->
@@ -499,6 +506,12 @@ private fun WebtoonMangaList(
             )
         }
     }
+    LaunchedEffect(listState) {
+        snapshotFlow { listState.layoutInfo }.collect { measured ->
+            // 高度更新不能用旧布局的像素锚点覆盖尚未生效的缩放锚点。
+            if (zoomAnchorLayout != null && measured !== zoomAnchorLayout) zoomAnchorLayout = null
+        }
+    }
     var zoom by remember { mutableFloatStateOf(1f) }
     var pan by remember { mutableStateOf(Offset.Zero) }
     val fraction = 1f - state.settings.sidePaddingPercent.coerceIn(0, 45) * 2f / 100f
@@ -527,13 +540,48 @@ private fun WebtoonMangaList(
     val latestItemWidthPx by rememberUpdatedState(itemWidthPx)
     val latestViewportSize by rememberUpdatedState(viewportSize)
 
-    fun toggleWebtoonZoom() {
-        if (zoom > 1f) {
-            zoom = 1f
-            pan = Offset.Zero
-        } else {
-            zoom = WEBTOON_DOUBLE_TAP_ZOOM
+    fun preserveWebtoonZoomPosition(targetZoom: Float, focalY: Float, panY: Float = 0f): Boolean {
+        val layout = listState.layoutInfo
+        val width = latestViewportSize.width
+        if (width <= 0 || layout.viewportSize.width <= 0) return false
+        // 手势事件可能快于重测;基于当前实际测量宽度,而非尚未布局的 zoom 状态计算倍率。
+        val pageWidthFraction = latestItemWidthPx / width
+        val measuredImageWidth =
+            (layout.viewportSize.width * pageWidthFraction).roundToInt().coerceAtLeast(1)
+        val targetImageWidth =
+            ((width * targetZoom).roundToInt() * pageWidthFraction).roundToInt().coerceAtLeast(1)
+        val ratio = targetImageWidth.toFloat() / measuredImageWidth
+        val visible = layout.visibleItemsInfo.filter {
+            latestReaderState.pages.getOrNull(it.index)?.key == it.key
+        }
+        val anchor = mangaWebtoonZoomAnchor(
+            visible.map { Triple(it.key.toString(), it.offset, it.size) },
+            focalY, aspectRatios.keys, ratio, panY,
+        ) ?: return false
+        val index = visible.firstOrNull { it.key == anchor.first }?.index ?: return false
+        // 与宽度变化在同次重测中生效,不先滚旧高度、下一帧再改图片高度。
+        zoomAnchorLayout = layout
+        listState.requestScrollToItem(index, anchor.second)
+        return true
+    }
+
+    fun toggleWebtoonZoom(tap: Offset) {
+        val target = if (zoom > 1f) 1f else WEBTOON_DOUBLE_TAP_ZOOM
+        preserveWebtoonZoomPosition(target, tap.y)
+        pan = if (target <= 1f) Offset.Zero else {
+            val ratio = target / zoom
+            clampZoomPan(
+                target = Offset(
+                    pan.x * ratio + (tap.x - latestViewportSize.width / 2f) * (1f - ratio),
+                    0f
+                ),
+                zoom = target,
+                itemWidth = latestItemWidthPx,
+                contentHeight = latestNaturalContentHeight,
+                viewport = latestViewportSize,
+            )
         }
+        zoom = target
     }
 
     // 列表使用真实的缩放后宽度测量,避免 LazyColumn 先按未缩放宽度裁掉图片两侧。
@@ -555,12 +603,14 @@ private fun WebtoonMangaList(
                         val newZoom =
                             (currentZoom * zoomChange).coerceIn(MIN_WEBTOON_ZOOM, MAX_WEBTOON_ZOOM)
                         val ratio = if (currentZoom > 0f) newZoom / currentZoom else 1f
+                        val center =
+                            Offset(latestViewportSize.width / 2f, latestViewportSize.height / 2f)
+                        val effectiveCentroid = centroid.takeIf { it.isSpecified } ?: center
+                        val anchored = if (ratio != 1f) preserveWebtoonZoomPosition(
+                            newZoom, effectiveCentroid.y, if (newZoom > 1f) panChange.y else 0f,
+                        ) else false
                         if (ratio != 1f) {
                             handled = true
-                            val width = latestViewportSize.width.coerceAtLeast(1).toFloat()
-                            val height = latestViewportSize.height.coerceAtLeast(1).toFloat()
-                            val center = Offset(width / 2f, height / 2f)
-                            val effectiveCentroid = centroid.takeIf { it.isSpecified } ?: center
                             pan = pan * ratio + (effectiveCentroid - center) * (1f - ratio)
                         }
                         if (newZoom <= 1f) {
@@ -574,7 +624,7 @@ private fun WebtoonMangaList(
                                 contentHeight = latestNaturalContentHeight,
                                 viewport = latestViewportSize,
                             )
-                            if (panChange.y != 0f) listState.dispatchRawDelta(-panChange.y)
+                            if (!anchored && panChange.y != 0f) listState.dispatchRawDelta(-panChange.y)
                         }
                         if (handled) pressed.forEach { it.consume() }
                         zoom = newZoom
@@ -785,7 +835,7 @@ private fun WebtoonMangaList(
                         pendingWebtoonTap = null
                         lastWebtoonTapAt = 0L
                         lastWebtoonTapPosition = Offset.Unspecified
-                        toggleWebtoonZoom()
+                        toggleWebtoonZoom(latest)
                     } else {
                         lastWebtoonTapAt = upAt
                         lastWebtoonTapPosition = latest
@@ -1408,6 +1458,7 @@ private fun MangaPageImage(
     }
     val fileOwner = remember(imagePipelineKey) { MangaImageFileOwner() }
     var webtoonImageDisplayed by remember(imagePipelineKey) { mutableStateOf(false) }
+    var webtoonResult by remember(imagePipelineKey) { mutableStateOf<SuccessResult?>(null) }
     val request = remember(imagePipelineKey) {
         page.imageRequest(
             settings = settings,
@@ -1525,7 +1576,10 @@ private fun MangaPageImage(
         }
         Box(imageModifier) {
             ZoomableImage(
-                image = rememberMangaZoomableImageSource(request, imageLoader, fileOwner, settings.enableEInk),
+                image = rememberMangaZoomableImageSource(
+                    request, imageLoader, fileOwner,
+                    wholeImage = settings.enableEInk,
+                ),
                 contentDescription = contentDescription,
                 state = zoomableImageState,
                 gestures = if (settings.disableScale || !interactionsEnabled) {
@@ -1561,10 +1615,8 @@ private fun MangaPageImage(
     }
 
     Box(imageModifier) {
-        // 条漫模式刻意不用 ZoomableAsyncImage:Telephoto 的 zoomable 会把每页内容渲染进
-        // 自己的图层再合成,在「长图逐页堆叠」时会在页边界丢一行像素,视觉上就是两页之间
-        // 的一条极细横线(图源本身不透明、页框几何也完全对齐,可排除数据与布局原因)。
-        // 条漫的缩放/平移由 WebtoonMangaList 在外层统一处理,单页不需要再包一层。
+        // Coil 整图作为预览,原图区域解码补充可见部分的高清细节。
+        // 缩放/平移仍由 WebtoonMangaList 统一处理。
         AsyncImage(
             model = request,
             imageLoader = imageLoader,
@@ -1572,9 +1624,32 @@ private fun MangaPageImage(
             contentScale = ContentScale.FillWidth,
             colorFilter = mangaColorFilter(settings),
             modifier = Modifier.fillMaxSize(),
-            onLoading = { webtoonImageDisplayed = it.painter != null },
-            onSuccess = { webtoonImageDisplayed = true },
-            onError = { webtoonImageDisplayed = false },
+            onLoading = {
+                webtoonImageDisplayed = it.painter != null
+                webtoonResult = null
+            },
+            onSuccess = {
+                webtoonImageDisplayed = true
+                webtoonResult = it.result
+            },
+            onError = {
+                webtoonImageDisplayed = false
+                webtoonResult = null
+            },
+        )
+        MangaWebtoonTiles(
+            result = webtoonResult,
+            owner = fileOwner,
+            layoutSize = imageViewportSize,
+            position = positionInRoot,
+            viewport = Rect(
+                viewportOrigin, androidx.compose.ui.geometry.Size(
+                    viewportSize.width.toFloat(), viewportSize.height.toFloat(),
+                )
+            ),
+            colorFilter = mangaColorFilter(settings),
+            transformations = remember(request) { request.transformations.toImmutableList() },
+            backgroundColor = settings.backgroundColor.copy(alpha = 1f),
         )
         MangaImageLoadOverlay(
             page.loadState,

+ 145 - 0
app/src/test/java/io/legado/app/help/coil/MangaAndroidRegionDecoderTest.kt

@@ -0,0 +1,145 @@
+package io.legado.app.help.coil
+
+import android.app.Application
+import android.graphics.Bitmap
+import io.legado.app.domain.reader.MangaImageRegion
+import io.legado.app.domain.reader.MangaImageSize
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.GraphicsMode
+import java.io.Closeable
+import java.io.File
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [26, 35])
+@GraphicsMode(GraphicsMode.Mode.NATIVE)
+class MangaAndroidRegionDecoderTest {
+    @Test
+    fun `all EXIF orientations preserve exact tile pixels`() = runBlocking {
+        val expected = listOf(
+            intArrayOf(1, 2, 3, 4, 5, 6), intArrayOf(2, 1, 4, 3, 6, 5),
+            intArrayOf(6, 5, 4, 3, 2, 1), intArrayOf(5, 6, 3, 4, 1, 2),
+            intArrayOf(1, 3, 5, 2, 4, 6), intArrayOf(5, 3, 1, 6, 4, 2),
+            intArrayOf(6, 4, 2, 5, 3, 1), intArrayOf(2, 4, 6, 1, 3, 5),
+        )
+        for (orientation in 1..8) {
+            var closes = 0
+            val decoder = MangaAndroidRegionDecoder(object : MangaAndroidRegionBackend {
+                override val size = MangaImageSize(2, 3)
+                override fun decode(region: MangaImageRegion, sampleSize: Int): Bitmap {
+                    assertEquals(MangaImageRegion(0, 0, 2, 3), region)
+                    assertEquals(1, sampleSize)
+                    return Bitmap.createBitmap(
+                        IntArray(6) { 0xff000000.toInt() or (it + 1) },
+                        2, 3, Bitmap.Config.ARGB_8888
+                    )
+                }
+
+                override fun close() {
+                    closes++
+                }
+            }, orientation)
+            val size = decoder.imageSize
+            val bitmap = decoder.decodeRegion(MangaImageRegion(0, 0, size.width, size.height), 1)
+            try {
+                decoder.close(); decoder.close()
+                assertEquals(1, closes)
+                assertFalse(bitmap.isRecycled)
+                val pixels = IntArray(6)
+                bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
+                assertArrayEquals(expected[orientation - 1].map { 0xff000000.toInt() or it }
+                    .toIntArray(), pixels)
+            } finally {
+                bitmap.recycle(); decoder.close()
+            }
+        }
+    }
+
+    @Test
+    fun `closing waits for native decoding and cancelled work is not published`() = runBlocking {
+        val started = CompletableDeferred<Unit>()
+        val gate = CountDownLatch(1)
+        val closed = AtomicBoolean()
+        val published = AtomicBoolean()
+        val decoder = MangaAndroidRegionDecoder(object : MangaAndroidRegionBackend {
+            override val size = MangaImageSize(2, 3)
+            override fun decode(region: MangaImageRegion, sampleSize: Int): Bitmap {
+                started.complete(Unit)
+                check(gate.await(10, TimeUnit.SECONDS))
+                assertFalse(closed.get())
+                return Bitmap.createBitmap(2, 3, Bitmap.Config.ARGB_8888)
+            }
+
+            override fun close() {
+                closed.set(true)
+            }
+        }, 1)
+        val work =
+            launch { decoder.decodeRegion(MangaImageRegion(0, 0, 2, 3), 1); published.set(true) }
+        try {
+            withTimeout(5_000) { started.await() }
+            work.cancel()
+            val closing = async(Dispatchers.Default) { decoder.close() }
+            assertFalse(closed.get())
+            gate.countDown()
+            withTimeout(5_000) { work.join(); closing.await() }
+            assertTrue(closed.get())
+            assertFalse(published.get())
+        } finally {
+            gate.countDown(); work.join(); decoder.close()
+        }
+    }
+
+    @Test
+    fun `invalid regions and closed decoders fail without touching the native backend`() =
+        runBlocking {
+            val decoder = MangaAndroidRegionDecoder(object : MangaAndroidRegionBackend {
+                override val size = MangaImageSize(2, 3)
+                override fun decode(region: MangaImageRegion, sampleSize: Int): Bitmap =
+                    error("Must not decode")
+
+                override fun close() = Unit
+            }, 1)
+            try {
+                try {
+                    decoder.decodeRegion(MangaImageRegion(-1, 0, 2, 3), 1); fail()
+                } catch (_: IllegalArgumentException) {
+                }
+                try {
+                    decoder.decodeRegion(MangaImageRegion(0, 0, 2, 3), 3); fail()
+                } catch (_: IllegalArgumentException) {
+                }
+                decoder.close()
+                try {
+                    decoder.decodeRegion(MangaImageRegion(0, 0, 2, 3), 1); fail()
+                } catch (_: IllegalStateException) {
+                }
+            } finally {
+                decoder.close()
+            }
+        }
+
+    @Test
+    fun `original source closes its independent lease exactly once`() {
+        var closes = 0
+        val source = MangaAndroidRegionSource.file(File("unused.png"), Closeable { closes++ })
+        source.close(); source.close()
+        assertEquals(1, closes)
+    }
+}

+ 79 - 0
app/src/test/java/io/legado/app/help/coil/MangaFileRequestTest.kt

@@ -13,6 +13,10 @@ import coil3.request.SuccessResult
 import coil3.request.allowHardware
 import coil3.request.crossfade
 import coil3.request.crossfadeMillis
+import coil3.request.maxBitmapSize
+import coil3.size.Dimension
+import coil3.size.Scale
+import coil3.size.Size
 import io.legado.app.data.entities.Book
 import io.legado.app.help.book.BookHelp
 import kotlinx.coroutines.delay
@@ -44,6 +48,65 @@ class MangaFileRequestTest {
         RuntimeEnvironment.getApplication().injectAsAppCtx()
     }
 
+    @Test
+    fun `switching loaded long webtoon to paged keeps preview bounded and original leased`() =
+        runBlocking {
+            val src = "https://invalid.example/mode-switch-long.png"
+            val raw = BookHelp.getImage(book, src)
+            val bitmap = Bitmap.createBitmap(128, 12_000, Bitmap.Config.ARGB_8888)
+            try {
+                bitmap.eraseColor(android.graphics.Color.RED)
+                ByteArrayOutputStream().use {
+                    bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)
+                    BookHelp.writeImage(book, src, it.toByteArray())
+                }
+            } finally {
+                bitmap.recycle()
+            }
+            val loader = ImageLoader.Builder(RuntimeEnvironment.getApplication()).components {
+                add(BitmapFactoryDecoder.Factory())
+                add(CoverInterceptor { _, data -> BookHelp.acquireReadingImage(null, book, data) })
+            }.build()
+            try {
+                for (webtoon in listOf(true, false, true, false)) {
+                    val owner = MangaImageFileOwner()
+                    try {
+                        val request = ImageRequest.Builder(RuntimeEnvironment.getApplication())
+                            .data(src).size(Size(Dimension(128), Dimension.Undefined))
+                            .scale(Scale.FILL).allowHardware(false)
+                            .memoryCacheKey("mode-switch-long")
+                            // Telephoto overrides this on every paged request.
+                            .maxBitmapSize(if (webtoon) Size(4096, 4096) else Size.ORIGINAL)
+                            .apply {
+                                extras[CoverExtras.MangaFileOwner] = owner
+                                extras[CoverExtras.MangaWebtoon] = webtoon
+                            }.build()
+                        val result = loader.execute(request)
+                        if (result is ErrorResult) throw result.throwable
+                        require(result is SuccessResult)
+                        assertTrue(
+                            "Preview height ${result.image.height} cannot be drawn safely",
+                            result.image.height <= 4096
+                        )
+                        if (!webtoon) assertEquals(raw, result.request.data)
+                        val borrowed = requireNotNull(owner.borrowForTiles())
+                        try {
+                            assertEquals(raw, borrowed.first)
+                            owner.close()
+                            BookHelp.clearCache(book)
+                            assertTrue(raw.isFile)
+                        } finally {
+                            borrowed.second.close()
+                        }
+                    } finally {
+                        owner.close()
+                    }
+                }
+            } finally {
+                loader.shutdown(); BookHelp.clearCache(book)
+            }
+        }
+
     @Test
     fun `webtoon reuses persisted preview after memory eviction and corrupt preview falls back to original`() =
         runBlocking {
@@ -110,6 +173,22 @@ class MangaFileRequestTest {
                         delay(10)
                     }
                 }
+                // 预览文件命中后,高清层仍借原图;请求退出后瓦片租约继续阻止清理。
+                val tiles = requireNotNull(
+                    mangaWebtoonTileSource(
+                        reused as SuccessResult, owner,
+                        RuntimeEnvironment.getApplication()
+                    )
+                )
+                try {
+                    owner.close()
+                    BookHelp.clearCache(book)
+                    assertTrue(raw.isFile)
+                } finally {
+                    tiles.close()
+                }
+                BookHelp.clearCache(book)
+                assertFalse(raw.exists())
             } finally {
                 owner.close(); loader.shutdown(); BookHelp.clearCache(book)
             }

+ 31 - 0
app/src/test/java/io/legado/app/help/coil/MangaRegionDecoderContractTest.kt

@@ -0,0 +1,31 @@
+package io.legado.app.help.coil
+
+import io.legado.app.domain.reader.MangaImageOrientation
+import io.legado.app.domain.reader.MangaImageRegion
+import io.legado.app.domain.reader.MangaImageSize
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class MangaRegionDecoderContractTest {
+    @Test
+    fun `all EXIF orientations map displayed subregions back to raw pixels`() {
+        val raw = MangaImageSize(8, 6)
+        val region = MangaImageRegion(1, 2, 3, 4)
+        val expected = listOf(
+            region, MangaImageRegion(5, 2, 7, 4),
+            MangaImageRegion(5, 2, 7, 4), region, MangaImageRegion(2, 1, 4, 3),
+            MangaImageRegion(2, 3, 4, 5), MangaImageRegion(4, 3, 6, 5),
+            MangaImageRegion(4, 1, 6, 3)
+        )
+        for (value in 1..8) {
+            val orientation = MangaImageOrientation(value)
+            assertEquals(expected[value - 1], orientation.rawRegion(region, raw))
+            val size = orientation.displayedSize(raw)
+            assertEquals(if (value < 5) raw else MangaImageSize(6, 8), size)
+            assertEquals(
+                MangaImageRegion(0, 0, 8, 6),
+                orientation.rawRegion(MangaImageRegion(0, 0, size.width, size.height), raw)
+            )
+        }
+    }
+}

+ 133 - 0
app/src/test/java/io/legado/app/help/coil/MangaWebtoonTileWindowTest.kt

@@ -0,0 +1,133 @@
+package io.legado.app.help.coil
+
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.painter.ColorPainter
+import androidx.compose.ui.unit.IntSize
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.withContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
+class MangaWebtoonTileWindowTest {
+    private fun plan(y: Float = 0f, height: Int = 20_000, width: Int = 800) = mangaWebtoonTiles(
+        IntSize(800, height), IntSize(width, height * width / 800), Offset(0f, -y),
+        Rect(0f, 0f, 1080f, 2400f),
+    )
+
+    @Test
+    fun `very tall images retain source width without making full height bitmaps`() {
+        for (height in listOf(20_000, 200_000)) {
+            val tiles = plan(10_000f, height)
+            assertTrue(tiles.size <= 6)
+            assertTrue(tiles.all { it.sampleSize == 1 && it.bounds.width == 800 })
+            assertTrue(tiles.all { it.decodeBounds.width <= 1026 && it.decodeBounds.height <= 1026 })
+            val rows = tiles.sortedBy { it.bounds.top }
+            assertTrue(rows.first().bounds.top <= 10_000)
+            assertTrue(rows.last().bounds.bottom >= 12_400)
+            rows.zipWithNext().forEach { (first, second) ->
+                assertEquals(first.bounds.bottom, second.bounds.top)
+                assertTrue(first.decodeBounds.bottom > second.decodeBounds.top)
+            }
+        }
+    }
+
+    @Test
+    fun `page edges are clamped and offscreen pages do not decode`() {
+        assertEquals(0, plan().minOf { it.bounds.top })
+        assertEquals(20_000, plan(19_000f).maxOf { it.bounds.bottom })
+        assertTrue(plan(20_001f).isEmpty())
+        assertTrue(plan(-2401f).isEmpty())
+        assertTrue(mangaWebtoonTiles(IntSize.Zero, IntSize.Zero, Offset.Zero, Rect.Zero).isEmpty())
+    }
+
+    @Test
+    fun `zoom uses a smaller sample and horizontally clipped viewports only load visible columns`() {
+        val source = IntSize(8000, 200_000)
+        val viewport = Rect(0f, 0f, 1080f, 2400f)
+        val normal = mangaWebtoonTiles(source, IntSize(1000, 25_000), Offset.Zero, viewport)
+        val zoomed =
+            mangaWebtoonTiles(source, IntSize(4000, 100_000), Offset(-1500f, -20_000f), viewport)
+        assertTrue(normal.all { it.sampleSize == 8 })
+        assertTrue(zoomed.all { it.sampleSize == 2 })
+        assertTrue(zoomed.all { it.bounds.left >= 2048 && it.bounds.right <= 6144 })
+        assertTrue(zoomed.all { it.decodeBounds.width / it.sampleSize <= 1026 })
+    }
+
+    @Test
+    fun `scrolling reuses overlap and releases tiles outside the viewport window`() = runTest {
+        val plans = MutableStateFlow(plan())
+        val decoded = mutableListOf<MangaWebtoonTile>()
+        var published = emptySet<MangaWebtoonTile>()
+        val job = launch {
+            loadMangaWebtoonTiles(
+                plans,
+                { decoded += it; ColorPainter(Color.Red) },
+                { published = it.keys })
+        }
+        runCurrent()
+        val first = plans.value.toSet()
+        val second = plan(1024f)
+        plans.value = second
+        runCurrent()
+        assertEquals(first.size + (second.toSet() - first).size, decoded.size)
+        assertEquals(second.toSet(), published)
+        plans.value = persistentListOf()
+        runCurrent()
+        assertTrue(published.isEmpty())
+        job.cancelAndJoin()
+    }
+
+    @Test
+    fun `cancellation propagates and does not publish a late native decode`() = runTest {
+        var delivered = false
+        val started = CompletableDeferred<Unit>()
+        val returned = CompletableDeferred<Unit>()
+        val job = launch {
+            loadMangaWebtoonTiles(flowOf(plan()), {
+                // Native decoders can return a bitmap after the calling job has been cancelled.
+                withContext(NonCancellable) {
+                    started.complete(Unit)
+                    returned.await()
+                    ColorPainter(Color.Red)
+                }
+            }, { if (it.isNotEmpty()) delivered = true })
+        }
+        started.await()
+        job.cancel()
+        returned.complete(Unit)
+        job.join()
+        assertTrue(job.isCancelled)
+        assertFalse(delivered)
+    }
+
+    @Test
+    fun `disposing an in flight decode cancels work`() = runTest {
+        var cancelled = false
+        val job = launch {
+            loadMangaWebtoonTiles(flowOf(plan()), {
+                try {
+                    awaitCancellation()
+                } finally {
+                    cancelled = true
+                }
+            }, {})
+        }
+        runCurrent()
+        job.cancelAndJoin()
+        assertTrue(cancelled)
+    }
+}

+ 204 - 0
app/src/test/java/io/legado/app/help/coil/MangaWebtoonTilesTest.kt

@@ -0,0 +1,204 @@
+package io.legado.app.help.coil
+
+import android.app.Application
+import android.graphics.Bitmap
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.graphics.Canvas
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
+import androidx.compose.ui.graphics.painter.BitmapPainter
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.IntRect
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.LayoutDirection
+import coil3.ImageLoader
+import coil3.decode.BitmapFactoryDecoder
+import coil3.request.ImageRequest
+import coil3.request.SuccessResult
+import coil3.size.Dimension
+import coil3.size.Scale
+import coil3.size.Size
+import kotlinx.collections.immutable.persistentMapOf
+import kotlinx.collections.immutable.toImmutableMap
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.GraphicsMode
+import java.io.File
+import androidx.compose.ui.geometry.Size as ComposeSize
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [26, 35])
+@GraphicsMode(GraphicsMode.Mode.NATIVE)
+class MangaWebtoonTilesTest {
+    @Test
+    fun `full width webtoon request loses horizontal detail on a very tall image`() = runBlocking {
+        val file = File.createTempFile("webtoon-long", ".png")
+        val bitmap = Bitmap.createBitmap(128, 12_000, Bitmap.Config.ARGB_8888)
+        val loader = ImageLoader.Builder(RuntimeEnvironment.getApplication())
+            .components { add(BitmapFactoryDecoder.Factory()) }.build()
+        try {
+            bitmap.eraseColor(android.graphics.Color.WHITE)
+            file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
+            val request = ImageRequest.Builder(RuntimeEnvironment.getApplication()).data(file)
+                .size(Size(Dimension(128), Dimension.Undefined)).scale(Scale.FILL).build()
+            val result = loader.execute(request) as SuccessResult
+            // Loading has succeeded, but the default height cap has also reduced its width.
+            assertTrue(result.image.height <= 4096)
+            assertTrue(result.image.width < 64)
+        } finally {
+            loader.shutdown()
+            bitmap.recycle()
+            file.delete()
+        }
+    }
+
+    @Test
+    fun `region painters preserve stripes and eink transforms are applied to each bounded tile`() =
+        runBlocking {
+            val original = Bitmap.createBitmap(128, 12_000, Bitmap.Config.ARGB_8888)
+            try {
+                val row =
+                    IntArray(128) { if (it % 2 == 0) 0xff444444.toInt() else 0xffcccccc.toInt() }
+                repeat(12_000) { original.setPixels(row, 0, 128, 0, it, 128, 1) }
+                val sourceSize = IntSize(128, 12_000)
+                for (tile in mangaWebtoonTiles(
+                    sourceSize, sourceSize, Offset(0f, -6000f),
+                    Rect(0f, 0f, 128f, 600f)
+                )) {
+                    // BitmapRegionDecoder JNI crashes in Windows Robolectric. Its real integration
+                    // is covered separately by MangaWebtoonRegionInstrumentedTest on Android.
+                    val bounds = tile.decodeBounds
+                    val region = Bitmap.createBitmap(
+                        original,
+                        bounds.left,
+                        bounds.top,
+                        bounds.width,
+                        bounds.height
+                    )
+                    try {
+                        val painter = BitmapPainter(region.asImageBitmap())
+                        val decoded =
+                            render(painter, tile.decodeBounds.width, tile.decodeBounds.height)
+                        try {
+                            assertEquals(row[0], decoded.getPixel(0, 100))
+                            assertEquals(row[1], decoded.getPixel(1, 100))
+                            assertTrue(decoded.height <= 1026)
+                        } finally {
+                            decoded.recycle()
+                        }
+                        val eink = transformMangaWebtoonTile(
+                            painter, tile,
+                            listOf(io.legado.app.ui.book.manga.MangaEInkTransformation(128))
+                        )
+                        val filtered =
+                            render(eink, tile.decodeBounds.width, tile.decodeBounds.height)
+                        try {
+                            assertEquals(android.graphics.Color.BLACK, filtered.getPixel(0, 100))
+                            assertEquals(android.graphics.Color.WHITE, filtered.getPixel(1, 100))
+                        } finally {
+                            filtered.recycle()
+                        }
+                    } finally {
+                        region.recycle()
+                    }
+                }
+            } finally {
+                original.recycle()
+            }
+        }
+
+    private fun render(
+        painter: androidx.compose.ui.graphics.painter.Painter,
+        width: Int,
+        height: Int
+    ): Bitmap {
+        val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+        CanvasDrawScope().draw(
+            Density(1f), LayoutDirection.Ltr, Canvas(bitmap.asImageBitmap()),
+            ComposeSize(width.toFloat(), height.toFloat())
+        ) {
+            with(painter) { draw(size) }
+        }
+        return bitmap
+    }
+
+    @Test
+    fun `fractional scaling draws adjacent regions without exposing page background`() {
+        val sourceSize = IntSize(128, 12_000)
+        val tiles = mangaWebtoonTiles(
+            sourceSize, sourceSize, Offset(0f, -6000f),
+            Rect(0f, 0f, 128f, 600f)
+        )
+        val bitmaps = tiles.associateWith { tile ->
+            Bitmap.createBitmap(
+                tile.decodeBounds.width,
+                tile.decodeBounds.height,
+                Bitmap.Config.ARGB_8888
+            )
+                .apply { eraseColor(android.graphics.Color.RED) }
+        }
+        val output = Bitmap.createBitmap(173, 16_219, Bitmap.Config.ARGB_8888)
+        try {
+            output.eraseColor(android.graphics.Color.GREEN)
+            val painters =
+                bitmaps.mapValues { BitmapPainter(it.value.asImageBitmap()) }.toImmutableMap()
+            CanvasDrawScope().draw(
+                Density(1f), LayoutDirection.Ltr, Canvas(output.asImageBitmap()),
+                ComposeSize(output.width.toFloat(), output.height.toFloat())
+            ) {
+                drawMangaWebtoonTiles(sourceSize, painters)
+            }
+            val sorted = tiles.sortedBy { it.bounds.top }
+            sorted.dropLast(1).forEach { tile ->
+                val join =
+                    (tile.bounds.bottom * output.height.toFloat() / sourceSize.height).toInt()
+                for (y in join - 2..join + 2) for (x in 0 until output.width) {
+                    assertEquals("gap at $x,$y", android.graphics.Color.RED, output.getPixel(x, y))
+                }
+            }
+        } finally {
+            output.recycle()
+            bitmaps.values.forEach { it.recycle() }
+        }
+    }
+
+    @Test
+    fun `transparent regions replace the preview instead of blending the same image twice`() {
+        val bitmap = Bitmap.createBitmap(16, 16, Bitmap.Config.ARGB_8888)
+        val output = Bitmap.createBitmap(16, 16, Bitmap.Config.ARGB_8888)
+        val expected = Bitmap.createBitmap(16, 16, Bitmap.Config.ARGB_8888)
+        try {
+            bitmap.eraseColor(0x800000ff.toInt())
+            val painter = BitmapPainter(bitmap.asImageBitmap())
+            val tile = MangaWebtoonTile(IntRect(0, 0, 16, 16), IntRect(0, 0, 16, 16), 1)
+            fun draw(target: Bitmap, tiles: Boolean) {
+                target.eraseColor(android.graphics.Color.WHITE)
+                CanvasDrawScope().draw(
+                    Density(1f), LayoutDirection.Ltr, Canvas(target.asImageBitmap()),
+                    ComposeSize(16f, 16f)
+                ) {
+                    with(painter) { draw(size) }
+                    if (tiles) drawMangaWebtoonTiles(
+                        IntSize(16, 16), persistentMapOf(tile to painter),
+                        backgroundColor = Color.White
+                    )
+                }
+            }
+            draw(expected, false)
+            draw(output, true)
+            assertEquals(expected.getPixel(8, 8), output.getPixel(8, 8))
+        } finally {
+            bitmap.recycle()
+            output.recycle()
+            expected.recycle()
+        }
+    }
+}

+ 52 - 0
app/src/test/java/io/legado/app/ui/book/manga/MangaReaderInteractionTest.kt

@@ -12,6 +12,58 @@ import org.junit.Assert.assertTrue
 import org.junit.Test
 
 class MangaReaderInteractionTest {
+    @Test
+    fun `keeping pixel scroll offset during long image zoom moves the reading point`() {
+        val oldOffset = -10_000
+        val focalY = 700f
+        val ratio = 2.5f
+        val originalPoint = focalY - oldOffset
+        val oldBehaviorPosition = oldOffset + originalPoint * ratio
+        assertTrue(kotlin.math.abs(oldBehaviorPosition - focalY) > 10_000)
+    }
+
+    @Test
+    fun `webtoon zoom preserves the original image point under the gesture`() {
+        val visible = listOf(Triple("long", -10_000, 20_000))
+        for (ratio in listOf(0.4f, 0.8f, 1.2f, 2.5f)) {
+            val anchor = requireNotNull(mangaWebtoonZoomAnchor(visible, 700f, setOf("long"), ratio))
+            assertEquals("long", anchor.first)
+            val originalPoint = 700f + 10_000
+            assertEquals(700f, -anchor.second + originalPoint * ratio, 1f)
+        }
+    }
+
+    @Test
+    fun `zoom anchors the touched image with vertical pan across fixed chapter gaps`() {
+        val visible = listOf(
+            Triple("first", -200, 400), Triple("edge", 200, 96),
+            Triple("second", 296, 20_000)
+        )
+        val anchor =
+            requireNotNull(mangaWebtoonZoomAnchor(visible, 500f, setOf("first", "second"), 2f, 30f))
+        assertEquals("second", anchor.first)
+        assertEquals(530f, -anchor.second + (500f - 296) * 2, 1f)
+        assertNull(mangaWebtoonZoomAnchor(visible, 250f, setOf("first", "second"), 2f))
+        assertNull(mangaWebtoonZoomAnchor(visible, 500f, emptySet(), 2f))
+    }
+
+    @Test
+    fun `zoom out restores the same reading point as zoom in`() {
+        val up = requireNotNull(
+            mangaWebtoonZoomAnchor(
+                listOf(Triple("long", -10_000, 20_000)),
+                700f, setOf("long"), 2.5f
+            )
+        )
+        val down = requireNotNull(
+            mangaWebtoonZoomAnchor(
+                listOf(Triple("long", -up.second, 50_000)),
+                700f, setOf("long"), 0.4f
+            )
+        )
+        assertEquals(10_000, down.second)
+    }
+
     @Test
     fun `dimension regrouping preserves the second half of a split wide page`() {
         val items = listOf(page(0), page(1), page(2))

+ 101 - 3
docs/dev/manga-subsampling-plan.md

@@ -15,7 +15,8 @@
 - `MangaReaderScreen` 的请求保留完整原始 URL 身份,共用显式解码尺寸。
   分页预取、展示与背景提色携带 `MangaImageFileOwner`;`CoverInterceptor` 在首次解码前
   经 BookHelp 获取原图并把请求改为 File,返回结果的 request.data 也是 File。
-  条漫也复用原图 File 获取链路,继续使用 AsyncImage 整页绘制。
+  条漫也复用原图 File 获取链路,AsyncImage 绘制有界预览;长图和放大后不足显示宽度的
+  静态图由视口区域解码块补充细节,不再把整图预览作为最终高清结果。
   条漫按窗口宽度与侧边留白解码;分页适应高度按高度解码,拉伸同时满足宽高,
   原始尺寸保留原图,旋转适配保守覆盖交换后的窗口尺寸。
 - 旧 `CoverFetcher` 的网络流或内存 Buffer 无法提供区域解码所需的文件模型。
@@ -316,7 +317,8 @@ Manual/Batch 队列请求保留显式用途,ReadPreload 不降级已有用途
 
 对照原版 Legado 的 `downloadOnly()` 预取,远端预取只准备原图,
 不生成预览位图、不执行 EInk 转换、不发送页面 Ready。分页和条漫在线页均进入共享原图文件链路,
-同图展示、预取、下载通过文件 mutex 合并成功获取;条漫继续由 AsyncImage 整页绘制,避免页边界细线。
+同图展示、预取、下载通过文件 mutex 合并成功获取;条漫预览由 AsyncImage 绘制,
+高清区域块直接绘制到 Canvas,不添加整页缩放图层,避免页边界细线。
 开启预取时,普通分页额外保留前后各两屏的真实渲染;原尺寸模式不额外保留,避免提前解码完整大图。
 条漫显示预热改用 Foundation 1.12.1 的 LazyLayoutCacheWindow:滚动方向前方四个视口、
 后方两个视口,按实际测量高度决定准备/保留哪些项,不以图片张数封顶。
@@ -393,9 +395,105 @@ MangaPrefetch*Test、MangaFileRequestTest、MangaImageDimensionsTest,共 55 
 加载遮罩依赖实际渲染状态,尚未运行设备绘制时序测试;真机首次显示/放大清晰耗时、
 相邻屏内存、条漫长图/页边界、网络并发与加密书源峰值内存仍待测量。
 
-确认支持格式下 `subSamplingState != null` 且最终达到 full-quality 状态,
+条漫的 `MangaWebtoonTiles` 保留 Coil 默认 4096 上限的整图预览,仅为实际视口及前后一行
+准备原图区域块;每块解码约 1024 像素,额外读取一圈采样像素并用共享浮点边界裁剪,
+避免分块之间透底。透明区域先恢复阅读背景,再绘制原图块,避免与预览重复叠画改变颜色。
+缩放后的实际布局宽度决定采样,离开窗口即释放位图引用。
+预览 PNG 命中时从 `MangaImageFileOwner` 借原图与独立租约,不把预览文件当区域解码源;
+墨水屏使用原始展示请求的变换配置处理各块,灰度/通道滤镜仍在绘制时应用。
+格式或区域解码失败保留预览并记录错误,取消不降级、不发布迟到结果。
+条漫区域解码现在由项目自己的 Android 平台适配器管理,使用公开的 `BitmapRegionDecoder`;
+原先访问 Telephoto 内部参数和 painter getter 的 Java 桥接已删除。
+
+新增测试复现长图加载成功后宽度被缩小,并覆盖有界视口块、缩放采样、窗口重叠复用/释放、
+取消、分数缩放接缝、各块墨水屏变换及预览缓存命中后的原图租约。Windows Robolectric
+的文件型 `BitmapRegionDecoder` JNI 导致测试进程退出,真实文件区域解码另放在
+`MangaWebtoonRegionInstrumentedTest`;当前无连接设备,且设备测试编译受既有
+`HttpTtsTest` 的 `AppConfig.speechRatePlay` 未解析引用阻塞,不能把原生解码或页面滚动算作已验收。
+最终执行 `:app:testAppDebugUnitTest`(过滤 `io.legado.app.help.coil.*Manga*Test` 和
+`io.legado.app.ui.book.manga.*Test`),91 项全部通过;Kotlin/Java 编译、
+`:app:assembleAppDebug`、`:app:lintAppDebug`、`verifyConfigArchitecture` 与
+`git diff --check` 均通过。lint 为 115 个警告、13 个提示,未放宽 baseline。
+
+分页确认支持格式下 `subSamplingState != null` 且最终达到 full-quality 状态,
 再比较 preview 尺寸、内存、首次清晰时间及是否闪烁。不把正常瓦片解码计为多余完整请求。
 
+2026-10-01 模式切换回归:Telephoto 0.19 在分页请求中设置 `maxBitmapSize(Size.ORIGINAL)`,
+按宽度加载长图时会创建数万像素高的整图预览,解码成功不代表 Canvas 能绘制。
+`MangaFileRequestTest` 用真实 Coil/拦截器链复现 128×12000 长图切换后生成高度 12000 的预览。
+`CoverInterceptor` 统一把漫画整图预览限制在 4096×4096 内,涵盖在线、文件和 content 路径;
+原图及区域解码源不变。回归测试覆盖同图反复条漫/分页切换和原图独立租约保留。
+93 项相关单元测试、编译、打包、架构门禁与 `git diff --check` 通过;
+`:app:lintAppDebug` 通过,仍为 115 个警告、13 个提示,未修改 baseline。
+真机切换后的绘制效果仍待验证。
+
+### 区域解码平台边界(2026-10-01)
+
+本切片只替换条漫解码器,不迁移 Gradle 模块或分页渲染器。新的
+`domain/reader/MangaRegionDecoder<Tile>` 契约使用纯 Kotlin 的尺寸、矩形和采样值,
+不引用 Android、Compose、Coil、Telephoto、文件或流;实际消费者将 Tile 绑定为 Android Bitmap。
+调用以显示方向的原图坐标描述区域;关闭幂等且与原生解码互斥,已返回的块不因关闭而失效。
+格式不支持/读取失败显式抛错,取消不发布结果,条漫宿主保留已显示的预览。
+
+| 职责                                                           | 当前依赖分类            | 当前归属 / 后续方向                                              |
+|--------------------------------------------------------------|-------------------|----------------------------------------------------------|
+| 解码契约、尺寸、区域、八种 EXIF 坐标映射                                      | common-ready      | `:app` 的纯 Kotlin 候选;尚未进入 `commonMain`                    |
+| Android file/content/resource/asset、文件租约、BitmapRegionDecoder | platform-island   | `MangaAndroidRegionSource` / `MangaAndroidRegionDecoder` |
+| 视口瓦片选择、绘制、淡入、墨水屏变换                                           | renderer-specific | 现有 Android Compose;后续先分离几何模型再评估 CMP                      |
+| 分页 ZoomableImage / RecoveringRegionImageSource               | platform-island   | 保留现有 Telephoto 渲染与错误回退;仍需按库版本验证                          |
+
+Android 适配器读取 EXIF,反向映射区域后仅解码该块,再校正块方向;不生成完整长图。
+API 26–30 使用旧版公开 newInstance 重载,31+ 使用新版重载。单个原生解码器串行工作,
+符合当前瓦片串行调度;独立原图租约仍覆盖源与解码器生命周期。
+测试包含八种方向的区域映射和像素、关闭与解码竞争、取消、无效输入及租约幂等关闭。
+真实文件分块测试已切换到新适配器,但设备执行证据仍待补齐。
+
+这不是已完成的 KMP/CMP 漫画阅读器:当前没有对应共享模块、Desktop/iOS 解码实现或非 Android
+目标编译。后续按已选宿主建立真实共享模块,将纯几何与调度纳入 commonTest,平台宿主选择解码
+实现与块类型;不把 Bitmap/Painter/Uri 塞进共享领域 API,也不为其他平台提供静默空实现。
+
+Coil 3 继续承担整图预览、图片缓存和变换。它的 Decoder 扩展允许接入自定义解码器,但默认
+图片解码并不提供按矩形读取原图的瓦片能力;将区域解码注册进 Coil 也不会消除平台实现。
+本切片保留直接的区域解码契约,避免每块都走一遍整图请求链。
+
+本切片实际运行 `:app:testAppDebugUnitTest`(同上两组过滤),102 项通过;
+`:app:assembleAppDebug`、`verifyConfigArchitecture`、`git diff --check` 通过。
+`:app:lintAppDebug` 完成,115 个警告、13 个提示,baseline 不变。完整设备测试编译仍受
+既有 HttpTtsTest 引用阻塞;临时 init script 将 AndroidTest.kotlin 源目录限定为
+`src/androidTest/java/io/legado/app/help/coil` 后,`:app:assembleAppDebugAndroidTest` 通过。
+测试 APK 包含长图区域、墨水屏和 JPEG EXIF 用例,不代表整个设备测试集通过。
+按用户要求没有安装或执行真机测试;设备侧阅读效果、快速滚动和模式切换由用户复测。
+
+### 条漫缩放定位(2026-10-01)
+
+用户确认跳位发生在双击/双指缩放。条漫通过改变 LazyColumn 实际宽度缩放图片,但旧实现
+保留缩放前的像素滚动偏移;长图中同一原图坐标会因此移出手势位置。
+新增数学复现与缩放锚点测试:例如长图顶端位于 -10000、手势 Y=700,放大 2.5 倍却
+保持旧偏移时,该内容点移动至 Y=16750。
+
+现在按手势所在的已知尺寸图片计算 `新滚动偏移 = (手势Y - 原条目顶端) × 实测宽度倍率
+
+- 手势Y - 纵向平移`,与宽度变化在下一次重测中一起提交。双击使用点击位置,双指使用
+  手势中心;倍率使用实际测量宽度及取整后的图片宽度,避免连续手势和边距取整累计误差。
+  固定高度章节项/未知占位不应用图片缩放公式;缩放锚点未完成测量时,高度更新不覆盖它。
+  双击放大也修正横向平移,在允许的平移边界内保持点击处内容。
+
+`:app:testAppDebugUnitTest --tests 'io.legado.app.ui.book.manga.*Test'` 的 56 项测试通过,
+覆盖缩放定位、往返缩放、纵向平移与章节间隔;编译、打包、架构门禁和文本检查通过。
+真机双击、双指手势及边界钳制效果由用户复测,不把数学测试等同于设备验收。
+
+### Coil 整图对照版本(2026-10-01)
+
+对照试验期间,`MangaReaderScreen` 的 `MANGA_REGION_DECODING_ENABLED` 为 false:
+条漫仅使用 Coil AsyncImage;分页强制使用 Coil painter,Telephoto 只保留手势/绘制宿主,
+不启用其区域解码。条漫解码实现当时保留用于对比后恢复,不参与对照版加载路径。
+整图继续保留 4096×4096 位图尺寸上限,原图文件缓存、预览缓存及缩放定位修复保持现状。
+这用于比较无区域解码时的显示效果;超长图清晰度可能下降,放大不会补载高清瓦片。
+本切片以编译、APK 打包、架构门禁与 `git diff --check` 验证;视觉效果由用户复测。
+
+用户完成对照后要求恢复分块版本:已删除临时开关,恢复条漫高清瓦片与分页区域解码。
+分页墨水屏模式仍保留原有整图变换路径。预览尺寸上限和缩放定位修复保持现状。
+
 必须覆盖:
 
 - 分页六种缩放、四种宽图模式、横竖窗口、双页/拆页、双击与九宫格。