Sfoglia il codice sorgente

feat: 补充详情页其他神秘组件

HapeLee 2 mesi fa
parent
commit
e4d7945744

+ 15 - 3
app/src/main/java/io/legado/app/model/webBook/BookInfo.kt

@@ -121,9 +121,21 @@ object BookInfo {
         coroutineContext.ensureActive()
         coroutineContext.ensureActive()
         Debug.log(bookSource.bookSourceUrl, "┌获取简介")
         Debug.log(bookSource.bookSourceUrl, "┌获取简介")
         try {
         try {
-            HtmlFormatter.format(analyzeRule.getString(infoRule.intro)).take(5000).let {
-                if (it.isNotEmpty()) book.intro = it
-                Debug.log(bookSource.bookSourceUrl, "└${it}")
+            // 渲染指令前缀(<usehtml>/<md>/<useweb>)的简介必须原样保留,
+            // 否则 <button>@onclick 等书源交互标记会被清理掉, 上游 BookInfoActivity 同此处理
+            val intro = analyzeRule.getString(infoRule.intro)
+            val introTrimS = intro.trimStart()
+            if (introTrimS.startsWith("<usehtml>") ||
+                introTrimS.startsWith("<md>") ||
+                introTrimS.startsWith("<useweb>")
+            ) {
+                book.intro = introTrimS
+                Debug.log(bookSource.bookSourceUrl, "└${introTrimS}")
+            } else {
+                HtmlFormatter.format(introTrimS).take(5000).let {
+                    if (it.isNotEmpty()) book.intro = it
+                    Debug.log(bookSource.bookSourceUrl, "└${it}")
+                }
             }
             }
         } catch (e: Exception) {
         } catch (e: Exception) {
             coroutineContext.ensureActive()
             coroutineContext.ensureActive()

+ 17 - 0
app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt

@@ -216,6 +216,15 @@ sealed interface BookInfoIntent {
     data object KnowledgeListClick : BookInfoIntent
     data object KnowledgeListClick : BookInfoIntent
     data object EventListClick : BookInfoIntent
     data object EventListClick : BookInfoIntent
     data class SetDefaultBookTreeUri(val value: String) : BookInfoIntent
     data class SetDefaultBookTreeUri(val value: String) : BookInfoIntent
+
+    /** 简介 HTML 中 `<button>名称@onclick:脚本</button>` 的点击。 */
+    data class IntroButtonClick(val name: String, val click: String) : BookInfoIntent
+
+    /** 简介 HTML 图片携带 {"click":"脚本"} 参数时的点击。 */
+    data class IntroImageClick(val click: String) : BookInfoIntent
+
+    /** 简介 HTML 图片长按。 */
+    data class IntroImageLongClick(val source: String) : BookInfoIntent
 }
 }
 
 
 sealed interface BookInfoEffect {
 sealed interface BookInfoEffect {
@@ -276,6 +285,14 @@ sealed interface BookInfoEffect {
     data class OpenEventList(
     data class OpenEventList(
         val bookUrl: String,
         val bookUrl: String,
     ) : BookInfoEffect
     ) : BookInfoEffect
+
+    /** 简介按钮/图片触发的书源 JS 执行,由宿主(持有 Activity)用 SourceLoginJsExtensions 运行。 */
+    data class RunIntroJs(
+        val name: String,
+        val click: String,
+        val source: BookSource?,
+        val book: Book,
+    ) : BookInfoEffect
 }
 }
 
 
 sealed interface BookInfoCallbackAction {
 sealed interface BookInfoCallbackAction {

+ 29 - 0
app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt

@@ -17,7 +17,10 @@ import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.LifecycleEventObserver
 import androidx.lifecycle.LifecycleEventObserver
 import androidx.lifecycle.compose.LocalLifecycleOwner
 import androidx.lifecycle.compose.LocalLifecycleOwner
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import com.script.rhino.runScriptWithContext
 import io.legado.app.R
 import io.legado.app.R
+import io.legado.app.constant.AppLog
 import io.legado.app.data.entities.BookGroup
 import io.legado.app.data.entities.BookGroup
 import io.legado.app.help.book.isAudio
 import io.legado.app.help.book.isAudio
 import io.legado.app.help.book.isImage
 import io.legado.app.help.book.isImage
@@ -26,12 +29,15 @@ import io.legado.app.model.SourceCallBack
 import io.legado.app.ui.book.info.edit.BookInfoEditActivity
 import io.legado.app.ui.book.info.edit.BookInfoEditActivity
 import io.legado.app.ui.book.toc.TocActivityResult
 import io.legado.app.ui.book.toc.TocActivityResult
 import io.legado.app.ui.file.HandleFileContract
 import io.legado.app.ui.file.HandleFileContract
+import io.legado.app.ui.login.SourceLoginJsExtensions
 import io.legado.app.utils.StartActivityContract
 import io.legado.app.utils.StartActivityContract
 import io.legado.app.utils.openFileUri
 import io.legado.app.utils.openFileUri
 import io.legado.app.utils.sendToClip
 import io.legado.app.utils.sendToClip
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.toastOnUi
 import kotlinx.collections.immutable.persistentListOf
 import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
 
 
 @OptIn(ExperimentalSharedTransitionApi::class)
 @OptIn(ExperimentalSharedTransitionApi::class)
 @Composable
 @Composable
@@ -162,6 +168,10 @@ fun BookInfoRouteScreen(
                     runSourceCallback(activity, effect, viewModel, onOpenSearch)
                     runSourceCallback(activity, effect, viewModel, onOpenSearch)
                 }
                 }
 
 
+                is BookInfoEffect.RunIntroJs -> {
+                    runIntroJs(activity, effect)
+                }
+
 
 
                 is BookInfoEffect.NavigateToBookInfo -> {
                 is BookInfoEffect.NavigateToBookInfo -> {
                     onNavigateToBookInfo(effect.name, effect.author, effect.bookUrl, effect.origin, effect.coverPath)
                     onNavigateToBookInfo(effect.name, effect.author, effect.bookUrl, effect.origin, effect.coverPath)
@@ -244,3 +254,22 @@ private fun runSourceCallback(
         }
         }
     }
     }
 }
 }
+
+private fun runIntroJs(activity: AppCompatActivity, effect: BookInfoEffect.RunIntroJs) {
+    val source = effect.source ?: return
+    activity.lifecycleScope.launch(IO) {
+        try {
+            val java = SourceLoginJsExtensions(activity, source)
+            runScriptWithContext {
+                source.evalJS(effect.click) {
+                    put("result", null)
+                    put("java", java)
+                    put("book", effect.book)
+                }
+            }
+        } catch (e: Throwable) {
+            AppLog.put("${source.bookSourceName}: ${e.localizedMessage}", e)
+            activity.toastOnUi("${effect.name} click error\n${e.localizedMessage}")
+        }
+    }
+}

+ 49 - 3
app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt

@@ -127,6 +127,7 @@ import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.icon.AppIcons
 import io.legado.app.ui.widget.components.icon.AppIcons
 import io.legado.app.ui.widget.components.image.cover.BookCoverImage
 import io.legado.app.ui.widget.components.image.cover.BookCoverImage
 import io.legado.app.ui.widget.components.image.cover.CoilBookCover
 import io.legado.app.ui.widget.components.image.cover.CoilBookCover
+import io.legado.app.ui.widget.components.image.cover.buildCoverImageRequest
 import io.legado.app.ui.widget.components.image.cover.usesDefaultBookCover
 import io.legado.app.ui.widget.components.image.cover.usesDefaultBookCover
 import io.legado.app.ui.widget.components.log.AppLogSheet
 import io.legado.app.ui.widget.components.log.AppLogSheet
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
@@ -405,6 +406,15 @@ private fun BookInfoScreenContent(
                                     onRemarkClick = { onIntent(BookInfoIntent.RemarkClick) },
                                     onRemarkClick = { onIntent(BookInfoIntent.RemarkClick) },
                                     bookSource = state.bookSource,
                                     bookSource = state.bookSource,
                                     onJumpToAnotherApp = jumpToAnotherApp,
                                     onJumpToAnotherApp = jumpToAnotherApp,
+                                    onIntroButtonClick = { name, click ->
+                                        onIntent(BookInfoIntent.IntroButtonClick(name, click))
+                                    },
+                                    onIntroImageClick = { click ->
+                                        onIntent(BookInfoIntent.IntroImageClick(click))
+                                    },
+                                    onIntroImageLongClick = { source ->
+                                        onIntent(BookInfoIntent.IntroImageLongClick(source))
+                                    },
                                 )
                                 )
                             }
                             }
                         }
                         }
@@ -1216,6 +1226,9 @@ private fun BookInfoSummary(
     onRemarkClick: () -> Unit,
     onRemarkClick: () -> Unit,
     bookSource: BookSource?,
     bookSource: BookSource?,
     onJumpToAnotherApp: (Uri) -> Unit,
     onJumpToAnotherApp: (Uri) -> Unit,
+    onIntroButtonClick: (name: String, click: String) -> Unit,
+    onIntroImageClick: (click: String) -> Unit,
+    onIntroImageLongClick: (source: String) -> Unit,
 ) {
 ) {
     Column(
     Column(
         modifier = Modifier
         modifier = Modifier
@@ -1293,6 +1306,9 @@ private fun BookInfoSummary(
                 ?.substringBefore(","),
                 ?.substringBefore(","),
             bookSource = bookSource,
             bookSource = bookSource,
             onJumpToAnotherApp = onJumpToAnotherApp,
             onJumpToAnotherApp = onJumpToAnotherApp,
+            onButtonClick = onIntroButtonClick,
+            onImageClick = onIntroImageClick,
+            onImageLongClick = onIntroImageLongClick,
         )
         )
     }
     }
 }
 }
@@ -1310,7 +1326,11 @@ private fun BookInfoIntro(
     baseUrl: String?,
     baseUrl: String?,
     bookSource: BookSource?,
     bookSource: BookSource?,
     onJumpToAnotherApp: (Uri) -> Unit,
     onJumpToAnotherApp: (Uri) -> Unit,
+    onButtonClick: (name: String, click: String) -> Unit,
+    onImageClick: (click: String) -> Unit,
+    onImageLongClick: (source: String) -> Unit,
 ) {
 ) {
+    val context = LocalContext.current
     val content = remember(intro) { parseBookInfoIntro(intro) }
     val content = remember(intro) { parseBookInfoIntro(intro) }
     if (content == null) {
     if (content == null) {
         AnimatedTextLine(
         AnimatedTextLine(
@@ -1327,9 +1347,35 @@ private fun BookInfoIntro(
             onJumpToAnotherApp = onJumpToAnotherApp,
             onJumpToAnotherApp = onJumpToAnotherApp,
         )
         )
 
 
-        is BookInfoIntroContent.Html -> HtmlContent(html = c.html)
+        is BookInfoIntroContent.Html -> HtmlContent(
+            html = c.html,
+            interactive = true,
+            onButtonClick = onButtonClick,
+            onImageClick = onImageClick,
+            onImageLongClick = onImageLongClick,
+            imageModel = { imageUrl ->
+                buildCoverImageRequest(
+                    context = context,
+                    data = imageUrl,
+                    sourceOrigin = bookSource?.bookSourceUrl,
+                    loadOnlyWifi = false,
+                )
+            },
+        )
 
 
-        is BookInfoIntroContent.Markdown -> MarkdownBlock(content = c.markdown)
+        is BookInfoIntroContent.Markdown -> MarkdownBlock(
+            content = c.markdown,
+            imageModel = { imageUrl ->
+                buildCoverImageRequest(
+                    context = context,
+                    data = imageUrl,
+                    sourceOrigin = bookSource?.bookSourceUrl,
+                    loadOnlyWifi = false,
+                )
+            },
+            onImageClick = onImageClick,
+            onImageLongClick = onImageLongClick,
+        )
 
 
         is BookInfoIntroContent.Plain -> AnimatedTextLine(
         is BookInfoIntroContent.Plain -> AnimatedTextLine(
             text = c.text,
             text = c.text,
@@ -1399,7 +1445,7 @@ private fun BookInfoWebIntro(
     val context = LocalContext.current
     val context = LocalContext.current
     val density = LocalDensity.current
     val density = LocalDensity.current
     var contentHeight by remember { mutableStateOf(0) }
     var contentHeight by remember { mutableStateOf(0) }
-    val webView = remember {
+    val webView = remember(bookSource?.bookSourceUrl) {
         WebView(context).apply {
         WebView(context).apply {
             setBackgroundColor(android.graphics.Color.TRANSPARENT)
             setBackgroundColor(android.graphics.Color.TRANSPARENT)
             settings.apply {
             settings.apply {

+ 16 - 0
app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt

@@ -368,9 +368,25 @@ class BookInfoViewModel(
             is BookInfoIntent.SetDefaultBookTreeUri -> viewModelScope.launch {
             is BookInfoIntent.SetDefaultBookTreeUri -> viewModelScope.launch {
                 otherSettingsGateway.update { it.copy(defaultBookTreeUri = intent.value) }
                 otherSettingsGateway.update { it.copy(defaultBookTreeUri = intent.value) }
             }
             }
+            is BookInfoIntent.IntroButtonClick -> runIntroJs(
+                "info button ${intent.name}",
+                intent.click
+            )
+
+            is BookInfoIntent.IntroImageClick -> runIntroJs("info image", intent.click)
+            is BookInfoIntent.IntroImageLongClick -> showDialog(
+                BookInfoDialog.PhotoPreview(intent.source)
+            )
         }
         }
     }
     }
 
 
+    /** 简介交互(按钮/图片)触发的书源 JS 执行,宿主通过 [BookInfoEffect.RunIntroJs] 运行。 */
+    private fun runIntroJs(name: String, click: String) {
+        val source = bookSource ?: return
+        val book = currentBook?.uiCopy() ?: return
+        emitEffect(BookInfoEffect.RunIntroJs(name, click, source, book))
+    }
+
     fun openEdit() {
     fun openEdit() {
         currentBook?.let {
         currentBook?.let {
             emitEffect(BookInfoEffect.OpenBookInfoEdit(it.bookUrl))
             emitEffect(BookInfoEffect.OpenBookInfoEdit(it.bookUrl))

+ 219 - 10
app/src/main/java/io/legado/app/ui/widget/components/text/HtmlContent.kt

@@ -1,17 +1,27 @@
 package io.legado.app.ui.widget.components.text
 package io.legado.app.ui.widget.components.text
 
 
 import androidx.compose.foundation.Image
 import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.BoxWithConstraints
 import androidx.compose.foundation.layout.BoxWithConstraints
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.foundation.text.BasicText
 import androidx.compose.foundation.text.BasicText
 import androidx.compose.foundation.text.InlineTextContent
 import androidx.compose.foundation.text.InlineTextContent
 import androidx.compose.foundation.text.appendInlineContent
 import androidx.compose.foundation.text.appendInlineContent
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.remember
+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.graphics.Color
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.platform.LocalDensity
@@ -28,20 +38,26 @@ import androidx.compose.ui.text.style.BaselineShift
 import androidx.compose.ui.text.style.TextDecoration
 import androidx.compose.ui.text.style.TextDecoration
 import androidx.compose.ui.text.withLink
 import androidx.compose.ui.text.withLink
 import androidx.compose.ui.text.withStyle
 import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.em
 import androidx.compose.ui.unit.em
 import androidx.compose.ui.unit.sp
 import androidx.compose.ui.unit.sp
 import androidx.core.graphics.toColorInt
 import androidx.core.graphics.toColorInt
 import coil3.compose.AsyncImagePainter
 import coil3.compose.AsyncImagePainter
 import coil3.compose.rememberAsyncImagePainter
 import coil3.compose.rememberAsyncImagePainter
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.utils.GSON
+import io.legado.app.utils.fromJsonObject
 import org.jsoup.Jsoup
 import org.jsoup.Jsoup
 import org.jsoup.nodes.Element
 import org.jsoup.nodes.Element
 import org.jsoup.nodes.Node
 import org.jsoup.nodes.Node
 import org.jsoup.nodes.TextNode
 import org.jsoup.nodes.TextNode
 
 
 /**
 /**
- * 通用 HTML 渲染组件:把不可信 HTML 解析为段落 + 行内样式/链接/图片,供 Compose 展示。
+ * 通用 HTML 渲染组件:把不可信 HTML 解析为段落 + 行内样式/链接/图片/按钮,供 Compose 展示。
  * 最初为词典内容(ui/dict)提取,书籍详情简介等纯文本 UI 复用同一实现。
  * 最初为词典内容(ui/dict)提取,书籍详情简介等纯文本 UI 复用同一实现。
+ *
+ * @param interactive 为 true 时启用 legado 交互约定(<button>名称@onclick:脚本</button>、
+ *                   图片 src 后缀 {...,"click":"脚本"} 的点击、图片长按),并把回调暴露给调用方。
  */
  */
 @Composable
 @Composable
 fun HtmlContent(
 fun HtmlContent(
@@ -50,6 +66,11 @@ fun HtmlContent(
     style: TextStyle = LegadoTheme.typography.bodyMedium.merge(
     style: TextStyle = LegadoTheme.typography.bodyMedium.merge(
         color = LegadoTheme.colorScheme.onSurface
         color = LegadoTheme.colorScheme.onSurface
     ),
     ),
+    interactive: Boolean = false,
+    onButtonClick: (name: String, click: String) -> Unit = { _, _ -> },
+    onImageClick: (click: String) -> Unit = {},
+    onImageLongClick: (source: String) -> Unit = {},
+    imageModel: (String) -> Any? = { it },
 ) {
 ) {
     val document = remember(html) { HtmlParser.parse(html) }
     val document = remember(html) { HtmlParser.parse(html) }
     val linkColor = LegadoTheme.colorScheme.primary
     val linkColor = LegadoTheme.colorScheme.primary
@@ -64,6 +85,11 @@ fun HtmlContent(
                     maxWidthPx = availableWidthPx,
                     maxWidthPx = availableWidthPx,
                     baseStyle = style,
                     baseStyle = style,
                     linkColor = linkColor,
                     linkColor = linkColor,
+                    interactive = interactive,
+                    onButtonClick = onButtonClick,
+                    onImageClick = onImageClick,
+                    onImageLongClick = onImageLongClick,
+                    imageModel = imageModel,
                 )
                 )
             }
             }
         }
         }
@@ -77,15 +103,87 @@ private fun HtmlParagraphContent(
     maxWidthPx: Int,
     maxWidthPx: Int,
     baseStyle: TextStyle,
     baseStyle: TextStyle,
     linkColor: Color,
     linkColor: Color,
+    interactive: Boolean,
+    onButtonClick: (name: String, click: String) -> Unit,
+    onImageClick: (click: String) -> Unit,
+    onImageLongClick: (source: String) -> Unit,
+    imageModel: (String) -> Any?,
+) {
+    if (paragraph.content.size == 1 && paragraph.content[0] is HtmlInline.HorizontalRule) {
+        HorizontalDivider(
+            modifier = Modifier.padding(vertical = 8.dp),
+            color = LegadoTheme.colorScheme.outlineVariant,
+            thickness = 1.dp,
+        )
+        return
+    }
+    Column {
+        var runStart = 0
+        paragraph.content.forEachIndexed { index, inline ->
+            if (inline is HtmlInline.Button) {
+                if (index > runStart) {
+                    HtmlTextRun(
+                        inlines = paragraph.content.subList(runStart, index),
+                        paragraphIndex = paragraphIndex,
+                        maxWidthPx = maxWidthPx,
+                        baseStyle = baseStyle,
+                        linkColor = linkColor,
+                        interactive = interactive,
+                        onImageClick = onImageClick,
+                        onImageLongClick = onImageLongClick,
+                        imageModel = imageModel,
+                    )
+                }
+                HtmlButtonChip(
+                    button = inline,
+                    baseStyle = baseStyle,
+                    interactive = interactive,
+                    onButtonClick = onButtonClick,
+                )
+                runStart = index + 1
+            }
+        }
+        if (runStart < paragraph.content.size) {
+            HtmlTextRun(
+                inlines = paragraph.content.subList(runStart, paragraph.content.size),
+                paragraphIndex = paragraphIndex,
+                maxWidthPx = maxWidthPx,
+                baseStyle = baseStyle,
+                linkColor = linkColor,
+                interactive = interactive,
+                onImageClick = onImageClick,
+                onImageLongClick = onImageLongClick,
+                imageModel = imageModel,
+            )
+        }
+    }
+}
+
+/**
+ * 渲染一段不含按钮的行内内容(文本/图片/换行),按钮由调用方拆出。
+ */
+@Composable
+private fun HtmlTextRun(
+    inlines: List<HtmlInline>,
+    paragraphIndex: Int,
+    maxWidthPx: Int,
+    baseStyle: TextStyle,
+    linkColor: Color,
+    interactive: Boolean,
+    onImageClick: (click: String) -> Unit,
+    onImageLongClick: (source: String) -> Unit,
+    imageModel: (String) -> Any?,
 ) {
 ) {
     val density = LocalDensity.current
     val density = LocalDensity.current
     val inlineContent = mutableMapOf<String, InlineTextContent>()
     val inlineContent = mutableMapOf<String, InlineTextContent>()
     var imageIndex = 0
     var imageIndex = 0
 
 
-    paragraph.content.forEach { inline ->
+    inlines.forEach { inline ->
         if (inline !is HtmlInline.Image) return@forEach
         if (inline !is HtmlInline.Image) return@forEach
         val id = "html-image-$paragraphIndex-${imageIndex++}"
         val id = "html-image-$paragraphIndex-${imageIndex++}"
-        val painter = rememberAsyncImagePainter(model = inline.source)
+        // The trailing JSON is a Legado image option, not part of the URL.
+        // Passing the raw value to Coil makes otherwise valid book-source images fail to load.
+        val painter = rememberAsyncImagePainter(model = imageModel(inline.loadSource))
         val painterState by painter.state.collectAsState()
         val painterState by painter.state.collectAsState()
         val image = (painterState as? AsyncImagePainter.State.Success)?.result?.image
         val image = (painterState as? AsyncImagePainter.State.Success)?.result?.image
         val drawableWidth = image?.width?.coerceAtLeast(1) ?: 1
         val drawableWidth = image?.width?.coerceAtLeast(1) ?: 1
@@ -97,6 +195,14 @@ private fun HtmlParagraphContent(
         }
         }
         val width = with(density) { (drawableWidth * scale).toDp().toSp() }
         val width = with(density) { (drawableWidth * scale).toDp().toSp() }
         val height = with(density) { (drawableHeight * scale).toDp().toSp() }
         val height = with(density) { (drawableHeight * scale).toDp().toSp() }
+        val imageModifier = if (interactive) {
+            Modifier.combinedClickable(
+                onClick = { if (inline.click != null) onImageClick(inline.click) },
+                onLongClick = { onImageLongClick(inline.source) },
+            )
+        } else {
+            Modifier
+        }
         inlineContent[id] = InlineTextContent(
         inlineContent[id] = InlineTextContent(
             placeholder = Placeholder(
             placeholder = Placeholder(
                 width = width.value.coerceAtLeast(1f).sp,
                 width = width.value.coerceAtLeast(1f).sp,
@@ -107,7 +213,9 @@ private fun HtmlParagraphContent(
             Image(
             Image(
                 painter = painter,
                 painter = painter,
                 contentDescription = inline.description,
                 contentDescription = inline.description,
-                modifier = Modifier.fillMaxSize(),
+                modifier = Modifier
+                    .fillMaxSize()
+                    .then(imageModifier),
                 contentScale = ContentScale.Fit,
                 contentScale = ContentScale.Fit,
             )
             )
         }
         }
@@ -115,12 +223,14 @@ private fun HtmlParagraphContent(
 
 
     val annotatedText = buildAnnotatedString {
     val annotatedText = buildAnnotatedString {
         imageIndex = 0
         imageIndex = 0
-        paragraph.content.forEach { inline ->
+        inlines.forEach { inline ->
             when (inline) {
             when (inline) {
                 is HtmlInline.Text -> appendStyledText(inline, linkColor)
                 is HtmlInline.Text -> appendStyledText(inline, linkColor)
                 is HtmlInline.Image -> {
                 is HtmlInline.Image -> {
                     val id = "html-image-$paragraphIndex-${imageIndex++}"
                     val id = "html-image-$paragraphIndex-${imageIndex++}"
-                    if (inline.link != null) {
+                    if (interactive && inline.click != null) {
+                        appendInlineContent(id, inline.description ?: "image")
+                    } else if (inline.link != null) {
                         withLink(LinkAnnotation.Url(inline.link)) {
                         withLink(LinkAnnotation.Url(inline.link)) {
                             appendInlineContent(id, inline.description ?: "image")
                             appendInlineContent(id, inline.description ?: "image")
                         }
                         }
@@ -130,6 +240,8 @@ private fun HtmlParagraphContent(
                 }
                 }
 
 
                 HtmlInline.LineBreak -> append('\n')
                 HtmlInline.LineBreak -> append('\n')
+                HtmlInline.HorizontalRule -> append('\n')
+                is HtmlInline.Button -> Unit
             }
             }
         }
         }
     }
     }
@@ -141,6 +253,50 @@ private fun HtmlParagraphContent(
     )
     )
 }
 }
 
 
+/**
+ * 独立渲染的书源交互按钮 chip,不依赖行内占位测量,保证可见。
+ */
+@Composable
+private fun HtmlButtonChip(
+    button: HtmlInline.Button,
+    baseStyle: TextStyle,
+    interactive: Boolean,
+    onButtonClick: (name: String, click: String) -> Unit,
+) {
+    val baseSize = if (baseStyle.fontSize.value.isNaN()) 14.sp else baseStyle.fontSize
+    val scaledSize = baseSize * 0.9f
+    val buttonFontSize = if (!scaledSize.value.isNaN() && scaledSize.value < 11f) {
+        11.sp
+    } else {
+        scaledSize
+    }
+    val buttonStyle = baseStyle.copy(
+        fontWeight = FontWeight.SemiBold,
+        fontSize = buttonFontSize,
+    )
+    val clickModifier = if (interactive) {
+        Modifier.clickable { onButtonClick(button.name, button.click) }
+    } else {
+        Modifier
+    }
+    Box(
+        modifier = Modifier
+            .padding(vertical = 2.dp)
+            .clip(RoundedCornerShape(8.dp))
+            .background(LegadoTheme.colorScheme.surfaceContainer)
+            .then(clickModifier)
+            .padding(horizontal = 8.dp, vertical = 4.dp),
+        contentAlignment = Alignment.Center,
+    ) {
+        Text(
+            text = button.name,
+            style = buttonStyle,
+            color = LegadoTheme.colorScheme.onSurface,
+            maxLines = 1,
+        )
+    }
+}
+
 private fun AnnotatedString.Builder.appendStyledText(
 private fun AnnotatedString.Builder.appendStyledText(
     text: HtmlInline.Text,
     text: HtmlInline.Text,
     linkColor: Color,
     linkColor: Color,
@@ -227,11 +383,21 @@ internal sealed interface HtmlInline {
 
 
     data class Image(
     data class Image(
         val source: String,
         val source: String,
+        val loadSource: String,
         val description: String?,
         val description: String?,
         val link: String? = null,
         val link: String? = null,
+        val click: String? = null,
+    ) : HtmlInline
+
+    /** `<button>名称@onclick:脚本</button>`,legado 书源交互约定。 */
+    data class Button(
+        val name: String,
+        val click: String,
     ) : HtmlInline
     ) : HtmlInline
 
 
     data object LineBreak : HtmlInline
     data object LineBreak : HtmlInline
+
+    data object HorizontalRule : HtmlInline
 }
 }
 
 
 internal data class HtmlTextStyle(
 internal data class HtmlTextStyle(
@@ -255,6 +421,32 @@ internal object HtmlParser {
         "hr", "main", "nav", "ol", "p", "pre", "section", "table", "ul",
         "hr", "main", "nav", "ol", "p", "pre", "section", "table", "ul",
     )
     )
 
 
+    private const val BUTTON_SPLIT = "@onclick:"
+
+    // 与上游 AnalyzeUrl.paramPattern 一致:图片 src 后的 JSON 参数,例如 ",{"width":"80%","click":"showCmt(...)"}"
+    private val imageParamPattern = Regex("\\s*,\\s*(?=\\{)")
+
+    private fun parseImageOptions(source: String): Pair<String, String?> {
+        val urlMatcher = imageParamPattern.find(source) ?: return source to null
+        val urlOptionStr = source.substring(urlMatcher.range.last + 1)
+        val click = GSON.fromJsonObject<Map<String, String>>(urlOptionStr).getOrNull()?.get("click")
+        return source.substring(0, urlMatcher.range.first) to click
+    }
+
+    private fun collectButtonText(element: Element): String {
+        val sb = StringBuilder()
+        collectText(element, sb)
+        return sb.toString()
+    }
+
+    private fun collectText(node: Node, sb: StringBuilder) {
+        when (node) {
+            is TextNode -> sb.append(normalizeWhitespace(node.wholeText))
+            is Element -> node.childNodes().forEach { collectText(it, sb) }
+            else -> Unit
+        }
+    }
+
     fun parse(html: String): HtmlDocument {
     fun parse(html: String): HtmlDocument {
         val paragraphs = mutableListOf<HtmlParagraph>()
         val paragraphs = mutableListOf<HtmlParagraph>()
         parseContainer(
         parseContainer(
@@ -314,9 +506,7 @@ internal object HtmlParser {
                 addParagraph(content, paragraphs)
                 addParagraph(content, paragraphs)
             }
             }
 
 
-            "hr" -> paragraphs += HtmlParagraph(
-                listOf(HtmlInline.Text("────────", style, inheritedLink))
-            )
+            "hr" -> paragraphs += HtmlParagraph(listOf(HtmlInline.HorizontalRule))
 
 
             else -> {
             else -> {
                 val content = mutableListOf<HtmlInline>()
                 val content = mutableListOf<HtmlInline>()
@@ -362,14 +552,33 @@ internal object HtmlParser {
                     .ifBlank { inheritedLink } else inheritedLink
                     .ifBlank { inheritedLink } else inheritedLink
                 when (node.normalName()) {
                 when (node.normalName()) {
                     "br" -> output += HtmlInline.LineBreak
                     "br" -> output += HtmlInline.LineBreak
+                    "hr" -> {
+                        output += HtmlInline.LineBreak
+                        output += HtmlInline.HorizontalRule
+                        output += HtmlInline.LineBreak
+                    }
+
                     "img" -> node.attr("src").takeIf(String::isNotBlank)?.let { source ->
                     "img" -> node.attr("src").takeIf(String::isNotBlank)?.let { source ->
+                        val (loadSource, click) = parseImageOptions(source)
                         output += HtmlInline.Image(
                         output += HtmlInline.Image(
                             source,
                             source,
+                            loadSource,
                             node.attr("alt").ifBlank { null },
                             node.attr("alt").ifBlank { null },
-                            link
+                            link,
+                            click,
                         )
                         )
                     }
                     }
 
 
+                    "button" -> {
+                        val buttonText = collectButtonText(node)
+                        val parts = buttonText.split(BUTTON_SPLIT, limit = 2)
+                        if (parts.size == 2) {
+                            output += HtmlInline.Button(parts[0].trim(), parts[1].trim())
+                        } else {
+                            appendText(output, buttonText, style, inheritedLink)
+                        }
+                    }
+
                     in blockTags -> {
                     in blockTags -> {
                         if (output.isNotEmpty() && output.last() !is HtmlInline.LineBreak) {
                         if (output.isNotEmpty() && output.last() !is HtmlInline.LineBreak) {
                             output += HtmlInline.LineBreak
                             output += HtmlInline.LineBreak

+ 67 - 10
app/src/main/java/io/legado/app/ui/widget/components/text/MarkdownBlock.kt

@@ -5,6 +5,7 @@ import androidx.compose.foundation.BorderStroke
 import androidx.compose.foundation.background
 import androidx.compose.foundation.background
 import androidx.compose.foundation.border
 import androidx.compose.foundation.border
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
 import androidx.compose.foundation.horizontalScroll
 import androidx.compose.foundation.horizontalScroll
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Column
@@ -12,6 +13,7 @@ import androidx.compose.foundation.layout.FlowRow
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.rememberScrollState
@@ -22,6 +24,7 @@ import androidx.compose.material3.LocalTextStyle
 import androidx.compose.material3.ProvideTextStyle
 import androidx.compose.material3.ProvideTextStyle
 import androidx.compose.material3.Text
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.mutableStateOf
@@ -29,11 +32,13 @@ import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberUpdatedState
 import androidx.compose.runtime.rememberUpdatedState
 import androidx.compose.runtime.setValue
 import androidx.compose.runtime.setValue
 import androidx.compose.runtime.snapshotFlow
 import androidx.compose.runtime.snapshotFlow
+import androidx.compose.runtime.staticCompositionLocalOf
 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
 import androidx.compose.ui.draw.drawWithContent
 import androidx.compose.ui.draw.drawWithContent
 import androidx.compose.ui.geometry.Size
 import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.res.painterResource
 import androidx.compose.ui.res.painterResource
@@ -52,6 +57,7 @@ import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.em
 import androidx.compose.ui.unit.em
 import androidx.compose.ui.unit.sp
 import androidx.compose.ui.unit.sp
 import androidx.core.net.toUri
 import androidx.core.net.toUri
+import coil3.compose.AsyncImage
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Dispatchers
@@ -60,7 +66,6 @@ import kotlinx.coroutines.flow.catch
 import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.flowOn
 import kotlinx.coroutines.flow.flowOn
 import kotlinx.coroutines.flow.mapLatest
 import kotlinx.coroutines.flow.mapLatest
-import splitties.systemservices.clipboardManager
 import org.intellij.markdown.IElementType
 import org.intellij.markdown.IElementType
 import org.intellij.markdown.MarkdownElementTypes
 import org.intellij.markdown.MarkdownElementTypes
 import org.intellij.markdown.MarkdownTokenTypes
 import org.intellij.markdown.MarkdownTokenTypes
@@ -70,6 +75,7 @@ import org.intellij.markdown.flavours.gfm.GFMElementTypes
 import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
 import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
 import org.intellij.markdown.flavours.gfm.GFMTokenTypes
 import org.intellij.markdown.flavours.gfm.GFMTokenTypes
 import org.intellij.markdown.parser.MarkdownParser
 import org.intellij.markdown.parser.MarkdownParser
+import splitties.systemservices.clipboardManager
 
 
 // ---- Markdown parser (lazy singleton) ----
 // ---- Markdown parser (lazy singleton) ----
 
 
@@ -86,6 +92,14 @@ private data class MarkdownParseResult(
     val astTree: ASTNode,
     val astTree: ASTNode,
 )
 )
 
 
+private data class MarkdownImageHandlers(
+    val model: (String) -> Any? = { it },
+    val onClick: (String) -> Unit = {},
+    val onLongClick: (String) -> Unit = {},
+)
+
+private val LocalMarkdownImageHandlers = staticCompositionLocalOf { MarkdownImageHandlers() }
+
 private fun parseMarkdown(content: String): MarkdownParseResult {
 private fun parseMarkdown(content: String): MarkdownParseResult {
     return MarkdownParseResult(content, parser.buildMarkdownTreeFromString(content))
     return MarkdownParseResult(content, parser.buildMarkdownTreeFromString(content))
 }
 }
@@ -104,6 +118,9 @@ fun MarkdownBlock(
     modifier: Modifier = Modifier,
     modifier: Modifier = Modifier,
     style: TextStyle = LegadoTheme.typography.bodyLarge,
     style: TextStyle = LegadoTheme.typography.bodyLarge,
     onClickLink: ((String) -> Unit)? = null,
     onClickLink: ((String) -> Unit)? = null,
+    imageModel: (String) -> Any? = { it },
+    onImageClick: (String) -> Unit = {},
+    onImageLongClick: (String) -> Unit = {},
 ) {
 ) {
     var data by remember { mutableStateOf(parseMarkdown(content)) }
     var data by remember { mutableStateOf(parseMarkdown(content)) }
 
 
@@ -117,14 +134,22 @@ fun MarkdownBlock(
             .collect { data = it }
             .collect { data = it }
     }
     }
 
 
-    ProvideTextStyle(style) {
-        Column(modifier = modifier.padding(horizontal = 4.dp)) {
-            data.astTree.children.fastForEach { child ->
-                MarkdownNode(
-                    node = child,
-                    content = data.content,
-                    onClickLink = onClickLink,
-                )
+    CompositionLocalProvider(
+        LocalMarkdownImageHandlers provides MarkdownImageHandlers(
+            model = imageModel,
+            onClick = onImageClick,
+            onLongClick = onImageLongClick,
+        )
+    ) {
+        ProvideTextStyle(style) {
+            Column(modifier = modifier.padding(horizontal = 4.dp)) {
+                data.astTree.children.fastForEach { child ->
+                    MarkdownNode(
+                        node = child,
+                        content = data.content,
+                        onClickLink = onClickLink,
+                    )
+                }
             }
             }
         }
         }
     }
     }
@@ -323,9 +348,38 @@ private fun MarkdownParagraph(
             style = textStyle,
             style = textStyle,
             overflow = TextOverflow.Visible,
             overflow = TextOverflow.Visible,
         )
         )
+        node.children
+            .filter { it.type == MarkdownElementTypes.IMAGE }
+            .forEach { imageNode ->
+                MarkdownImage(
+                    source = imageNode.findChildOfTypeRecursive(MarkdownElementTypes.LINK_DESTINATION)
+                        ?.getTextInNode(content).orEmpty(),
+                    description = imageNode.findChildOfTypeRecursive(MarkdownElementTypes.LINK_TEXT)
+                        ?.getTextInNode(content).orEmpty(),
+                )
+            }
     }
     }
 }
 }
 
 
+@Composable
+private fun MarkdownImage(source: String, description: String) {
+    if (source.isBlank()) return
+    val handlers = LocalMarkdownImageHandlers.current
+    AsyncImage(
+        model = handlers.model(source),
+        contentDescription = description.ifBlank { null },
+        contentScale = ContentScale.Fit,
+        modifier = Modifier
+            .fillMaxWidth()
+            .heightIn(max = 480.dp)
+            .padding(vertical = 4.dp)
+            .combinedClickable(
+                onClick = { handlers.onClick(source) },
+                onLongClick = { handlers.onLongClick(source) },
+            ),
+    )
+}
+
 @Composable
 @Composable
 private fun MarkdownHeading(
 private fun MarkdownHeading(
     node: ASTNode,
     node: ASTNode,
@@ -652,7 +706,10 @@ private fun MarkdownTable(
         modifier = modifier
         modifier = modifier
             .padding(vertical = 8.dp)
             .padding(vertical = 8.dp)
             .clip(RoundedCornerShape(8.dp))
             .clip(RoundedCornerShape(8.dp))
-            .border(BorderStroke(0.5.dp, LegadoTheme.colorScheme.outlineVariant), RoundedCornerShape(8.dp))
+            .border(
+                BorderStroke(0.5.dp, LegadoTheme.colorScheme.outlineVariant),
+                RoundedCornerShape(8.dp)
+            )
     ) {
     ) {
         // Header
         // Header
         Row(
         Row(

+ 72 - 0
app/src/test/java/io/legado/app/ui/widget/components/text/HtmlParserTest.kt

@@ -0,0 +1,72 @@
+package io.legado.app.ui.widget.components.text
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class HtmlParserTest {
+
+    @Test
+    fun button_withOnClickSplitsNameAndClick() {
+        val doc =
+            HtmlParser.parse("<p>阅读 <button>评论@onclick:showCmt(https://example.com/1)</button></p>")
+
+        val paragraph = doc.paragraphs.single()
+        val button = paragraph.content.filterIsInstance<HtmlInline.Button>().single()
+        assertEquals("评论", button.name)
+        assertEquals("showCmt(https://example.com/1)", button.click)
+    }
+
+    @Test
+    fun buttonWithoutOnClickStaysPlainText() {
+        val doc = HtmlParser.parse("<p>点击 <button>确认</button></p>")
+
+        val paragraph = doc.paragraphs.single()
+        assertTrue(paragraph.content.none { it is HtmlInline.Button })
+        val text =
+            paragraph.content.filterIsInstance<HtmlInline.Text>().joinToString("") { it.value }
+        assertTrue(text.contains("确认"))
+    }
+
+    @Test
+    fun buttonInsideDivUsehtmlStyleIsParsed() {
+        val doc = HtmlParser.parse(
+            "<div>评论:<button>点赞@onclick:showCmt(\"https://a.com/x\")</button></div>"
+        )
+
+        val buttons = doc.paragraphs.flatMap { it.content }.filterIsInstance<HtmlInline.Button>()
+        assertEquals(1, buttons.size)
+        assertEquals("点赞", buttons[0].name)
+        assertEquals("showCmt(\"https://a.com/x\")", buttons[0].click)
+    }
+
+    @Test
+    fun hrBecomesHorizontalRuleParagraph() {
+        val doc = HtmlParser.parse("<div>a<hr>b</div>")
+
+        assertTrue(doc.paragraphs.any { paragraph ->
+            paragraph.content.size == 1 && paragraph.content[0] is HtmlInline.HorizontalRule
+        })
+    }
+
+    @Test
+    fun imageWithClickParamExtractsClick() {
+        val doc = HtmlParser.parse(
+            "<img src=\"https://a.com/1.png,{&quot;click&quot;:&quot;showCmt(1)&quot;,&quot;width&quot;:&quot;80%&quot;}\">"
+        )
+
+        val image =
+            doc.paragraphs.flatMap { it.content }.filterIsInstance<HtmlInline.Image>().single()
+        assertEquals("showCmt(1)", image.click)
+        assertEquals("https://a.com/1.png", image.loadSource)
+    }
+
+    @Test
+    fun imageWithoutClickParamKeepsClickNull() {
+        val doc = HtmlParser.parse("<img src=\"https://a.com/1.png\">")
+
+        val image =
+            doc.paragraphs.flatMap { it.content }.filterIsInstance<HtmlInline.Image>().single()
+        assertEquals(null, image.click)
+    }
+}