فهرست منبع

feat: 优化字典

HapeLee 3 ماه پیش
والد
کامیت
ebdc7dc36f

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

@@ -0,0 +1,201 @@
+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.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 coil.compose.AsyncImagePainter
+import coil.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 drawable = (painter.state as? AsyncImagePainter.State.Success)?.result?.drawable
+        val drawableWidth = drawable?.intrinsicWidth?.coerceAtLeast(1) ?: 1
+        val drawableHeight = drawable?.intrinsicHeight?.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()
+}

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

@@ -0,0 +1,307 @@
+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)
+}

+ 8 - 67
app/src/main/java/io/legado/app/ui/dict/DictSheet.kt

@@ -3,15 +3,15 @@ package io.legado.app.ui.dict
 import androidx.compose.animation.core.FastOutSlowInEasing
 import androidx.compose.animation.core.FastOutSlowInEasing
 import androidx.compose.animation.core.spring
 import androidx.compose.animation.core.spring
 import androidx.compose.animation.core.tween
 import androidx.compose.animation.core.tween
-import androidx.compose.foundation.pager.HorizontalPager
-import androidx.compose.foundation.pager.rememberPagerState
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.getValue
@@ -23,15 +23,10 @@ import androidx.compose.runtime.snapshotFlow
 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.clipToBounds
 import androidx.compose.ui.draw.clipToBounds
-import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.layout.onSizeChanged
 import androidx.compose.ui.layout.onSizeChanged
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.AnnotatedString
-import androidx.compose.ui.text.ExperimentalTextApi
-import androidx.compose.ui.text.fromHtml
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import coil.compose.AsyncImage
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.ui.widget.components.EmptyMessage
 import io.legado.app.ui.widget.components.EmptyMessage
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
@@ -39,7 +34,6 @@ 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.pager.rememberPagerAnimatedHeight
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
 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.tabRow.AppTabRow
-import io.legado.app.ui.widget.components.text.AppText
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.drop
 import kotlinx.coroutines.flow.drop
@@ -65,7 +59,7 @@ fun DictSheet(
         show = show,
         show = show,
         onDismissRequest = onDismissRequest,
         onDismissRequest = onDismissRequest,
         title = word,
         title = word,
-        //内部pager管理高度,避免二次动画
+        contentPaddingEnabled = false,
         animateContentSize = false,
         animateContentSize = false,
     ) {
     ) {
         DictSheetContent(
         DictSheetContent(
@@ -175,7 +169,9 @@ private fun DictPagerContent(
                     )
                     )
                 }
                 }
             },
             },
-            modifier = Modifier.fillMaxWidth(),
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(horizontal = 16.dp),
             isScrollable = true,
             isScrollable = true,
         )
         )
 
 
@@ -262,58 +258,3 @@ private fun DictPageContent(
         }
         }
     }
     }
 }
 }
-
-@OptIn(ExperimentalTextApi::class)
-@Composable
-private fun DictHtmlContent(
-    htmlContent: String,
-    modifier: Modifier = Modifier,
-) {
-    val blocks = remember(htmlContent) { parseHtmlBlocks(htmlContent) }
-    Column(modifier = modifier.padding(horizontal = 32.dp, vertical = 16.dp)) {
-        blocks.forEach { block ->
-            when (block) {
-                is HtmlBlock.Text -> {
-                    AppText(
-                        text = remember(block.content) {
-                            AnnotatedString.fromHtml(block.content)
-                        },
-                    )
-                }
-                is HtmlBlock.Image -> {
-                    AsyncImage(
-                        model = block.url,
-                        contentDescription = null,
-                        modifier = Modifier.fillMaxWidth(),
-                        contentScale = ContentScale.FillWidth,
-                    )
-                }
-            }
-        }
-    }
-}
-
-private sealed interface HtmlBlock {
-    data class Text(val content: String) : HtmlBlock
-    data class Image(val url: String) : HtmlBlock
-}
-
-private val IMG_REGEX = Regex("""<img\s+[^>]*src\s*=\s*["']([^"']+)["'][^>]*/?>""")
-
-private fun parseHtmlBlocks(html: String): List<HtmlBlock> {
-    val blocks = mutableListOf<HtmlBlock>()
-    var lastIndex = 0
-    IMG_REGEX.findAll(html).forEach { match ->
-        if (match.range.first > lastIndex) {
-            val textPart = html.substring(lastIndex, match.range.first).trim()
-            if (textPart.isNotBlank()) blocks.add(HtmlBlock.Text(textPart))
-        }
-        blocks.add(HtmlBlock.Image(match.groupValues[1]))
-        lastIndex = match.range.last + 1
-    }
-    if (lastIndex < html.length) {
-        val remaining = html.substring(lastIndex).trim()
-        if (remaining.isNotBlank()) blocks.add(HtmlBlock.Text(remaining))
-    }
-    return blocks
-}

+ 61 - 0
app/src/test/java/io/legado/app/ui/dict/DictHtmlParserTest.kt

@@ -0,0 +1,61 @@
+package io.legado.app.ui.dict
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class DictHtmlParserTest {
+
+    @Test
+    fun preservesStyleAndTextAcrossInlineImage() {
+        val document = DictHtmlParser.parse(
+            """<span style="color:#f00;text-decoration:underline">拼音<img src="tone.png">释义</span>"""
+        )
+
+        val content = document.paragraphs.single().content
+        assertEquals("拼音", (content[0] as DictHtmlInline.Text).value)
+        assertEquals("tone.png", (content[1] as DictHtmlInline.Image).source)
+        assertEquals("释义", (content[2] as DictHtmlInline.Text).value)
+        listOf(content[0], content[2]).filterIsInstance<DictHtmlInline.Text>().forEach {
+            assertEquals("#f00", it.style.color)
+            assertTrue(it.style.underline)
+        }
+    }
+
+    @Test
+    fun keepsNestedLinkOnTextAndImage() {
+        val content = DictHtmlParser.parse(
+            """<a href="https://example.com"><b>词条</b><img src="icon.png"></a>"""
+        ).paragraphs.single().content
+
+        val text = content[0] as DictHtmlInline.Text
+        val image = content[1] as DictHtmlInline.Image
+        assertTrue(text.style.bold)
+        assertEquals("https://example.com", text.link)
+        assertEquals("https://example.com", image.link)
+    }
+
+    @Test
+    fun parsesLegacyFontAndInlineCss() {
+        val text = DictHtmlParser.parse(
+            """<font color="blue"><u>A</u></font><span style="background-color:#123456;font-style:italic">B</span>"""
+        ).paragraphs.single().content.filterIsInstance<DictHtmlInline.Text>()
+
+        assertEquals("blue", text[0].style.color)
+        assertTrue(text[0].style.underline)
+        assertEquals("#123456", text[1].style.backgroundColor)
+        assertTrue(text[1].style.italic)
+    }
+
+    @Test
+    fun malformedHtmlRetainsReadableContent() {
+        val document = DictHtmlParser.parse("<div>前<span style='color:red'>中<img src=x>后</div>")
+
+        val content = document.paragraphs.single().content
+        assertEquals(
+            "前中",
+            content.filterIsInstance<DictHtmlInline.Text>().take(2).joinToString("") { it.value })
+        assertEquals("x", content.filterIsInstance<DictHtmlInline.Image>().single().source)
+        assertEquals("后", content.filterIsInstance<DictHtmlInline.Text>().last().value)
+    }
+}