HapeLee пре 2 месеци
родитељ
комит
82d4e4e005

+ 3 - 2
app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt

@@ -32,7 +32,8 @@ data class BookInfoUiState(
     val readRecordTotalTime: Long = 0L,
     val readRecordTimelineDays: List<ReadRecordTimelineDay> = emptyList(),
     val inBookshelf: Boolean = false,
-    val bookSource: BookInfoSourceUi? = null,
+    val bookSource: BookSource? = null,
+    val bookSourceUi: BookInfoSourceUi? = null,
     val relatedBooks: ImmutableList<RelatedBooksUi> = persistentListOf(),
     val characters: ImmutableList<BookInfoCharacterUi> = persistentListOf(),
     val knowledgeEntries: ImmutableList<BookInfoKnowledgeUi> = persistentListOf(),
@@ -73,7 +74,7 @@ data class BookInfoBookUi(
     val durChapterIndex: Int,
     val durChapterPos: Int,
     val remark: String?,
-    val displayIntro: String?,
+    val intro: String?,
 )
 
 @Stable

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

@@ -1,5 +1,11 @@
 package io.legado.app.ui.book.info
 
+import android.content.Intent
+import android.graphics.Bitmap
+import android.net.Uri
+import android.webkit.WebResourceRequest
+import android.webkit.WebView
+import android.webkit.WebViewClient
 import androidx.compose.animation.AnimatedVisibilityScope
 import androidx.compose.animation.Crossfade
 import androidx.compose.animation.EnterExitState
@@ -48,13 +54,18 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.material3.ExtendedFloatingActionButton
 import androidx.compose.material3.Icon
 import androidx.compose.material3.MediumFlexibleTopAppBar
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.SnackbarResult
 import androidx.compose.material3.Text
 import androidx.compose.material3.TopAppBarDefaults
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
@@ -67,6 +78,8 @@ import androidx.compose.ui.graphics.lerp
 import androidx.compose.ui.graphics.vector.ImageVector
 import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.semantics.Role
 import androidx.compose.ui.semantics.clearAndSetSemantics
@@ -75,14 +88,20 @@ import androidx.compose.ui.semantics.role
 import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
 import coil3.ImageLoader
 import coil3.compose.AsyncImage
 import coil3.size.Size
 import io.legado.app.R
 import io.legado.app.constant.BookType
+import io.legado.app.data.entities.BaseSource
 import io.legado.app.data.entities.BookGroup
+import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.SearchBook
+import io.legado.app.help.WebCacheManager
 import io.legado.app.help.coil.CoverExtras
+import io.legado.app.help.webView.WebJsExtensions
+import io.legado.app.ui.association.OnLineImportActivity
 import io.legado.app.ui.main.homepage.modules.BannerModule
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LocalHazeState
@@ -115,6 +134,8 @@ import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
 import io.legado.app.ui.widget.components.text.AnimatedTextLine
 import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.ui.widget.components.text.HtmlContent
+import io.legado.app.ui.widget.components.text.MarkdownBlock
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarScrollBehavior
 import io.legado.app.ui.widget.components.topbar.M3GlassScrollBehavior
@@ -125,9 +146,12 @@ import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import io.legado.app.ui.widget.components.topbar.miuixTopBarActionsEndPadding
 import io.legado.app.ui.widget.components.topbar.miuixTopBarSlotPadding
 import io.legado.app.ui.widget.components.variable.VariableEditorSheet
+import io.legado.app.utils.HtmlFormatter
+import io.legado.app.utils.openUrl
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
 import org.koin.compose.koinInject
 import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
 import io.legado.app.model.BookCover as BookCoverModel
@@ -235,6 +259,20 @@ private fun BookInfoScreenContent(
         M3GlassScrollBehavior(TopAppBarDefaults.exitUntilCollapsedScrollBehavior())
     }
     val listState = rememberLazyListState()
+    val snackbarHostState = remember { SnackbarHostState() }
+    val scope = rememberCoroutineScope()
+    val context = LocalContext.current
+    val jumpToAnotherApp: (Uri) -> Unit = { uri ->
+        scope.launch {
+            val result = snackbarHostState.showSnackbar(
+                message = context.getString(R.string.jump_to_another_app),
+                actionLabel = context.getString(R.string.confirm),
+            )
+            if (result == SnackbarResult.ActionPerformed) {
+                context.openUrl(uri)
+            }
+        }
+    }
 
     AppScaffold(
         modifier = Modifier
@@ -248,6 +286,7 @@ private fun BookInfoScreenContent(
                 scrollBehavior = scrollBehavior,
             )
         },
+        snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
         floatingActionButton = {
             ExtendedFloatingActionButton(
                 onClick = { onIntent(BookInfoIntent.ReadClick) },
@@ -364,6 +403,8 @@ private fun BookInfoScreenContent(
                                     book = book,
                                     tocLoadFailed = state.tocLoadFailed,
                                     onRemarkClick = { onIntent(BookInfoIntent.RemarkClick) },
+                                    bookSource = state.bookSource,
+                                    onJumpToAnotherApp = jumpToAnotherApp,
                                 )
                             }
                         }
@@ -797,7 +838,7 @@ private fun BookInfoOverflowMenu(
 ) {
     val book = state.book
     RoundDropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
-        if (state.bookSource?.hasCustomButton == true) {
+        if (state.bookSourceUi?.hasCustomButton == true) {
             RoundDropdownMenuItem(
                 text = stringResource(R.string.custom_button),
                 onClick = { onMenuAction(BookInfoMenuAction.CustomButton) }
@@ -827,13 +868,13 @@ private fun BookInfoOverflowMenu(
                 onClick = { onMenuAction(BookInfoMenuAction.Upload) }
             )
         }
-        if (state.bookSource?.hasLogin == true) {
+        if (state.bookSourceUi?.hasLogin == true) {
             RoundDropdownMenuItem(
                 text = stringResource(R.string.login),
                 onClick = { onMenuAction(BookInfoMenuAction.Login) }
             )
         }
-        if (state.bookSource != null) {
+        if (state.bookSourceUi != null) {
             RoundDropdownMenuItem(
                 text = stringResource(R.string.set_source_variable),
                 onClick = { onMenuAction(BookInfoMenuAction.SetSourceVariable) }
@@ -1173,6 +1214,8 @@ private fun BookInfoSummary(
     book: BookInfoBookUi,
     tocLoadFailed: Boolean,
     onRemarkClick: () -> Unit,
+    bookSource: BookSource?,
+    onJumpToAnotherApp: (Uri) -> Unit,
 ) {
     Column(
         modifier = Modifier
@@ -1243,12 +1286,195 @@ private fun BookInfoSummary(
             }
         }
         Spacer(modifier = Modifier.height(4.dp))
+        BookInfoIntro(
+            intro = book.intro,
+            baseUrl = book.bookUrl
+                .takeIf { it.startsWith("http", true) }
+                ?.substringBefore(","),
+            bookSource = bookSource,
+            onJumpToAnotherApp = onJumpToAnotherApp,
+        )
+    }
+}
+
+/**
+ * 上游书籍详情页的简介渲染:
+ * - `<useweb>...<` 用 WebView 渲染(注入缓存/书源/Java 桥接 JS,处理 legado/yuedu 等 scheme)
+ * - `<usehtml>...<` 用 HTML 渲染(含行内图片/链接/样式)
+ * - `<md>...<` 用 Markdown 渲染
+ * - 其余纯文本
+ */
+@Composable
+private fun BookInfoIntro(
+    intro: String?,
+    baseUrl: String?,
+    bookSource: BookSource?,
+    onJumpToAnotherApp: (Uri) -> Unit,
+) {
+    val content = remember(intro) { parseBookInfoIntro(intro) }
+    if (content == null) {
         AnimatedTextLine(
-            text = book.displayIntro.orEmpty().ifBlank { stringResource(R.string.intro_show_null) },
+            text = stringResource(R.string.intro_show_null),
+            style = LegadoTheme.typography.bodyMedium,
+        )
+        return
+    }
+    when (val c = content) {
+        is BookInfoIntroContent.Web -> BookInfoWebIntro(
+            html = c.html,
+            baseUrl = baseUrl,
+            bookSource = bookSource,
+            onJumpToAnotherApp = onJumpToAnotherApp,
+        )
+
+        is BookInfoIntroContent.Html -> HtmlContent(html = c.html)
+
+        is BookInfoIntroContent.Markdown -> MarkdownBlock(content = c.markdown)
+
+        is BookInfoIntroContent.Plain -> AnimatedTextLine(
+            text = c.text,
             style = LegadoTheme.typography.bodyMedium,
         )
     }
 }
+
+private sealed interface BookInfoIntroContent {
+    data class Web(val html: String) : BookInfoIntroContent
+    data class Html(val html: String) : BookInfoIntroContent
+    data class Markdown(val markdown: String) : BookInfoIntroContent
+    data class Plain(val text: String) : BookInfoIntroContent
+}
+
+/**
+ * 解析简介前缀,与上游 showBookIntro 一致:
+ * 前缀 `<useweb>`/`<usehtml>`/`<md>` 后直到最后一个 `<` 之间的内容为待渲染文本;
+ * 前缀残缺时按纯文本回退。
+ */
+private fun parseBookInfoIntro(intro: String?): BookInfoIntroContent? {
+    if (intro.isNullOrBlank()) return null
+    return when {
+        intro.startsWith("<useweb>") -> {
+            val lastIndex = intro.lastIndexOf("<")
+            if (lastIndex < 8) {
+                BookInfoIntroContent.Plain(HtmlFormatter.formatDisplayText(intro))
+            } else {
+                BookInfoIntroContent.Web(intro.substring(8, lastIndex))
+            }
+        }
+
+        intro.startsWith("<usehtml>") -> {
+            val lastIndex = intro.lastIndexOf("<")
+            if (lastIndex < 9) {
+                BookInfoIntroContent.Plain(HtmlFormatter.formatDisplayText(intro))
+            } else {
+                BookInfoIntroContent.Html(intro.substring(9, lastIndex))
+            }
+        }
+
+        intro.startsWith("<md>") -> {
+            val lastIndex = intro.lastIndexOf("<")
+            if (lastIndex < 4) {
+                BookInfoIntroContent.Plain(HtmlFormatter.formatDisplayText(intro))
+            } else {
+                BookInfoIntroContent.Markdown(intro.substring(4, lastIndex))
+            }
+        }
+
+        else -> BookInfoIntroContent.Plain(HtmlFormatter.formatDisplayText(intro))
+    }
+}
+
+/**
+ * `<useweb>` 简介:用 WebView 渲染,注入与上游一致的 JS 桥接
+ * (WebCacheManager 缓存、书源对象、WebJsExtensions),
+ * 并处理 legado/yuedu scheme(导入)与其他 scheme(确认后跳转)。
+ */
+@Composable
+private fun BookInfoWebIntro(
+    html: String,
+    baseUrl: String?,
+    bookSource: BookSource?,
+    onJumpToAnotherApp: (Uri) -> Unit,
+) {
+    val context = LocalContext.current
+    val density = LocalDensity.current
+    var contentHeight by remember { mutableStateOf(0) }
+    val webView = remember {
+        WebView(context).apply {
+            setBackgroundColor(android.graphics.Color.TRANSPARENT)
+            settings.apply {
+                javaScriptEnabled = true
+                domStorageEnabled = true
+                useWideViewPort = true
+                loadWithOverviewMode = true
+            }
+            webViewClient = object : WebViewClient() {
+                override fun shouldOverrideUrlLoading(
+                    view: WebView?,
+                    request: WebResourceRequest?,
+                ): Boolean {
+                    request?.url?.let { url ->
+                        return when (url.scheme) {
+                            "http", "https" -> false
+                            "legado", "yuedu" -> {
+                                context.startActivity(
+                                    Intent(context, OnLineImportActivity::class.java).apply {
+                                        data = url
+                                    }
+                                )
+                                true
+                            }
+
+                            else -> {
+                                onJumpToAnotherApp(url)
+                                true
+                            }
+                        }
+                    }
+                    return super.shouldOverrideUrlLoading(view, request)
+                }
+
+                override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
+                    super.onPageStarted(view, url, favicon)
+                    view?.evaluateJavascript(WebJsExtensions.getInjectionString, null)
+                }
+
+                override fun onPageFinished(view: WebView?, url: String?) {
+                    super.onPageFinished(view, url)
+                    view?.post {
+                        contentHeight = view.contentHeight
+                    }
+                }
+            }
+            addJavascriptInterface(WebCacheManager, WebJsExtensions.nameCache)
+            bookSource?.let { source ->
+                addJavascriptInterface(source as BaseSource, WebJsExtensions.nameSource)
+                addJavascriptInterface(
+                    WebJsExtensions(source, null, this),
+                    WebJsExtensions.nameJava
+                )
+            }
+        }
+    }
+    DisposableEffect(Unit) {
+        onDispose {
+            webView.destroy()
+        }
+    }
+    AndroidView(
+        factory = { webView },
+        modifier = Modifier
+            .fillMaxWidth()
+            .height(with(density) { contentHeight.toDp() }),
+        update = { view ->
+            val loadedHtml = view.tag as? String
+            if (loadedHtml != html) {
+                view.tag = html
+                view.loadDataWithBaseURL(baseUrl, html, "text/html", "utf-8", baseUrl)
+            }
+        },
+    )
+}
 @Composable
 private fun BookInfoDialogs(
     state: BookInfoUiState,

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

@@ -66,7 +66,6 @@ import io.legado.app.ui.main.MainIntent
 import io.legado.app.ui.widget.components.image.cover.buildCoverImageRequest
 import io.legado.app.utils.ArchiveUtils
 import io.legado.app.utils.GSON
-import io.legado.app.utils.HtmlFormatter
 import io.legado.app.utils.ImageSaveUtils
 import io.legado.app.utils.UrlUtil
 import io.legado.app.utils.fromJsonArray
@@ -1439,7 +1438,8 @@ class BookInfoViewModel(
                 readRecordTotalTime = currentReadRecordTotalTime,
                 readRecordTimelineDays = currentReadRecordTimelineDays,
                 inBookshelf = inBookshelf,
-                bookSource = bookSource?.toBookInfoSourceUi(),
+                bookSource = bookSource,
+                bookSourceUi = bookSource?.toBookInfoSourceUi(),
                 isTocLoading = isTocLoading,
                 deleteAlertEnabled = LocalConfig.bookInfoDeleteAlert,
                 deleteOriginal = LocalConfig.deleteBookOriginal,
@@ -1722,7 +1722,7 @@ class BookInfoViewModel(
             durChapterIndex = durChapterIndex,
             durChapterPos = durChapterPos,
             remark = remark,
-            displayIntro = HtmlFormatter.formatDisplayText(getDisplayIntro()),
+            intro = getDisplayIntro(),
         )
     }
 

+ 0 - 204
app/src/main/java/io/legado/app/ui/dict/DictHtmlContent.kt

@@ -1,204 +0,0 @@
-package io.legado.app.ui.dict
-
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.layout.BoxWithConstraints
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.text.BasicText
-import androidx.compose.foundation.text.InlineTextContent
-import androidx.compose.foundation.text.appendInlineContent
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.text.AnnotatedString
-import androidx.compose.ui.text.LinkAnnotation
-import androidx.compose.ui.text.Placeholder
-import androidx.compose.ui.text.PlaceholderVerticalAlign
-import androidx.compose.ui.text.SpanStyle
-import androidx.compose.ui.text.TextStyle
-import androidx.compose.ui.text.buildAnnotatedString
-import androidx.compose.ui.text.font.FontStyle
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.BaselineShift
-import androidx.compose.ui.text.style.TextDecoration
-import androidx.compose.ui.text.withLink
-import androidx.compose.ui.text.withStyle
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.em
-import androidx.compose.ui.unit.sp
-import androidx.core.graphics.toColorInt
-import coil3.compose.AsyncImagePainter
-import coil3.compose.rememberAsyncImagePainter
-import io.legado.app.ui.theme.LegadoTheme
-
-@Composable
-internal fun DictHtmlContent(
-    htmlContent: String,
-    modifier: Modifier = Modifier,
-) {
-    val document = remember(htmlContent) { DictHtmlParser.parse(htmlContent) }
-    val baseStyle =
-        LegadoTheme.typography.bodyMedium.merge(color = LegadoTheme.colorScheme.onSurface)
-    val linkColor = LegadoTheme.colorScheme.primary
-
-    BoxWithConstraints(modifier = modifier.padding(horizontal = 32.dp, vertical = 16.dp)) {
-        val availableWidthPx = constraints.maxWidth
-        Column {
-            document.paragraphs.forEachIndexed { paragraphIndex, paragraph ->
-                DictHtmlParagraphContent(
-                    paragraph = paragraph,
-                    paragraphIndex = paragraphIndex,
-                    maxWidthPx = availableWidthPx,
-                    baseStyle = baseStyle,
-                    linkColor = linkColor,
-                )
-            }
-        }
-    }
-}
-
-@Composable
-private fun DictHtmlParagraphContent(
-    paragraph: DictHtmlParagraph,
-    paragraphIndex: Int,
-    maxWidthPx: Int,
-    baseStyle: TextStyle,
-    linkColor: Color,
-) {
-    val density = LocalDensity.current
-    val inlineContent = mutableMapOf<String, InlineTextContent>()
-    var imageIndex = 0
-
-    paragraph.content.forEach { inline ->
-        if (inline !is DictHtmlInline.Image) return@forEach
-        val id = "dict-image-$paragraphIndex-${imageIndex++}"
-        val painter = rememberAsyncImagePainter(model = inline.source)
-        val painterState by painter.state.collectAsState()
-        val image = (painterState as? AsyncImagePainter.State.Success)?.result?.image
-        val drawableWidth = image?.width?.coerceAtLeast(1) ?: 1
-        val drawableHeight = image?.height?.coerceAtLeast(1) ?: 1
-        val scale = if (maxWidthPx in 1..<drawableWidth) {
-            maxWidthPx.toFloat() / drawableWidth
-        } else {
-            1f
-        }
-        val width = with(density) { (drawableWidth * scale).toDp().toSp() }
-        val height = with(density) { (drawableHeight * scale).toDp().toSp() }
-        inlineContent[id] = InlineTextContent(
-            placeholder = Placeholder(
-                width = width.value.coerceAtLeast(1f).sp,
-                height = height.value.coerceAtLeast(1f).sp,
-                placeholderVerticalAlign = PlaceholderVerticalAlign.TextBottom,
-            )
-        ) {
-            Image(
-                painter = painter,
-                contentDescription = inline.description,
-                modifier = Modifier.fillMaxSize(),
-                contentScale = ContentScale.Fit,
-            )
-        }
-    }
-
-    val annotatedText = buildAnnotatedString {
-        imageIndex = 0
-        paragraph.content.forEach { inline ->
-            when (inline) {
-                is DictHtmlInline.Text -> appendStyledText(inline, linkColor)
-                is DictHtmlInline.Image -> {
-                    val id = "dict-image-$paragraphIndex-${imageIndex++}"
-                    if (inline.link != null) {
-                        withLink(LinkAnnotation.Url(inline.link)) {
-                            appendInlineContent(id, inline.description ?: "image")
-                        }
-                    } else {
-                        appendInlineContent(id, inline.description ?: "image")
-                    }
-                }
-
-                DictHtmlInline.LineBreak -> append('\n')
-            }
-        }
-    }
-
-    BasicText(
-        text = annotatedText,
-        inlineContent = inlineContent,
-        style = baseStyle,
-    )
-}
-
-private fun AnnotatedString.Builder.appendStyledText(
-    text: DictHtmlInline.Text,
-    linkColor: Color,
-) {
-    val spanStyle = text.style.toSpanStyle(
-        fallbackColor = if (text.link != null) linkColor else null,
-        underlineLink = text.link != null,
-    )
-    if (text.link != null) {
-        withLink(LinkAnnotation.Url(text.link)) {
-            withStyle(spanStyle) { append(text.value) }
-        }
-    } else {
-        withStyle(spanStyle) { append(text.value) }
-    }
-}
-
-private fun DictHtmlTextStyle.toSpanStyle(
-    fallbackColor: Color?,
-    underlineLink: Boolean,
-): SpanStyle {
-    val decorations = buildList {
-        if (underline || underlineLink) add(TextDecoration.Underline)
-        if (strikeThrough) add(TextDecoration.LineThrough)
-    }
-    return SpanStyle(
-        color = parseHtmlColor(color) ?: fallbackColor ?: Color.Unspecified,
-        background = parseHtmlColor(backgroundColor) ?: Color.Unspecified,
-        fontWeight = if (bold) FontWeight.Bold else null,
-        fontStyle = if (italic) FontStyle.Italic else null,
-        fontSize = if (relativeFontSize == 1f) androidx.compose.ui.unit.TextUnit.Unspecified else relativeFontSize.em,
-        textDecoration = decorations.takeIf { it.isNotEmpty() }?.let(TextDecoration::combine),
-        baselineShift = when (baseline) {
-            DictHtmlBaseline.Normal -> null
-            DictHtmlBaseline.Subscript -> BaselineShift.Subscript
-            DictHtmlBaseline.Superscript -> BaselineShift.Superscript
-        },
-    )
-}
-
-private fun parseHtmlColor(value: String?): Color? {
-    if (value.isNullOrBlank()) return null
-    val normalized = value.trim().lowercase()
-    return runCatching {
-        when {
-            normalized.startsWith("rgb(") -> {
-                val channels = normalized.substringAfter('(').substringBefore(')').split(',')
-                    .map { it.trim().toInt().coerceIn(0, 255) }
-                if (channels.size != 3) return null
-                Color(channels[0], channels[1], channels[2])
-            }
-
-            normalized.startsWith("rgba(") -> {
-                val channels =
-                    normalized.substringAfter('(').substringBefore(')').split(',').map(String::trim)
-                if (channels.size != 4) return null
-                Color(
-                    red = channels[0].toInt().coerceIn(0, 255),
-                    green = channels[1].toInt().coerceIn(0, 255),
-                    blue = channels[2].toInt().coerceIn(0, 255),
-                    alpha = (channels[3].toFloat().coerceIn(0f, 1f) * 255).toInt(),
-                )
-            }
-
-            else -> Color(normalized.toColorInt())
-        }
-    }.getOrNull()
-}

+ 0 - 307
app/src/main/java/io/legado/app/ui/dict/DictHtmlParser.kt

@@ -1,307 +0,0 @@
-package io.legado.app.ui.dict
-
-import org.jsoup.Jsoup
-import org.jsoup.nodes.Element
-import org.jsoup.nodes.Node
-import org.jsoup.nodes.TextNode
-
-internal data class DictHtmlDocument(
-    val paragraphs: List<DictHtmlParagraph>,
-)
-
-internal data class DictHtmlParagraph(
-    val content: List<DictHtmlInline>,
-)
-
-internal sealed interface DictHtmlInline {
-    data class Text(
-        val value: String,
-        val style: DictHtmlTextStyle,
-        val link: String? = null,
-    ) : DictHtmlInline
-
-    data class Image(
-        val source: String,
-        val description: String?,
-        val link: String? = null,
-    ) : DictHtmlInline
-
-    data object LineBreak : DictHtmlInline
-}
-
-internal data class DictHtmlTextStyle(
-    val color: String? = null,
-    val backgroundColor: String? = null,
-    val bold: Boolean = false,
-    val italic: Boolean = false,
-    val underline: Boolean = false,
-    val strikeThrough: Boolean = false,
-    val relativeFontSize: Float = 1f,
-    val baseline: DictHtmlBaseline = DictHtmlBaseline.Normal,
-)
-
-internal enum class DictHtmlBaseline { Normal, Subscript, Superscript }
-
-internal object DictHtmlParser {
-
-    private val blockTags = setOf(
-        "address", "article", "aside", "blockquote", "div", "dl", "fieldset", "figcaption",
-        "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header",
-        "hr", "main", "nav", "ol", "p", "pre", "section", "table", "ul",
-    )
-
-    fun parse(html: String): DictHtmlDocument {
-        val paragraphs = mutableListOf<DictHtmlParagraph>()
-        parseContainer(
-            nodes = Jsoup.parseBodyFragment(html).body().childNodes(),
-            inheritedStyle = DictHtmlTextStyle(),
-            inheritedLink = null,
-            paragraphs = paragraphs,
-        )
-        return DictHtmlDocument(paragraphs)
-    }
-
-    private fun parseContainer(
-        nodes: List<Node>,
-        inheritedStyle: DictHtmlTextStyle,
-        inheritedLink: String?,
-        paragraphs: MutableList<DictHtmlParagraph>,
-    ) {
-        var current = mutableListOf<DictHtmlInline>()
-
-        fun flush() {
-            trimBoundaryWhitespace(current)
-            if (current.isNotEmpty()) paragraphs += DictHtmlParagraph(current)
-            current = mutableListOf()
-        }
-
-        nodes.forEach { node ->
-            if (node is Element && node.normalName() in blockTags) {
-                flush()
-                parseBlock(node, inheritedStyle, inheritedLink, paragraphs)
-            } else {
-                parseInline(node, inheritedStyle, inheritedLink, current)
-            }
-        }
-        flush()
-    }
-
-    private fun parseBlock(
-        element: Element,
-        inheritedStyle: DictHtmlTextStyle,
-        inheritedLink: String?,
-        paragraphs: MutableList<DictHtmlParagraph>,
-    ) {
-        val style = mergeStyle(inheritedStyle, element)
-        when (element.normalName()) {
-            "div", "section", "article", "main", "header", "footer", "nav", "aside", "form",
-            "fieldset", "figure", "figcaption", "address", "blockquote" -> parseContainer(
-                element.childNodes(), style, inheritedLink, paragraphs
-            )
-
-            "ul", "ol" -> parseList(element, style, inheritedLink, paragraphs)
-            "table" -> element.select("tr").forEach { row ->
-                val content = mutableListOf<DictHtmlInline>()
-                row.select(":scope > th, :scope > td").forEachIndexed { index, cell ->
-                    if (index > 0) appendText(content, "  ", style, inheritedLink)
-                    cell.childNodes().forEach { parseInline(it, style, inheritedLink, content) }
-                }
-                addParagraph(content, paragraphs)
-            }
-
-            "hr" -> paragraphs += DictHtmlParagraph(
-                listOf(DictHtmlInline.Text("────────", style, inheritedLink))
-            )
-
-            else -> {
-                val content = mutableListOf<DictHtmlInline>()
-                element.childNodes().forEach { parseInline(it, style, inheritedLink, content) }
-                addParagraph(content, paragraphs)
-            }
-        }
-    }
-
-    private fun parseList(
-        list: Element,
-        style: DictHtmlTextStyle,
-        link: String?,
-        paragraphs: MutableList<DictHtmlParagraph>,
-    ) {
-        val ordered = list.normalName() == "ol"
-        val start = list.attr("start").toIntOrNull() ?: 1
-        list.children().filter { it.normalName() == "li" }.forEachIndexed { index, item ->
-            val content = mutableListOf<DictHtmlInline>()
-            appendText(content, if (ordered) "${start + index}. " else "• ", style, link)
-            item.childNodes().forEach { parseInline(it, style, link, content) }
-            addParagraph(content, paragraphs)
-        }
-    }
-
-    private fun parseInline(
-        node: Node,
-        inheritedStyle: DictHtmlTextStyle,
-        inheritedLink: String?,
-        output: MutableList<DictHtmlInline>,
-    ) {
-        when (node) {
-            is TextNode -> appendText(
-                output,
-                normalizeWhitespace(node.wholeText),
-                inheritedStyle,
-                inheritedLink
-            )
-
-            is Element -> {
-                val style = mergeStyle(inheritedStyle, node)
-                val link = if (node.normalName() == "a") node.attr("href")
-                    .ifBlank { inheritedLink } else inheritedLink
-                when (node.normalName()) {
-                    "br" -> output += DictHtmlInline.LineBreak
-                    "img" -> node.attr("src").takeIf(String::isNotBlank)?.let { source ->
-                        output += DictHtmlInline.Image(
-                            source,
-                            node.attr("alt").ifBlank { null },
-                            link
-                        )
-                    }
-
-                    in blockTags -> {
-                        if (output.isNotEmpty() && output.last() !is DictHtmlInline.LineBreak) {
-                            output += DictHtmlInline.LineBreak
-                        }
-                        node.childNodes().forEach { parseInline(it, style, link, output) }
-                        if (output.isNotEmpty() && output.last() !is DictHtmlInline.LineBreak) {
-                            output += DictHtmlInline.LineBreak
-                        }
-                    }
-
-                    else -> node.childNodes().forEach { parseInline(it, style, link, output) }
-                }
-            }
-        }
-    }
-
-    private fun appendText(
-        output: MutableList<DictHtmlInline>,
-        value: String,
-        style: DictHtmlTextStyle,
-        link: String?,
-    ) {
-        if (value.isEmpty()) return
-        val previous = output.lastOrNull() as? DictHtmlInline.Text
-        if (previous != null && previous.style == style && previous.link == link) {
-            output[output.lastIndex] = previous.copy(value = previous.value + value)
-        } else {
-            output += DictHtmlInline.Text(value, style, link)
-        }
-    }
-
-    private fun addParagraph(
-        content: MutableList<DictHtmlInline>,
-        paragraphs: MutableList<DictHtmlParagraph>,
-    ) {
-        trimBoundaryWhitespace(content)
-        if (content.isNotEmpty()) paragraphs += DictHtmlParagraph(content)
-    }
-
-    private fun trimBoundaryWhitespace(content: MutableList<DictHtmlInline>) {
-        val first = content.firstOrNull() as? DictHtmlInline.Text
-        if (first != null) {
-            val trimmed = first.value.trimStart()
-            if (trimmed.isEmpty()) content.removeAt(0) else content[0] = first.copy(value = trimmed)
-        }
-        val last = content.lastOrNull() as? DictHtmlInline.Text
-        if (last != null) {
-            val trimmed = last.value.trimEnd()
-            if (trimmed.isEmpty()) content.removeAt(content.lastIndex)
-            else content[content.lastIndex] = last.copy(value = trimmed)
-        }
-        while (content.firstOrNull() is DictHtmlInline.LineBreak) content.removeAt(0)
-        while (content.lastOrNull() is DictHtmlInline.LineBreak) content.removeAt(content.lastIndex)
-    }
-
-    private fun normalizeWhitespace(value: String): String = value.replace(Regex("\\s+"), " ")
-
-    private fun mergeStyle(parent: DictHtmlTextStyle, element: Element): DictHtmlTextStyle {
-        var style = when (element.normalName()) {
-            "b", "strong" -> parent.copy(bold = true)
-            "i", "em", "cite", "dfn" -> parent.copy(italic = true)
-            "u", "ins" -> parent.copy(underline = true)
-            "s", "strike", "del" -> parent.copy(strikeThrough = true)
-            "sub" -> parent.copy(
-                relativeFontSize = parent.relativeFontSize * 0.8f,
-                baseline = DictHtmlBaseline.Subscript
-            )
-
-            "sup" -> parent.copy(
-                relativeFontSize = parent.relativeFontSize * 0.8f,
-                baseline = DictHtmlBaseline.Superscript
-            )
-
-            "small" -> parent.copy(relativeFontSize = parent.relativeFontSize * 0.8f)
-            "big" -> parent.copy(relativeFontSize = parent.relativeFontSize * 1.2f)
-            "h1" -> parent.copy(bold = true, relativeFontSize = parent.relativeFontSize * 2f)
-            "h2" -> parent.copy(bold = true, relativeFontSize = parent.relativeFontSize * 1.5f)
-            "h3" -> parent.copy(bold = true, relativeFontSize = parent.relativeFontSize * 1.17f)
-            "h4", "h5", "h6" -> parent.copy(bold = true)
-            "mark" -> parent.copy(backgroundColor = "#ffff00")
-            else -> parent
-        }
-        if (element.normalName() == "font") {
-            element.attr("color").takeIf(String::isNotBlank)?.let { style = style.copy(color = it) }
-            element.attr("size").toIntOrNull()?.let { size ->
-                val scale = when (size.coerceIn(1, 7)) {
-                    1 -> .63f; 2 -> .82f; 3 -> 1f; 4 -> 1.13f; 5 -> 1.5f; 6 -> 2f; else -> 3f
-                }
-                style = style.copy(relativeFontSize = scale)
-            }
-        }
-        element.attr("style").split(';').forEach { declaration ->
-            val (name, rawValue) = declaration.split(':', limit = 2).takeIf { it.size == 2 }
-                ?: return@forEach
-            val value = rawValue.trim().removeSuffix("!important").trim()
-            style = when (name.trim().lowercase()) {
-                "color" -> style.copy(color = value)
-                "background", "background-color" -> style.copy(backgroundColor = value)
-                "font-weight" -> style.copy(
-                    bold = value.equals("bold", true) || value.toIntOrNull()
-                        ?.let { it >= 600 } == true)
-
-                "font-style" -> style.copy(
-                    italic = value.equals(
-                        "italic",
-                        true
-                    ) || value.equals("oblique", true)
-                )
-
-                "text-decoration", "text-decoration-line" -> style.copy(
-                    underline = "underline" in value.lowercase(),
-                    strikeThrough = "line-through" in value.lowercase(),
-                )
-
-                "font-size" -> parseRelativeFontSize(value)?.let { style.copy(relativeFontSize = it) }
-                    ?: style
-
-                "vertical-align" -> style.copy(
-                    baseline = when (value.lowercase()) {
-                        "sub" -> DictHtmlBaseline.Subscript
-                        "super" -> DictHtmlBaseline.Superscript
-                        else -> DictHtmlBaseline.Normal
-                    }
-                )
-
-                else -> style
-            }
-        }
-        return style
-    }
-
-    private fun parseRelativeFontSize(value: String): Float? = when {
-        value.endsWith("em", true) -> value.dropLast(2).trim().toFloatOrNull()
-        value.endsWith("%") -> value.dropLast(1).trim().toFloatOrNull()?.div(100f)
-        value.endsWith("px", true) -> value.dropLast(2).trim().toFloatOrNull()?.div(16f)
-        value.equals("smaller", true) -> 0.8f
-        value.equals("larger", true) -> 1.2f
-        else -> null
-    }?.coerceIn(0.5f, 4f)
-}

+ 6 - 3
app/src/main/java/io/legado/app/ui/dict/DictSheet.kt

@@ -34,6 +34,7 @@ import io.legado.app.ui.widget.components.pager.pagerHeight
 import io.legado.app.ui.widget.components.pager.rememberPagerAnimatedHeight
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
 import io.legado.app.ui.widget.components.tabRow.AppTabRow
+import io.legado.app.ui.widget.components.text.HtmlContent
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.drop
@@ -250,9 +251,11 @@ private fun DictPageContent(
             }
 
             state.htmlContent.isNotBlank() -> {
-                DictHtmlContent(
-                    htmlContent = state.htmlContent,
-                    modifier = Modifier.fillMaxWidth(),
+                HtmlContent(
+                    html = state.htmlContent,
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .padding(horizontal = 32.dp, vertical = 16.dp),
                 )
             }
         }

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

@@ -0,0 +1,512 @@
+package io.legado.app.ui.widget.components.text
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.foundation.text.InlineTextContent
+import androidx.compose.foundation.text.appendInlineContent
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.LinkAnnotation
+import androidx.compose.ui.text.Placeholder
+import androidx.compose.ui.text.PlaceholderVerticalAlign
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.BaselineShift
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.text.withLink
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.em
+import androidx.compose.ui.unit.sp
+import androidx.core.graphics.toColorInt
+import coil3.compose.AsyncImagePainter
+import coil3.compose.rememberAsyncImagePainter
+import io.legado.app.ui.theme.LegadoTheme
+import org.jsoup.Jsoup
+import org.jsoup.nodes.Element
+import org.jsoup.nodes.Node
+import org.jsoup.nodes.TextNode
+
+/**
+ * 通用 HTML 渲染组件:把不可信 HTML 解析为段落 + 行内样式/链接/图片,供 Compose 展示。
+ * 最初为词典内容(ui/dict)提取,书籍详情简介等纯文本 UI 复用同一实现。
+ */
+@Composable
+fun HtmlContent(
+    html: String,
+    modifier: Modifier = Modifier,
+    style: TextStyle = LegadoTheme.typography.bodyMedium.merge(
+        color = LegadoTheme.colorScheme.onSurface
+    ),
+) {
+    val document = remember(html) { HtmlParser.parse(html) }
+    val linkColor = LegadoTheme.colorScheme.primary
+
+    BoxWithConstraints(modifier = modifier) {
+        val availableWidthPx = constraints.maxWidth
+        Column {
+            document.paragraphs.forEachIndexed { paragraphIndex, paragraph ->
+                HtmlParagraphContent(
+                    paragraph = paragraph,
+                    paragraphIndex = paragraphIndex,
+                    maxWidthPx = availableWidthPx,
+                    baseStyle = style,
+                    linkColor = linkColor,
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun HtmlParagraphContent(
+    paragraph: HtmlParagraph,
+    paragraphIndex: Int,
+    maxWidthPx: Int,
+    baseStyle: TextStyle,
+    linkColor: Color,
+) {
+    val density = LocalDensity.current
+    val inlineContent = mutableMapOf<String, InlineTextContent>()
+    var imageIndex = 0
+
+    paragraph.content.forEach { inline ->
+        if (inline !is HtmlInline.Image) return@forEach
+        val id = "html-image-$paragraphIndex-${imageIndex++}"
+        val painter = rememberAsyncImagePainter(model = inline.source)
+        val painterState by painter.state.collectAsState()
+        val image = (painterState as? AsyncImagePainter.State.Success)?.result?.image
+        val drawableWidth = image?.width?.coerceAtLeast(1) ?: 1
+        val drawableHeight = image?.height?.coerceAtLeast(1) ?: 1
+        val scale = if (maxWidthPx in 1..<drawableWidth) {
+            maxWidthPx.toFloat() / drawableWidth
+        } else {
+            1f
+        }
+        val width = with(density) { (drawableWidth * scale).toDp().toSp() }
+        val height = with(density) { (drawableHeight * scale).toDp().toSp() }
+        inlineContent[id] = InlineTextContent(
+            placeholder = Placeholder(
+                width = width.value.coerceAtLeast(1f).sp,
+                height = height.value.coerceAtLeast(1f).sp,
+                placeholderVerticalAlign = PlaceholderVerticalAlign.TextBottom,
+            )
+        ) {
+            Image(
+                painter = painter,
+                contentDescription = inline.description,
+                modifier = Modifier.fillMaxSize(),
+                contentScale = ContentScale.Fit,
+            )
+        }
+    }
+
+    val annotatedText = buildAnnotatedString {
+        imageIndex = 0
+        paragraph.content.forEach { inline ->
+            when (inline) {
+                is HtmlInline.Text -> appendStyledText(inline, linkColor)
+                is HtmlInline.Image -> {
+                    val id = "html-image-$paragraphIndex-${imageIndex++}"
+                    if (inline.link != null) {
+                        withLink(LinkAnnotation.Url(inline.link)) {
+                            appendInlineContent(id, inline.description ?: "image")
+                        }
+                    } else {
+                        appendInlineContent(id, inline.description ?: "image")
+                    }
+                }
+
+                HtmlInline.LineBreak -> append('\n')
+            }
+        }
+    }
+
+    BasicText(
+        text = annotatedText,
+        inlineContent = inlineContent,
+        style = baseStyle,
+    )
+}
+
+private fun AnnotatedString.Builder.appendStyledText(
+    text: HtmlInline.Text,
+    linkColor: Color,
+) {
+    val spanStyle = text.style.toSpanStyle(
+        fallbackColor = if (text.link != null) linkColor else null,
+        underlineLink = text.link != null,
+    )
+    if (text.link != null) {
+        withLink(LinkAnnotation.Url(text.link)) {
+            withStyle(spanStyle) { append(text.value) }
+        }
+    } else {
+        withStyle(spanStyle) { append(text.value) }
+    }
+}
+
+private fun HtmlTextStyle.toSpanStyle(
+    fallbackColor: Color?,
+    underlineLink: Boolean,
+): SpanStyle {
+    val decorations = buildList {
+        if (underline || underlineLink) add(TextDecoration.Underline)
+        if (strikeThrough) add(TextDecoration.LineThrough)
+    }
+    return SpanStyle(
+        color = parseHtmlColor(color) ?: fallbackColor ?: Color.Unspecified,
+        background = parseHtmlColor(backgroundColor) ?: Color.Unspecified,
+        fontWeight = if (bold) FontWeight.Bold else null,
+        fontStyle = if (italic) FontStyle.Italic else null,
+        fontSize = if (relativeFontSize == 1f) androidx.compose.ui.unit.TextUnit.Unspecified else relativeFontSize.em,
+        textDecoration = decorations.takeIf { it.isNotEmpty() }?.let(TextDecoration::combine),
+        baselineShift = when (baseline) {
+            HtmlBaseline.Normal -> null
+            HtmlBaseline.Subscript -> BaselineShift.Subscript
+            HtmlBaseline.Superscript -> BaselineShift.Superscript
+        },
+    )
+}
+
+private fun parseHtmlColor(value: String?): Color? {
+    if (value.isNullOrBlank()) return null
+    val normalized = value.trim().lowercase()
+    return runCatching {
+        when {
+            normalized.startsWith("rgb(") -> {
+                val channels = normalized.substringAfter('(').substringBefore(')').split(',')
+                    .map { it.trim().toInt().coerceIn(0, 255) }
+                if (channels.size != 3) return null
+                Color(channels[0], channels[1], channels[2])
+            }
+
+            normalized.startsWith("rgba(") -> {
+                val channels =
+                    normalized.substringAfter('(').substringBefore(')').split(',').map(String::trim)
+                if (channels.size != 4) return null
+                Color(
+                    red = channels[0].toInt().coerceIn(0, 255),
+                    green = channels[1].toInt().coerceIn(0, 255),
+                    blue = channels[2].toInt().coerceIn(0, 255),
+                    alpha = (channels[3].toFloat().coerceIn(0f, 1f) * 255).toInt(),
+                )
+            }
+
+            else -> Color(normalized.toColorInt())
+        }
+    }.getOrNull()
+}
+
+internal data class HtmlDocument(
+    val paragraphs: List<HtmlParagraph>,
+)
+
+internal data class HtmlParagraph(
+    val content: List<HtmlInline>,
+)
+
+internal sealed interface HtmlInline {
+    data class Text(
+        val value: String,
+        val style: HtmlTextStyle,
+        val link: String? = null,
+    ) : HtmlInline
+
+    data class Image(
+        val source: String,
+        val description: String?,
+        val link: String? = null,
+    ) : HtmlInline
+
+    data object LineBreak : HtmlInline
+}
+
+internal data class HtmlTextStyle(
+    val color: String? = null,
+    val backgroundColor: String? = null,
+    val bold: Boolean = false,
+    val italic: Boolean = false,
+    val underline: Boolean = false,
+    val strikeThrough: Boolean = false,
+    val relativeFontSize: Float = 1f,
+    val baseline: HtmlBaseline = HtmlBaseline.Normal,
+)
+
+internal enum class HtmlBaseline { Normal, Subscript, Superscript }
+
+internal object HtmlParser {
+
+    private val blockTags = setOf(
+        "address", "article", "aside", "blockquote", "div", "dl", "fieldset", "figcaption",
+        "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header",
+        "hr", "main", "nav", "ol", "p", "pre", "section", "table", "ul",
+    )
+
+    fun parse(html: String): HtmlDocument {
+        val paragraphs = mutableListOf<HtmlParagraph>()
+        parseContainer(
+            nodes = Jsoup.parseBodyFragment(html).body().childNodes(),
+            inheritedStyle = HtmlTextStyle(),
+            inheritedLink = null,
+            paragraphs = paragraphs,
+        )
+        return HtmlDocument(paragraphs)
+    }
+
+    private fun parseContainer(
+        nodes: List<Node>,
+        inheritedStyle: HtmlTextStyle,
+        inheritedLink: String?,
+        paragraphs: MutableList<HtmlParagraph>,
+    ) {
+        var current = mutableListOf<HtmlInline>()
+
+        fun flush() {
+            trimBoundaryWhitespace(current)
+            if (current.isNotEmpty()) paragraphs += HtmlParagraph(current)
+            current = mutableListOf()
+        }
+
+        nodes.forEach { node ->
+            if (node is Element && node.normalName() in blockTags) {
+                flush()
+                parseBlock(node, inheritedStyle, inheritedLink, paragraphs)
+            } else {
+                parseInline(node, inheritedStyle, inheritedLink, current)
+            }
+        }
+        flush()
+    }
+
+    private fun parseBlock(
+        element: Element,
+        inheritedStyle: HtmlTextStyle,
+        inheritedLink: String?,
+        paragraphs: MutableList<HtmlParagraph>,
+    ) {
+        val style = mergeStyle(inheritedStyle, element)
+        when (element.normalName()) {
+            "div", "section", "article", "main", "header", "footer", "nav", "aside", "form",
+            "fieldset", "figure", "figcaption", "address", "blockquote" -> parseContainer(
+                element.childNodes(), style, inheritedLink, paragraphs
+            )
+
+            "ul", "ol" -> parseList(element, style, inheritedLink, paragraphs)
+            "table" -> element.select("tr").forEach { row ->
+                val content = mutableListOf<HtmlInline>()
+                row.select(":scope > th, :scope > td").forEachIndexed { index, cell ->
+                    if (index > 0) appendText(content, "  ", style, inheritedLink)
+                    cell.childNodes().forEach { parseInline(it, style, inheritedLink, content) }
+                }
+                addParagraph(content, paragraphs)
+            }
+
+            "hr" -> paragraphs += HtmlParagraph(
+                listOf(HtmlInline.Text("────────", style, inheritedLink))
+            )
+
+            else -> {
+                val content = mutableListOf<HtmlInline>()
+                element.childNodes().forEach { parseInline(it, style, inheritedLink, content) }
+                addParagraph(content, paragraphs)
+            }
+        }
+    }
+
+    private fun parseList(
+        list: Element,
+        style: HtmlTextStyle,
+        link: String?,
+        paragraphs: MutableList<HtmlParagraph>,
+    ) {
+        val ordered = list.normalName() == "ol"
+        val start = list.attr("start").toIntOrNull() ?: 1
+        list.children().filter { it.normalName() == "li" }.forEachIndexed { index, item ->
+            val content = mutableListOf<HtmlInline>()
+            appendText(content, if (ordered) "${start + index}. " else "• ", style, link)
+            item.childNodes().forEach { parseInline(it, style, link, content) }
+            addParagraph(content, paragraphs)
+        }
+    }
+
+    private fun parseInline(
+        node: Node,
+        inheritedStyle: HtmlTextStyle,
+        inheritedLink: String?,
+        output: MutableList<HtmlInline>,
+    ) {
+        when (node) {
+            is TextNode -> appendText(
+                output,
+                normalizeWhitespace(node.wholeText),
+                inheritedStyle,
+                inheritedLink
+            )
+
+            is Element -> {
+                val style = mergeStyle(inheritedStyle, node)
+                val link = if (node.normalName() == "a") node.attr("href")
+                    .ifBlank { inheritedLink } else inheritedLink
+                when (node.normalName()) {
+                    "br" -> output += HtmlInline.LineBreak
+                    "img" -> node.attr("src").takeIf(String::isNotBlank)?.let { source ->
+                        output += HtmlInline.Image(
+                            source,
+                            node.attr("alt").ifBlank { null },
+                            link
+                        )
+                    }
+
+                    in blockTags -> {
+                        if (output.isNotEmpty() && output.last() !is HtmlInline.LineBreak) {
+                            output += HtmlInline.LineBreak
+                        }
+                        node.childNodes().forEach { parseInline(it, style, link, output) }
+                        if (output.isNotEmpty() && output.last() !is HtmlInline.LineBreak) {
+                            output += HtmlInline.LineBreak
+                        }
+                    }
+
+                    else -> node.childNodes().forEach { parseInline(it, style, link, output) }
+                }
+            }
+        }
+    }
+
+    private fun appendText(
+        output: MutableList<HtmlInline>,
+        value: String,
+        style: HtmlTextStyle,
+        link: String?,
+    ) {
+        if (value.isEmpty()) return
+        val previous = output.lastOrNull() as? HtmlInline.Text
+        if (previous != null && previous.style == style && previous.link == link) {
+            output[output.lastIndex] = previous.copy(value = previous.value + value)
+        } else {
+            output += HtmlInline.Text(value, style, link)
+        }
+    }
+
+    private fun addParagraph(
+        content: MutableList<HtmlInline>,
+        paragraphs: MutableList<HtmlParagraph>,
+    ) {
+        trimBoundaryWhitespace(content)
+        if (content.isNotEmpty()) paragraphs += HtmlParagraph(content)
+    }
+
+    private fun trimBoundaryWhitespace(content: MutableList<HtmlInline>) {
+        val first = content.firstOrNull() as? HtmlInline.Text
+        if (first != null) {
+            val trimmed = first.value.trimStart()
+            if (trimmed.isEmpty()) content.removeAt(0) else content[0] = first.copy(value = trimmed)
+        }
+        val last = content.lastOrNull() as? HtmlInline.Text
+        if (last != null) {
+            val trimmed = last.value.trimEnd()
+            if (trimmed.isEmpty()) content.removeAt(content.lastIndex)
+            else content[content.lastIndex] = last.copy(value = trimmed)
+        }
+        while (content.firstOrNull() is HtmlInline.LineBreak) content.removeAt(0)
+        while (content.lastOrNull() is HtmlInline.LineBreak) content.removeAt(content.lastIndex)
+    }
+
+    private fun normalizeWhitespace(value: String): String = value.replace(Regex("\\s+"), " ")
+
+    private fun mergeStyle(parent: HtmlTextStyle, element: Element): HtmlTextStyle {
+        var style = when (element.normalName()) {
+            "b", "strong" -> parent.copy(bold = true)
+            "i", "em", "cite", "dfn" -> parent.copy(italic = true)
+            "u", "ins" -> parent.copy(underline = true)
+            "s", "strike", "del" -> parent.copy(strikeThrough = true)
+            "sub" -> parent.copy(
+                relativeFontSize = parent.relativeFontSize * 0.8f,
+                baseline = HtmlBaseline.Subscript
+            )
+
+            "sup" -> parent.copy(
+                relativeFontSize = parent.relativeFontSize * 0.8f,
+                baseline = HtmlBaseline.Superscript
+            )
+
+            "small" -> parent.copy(relativeFontSize = parent.relativeFontSize * 0.8f)
+            "big" -> parent.copy(relativeFontSize = parent.relativeFontSize * 1.2f)
+            "h1" -> parent.copy(bold = true, relativeFontSize = parent.relativeFontSize * 2f)
+            "h2" -> parent.copy(bold = true, relativeFontSize = parent.relativeFontSize * 1.5f)
+            "h3" -> parent.copy(bold = true, relativeFontSize = parent.relativeFontSize * 1.17f)
+            "h4", "h5", "h6" -> parent.copy(bold = true)
+            "mark" -> parent.copy(backgroundColor = "#ffff00")
+            else -> parent
+        }
+        if (element.normalName() == "font") {
+            element.attr("color").takeIf(String::isNotBlank)?.let { style = style.copy(color = it) }
+            element.attr("size").toIntOrNull()?.let { size ->
+                val scale = when (size.coerceIn(1, 7)) {
+                    1 -> .63f; 2 -> .82f; 3 -> 1f; 4 -> 1.13f; 5 -> 1.5f; 6 -> 2f; else -> 3f
+                }
+                style = style.copy(relativeFontSize = scale)
+            }
+        }
+        element.attr("style").split(';').forEach { declaration ->
+            val (name, rawValue) = declaration.split(':', limit = 2).takeIf { it.size == 2 }
+                ?: return@forEach
+            val value = rawValue.trim().removeSuffix("!important").trim()
+            style = when (name.trim().lowercase()) {
+                "color" -> style.copy(color = value)
+                "background", "background-color" -> style.copy(backgroundColor = value)
+                "font-weight" -> style.copy(
+                    bold = value.equals("bold", true) || value.toIntOrNull()
+                        ?.let { it >= 600 } == true)
+
+                "font-style" -> style.copy(
+                    italic = value.equals(
+                        "italic",
+                        true
+                    ) || value.equals("oblique", true)
+                )
+
+                "text-decoration", "text-decoration-line" -> style.copy(
+                    underline = "underline" in value.lowercase(),
+                    strikeThrough = "line-through" in value.lowercase(),
+                )
+
+                "font-size" -> parseRelativeFontSize(value)?.let { style.copy(relativeFontSize = it) }
+                    ?: style
+
+                "vertical-align" -> style.copy(
+                    baseline = when (value.lowercase()) {
+                        "sub" -> HtmlBaseline.Subscript
+                        "super" -> HtmlBaseline.Superscript
+                        else -> HtmlBaseline.Normal
+                    }
+                )
+
+                else -> style
+            }
+        }
+        return style
+    }
+
+    private fun parseRelativeFontSize(value: String): Float? = when {
+        value.endsWith("em", true) -> value.dropLast(2).trim().toFloatOrNull()
+        value.endsWith("%") -> value.dropLast(1).trim().toFloatOrNull()?.div(100f)
+        value.endsWith("px", true) -> value.dropLast(2).trim().toFloatOrNull()?.div(16f)
+        value.equals("smaller", true) -> 0.8f
+        value.equals("larger", true) -> 1.2f
+        else -> null
+    }?.coerceIn(0.5f, 4f)
+}