Przeglądaj źródła

fix: 封面持久化缓存与离线加载、目录字数剔除富文本、书架滚动性能等修复 (#2242)

* fix: 封面持久化缓存与离线加载、目录字数剔除富文本、书架滚动性能等修复

本 PR 包含以下修复(代码内搜索 [FIX-AI] 可定位全部改动点,每处注明原版行为与修复后行为):

1. 封面冷启动/重启重新下载、断网无封面
   - 原版 Coil 磁盘缓存(cacheDir/image_cache)命中时 Fetcher 不执行,且 cacheDir 可被系统回收;
     封面缓存键落在书源规则解析后的最终 URL 上,带时效 token 的书源每次解析都会变化。
   - 新增 CoverFileCache(filesDir/cover_cache,100MB LRU):以书架存储的原始封面地址
     为精确键 + bookUrl 别名键双索引;命中直接返回本地文件,跳过规则解析与网络。
   - 书维度请求(书架/详情页/编辑页/管理页/阅读抽屉/听书胶囊)才写入持久缓存,
     发现页/搜索页临时封面不再挤占 LRU。
   - 非漫画请求禁用 Coil 磁盘缓存层,保证 Fetcher 必然回填持久缓存。
   - 网络失败回退本书别名缓存,弱网不灰图。
   - 设置页"清除封面缓存"同时清除持久缓存目录。

2. 书架冷启动/滚动时封面请求触发书源规则脚本(未登录/版本检测类书源弹 toast)
   - 封面命中本地缓存时不再执行 AnalyzeUrl 规则解析,消除弹窗来源。

3. 打开书籍编辑页会对全部启用书源发起并发搜索(ChangeCoverSheet 隐藏时仍收集 dataFlow,
   callbackFlow 内 size<=1 即 startSearch)
   - 改为仅在面板真正展开(show=true)时开始收集/搜索。

4. 目录章节字数把正文内联的 <img base64>/<svg> 等富文本源码计入
   - BookHelp.saveText 改用 HtmlFormatter.countReadableTextLength():剔除媒体标签、
     data:URI、Markdown 图片语法后按可读纯文本计数。

5. 书架滚动卡顿(400+ 本书)
   - BookItem 简介 Jsoup 解析改为仅列表模式按需执行,并增加跨 LazyList 回收存活的
     全局 LRU 缓存,避免来回滚动反复重解析。

6. 阅读页目录侧栏"信息"页内容简介显示未渲染的 HTML/onclick 源码
   - 新增 HtmlFormatter.formatReadableText():去 <usehtml> 等前缀、丢弃 @onclick:JS
     片段(保留按钮文字)、不补段首缩进,与详情页观感一致。

Co-authored-by: Chatbox <chatbox@chatboxai.com>

* refactor(cover): 清理 AI 标记注释、移除遗留兼容死代码并收紧磁盘缓存条件

- 移除 18 个文件中指向不存在文档的 [FIX-AI] 文件头横幅与行尾标记,
  注释改写为中性技术说明,不再叙述"原版行为 -> 修复后"与 AI 署名
- CoverFetcher:删除 readLegacyCacheFile / legacyCacheDir 及旧键回退分支。
  该兼容逻辑针对"上一版把持久缓存写成 cover_cache/<md5(finalUrl)>.img",
  但该版本从未进入 main(main 无 cover_cache / CoverFileCache),
  在任何真实升级路径上都不可达,属死代码;同时移除随之失效的
  MD5Utils / File 导入
- CoverInterceptor:磁盘缓存策略收紧为仅在请求确实写入持久缓存时关闭
  (isManga=false 且 bookUrl!=null)。原实现对所有非漫画请求无条件关闭
  Coil 磁盘缓存,连带发现页/搜索页等无 bookUrl 的临时封面也退化为只能联网
- 删除误提交到仓库根目录的 COMMIT_MSG.txt

---------

Co-authored-by: Chatbox <chatbox@chatboxai.com>
Co-authored-by: HapeLee <63206378+HapeLee@users.noreply.github.com>
ExGen 1 miesiąc temu
rodzic
commit
bf457671b6

+ 6 - 1
app/src/main/java/io/legado/app/help/book/BookHelp.kt

@@ -19,6 +19,7 @@ import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
 import io.legado.app.domain.gateway.ReadSettingsGateway
 import io.legado.app.domain.gateway.ReadSettingsGateway
 import io.legado.app.utils.ArchiveUtils
 import io.legado.app.utils.ArchiveUtils
 import io.legado.app.utils.FileUtils
 import io.legado.app.utils.FileUtils
+import io.legado.app.utils.HtmlFormatter
 import io.legado.app.utils.ImageUtils
 import io.legado.app.utils.ImageUtils
 import io.legado.app.utils.MD5Utils
 import io.legado.app.utils.MD5Utils
 import io.legado.app.utils.NetworkUtils
 import io.legado.app.utils.NetworkUtils
@@ -209,7 +210,11 @@ object BookHelp {
             bookChapter.getFileName(),
             bookChapter.getFileName(),
         ).writeText(content)
         ).writeText(content)
         if (book.isOnLineTxt && readGateway.currentSettings.tocCountWords) {
         if (book.isOnLineTxt && readGateway.currentSettings.tocCountWords) {
-            val wordCount = StringUtils.wordCountFormat(content.length)
+            // 正文里携带的 <img src="data:base64">、内联 SVG 等富文本源码会把章节字数虚抬
+            // 几倍(3 页正文显示 3000+ 字)。这里剔除标签/Base64 后按可读纯文本计数,
+            // 取代原先的 StringUtils.wordCountFormat(content.length)。
+            val readableLength = HtmlFormatter.countReadableTextLength(content)
+            val wordCount = StringUtils.wordCountFormat(readableLength)
             bookChapter.wordCount = wordCount
             bookChapter.wordCount = wordCount
             appDb.bookChapterDao.update(bookChapter)
             appDb.bookChapterDao.update(bookChapter)
         }
         }

+ 21 - 0
app/src/main/java/io/legado/app/help/coil/CoverExtras.kt

@@ -26,4 +26,25 @@ object CoverExtras {
 
 
     /** 漫画图片所属书籍。图片解密必须显式携带,不能读取全局阅读会话。 */
     /** 漫画图片所属书籍。图片解密必须显式携带,不能读取全局阅读会话。 */
     val MangaBookUrl = Extras.Key<String?>(null)
     val MangaBookUrl = Extras.Key<String?>(null)
+
+    /**
+     * CoverInterceptor 在把 request.data 改写为最终解析 URL 之前,把书架/详情页
+     * 存下的原始封面地址放进来。CoverFetcher 回写持久文件缓存时用这个稳定键
+     * (而不是带动态 token 的最终 URL),保证重启后拦截器快速路径仍能命中。
+     */
+    val OriginalUrl = Extras.Key<String?>(null)
+
+    /**
+     * 封面请求所属书籍的 bookUrl,用于别名缓存键:部分书源启动刷新时会把 coverUrl
+     * 重写成带新 token 的链接(图片内容相同),纯 URL 键会 miss,书架只能重新下载。
+     * 别名键=“本书最近一次成功缓存的封面”,与链接无关,书架优先命中它。
+     */
+    val BookUrl = Extras.Key<String?>(null)
+
+    /**
+     * 书架类请求专用:本地有缓存(含别名命中)就直接用,绝不进“解析书源规则 →
+     * 可能跑登录检测脚本/弹 toast/走网络”的慢速路径。
+     * 详情页不设置此标志:在线时仍会拉新链接并刷新别名,保证封面真换图后能更新。
+     */
+    val PreferCache = Extras.Key<Boolean?>(null)
 }
 }

+ 82 - 25
app/src/main/java/io/legado/app/help/coil/CoverFetcher.kt

@@ -84,33 +84,57 @@ class CoverFetcher(
             )
             )
         }
         }
 
 
-        if (loadOnlyWifi && !appCtx.isWifiConnect) {
-            throw IOException("WiFi not available, loadOnlyWifi enabled")
-        }
-
-        if (isFailed(url)) {
-            throw IOException("URL previously failed, skipping: $url")
-        }
+        // ===== 本地优先:持久化封面文件缓存 =====
+        // 键为【原始封面地址】(由 CoverInterceptor 携带),书架与详情页共享;
+        // 链接带动态 token 的书源重启后也能命中。
+        val originalUrl = options.extras[CoverExtras.OriginalUrl] ?: url
+        // 仅“书”维度请求(书架/详情页等带 bookUrl)写入持久缓存;
+        // 发现页/搜索页/首页模块的临时封面不传 bookUrl,不落盘,
+        // 缓存体积只与书架藏书量成正比。
+        val bookUrl = options.extras[CoverExtras.BookUrl]
 
 
-        // Try OkHttp HTTP cache: FORCE_CACHE serves from cache or returns 504 on miss
         val requestHeaders = options.extras[CoverExtras.Headers]
         val requestHeaders = options.extras[CoverExtras.Headers]
-        val cacheRequest = Request.Builder()
-            .url(url)
-            .tag(io.legado.app.data.entities.BaseSource::class.java, source)
-            .apply { requestHeaders?.forEach { (key, value) -> addHeader(key, value) } }
-            .cacheControl(CacheControl.FORCE_CACHE)
-            .build()
 
 
+        // ===== 第二级:OkHttp HTTP 缓存(FORCE_CACHE 只读缓存,miss 返回 504,不碰网络)=====
+        // 注意:WiFi 限制与失败冷却不能挡在本地缓存读取之前,
+        // 否则某个 URL 一次失败后 5 分钟内连本地已有缓存都会被强制置灰。
+        var rawBytes: ByteArray? = null
         var fromCache = false
         var fromCache = false
-        val rawBytes = try {
+        try {
             withContext(Dispatchers.IO) {
             withContext(Dispatchers.IO) {
+                val cacheRequest = Request.Builder()
+                    .url(url)
+                    .tag(io.legado.app.data.entities.BaseSource::class.java, source)
+                    .apply { requestHeaders?.forEach { (key, value) -> addHeader(key, value) } }
+                    .cacheControl(CacheControl.FORCE_CACHE)
+                    .build()
                 val cacheResponse = callFactory.newCall(cacheRequest).execute()
                 val cacheResponse = callFactory.newCall(cacheRequest).execute()
                 if (cacheResponse.isSuccessful) {
                 if (cacheResponse.isSuccessful) {
                     fromCache = true
                     fromCache = true
-                    cacheResponse.body.use { it.bytes() }
+                    cacheResponse.body.use { rawBytes = it.bytes() }
                 } else {
                 } else {
                     cacheResponse.close()
                     cacheResponse.close()
-                    // Cache miss, fetch from network
+                    // 缓存 miss,下面再走网络
+                }
+            }
+        } catch (e: CancellationException) {
+            throw e
+        } catch (e: Exception) {
+            // HTTP 缓存读取异常(如缓存损坏),降级走网络
+        }
+
+        // ===== 第三级:本地全部 miss,才允许请求网络 =====
+        if (rawBytes == null) {
+            if (loadOnlyWifi && !appCtx.isWifiConnect) {
+                throw IOException("WiFi not available, loadOnlyWifi enabled")
+            }
+
+            if (isFailed(url)) {
+                throw IOException("URL previously failed, skipping: $url")
+            }
+
+            rawBytes = try {
+                withContext(Dispatchers.IO) {
                     val networkRequest = Request.Builder()
                     val networkRequest = Request.Builder()
                         .url(url)
                         .url(url)
                         .tag(io.legado.app.data.entities.BaseSource::class.java, source)
                         .tag(io.legado.app.data.entities.BaseSource::class.java, source)
@@ -130,28 +154,61 @@ class CoverFetcher(
                     }
                     }
                     body.use { it.bytes() }
                     body.use { it.bytes() }
                 }
                 }
+            } catch (e: CancellationException) {
+                throw e
+            } catch (e: Exception) {
+                markFailed(url)
+                // 网络彻底失败(断网/源挂)时回退到本书别名缓存:
+                // 只要这本书曾经成功加载过封面,弱网/断网下仍显示上次缓存的封面,而不是灰图。
+                if (!isManga && bookUrl != null) {
+                    val stale = withContext(Dispatchers.IO) {
+                        CoverFileCache.readByBookUrl(bookUrl)?.let { f ->
+                            try {
+                                if (f.length() in 1..20L * 1024 * 1024) f.readBytes() else null
+                            } catch (ex: Exception) {
+                                null
+                            }
+                        }
+                    }
+                    if (stale != null) {
+                        return SourceFetchResult(
+                            source = ImageSource(
+                                source = Buffer().write(stale),
+                                fileSystem = options.fileSystem
+                            ),
+                            mimeType = null,
+                            dataSource = DataSource.DISK
+                        )
+                    }
+                }
+                throw e
             }
             }
-        } catch (e: CancellationException) {
-            throw e
-        } catch (e: Exception) {
-            markFailed(url)
-            throw e
         }
         }
 
 
+        // 到这里必定已拿到字节(本地缓存/OkHttp 缓存/网络三选一,否则已抛出)。
+        // rawBytes 在 lambda 内赋值,Kotlin 无法智能转换为非空,这里显式收敛。
+        val fetchedBytes = rawBytes ?: throw IOException("封面数据为空: $url")
+
         // Decrypt if needed (applies to both cached and network bytes)
         // Decrypt if needed (applies to both cached and network bytes)
         val decodedBytes = if (ImageUtils.skipDecode(source, !isManga)) {
         val decodedBytes = if (ImageUtils.skipDecode(source, !isManga)) {
-            rawBytes
+            fetchedBytes
         } else {
         } else {
             withContext(Dispatchers.IO) {
             withContext(Dispatchers.IO) {
                 if (isManga) {
                 if (isManga) {
-                    ImageUtils.decode(url, rawBytes, false, source, mangaBook)
+                    ImageUtils.decode(url, fetchedBytes, false, source, mangaBook)
                 } else {
                 } else {
-                    ImageUtils.decode(url, rawBytes, true, source)
+                    ImageUtils.decode(url, fetchedBytes, true, source)
                 }
                 }
             } ?: throw IOException("图片解密失败")
             } ?: throw IOException("图片解密失败")
         }
         }
 
 
         clearFailure(url)
         clearFailure(url)
+        // 网络/OkHttp 缓存/解密完成后,按原始 URL 精确键 + bookUrl 别名键双写持久缓存:
+        // 下次冷启动由 CoverInterceptor 快速路径直接命中;书源刷新换了带 token 的新链接时,
+        // 书架靠别名键也能秒出旧图。仅书维度请求(bookUrl 非空)写入。
+        if (!isManga && bookUrl != null) {
+            withContext(Dispatchers.IO) { CoverFileCache.write(originalUrl, decodedBytes, bookUrl) }
+        }
         return SourceFetchResult(
         return SourceFetchResult(
             source = ImageSource(
             source = ImageSource(
                 source = Buffer().write(decodedBytes),
                 source = Buffer().write(decodedBytes),

+ 134 - 0
app/src/main/java/io/legado/app/help/coil/CoverFileCache.kt

@@ -0,0 +1,134 @@
+package io.legado.app.help.coil
+
+import io.legado.app.utils.MD5Utils
+import splitties.init.appCtx
+import java.io.File
+
+/**
+ * filesDir 下的持久化封面文件缓存(不受“清除缓存”策略与系统低存储回收影响)。
+ *
+ * 存在的理由:封面链路的缓存键传统上落在 AnalyzeUrl 解析出的最终 URL 上,而不少
+ * 书源的封面最终 URL 带时效 token/签名,每次解析都不同 → Coil 与 OkHttp 缓存全部
+ * miss,重启后书架要重新走网络;解析过程还会执行书源 headerRule/coverUrl 里的
+ * JS/Java 检查脚本,触发“未登录”“非官方版本”等书源自定义弹窗。
+ *
+ * 因此这里以【书架/详情页存下的原始封面地址】(稳定)的 MD5 为键保存解密后的图片字节:
+ * - CoverInterceptor 命中时直接改写请求为本地文件,完全跳过规则解析与网络,
+ *   重启加载、编辑页重载封面都不再执行书源脚本。
+ * - 封面链接真的变化(换源/刷新详情)时 key 变化,自动回退到解析+网络路径。
+ * - 总量上限 100MB,LRU 修剪到 80MB;单文件上限 20MB。
+ * - 设置 → 下载与缓存 → “清除封面缓存”会连同本目录一起清空(见 HttpHelper.clearHttpCache)。
+ */
+object CoverFileCache {
+
+    /** 持久缓存目录:filesDir 下,不受"清除缓存"策略与系统低存储清理影响 */
+    private val cacheDir: File by lazy {
+        File(appCtx.filesDir, "cover_cache").apply { if (!exists()) mkdirs() }
+    }
+
+    private const val MAX_CACHE_FILE_BYTES = 20L * 1024 * 1024        // 单封面 20MB 上限
+    private const val CACHE_MAX_BYTES = 100L * 1024L * 1024L          // 总量 100MB 上限
+    private const val CACHE_TRIM_TO_BYTES = 80L * 1024L * 1024L       // 修剪目标 80MB
+    private const val TRIM_INTERVAL_MS = 30 * 60 * 1000L              // 修剪节流 30 分钟
+
+    @Volatile
+    private var lastTrimTime = 0L
+
+    fun fileFor(keyUrl: String): File =
+        File(cacheDir, MD5Utils.md5Encode(keyUrl) + ".img")
+
+    /** 命中返回可读的缓存文件,未命中返回 null */
+    fun read(keyUrl: String): File? {
+        val f = fileFor(keyUrl)
+        val len = f.length()
+        return if (len > 0L && len <= MAX_CACHE_FILE_BYTES) f else null
+    }
+
+    // bookUrl 别名键。链接带 token 轮转的书源每次刷新 coverUrl 都会变,
+    // 纯 URL 键必 miss;别名=“这本书最近一次成功缓存的封面文件”,与链接无关。
+    // 实现:bk_<md5(bookUrl)>.lnk 小文本文件,内容为主缓存文件名,不重复存图片字节。
+    private fun aliasFileFor(bookUrl: String): File =
+        File(cacheDir, "bk_" + MD5Utils.md5Encode(bookUrl) + ".lnk")
+
+    /** 按书籍维度取最近一次成功缓存的封面文件;无别名/目标失效返回 null */
+    fun readByBookUrl(bookUrl: String): File? {
+        return try {
+            val lnk = aliasFileFor(bookUrl)
+            if (!lnk.exists()) return null
+            val targetName = lnk.readText().trim()
+            if (targetName.isEmpty()) return null
+            val f = File(cacheDir, targetName)
+            val len = f.length()
+            if (len > 0L && len <= MAX_CACHE_FILE_BYTES) f else null
+        } catch (e: Exception) {
+            null
+        }
+    }
+
+    /**
+     * 别名回填:拦截器精确键命中时调用,把“本书→该文件”的指针补上
+     * (已有且指向相同则直接返回),之后书源再轮换 URL 也能命中。
+     */
+    fun ensureAlias(bookUrl: String, targetFile: File) {
+        try {
+            val lnk = aliasFileFor(bookUrl)
+            if (lnk.exists() && lnk.readText().trim() == targetFile.name) return
+            lnk.writeText(targetFile.name)
+        } catch (e: Exception) {
+            // 别名写失败不影响本次加载
+        }
+    }
+
+    /** 原子写入:先写 tmp 再 rename,避免滚动并发读到半截文件;bookUrl 非空时同步更新别名 */
+    fun write(keyUrl: String, bytes: ByteArray, bookUrl: String? = null) {
+        if (bytes.isEmpty() || bytes.size.toLong() > MAX_CACHE_FILE_BYTES) return
+        val file = fileFor(keyUrl)
+        try {
+            val tmp = File(file.parentFile, file.name + "." + System.nanoTime() + ".tmp")
+            tmp.writeBytes(bytes)
+            if (!tmp.renameTo(file)) {
+                file.delete()
+                tmp.renameTo(file)
+            }
+        } catch (e: Exception) {
+            // 写缓存失败不影响本次加载
+        }
+        if (bookUrl != null) {
+            try {
+                aliasFileFor(bookUrl).writeText(file.name)
+            } catch (e: Exception) {
+                // 别名写失败不影响主缓存
+            }
+        }
+        maybeTrim()
+    }
+
+    /** 设置页"清除封面缓存"调用 */
+    fun clear() {
+        try {
+            cacheDir.deleteRecursively()
+            cacheDir.mkdirs()
+        } catch (e: Exception) {
+            // 清理失败下次重试
+        }
+    }
+
+    /** 按 lastModified 做 LRU 清理,防止常年累积占满存储 */
+    private fun maybeTrim() {
+        val now = System.currentTimeMillis()
+        if (now - lastTrimTime < TRIM_INTERVAL_MS) return
+        lastTrimTime = now
+        try {
+            val files = cacheDir.listFiles()?.toList() ?: return
+            var total = files.sumOf { it.length() }
+            if (total <= CACHE_MAX_BYTES) return
+            files.sortedBy { it.lastModified() }.forEach { f ->
+                if (total <= CACHE_TRIM_TO_BYTES) return
+                total -= f.length()
+                f.delete()
+            }
+        } catch (e: Exception) {
+            // 清理失败不影响图片加载
+        }
+    }
+}

+ 46 - 0
app/src/main/java/io/legado/app/help/coil/CoverInterceptor.kt

@@ -1,6 +1,7 @@
 package io.legado.app.help.coil
 package io.legado.app.help.coil
 
 
 import coil3.intercept.Interceptor
 import coil3.intercept.Interceptor
+import coil3.request.CachePolicy
 import coil3.request.ImageResult
 import coil3.request.ImageResult
 import io.legado.app.help.source.SourceHelp
 import io.legado.app.help.source.SourceHelp
 import io.legado.app.model.analyzeRule.AnalyzeUrl
 import io.legado.app.model.analyzeRule.AnalyzeUrl
@@ -35,6 +36,40 @@ class CoverInterceptor : Interceptor {
         val data = request.data
         val data = request.data
 
 
         if (data is String && data.isNotBlank()) {
         if (data is String && data.isNotBlank()) {
+            // 本地封面缓存快速路径:命中就改写为本地文件,跳过 AnalyzeUrl 的书源规则调用
+            // (部分书源的 headerRule/coverUrl 会执行 JS/Java 检查脚本,冷启动或重载封面时
+            // 会弹“未登录/版本检测”提示)与网络请求。
+            //
+            // 命中规则:
+            //   ① 精确命中(md5(原始封面地址))→ 任何页面都直接用本地文件;
+            //   ② 书架类请求(PreferCache)额外接受“本书别名”命中:启动刷新把 coverUrl
+            //     重写成带新 token 的链接时,图片内容其实没变,直接取别名指向的文件,
+            //     不解析规则、不跑脚本、不联网;
+            //   ③ 详情页不设 PreferCache:在线时仍走慢速路径拉新链接,成功后同时刷新
+            //     精确键与别名键 → 下次书架展示的就是新封面。
+            // 漫画模式走独立缓存目录不在此列;data: 内联图无需缓存。
+            val isManga = request.extras[CoverExtras.Manga] == true
+            val bookUrl = request.extras[CoverExtras.BookUrl]
+            val preferCache = request.extras[CoverExtras.PreferCache] == true
+            if (!isManga && !data.startsWith("data:", true)) {
+                val exactFile = CoverFileCache.read(data)
+                val cachedFile = exactFile
+                    ?: bookUrl?.takeIf { preferCache }?.let { CoverFileCache.readByBookUrl(it) }
+                cachedFile?.let { file ->
+                    // 精确命中且带 bookUrl 时,顺手把本书别名指向这个文件,
+                    // 之后书源轮换 URL 也能靠别名命中,不必重新下载。
+                    if (exactFile != null && bookUrl != null) {
+                        withContext(Dispatchers.IO) {
+                            CoverFileCache.ensureAlias(bookUrl, file)
+                        }
+                    }
+                    val localRequest = request.newBuilder()
+                        .data(file)
+                        .build()
+                    return chain.withRequest(localRequest).proceed()
+                }
+            }
+
             val sourceOrigin = request.extras[CoverExtras.SourceOrigin]
             val sourceOrigin = request.extras[CoverExtras.SourceOrigin]
             val source = sourceOrigin?.let { origin ->
             val source = sourceOrigin?.let { origin ->
                 withContext(Dispatchers.IO) {
                 withContext(Dispatchers.IO) {
@@ -60,6 +95,17 @@ class CoverInterceptor : Interceptor {
                 .apply {
                 .apply {
                     extras[CoverExtras.Source] = source
                     extras[CoverExtras.Source] = source
                     extras[CoverExtras.Headers] = headers
                     extras[CoverExtras.Headers] = headers
+                    // 携带原始地址,供 CoverFetcher 回写稳定键的持久缓存
+                    extras[CoverExtras.OriginalUrl] = data
+                    // 关闭 Coil 自带的磁盘缓存(位于 cacheDir/image_cache,系统低存储时会被回收,
+                    // 设置页清缓存也会删)。Coil 磁盘缓存一旦命中就直接返回,CoverFetcher 不会执行,
+                    // filesDir 下的持久缓存也就永远得不到回填;而 cacheDir 被回收后,断网重启会大量丢封面。
+                    // 这里只对“确实会写持久缓存”的请求(带 bookUrl)关闭,保证字节一定经过
+                    // CoverFetcher 落到 filesDir;无 bookUrl 的临时封面(发现页/搜索页)不写持久缓存,
+                    // 保留 Coil 磁盘缓存,避免它们退化成只能重新联网。
+                    if (!isManga && bookUrl != null) {
+                        diskCachePolicy(CachePolicy.DISABLED)
+                    }
                 }
                 }
                 .build()
                 .build()
 
 

+ 7 - 1
app/src/main/java/io/legado/app/help/http/HttpHelper.kt

@@ -179,7 +179,13 @@ fun getHttpCacheSize(type: HttpCacheType): Long {
 
 
 fun clearHttpCache(type: HttpCacheType) {
 fun clearHttpCache(type: HttpCacheType) {
     when (type) {
     when (type) {
-        HttpCacheType.COVER -> okHttpClient.cache?.delete()
+        // 设置页“封面缓存”条目同时清掉持久化封面文件缓存(CoverFileCache,
+        // 位于 filesDir/cover_cache,不在“清除缓存”目录扫描范围内),
+        // 保证用户能彻底删除封面数据腾空间。
+        HttpCacheType.COVER -> {
+            okHttpClient.cache?.delete()
+            io.legado.app.help.coil.CoverFileCache.clear()
+        }
         HttpCacheType.MANGA -> okHttpClientManga.cache?.delete()
         HttpCacheType.MANGA -> okHttpClientManga.cache?.delete()
     }
     }
 }
 }

+ 4 - 0
app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt

@@ -998,6 +998,10 @@ private fun BookInfoHeader(
                         author = book.author,
                         author = book.author,
                         path = if (usesDefaultCover) null else book.coverPath,
                         path = if (usesDefaultCover) null else book.coverPath,
                         sourceOrigin = if (usesDefaultCover) null else book.origin,
                         sourceOrigin = if (usesDefaultCover) null else book.origin,
+                        // 传 bookUrl 供别名缓存键。详情页故意不设 preferCache:
+                        // 在线时仍走完整链路拉新链接并刷新别名,保证封面换图后书架也能更新;
+                        // 精确命中时同样不跑脚本。
+                        bookUrl = book.bookUrl,
                         onError = onNetworkCoverLoadError,
                         onError = onNetworkCoverLoadError,
                         modifier = Modifier
                         modifier = Modifier
                             .width(112.dp)
                             .width(112.dp)

+ 11 - 1
app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt

@@ -37,6 +37,7 @@ import androidx.compose.runtime.mutableLongStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.runtime.setValue
+import kotlinx.coroutines.flow.flowOf
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
 import androidx.compose.ui.draw.clip
@@ -198,7 +199,16 @@ fun ChangeCoverSheet(
     onSelect: (String) -> Unit,
     onSelect: (String) -> Unit,
     viewModel: ChangeCoverViewModel = koinViewModel(key = "cover-$name-$author"),
     viewModel: ChangeCoverViewModel = koinViewModel(key = "cover-$name-$author"),
 ) {
 ) {
-    val items by viewModel.dataFlow.collectAsStateWithLifecycle(initialValue = emptyList<SearchBook>())
+    val items by remember(show) {
+        // dataFlow 若无条件 collectAsStateWithLifecycle 收集会带来副作用:
+        // ChangeCoverSheet 在编辑页(BookInfoEditContent)里是 show=false 也照样组合的,
+        // callbackFlow 收集体立即执行:查库后若结果 ≤ 1 条就 startSearch() → 用全部启用
+        // 书源并发搜书(每源执行搜索规则/登录检测脚本),这就是“每次点开编辑页都弹某书源
+        // 未登录提示”的根源。
+        // 这里只有用户真正打开换封面面板(show=true)才开始收集/自动搜索;
+        // 隐藏时收集一个空的已完成 Flow,不触发任何网络与脚本。
+        if (show) viewModel.dataFlow else flowOf(emptyList())
+    }.collectAsStateWithLifecycle(initialValue = emptyList<SearchBook>())
     val isSearching by viewModel.isSearching.collectAsStateWithLifecycle()
     val isSearching by viewModel.isSearching.collectAsStateWithLifecycle()
 
 
     LaunchedEffect(name, author) {
     LaunchedEffect(name, author) {

+ 6 - 0
app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt

@@ -176,6 +176,12 @@ fun BookInfoEditContent(
                 name = uiState.name,
                 name = uiState.name,
                 author = uiState.author,
                 author = uiState.author,
                 path = uiState.coverUrl,
                 path = uiState.coverUrl,
+                // 编辑页封面同样本地优先:编辑页封面请求不带书标识时,URL 被启动刷新轮换后
+                // 精确键会 miss,只能走慢速路径解析书源规则(执行 headerRule 里的登录检测脚本),
+                // 每次进编辑页都弹“未登录”提示。
+                // 传 bookUrl 后,书架/详情页缓存过的封面(含别名命中)直接本地返回,不碰书源。
+                bookUrl = uiState.book?.bookUrl,
+                preferCache = true,
                 modifier = Modifier
                 modifier = Modifier
                     .width(110.dp)
                     .width(110.dp)
             )
             )

+ 3 - 0
app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt

@@ -1687,6 +1687,9 @@ private fun PreviewBookInfo(
                 author = book.author,
                 author = book.author,
                 path = book.getDisplayCover(),
                 path = book.getDisplayCover(),
                 sourceOrigin = book.origin,
                 sourceOrigin = book.origin,
+                // 书架管理类页面本地优先,不重复跑书源脚本
+                bookUrl = book.bookUrl,
+                preferCache = true,
                 modifier = Modifier.width(54.dp),
                 modifier = Modifier.width(54.dp),
             )
             )
             AppText(
             AppText(

+ 3 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadAloudCapsule.kt

@@ -268,6 +268,9 @@ private fun ExpandedCapsuleContent(
             author = book?.author,
             author = book?.author,
             path = book?.getDisplayCover(),
             path = book?.getDisplayCover(),
             sourceOrigin = book?.origin,
             sourceOrigin = book?.origin,
+            // 听书胶囊也是书维度场景,本地优先不跑书源脚本
+            bookUrl = book?.bookUrl,
+            preferCache = true,
             modifier = Modifier
             modifier = Modifier
                 .size(40.dp)
                 .size(40.dp)
                 .graphicsLayer { rotationZ = coverRotation }
                 .graphicsLayer { rotationZ = coverRotation }

+ 18 - 2
app/src/main/java/io/legado/app/ui/book/read/sheet/ReaderBookSheet.kt

@@ -62,6 +62,7 @@ import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.setValue
 import androidx.compose.runtime.setValue
+import io.legado.app.utils.HtmlFormatter
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
 import androidx.compose.ui.draw.clip
@@ -420,6 +421,9 @@ internal fun ReaderBookHeader(
             author = book?.author,
             author = book?.author,
             path = book?.getDisplayCover(),
             path = book?.getDisplayCover(),
             sourceOrigin = book?.origin,
             sourceOrigin = book?.origin,
+            // 阅读抽屉也是书维度场景,本地优先不跑书源脚本
+            bookUrl = book?.bookUrl,
+            preferCache = true,
             modifier = Modifier.width(40.dp),
             modifier = Modifier.width(40.dp),
         )
         )
         Column(
         Column(
@@ -575,6 +579,18 @@ private fun ReaderBookSourceDropdown(
 private fun ReaderBookInformation(
 private fun ReaderBookInformation(
     book: Book?,
     book: Book?,
 ) {
 ) {
+    // 简介直接以 book.intro 原样塑进 Text 会有问题:部分书源的简介带
+    // <usehtml>/<useweb>/<md> 前缀、<button>@onclick:JS</button> 与 HTML 标签,
+    // 展示出来就是一堆调用源码;formatDisplayText 还会给每行补段首缩进
+    // (两个全角空格),与详情页观感不一致。
+    // 这里改用与详情页同一套解析规则的 formatReadableText 清洗——去前缀、
+    // 丢弃 @onclick:JS 片段(只保留按钮文字,如“💬 本书讨论”)、不补缩进。
+    // 注意:LazyColumn 的 content lambda 不是 composable 作用域,remember 必须
+    // 提在本函数体开头调用。
+    val rawIntro = book?.getDisplayIntro()?.takeIf { it.isNotBlank() }
+    val formattedIntro = remember(rawIntro) {
+        rawIntro?.let { HtmlFormatter.formatReadableText(it) }
+    }
     LazyColumn(
     LazyColumn(
         modifier = Modifier.fillMaxSize(),
         modifier = Modifier.fillMaxSize(),
         contentPadding = PaddingValues(vertical = 8.dp),
         contentPadding = PaddingValues(vertical = 8.dp),
@@ -590,8 +606,8 @@ private fun ReaderBookInformation(
         book?.remark?.takeIf { it.isNotBlank() }?.let { remark ->
         book?.remark?.takeIf { it.isNotBlank() }?.let { remark ->
             item { InformationRow(stringResource(R.string.book_remark), remark) }
             item { InformationRow(stringResource(R.string.book_remark), remark) }
         }
         }
-        book?.getDisplayIntro()?.takeIf { it.isNotBlank() }?.let { intro ->
-            item { InformationRow(stringResource(R.string.book_intro), intro) }
+        if (!formattedIntro.isNullOrBlank()) {
+            item { InformationRow(stringResource(R.string.book_intro), formattedIntro) }
         }
         }
     }
     }
 }
 }

+ 59 - 7
app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt

@@ -277,6 +277,43 @@ private fun Modifier.bookshelfItemSemantics(label: String, isSelected: Boolean):
         if (isSelected) selected = true
         if (isSelected) selected = true
     }
     }
 
 
+/**
+ * 简介排版缓存:跨 LazyList 回收存活。
+ *
+ * LazyGrid/LazyList 滚出可视范围的条目会离开组合,其 remember 被丢弃,滚回来又重建,
+ * 于是每本含长简介的书会被反复 Jsoup 解析——400+ 本书上下滚动时足够把主线程打爆。
+ * 这里用“行数设置 + 原始简介”做 key,全局只解析一次;上限限制避免无限增长。
+ * 网格模式不展示简介、不参与解析,只有列表模式确实要显示时才解析。
+ */
+private object formattedIntroCache {
+    private const val MAX_ENTRIES = 500
+
+    // accessOrder=true 的 LinkedHashMap 天然 LRU;synchronized 内查写保证同 key 只解析一次。
+    private val cache = object : LinkedHashMap<String, String?>(64, 0.75f, true) {
+        override fun removeEldestEntry(eldest: Map.Entry<String, String?>): Boolean =
+            size > MAX_ENTRIES
+    }
+
+    fun getOrFormat(intro: String?, maxLines: Int): String? {
+        // 空简介直接返回 null,不进缓存,也不触发解析。
+        if (intro.isNullOrBlank()) return null
+        val key = "$maxLines|$intro"
+        return synchronized(cache) {
+            if (cache.containsKey(key)) {
+                cache[key]
+            } else {
+                val formatted = if (maxLines == 0) {
+                    HtmlFormatter.formatIntroText(intro)
+                } else {
+                    HtmlFormatter.formatSummaryText(intro)
+                }.takeIf { it.isNotBlank() }
+                cache[key] = formatted
+                formatted
+            }
+        }
+    }
+}
+
 @Composable
 @Composable
 fun BookGroupCover(
 fun BookGroupCover(
     settings: BookshelfSettings,
     settings: BookshelfSettings,
@@ -324,6 +361,8 @@ fun BookGroupCover(
                                     name = it.name,
                                     name = it.name,
                                     author = it.author,
                                     author = it.author,
                                     path = it.getDisplayCover(),
                                     path = it.getDisplayCover(),
+                                    bookUrl = it.bookUrl,
+                                    preferCache = true,
                                     modifier = Modifier.fillMaxSize()
                                     modifier = Modifier.fillMaxSize()
                                 )
                                 )
                             }
                             }
@@ -339,6 +378,8 @@ fun BookGroupCover(
                                     name = it.name,
                                     name = it.name,
                                     author = it.author,
                                     author = it.author,
                                     path = it.getDisplayCover(),
                                     path = it.getDisplayCover(),
+                                    bookUrl = it.bookUrl,
+                                    preferCache = true,
                                     modifier = Modifier.fillMaxSize()
                                     modifier = Modifier.fillMaxSize()
                                 )
                                 )
                             }
                             }
@@ -356,6 +397,8 @@ fun BookGroupCover(
                                     name = it.name,
                                     name = it.name,
                                     author = it.author,
                                     author = it.author,
                                     path = it.getDisplayCover(),
                                     path = it.getDisplayCover(),
+                                    bookUrl = it.bookUrl,
+                                    preferCache = true,
                                     modifier = Modifier.fillMaxSize()
                                     modifier = Modifier.fillMaxSize()
                                 )
                                 )
                             }
                             }
@@ -371,6 +414,8 @@ fun BookGroupCover(
                                     name = it.name,
                                     name = it.name,
                                     author = it.author,
                                     author = it.author,
                                     path = it.getDisplayCover(),
                                     path = it.getDisplayCover(),
+                                    bookUrl = it.bookUrl,
+                                    preferCache = true,
                                     modifier = Modifier.fillMaxSize()
                                     modifier = Modifier.fillMaxSize()
                                 )
                                 )
                             }
                             }
@@ -594,6 +639,8 @@ fun BookGroupItemHorizontalCovers(
                                 name = book.name,
                                 name = book.name,
                                 author = book.author,
                                 author = book.author,
                                 path = book.getDisplayCover(),
                                 path = book.getDisplayCover(),
+                                bookUrl = book.bookUrl,
+                                preferCache = true,
                                 modifier = Modifier.fillMaxSize()
                                 modifier = Modifier.fillMaxSize()
                             )
                             )
                         }
                         }
@@ -638,16 +685,18 @@ fun BookItem(
     onLongClick: (() -> Unit)?
     onLongClick: (() -> Unit)?
 ) {
 ) {
     val book = bookUi.book
     val book = bookUi.book
-    val intro = remember(book.intro, settings.bookshelfIntroMaxLines) {
-        val formatted = if (settings.bookshelfIntroMaxLines == 0) {
-            HtmlFormatter.formatIntroText(book.intro)
+    val showListDetails = layoutMode == 0 && !isCompact && settings.showBookIntro
+    val showIntroText = showListDetails && settings.bookshelfShowIntro
+    // 简介排版包含 Jsoup 解析 + 多轮正则,是主线程重活;LazyList 回收后 remember 会丢失,
+    // 来回滚动就会反复重解析。这里只在列表模式真要显示时才算,并用跨回收的 LRU 缓存兜住。
+    val intro = remember(showIntroText, book.intro, settings.bookshelfIntroMaxLines) {
+        if (showIntroText) {
+            formattedIntroCache.getOrFormat(book.intro, settings.bookshelfIntroMaxLines)
         } else {
         } else {
-            HtmlFormatter.formatSummaryText(book.intro)
+            null
         }
         }
-        formatted.takeIf { it.isNotBlank() }
     }
     }
-    val showListDetails = layoutMode == 0 && !isCompact && settings.showBookIntro
-    val showIntro = showListDetails && settings.bookshelfShowIntro && intro != null
+    val showIntro = showIntroText && intro != null
     val showIntroBelowContent = showIntro && settings.bookshelfListIntroBelowContent
     val showIntroBelowContent = showIntro && settings.bookshelfListIntroBelowContent
     val unreadCount = book.getUnreadChapterNum()
     val unreadCount = book.getUnreadChapterNum()
     val unreadText = if (settings.showUnread && unreadCount > 0) unreadCount.toString() else null
     val unreadText = if (settings.showUnread && unreadCount > 0) unreadCount.toString() else null
@@ -684,6 +733,9 @@ fun BookItem(
                 .fillMaxWidth()
                 .fillMaxWidth()
                 .aspectRatio(5f / 7f),
                 .aspectRatio(5f / 7f),
             sourceOrigin = book.origin,
             sourceOrigin = book.origin,
+            // 传本书 bookUrl:封面命中本地(含别名)缓存时
+            // 不解析书源规则、不弹登录提示、不重新下载
+            bookUrl = book.bookUrl,
             badgeText = if (layoutMode != 0) unreadText else null,
             badgeText = if (layoutMode != 0) unreadText else null,
             showBadgeDot = showUpdateBadge,
             showBadgeDot = showUpdateBadge,
             leftBottomText = matchedSourceLabel ?: bookTypeLabel,
             leftBottomText = matchedSourceLabel ?: bookTypeLabel,

+ 5 - 0
app/src/main/java/io/legado/app/ui/widget/components/image/cover/BookshelfCover.kt

@@ -32,6 +32,9 @@ fun BookshelfCover(
     showBadgeDot: Boolean = false,
     showBadgeDot: Boolean = false,
     leftBottomText: String? = null,
     leftBottomText: String? = null,
     sourceOrigin: String? = null,
     sourceOrigin: String? = null,
+    // 本书 bookUrl,供封面别名缓存键;书架组件默认本地优先
+    // (preferCache=true):有缓存(含别名命中)直接显示,不跑书源规则脚本、不联网。
+    bookUrl: String? = null,
     onLoadFinish: (() -> Unit)? = null,
     onLoadFinish: (() -> Unit)? = null,
     showLoadingPlaceholder: Boolean = true,
     showLoadingPlaceholder: Boolean = true,
     sharedTransitionScope: SharedTransitionScope? = null,
     sharedTransitionScope: SharedTransitionScope? = null,
@@ -45,6 +48,8 @@ fun BookshelfCover(
             path = path,
             path = path,
             modifier = coverModifier,
             modifier = coverModifier,
             sourceOrigin = sourceOrigin,
             sourceOrigin = sourceOrigin,
+            bookUrl = bookUrl,
+            preferCache = true,
             onLoadFinish = onLoadFinish,
             onLoadFinish = onLoadFinish,
             showLoadingPlaceholder = showLoadingPlaceholder,
             showLoadingPlaceholder = showLoadingPlaceholder,
             sharedTransitionScope = sharedTransitionScope,
             sharedTransitionScope = sharedTransitionScope,

+ 10 - 0
app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt

@@ -77,6 +77,9 @@ fun BookCoverImage(
     modifier: Modifier = Modifier,
     modifier: Modifier = Modifier,
     sourceOrigin: String? = null,
     sourceOrigin: String? = null,
     memoryCacheKey: String? = null,
     memoryCacheKey: String? = null,
+    // 本书 bookUrl(别名缓存键)与书架本地优先标志,透传给 buildCoverImageRequest。
+    bookUrl: String? = null,
+    preferCache: Boolean = false,
     ignoreUseDefaultCover: Boolean = false,
     ignoreUseDefaultCover: Boolean = false,
     showLoadingPlaceholder: Boolean = true,
     showLoadingPlaceholder: Boolean = true,
     contentScale: ContentScale = ContentScale.Crop,
     contentScale: ContentScale = ContentScale.Crop,
@@ -186,6 +189,8 @@ fun BookCoverImage(
                     memoryCacheKey = sharedCoverKey?.let {
                     memoryCacheKey = sharedCoverKey?.let {
                         "$it:cover:${memoryCacheKey ?: finalPath}"
                         "$it:cover:${memoryCacheKey ?: finalPath}"
                     } ?: memoryCacheKey ?: finalPath,
                     } ?: memoryCacheKey ?: finalPath,
+                    bookUrl = bookUrl,
+                    preferCache = preferCache,
                     configure = requestBuilder,
                     configure = requestBuilder,
                 ),
                 ),
                 contentDescription = null,
                 contentDescription = null,
@@ -223,6 +228,9 @@ fun CoilBookCover(
     radius: Dp = 4.dp,
     radius: Dp = 4.dp,
     modifier: Modifier = Modifier.width(64.dp),
     modifier: Modifier = Modifier.width(64.dp),
     sourceOrigin: String? = null,
     sourceOrigin: String? = null,
+    // 本书 bookUrl + 书架本地优先标志,透传给 BookCoverImage
+    bookUrl: String? = null,
+    preferCache: Boolean = false,
     onLoadFinish: (() -> Unit)? = null,
     onLoadFinish: (() -> Unit)? = null,
     onError: (() -> Unit)? = null,
     onError: (() -> Unit)? = null,
     ignoreUseDefaultCover: Boolean = false,
     ignoreUseDefaultCover: Boolean = false,
@@ -299,6 +307,8 @@ fun CoilBookCover(
             path = path,
             path = path,
             modifier = Modifier.fillMaxSize(),
             modifier = Modifier.fillMaxSize(),
             sourceOrigin = sourceOrigin,
             sourceOrigin = sourceOrigin,
+            bookUrl = bookUrl,
+            preferCache = preferCache,
             ignoreUseDefaultCover = ignoreUseDefaultCover,
             ignoreUseDefaultCover = ignoreUseDefaultCover,
             showLoadingPlaceholder = showLoadingPlaceholder,
             showLoadingPlaceholder = showLoadingPlaceholder,
             onSuccess = {
             onSuccess = {

+ 7 - 0
app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoverImageRequest.kt

@@ -12,6 +12,10 @@ fun buildCoverImageRequest(
     loadOnlyWifi: Boolean,
     loadOnlyWifi: Boolean,
     crossfade: Boolean = true,
     crossfade: Boolean = true,
     memoryCacheKey: String? = null,
     memoryCacheKey: String? = null,
+    // bookUrl 供别名缓存键;preferCache 标记书架类
+    // “本地优先、绝不跑书源脚本”的请求。
+    bookUrl: String? = null,
+    preferCache: Boolean = false,
     configure: ImageRequest.Builder.() -> Unit = {},
     configure: ImageRequest.Builder.() -> Unit = {},
 ): ImageRequest {
 ): ImageRequest {
     return ImageRequest.Builder(context)
     return ImageRequest.Builder(context)
@@ -24,6 +28,9 @@ fun buildCoverImageRequest(
             }
             }
             extras[CoverExtras.SourceOrigin] = sourceOrigin
             extras[CoverExtras.SourceOrigin] = sourceOrigin
             extras[CoverExtras.LoadOnlyWifi] = loadOnlyWifi
             extras[CoverExtras.LoadOnlyWifi] = loadOnlyWifi
+            // 透传别名键与书架优先标志给 CoverInterceptor/CoverFetcher
+            extras[CoverExtras.BookUrl] = bookUrl
+            extras[CoverExtras.PreferCache] = preferCache
         }
         }
         .apply(configure)
         .apply(configure)
         .build()
         .build()

+ 72 - 0
app/src/main/java/io/legado/app/utils/HtmlFormatter.kt

@@ -13,6 +13,30 @@ object HtmlFormatter {
     private val commentRegex = "<!--[^>]*-->".toRegex() //注释
     private val commentRegex = "<!--[^>]*-->".toRegex() //注释
     private val notImgHtmlRegex = "</?(?!img)[a-zA-Z]+(?=[ >])[^<>]*>".toRegex()
     private val notImgHtmlRegex = "</?(?!img)[a-zA-Z]+(?=[ >])[^<>]*>".toRegex()
     private val otherHtmlRegex = "</?[a-zA-Z]+(?=[ >])[^<>]*>".toRegex()
     private val otherHtmlRegex = "</?[a-zA-Z]+(?=[ >])[^<>]*>".toRegex()
+
+    // 字数统计专用:正文里携带的图片 Base64/SVG 源码不能计入字数。
+    // 容器型媒体标签整体连内容一起删除(base64、SVG 路径数据都藏在标签内部)
+    private val mediaBlockRegex = Regex(
+        "(?is)<(script|style|svg|math|video|audio|canvas|picture|iframe|object|embed|figure|template)\\b[^>]*>.*?</\\1\\s*>"
+    )
+    // 未闭合的 <svg>(后面没有 </svg>): 删到结尾, 避免剩余矢量数据被当成正文
+    private val unclosedSvgRegex = Regex("(?is)<svg\\b[^>]*>[\\s\\S]*$")
+    // 自闭合/空元素标签(img 的 src 里就是整段 data:image;base64)
+    private val voidMediaRegex = Regex(
+        "(?is)<(?:img|br|hr|input|source|track|area|col|link|meta)\\b[^>]*/?>"
+    )
+    // 散落在属性之外的 data: URI 与 XML 声明/doctype
+    private val dataUriRegex = Regex(
+        "(?i)['\"(]?data:[a-z0-9.+-]+/[a-z0-9.+,-]+(?:;[a-z0-9.+-]+)?(?:;base64)?[^\\s'\"<>)]*['\")]?"
+    )
+    private val xmlDeclRegex = Regex("(?is)<\\?[\\s\\S]*?\\?>|<!doctype[^>]*>")
+    // Markdown 图片/链接语法 ![alt](url)
+    private val markdownMediaRegex = Regex("!\\[[^\\]]*\\]\\([^)]*\\)")
+
+    // 简介前缀与按钮 onclick 片段,供 formatReadableText 使用
+    private val introPrefixRegex = Regex("^<(usehtml|useweb|md)>", RegexOption.IGNORE_CASE)
+    private val onClickSuffixRegex = Regex("@onclick:[^<\\n]*", RegexOption.IGNORE_CASE)
+
     private val formatImagePattern = Regex(
     private val formatImagePattern = Regex(
         "<img[^>]*\\ssrc\\s*=\\s*['\"]([^'\"{>]*\\{(?:[^{}]|\\{[^}>]+\\})+\\})['\"][^>]*>|<img[^>]*\\s(?:data-src|src)\\s*=\\s*['\"]([^'\">]+)['\"][^>]*>|<img[^>]*\\sdata-[^=>]*=\\s*['\"]([^'\">]*)['\"][^>]*>",
         "<img[^>]*\\ssrc\\s*=\\s*['\"]([^'\"{>]*\\{(?:[^{}]|\\{[^}>]+\\})+\\})['\"][^>]*>|<img[^>]*\\s(?:data-src|src)\\s*=\\s*['\"]([^'\">]+)['\"][^>]*>|<img[^>]*\\sdata-[^=>]*=\\s*['\"]([^'\">]*)['\"][^>]*>",
         RegexOption.IGNORE_CASE
         RegexOption.IGNORE_CASE
@@ -143,4 +167,52 @@ object HtmlFormatter {
         )
         )
         return sb.toString()
         return sb.toString()
     }
     }
+
+    /**
+     * 把书源简介渲染成与详情页观感一致的“可读纯文本”,用于
+     * 不支持交互的只读卡片(如阅读页目录侧栏“信息”页):
+     * 1) 去掉 <usehtml>/<useweb>/<md> 前缀(与详情页 parseBookInfoIntro 同一处理,
+     *    否则字面量前缀会被当未知标签丢弃但尾部残留调用代码);
+     * 2) 丢弃按钮里的 @onclick:JS 片段(详情页渲染成可点按钮,只读场景只保留按钮文字,
+     *    如“💬 本书讨论”);
+     * 3) 不补段首缩进(formatDisplayText 会给每行加两个全角空格,只读卡片里显得
+     *    每行缩进,详情页则没有)。
+     */
+    fun formatReadableText(html: String?): String {
+        if (html.isNullOrBlank()) return ""
+        var body = html.trim()
+        introPrefixRegex.find(body)?.let { m ->
+            val lastLt = body.lastIndexOf('<')
+            body = if (lastLt > m.range.last) {
+                body.substring(m.range.last + 1, lastLt)
+            } else {
+                body.substring(m.range.last + 1)
+            }
+        }
+        body = onClickSuffixRegex.replace(body, "")
+        val document = Jsoup.parseBodyFragment(body)
+        document.outputSettings().prettyPrint(false)
+        document.select("script, style, noscript").remove()
+        return format(document.body().html(), otherHtmlRegex, "")
+    }
+
+    /**
+     * 统计正文真实可读字数。部分书源的 content 携带
+     * `<img src="data:image/png;base64,...">`、内联 `<svg>...</svg>` 等富文本源码,
+     * 直接取 content.length 会把整段 Base64/标签算进目录字数(几百字显示成几千字)。
+     * 这里先剔除媒体标签及其内容、残留 data: URI 与 Markdown 图片语法,
+     * 再按纯文本(解码实体、去空白)计算字符数。
+     */
+    fun countReadableTextLength(content: String?): Int {
+        if (content.isNullOrEmpty()) return 0
+        var text = mediaBlockRegex.replace(content, "")
+        text = unclosedSvgRegex.replace(text, "")
+        text = voidMediaRegex.replace(text, "")
+        text = markdownMediaRegex.replace(text, "")
+        text = xmlDeclRegex.replace(text, "")
+        text = dataUriRegex.replace(text, "")
+        // 剩余标签与 HTML 实体交给 Jsoup 按纯文本解出
+        text = Jsoup.parse(text).text()
+        return text.count { !it.isWhitespace() && it != '\u00a0' && it != '\u3000' }
+    }
 }
 }