Просмотр исходного кода

[新增] 新增了可选的文本强调方式
[新增] 新增了Html文本渲染(@Luoyacheng)
[修复] 修复了某些情况下下划线错误渲染的问题 #551

HapeLee 9 месяцев назад
Родитель
Сommit
de04a1f69d

+ 7 - 1
app/src/main/java/io/legado/app/constant/AppPattern.kt

@@ -11,6 +11,12 @@ object AppPattern {
     //匹配格式化后的图片格式
     val imgPattern: Pattern = Pattern.compile("<img[^>]*src=\"([^\"]*(?:\"[^>]+\\})?)\"[^>]*>")
 
+    //匹配自定义html格式字符串
+    val useHtmlRegex = Regex("<usehtml>.*?</usehtml>", RegexOption.DOT_MATCHES_ALL) //.包含换行
+
+    //匹配html字符串中的head
+    val htmlHeadRegex = Regex("<head[^>]*>", RegexOption.IGNORE_CASE)
+
     //dataURL图片类型
     val dataUriRegex = Regex("^data:.*?;base64,(.*)")
     //提取标题中的段评
@@ -67,4 +73,4 @@ object AppPattern {
     val regexCharRegex = "[{}()\\[\\].+*?^$\\\\|]".toRegex()
 
     val LFRegex = "\n".toRegex()
-}
+}

+ 6 - 0
app/src/main/java/io/legado/app/constant/BookType.kt

@@ -8,6 +8,12 @@ import androidx.annotation.IntDef
  */
 @Suppress("ConstPropertyName")
 object BookType {
+
+    /**
+     * 4 视频
+     */
+    const val video = 0b100
+
     /**
      * 8 文本
      */

+ 5 - 2
app/src/main/java/io/legado/app/help/book/BookExtensions.kt

@@ -3,6 +3,7 @@
 package io.legado.app.help.book
 
 import android.net.Uri
+import androidx.core.net.toUri
 import com.script.buildScriptBindings
 import com.script.rhino.RhinoScriptEngine
 import io.legado.app.constant.AppLog
@@ -24,6 +25,7 @@ import io.legado.app.utils.exists
 import io.legado.app.utils.find
 import io.legado.app.utils.inputStream
 import io.legado.app.utils.isUri
+import io.legado.app.utils.normalizeFileName
 import io.legado.app.utils.toastOnUi
 import splitties.init.appCtx
 import java.io.File
@@ -32,8 +34,6 @@ import java.time.temporal.ChronoUnit
 import java.util.concurrent.ConcurrentHashMap
 import kotlin.math.max
 import kotlin.math.min
-import androidx.core.net.toUri
-import io.legado.app.utils.normalizeFileName
 
 
 val Book.isAudio: Boolean
@@ -42,6 +42,9 @@ val Book.isAudio: Boolean
 val Book.isImage: Boolean
     get() = isType(BookType.image)
 
+val Book.isVideo: Boolean
+    get() = isType(BookType.video)
+
 val Book.isLocal: Boolean
     get() {
         if (type == 0) {

+ 9 - 0
app/src/main/java/io/legado/app/help/book/ContentProcessor.kt

@@ -2,6 +2,7 @@ package io.legado.app.help.book
 
 import android.os.Build
 import io.legado.app.constant.AppLog
+import io.legado.app.constant.AppPattern
 import io.legado.app.constant.AppPattern.spaceRegex
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
@@ -143,6 +144,14 @@ class ContentProcessor private constructor(
                     appCtx.toastOnUi("简繁转换出错")
                 }
             }
+            val useHtmlMap = mutableMapOf<String, String>()
+            if (AppConfig.adaptSpecialStyle) { //html处理
+                mContent = AppPattern.useHtmlRegex.replace(mContent) { matchResult ->
+                    val placeholder = "特殊格式的占位不应该被看见${useHtmlMap.size}。"
+                    useHtmlMap[placeholder] = "\n${matchResult.value.replace("\n", "")}\n"
+                    placeholder
+                }
+            }
             if (useReplace && book.getUseReplaceRule()) {
                 //替换
                 effectiveReplaceRules = arrayListOf()

+ 20 - 4
app/src/main/java/io/legado/app/model/webBook/BookContent.kt

@@ -10,6 +10,8 @@ import io.legado.app.data.entities.rule.ContentRule
 import io.legado.app.exception.ContentEmptyException
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.book.BookHelp
+import io.legado.app.help.book.isAudio
+import io.legado.app.help.book.isVideo
 import io.legado.app.help.config.AppConfig
 import io.legado.app.model.Debug
 import io.legado.app.model.analyzeRule.AnalyzeRule
@@ -20,6 +22,7 @@ import io.legado.app.model.analyzeRule.AnalyzeUrl
 import io.legado.app.utils.HtmlFormatter
 import io.legado.app.utils.NetworkUtils
 import io.legado.app.utils.mapAsync
+import kotlinx.coroutines.currentCoroutineContext
 import kotlinx.coroutines.ensureActive
 import kotlinx.coroutines.flow.flow
 import org.apache.commons.text.StringEscapeUtils
@@ -181,16 +184,29 @@ object BookContent {
     ): Pair<String, List<String>> {
         val analyzeRule = AnalyzeRule(book, bookSource)
         analyzeRule.setContent(body, baseUrl)
-        analyzeRule.setCoroutineContext(coroutineContext)
+        analyzeRule.setCoroutineContext(currentCoroutineContext())
         val rUrl = analyzeRule.setRedirectUrl(redirectUrl)
         analyzeRule.setNextChapterUrl(nextChapterUrl)
         val nextUrlList = arrayListOf<String>()
         analyzeRule.setChapter(chapter)
         //获取正文
         var content = analyzeRule.getString(contentRule.content, unescape = false)
-        content = HtmlFormatter.formatKeepImg(content, rUrl)
-        if (content.indexOf('&') > -1) {
-            content = StringEscapeUtils.unescapeHtml4(content)
+        if (!book.isAudio && !book.isVideo) { //音频和视频获取的是链接,不需要html格式化 && !book.isVideo
+            val useHtmlMap = mutableMapOf<String, String>()
+            if (AppConfig.adaptSpecialStyle) {
+                content = AppPattern.useHtmlRegex.replace(content) { matchResult ->
+                    val placeholder = "{usehtml_${useHtmlMap.size}}"
+                    useHtmlMap[placeholder] = matchResult.value
+                    placeholder
+                }
+            }
+            content = HtmlFormatter.formatKeepImg(content, rUrl) //内置净化格式化
+            if (content.indexOf('&') > -1) {
+                content = StringEscapeUtils.unescapeHtml4(content)
+            }
+            useHtmlMap.forEach { (placeholder, originalContent) ->
+                content = content.replace(placeholder, originalContent)
+            }
         }
         //获取下一页链接
         if (getNextPageUrl) {

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/read/config/MoreConfigDialog.kt

@@ -97,7 +97,7 @@ class MoreConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_more_conf
 
                 PreferKey.textFullJustify,
                 PreferKey.textBottomJustify,
-                PreferKey.useZhLayout -> {
+                PreferKey.useZhLayout, PreferKey.adaptSpecialStyle, PreferKey.useUnderline -> {
                     postEvent(EventBus.UP_CONFIG, arrayListOf(5))
                 }
 

+ 14 - 0
app/src/main/java/io/legado/app/ui/book/read/page/TextBaseColumn.kt

@@ -0,0 +1,14 @@
+package io.legado.app.ui.book.read.page
+
+import io.legado.app.ui.book.read.page.entities.column.BaseColumn
+
+/**
+ * 文字基列
+ */
+interface TextBaseColumn : BaseColumn {
+    override var start: Float
+    override var end: Float
+    val charData: String
+    var selected: Boolean
+    var isSearchResult: Boolean
+}

+ 89 - 0
app/src/main/java/io/legado/app/ui/book/read/page/entities/TextHtmlColumn.kt

@@ -0,0 +1,89 @@
+package io.legado.app.ui.book.read.page.entities
+
+import android.graphics.Canvas
+import android.os.Build
+import android.text.TextPaint
+import androidx.annotation.Keep
+import io.legado.app.help.config.ReadBookConfig
+import io.legado.app.ui.book.read.page.ContentTextView
+import io.legado.app.ui.book.read.page.TextBaseColumn
+import io.legado.app.ui.book.read.page.entities.TextLine.Companion.emptyTextLine
+import io.legado.app.ui.book.read.page.provider.ChapterProvider
+
+/**
+ * 带html样式的文字列
+ */
+@Keep
+data class TextHtmlColumn(
+    override var start: Float,
+    override var end: Float,
+    override val charData: String,
+    val mTextSize: Float,
+    val mTextColor: Int,
+    val linkUrl: String?
+) : TextBaseColumn {
+
+    override var textLine: TextLine = emptyTextLine
+
+    private val textPaint: TextPaint by lazy {
+        TextPaint(ChapterProvider.contentPaint).apply {
+            textSize = mTextSize
+        }
+    }
+
+    override var selected: Boolean = false
+        set(value) {
+            if (field != value) {
+                textLine.invalidate()
+            }
+            field = value
+        }
+
+    override var isSearchResult: Boolean = false
+        set(value) {
+            if (field != value) {
+                textLine.invalidate()
+                if (value) {
+                    textLine.searchResultColumnCount++
+                } else {
+                    textLine.searchResultColumnCount--
+                }
+            }
+            field = value
+        }
+
+    override fun draw(view: ContentTextView, canvas: Canvas) {
+        val y = textLine.lineBase - textLine.lineTop
+        if (linkUrl != null) {
+            textPaint.run {
+                color = ReadBookConfig.textAccentColor
+                isUnderlineText = true
+            }
+            drawText(view, canvas, y, textPaint)
+            return
+        }
+        textPaint.run {
+            color = if (textLine.isReadAloud || isSearchResult) {
+                ReadBookConfig.textAccentColor
+            } else {
+                mTextColor
+            }
+            isUnderlineText = false
+        }
+        drawText(view, canvas, y, textPaint)
+    }
+
+    private fun drawText(view: ContentTextView, canvas: Canvas, y: Float, textPaint: TextPaint) {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
+            val letterSpacing = textPaint.letterSpacing * textPaint.textSize
+            val letterSpacingHalf = letterSpacing * 0.5f
+            canvas.drawText(charData, start + letterSpacingHalf, y, textPaint)
+        } else {
+            canvas.drawText(charData, start, y, textPaint)
+        }
+        if (selected) {
+            canvas.drawRect(start, 0f, end, textLine.height, view.selectedPaint)
+        }
+    }
+
+}

+ 12 - 2
app/src/main/java/io/legado/app/ui/book/read/page/entities/TextLine.kt

@@ -37,6 +37,7 @@ data class TextLine(
     val isTitle: Boolean = false,
     var isParagraphEnd: Boolean = false,
     var isImage: Boolean = false,
+    var isHtml: Boolean = false,
     var startX: Float = 0f,
     var indentSize: Int = 0,
     var extraLetterSpacing: Float = 0f,
@@ -66,7 +67,8 @@ data class TextLine(
         }
     var textPage: TextPage = emptyTextPage
     var isLeftLine = true
-
+    val useUnderline: Boolean
+        get() = AppConfig.useUnderline
     fun addColumn(column: BaseColumn) {
         if (column !is TextColumn) {
             onlyTextColumn = false
@@ -75,6 +77,14 @@ data class TextLine(
         textColumns.add(column)
     }
 
+    fun addColumns(columns: Collection<BaseColumn>) {
+        onlyTextColumn = false
+        columns.forEach { column ->
+            column.textLine = this
+        }
+        textColumns.addAll(columns)
+    }
+
     fun getColumn(index: Int): BaseColumn {
         return textColumns.getOrElse(index) {
             textColumns.last()
@@ -158,7 +168,7 @@ data class TextLine(
             for (i in columns.indices) columns[i].draw(view, canvas)
         }
 
-        if (AppConfig.isEInkMode && (isReadAloud || searchResultColumnCount > 0)) {
+        if (useUnderline && (isReadAloud || searchResultColumnCount > 0)) {
             val linePaint = ChapterProvider.linePaint
             val lineY = height - 1.dpToPx()
             canvas.drawLine(lineStart + indentWidth, lineY, lineEnd, lineY, linePaint)

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/read/page/entities/column/TextColumn.kt

@@ -47,7 +47,7 @@ data class TextColumn(
         } else {
             ChapterProvider.contentPaint
         }
-        val textColor = if (textLine.isReadAloud || isSearchResult) {
+        val textColor = if (!textLine.useUnderline && (textLine.isReadAloud || isSearchResult)) {
             ReadBookConfig.textAccentColor
         } else {
             ReadBookConfig.textColor

+ 3 - 1
app/src/main/java/io/legado/app/ui/book/read/page/provider/ChapterProvider.kt

@@ -50,7 +50,9 @@ import java.util.Locale
 @Suppress("DEPRECATION", "ConstPropertyName")
 object ChapterProvider {
     //用于图片字的替换
-    const val srcReplaceChar = "⼁"//▩ 丨
+    const val srcReplaceChar = "袮" //▩▣ 丨⼁ //换成袮,这是不应该存在的汉字,替换为祢
+    const val srcReplaceCharC = '袮' //可能有略微的提升
+    const val srcReplaceCharD = '祢'
 
     //用于评论按钮的替换
     const val reviewChar = "꧁"

+ 303 - 125
app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt

@@ -1,10 +1,17 @@
 package io.legado.app.ui.book.read.page.provider
 
 import android.graphics.Paint
+import android.os.Build
 import android.text.Layout
+import android.text.Spanned
 import android.text.StaticLayout
 import android.text.TextPaint
+import android.text.style.ForegroundColorSpan
+import android.text.style.RelativeSizeSpan
+import android.text.style.URLSpan
 import android.util.Size
+import androidx.core.text.HtmlCompat
+import androidx.core.text.parseAsHtml
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.AppPattern
 import io.legado.app.constant.AppPattern.noWordCountRegex
@@ -21,10 +28,15 @@ import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.model.ImageProvider
 import io.legado.app.model.ReadBook
 import io.legado.app.ui.book.read.page.entities.TextChapter
+import io.legado.app.ui.book.read.page.entities.TextHtmlColumn
 import io.legado.app.ui.book.read.page.entities.TextLine
 import io.legado.app.ui.book.read.page.entities.TextPage
 import io.legado.app.ui.book.read.page.entities.column.ImageColumn
 import io.legado.app.ui.book.read.page.entities.column.TextColumn
+import io.legado.app.ui.book.read.page.provider.ChapterProvider.reviewChar
+import io.legado.app.ui.book.read.page.provider.ChapterProvider.srcReplaceChar
+import io.legado.app.ui.book.read.page.provider.ChapterProvider.srcReplaceCharC
+import io.legado.app.ui.book.read.page.provider.ChapterProvider.srcReplaceCharD
 import io.legado.app.utils.StringUtils
 import io.legado.app.utils.dpToPx
 import io.legado.app.utils.fastSum
@@ -39,7 +51,6 @@ import kotlinx.coroutines.currentCoroutineContext
 import kotlinx.coroutines.ensureActive
 import kotlinx.coroutines.launch
 import java.util.LinkedList
-import kotlin.coroutines.coroutineContext
 import kotlin.math.roundToInt
 
 class TextChapterLayout(
@@ -67,8 +78,6 @@ class TextChapterLayout(
     private val titleTopSpacing = ChapterProvider.titleTopSpacing
     private val titleBottomSpacing = ChapterProvider.titleBottomSpacing
     private val lineSpacingExtra = ChapterProvider.lineSpacingExtra
-    private val titleLineSpacingExtra = ChapterProvider.titleLineSpacingExtra
-
     private val paragraphSpacing = ChapterProvider.paragraphSpacing
 
     private val visibleHeight = ChapterProvider.visibleHeight
@@ -84,12 +93,8 @@ class TextChapterLayout(
     private val useZhLayout = ReadBookConfig.useZhLayout
     private val isMiddleTitle = ReadBookConfig.isMiddleTitle
     private val textFullJustify = ReadBookConfig.textFullJustify
+    private val adaptSpecialStyle = AppConfig.adaptSpecialStyle
     private val pageAnim = book.getPageAnim()
-    private val titleSegType = ReadBookConfig.titleSegType
-    private val titleSegDistance = ReadBookConfig.titleSegDistance
-    private val titleSegFlag = ReadBookConfig.titleSegFlag
-    private val titleSegScaling = ReadBookConfig.titleSegScaling
-
 
     private var pendingTextPage = TextPage()
 
@@ -201,48 +206,6 @@ class TextChapterLayout(
         }
     }
 
-    private fun parseTitleSegments(
-        rawTitle: String,
-        segType: Int,
-        segDistance: Int,
-        segFlag: String
-    ): List<String> {
-        if (rawTitle.isBlank()) return emptyList()
-
-        return when (segType) {
-            // 0:不分段
-            0 -> listOf(rawTitle)
-
-            // 1:按字符数分段
-            1 -> {
-                if (segDistance <= 0 || segDistance >= rawTitle.length)
-                    listOf(rawTitle)
-                else
-                    listOf(
-                        rawTitle.substring(0, segDistance),
-                        rawTitle.substring(segDistance)
-                    )
-            }
-
-            // 2:按标志字符串分段
-            2 -> {
-                val flags = segFlag.split(",").map { it.trim() }.filter { it.isNotEmpty() }
-                if (flags.isEmpty()) return listOf(rawTitle)
-
-                val pattern = flags.joinToString("|") { Regex.escape(it) }
-                val regex = Regex("(?<=$pattern)") // 在分隔符后断开
-                val segments = rawTitle.split(regex)
-                    .map { it.trim() }
-                    .filter { it.isNotEmpty() }
-
-                segments.ifEmpty { listOf(rawTitle) }
-            }
-
-            else -> listOf(rawTitle)
-        }
-    }
-
-
     /**
      * 获取拆分完的章节数据
      */
@@ -258,48 +221,37 @@ class TextChapterLayout(
         val isTextImageStyle = imageStyle.equals(Book.imgStyleText, true)
 
         if (titleMode != 2 || bookChapter.isVolume || contents.isEmpty()) {
-            val baseTitles = displayTitle.splitNotBlank("\n")
-            baseTitles.forEach { rawTitle ->
-                val titleSegments = parseTitleSegments(
-                    rawTitle,
-                    titleSegType,
-                    titleSegDistance,
-                    titleSegFlag
-                )
-                titleSegments.forEachIndexed { index, segment ->
-                    val srcList = LinkedList<String>()
-                    val reviewImg = bookChapter.reviewImg
-                    var reviewTxt = ""
-                    if (reviewImg != null) {
-                        srcList.add(reviewImg)
-                        reviewTxt = if (reviewImg.contains("TEXT")) {
-                            ChapterProvider.reviewChar
-                        } else {
-                            ChapterProvider.srcReplaceChar
-                        }
-                    }
-                    val paint = if (index == 0) titlePaint else {
-                        TextPaint(titlePaint).apply {
-                            textSize = titlePaint.textSize * titleSegScaling
-                        }
+            //标题非隐藏
+            displayTitle.splitNotBlank("\n").forEach { text ->
+                val srcList = LinkedList<String>()
+                val reviewImg = bookChapter.reviewImg
+                var reviewTxt = ""
+                if (!reviewImg.isNullOrEmpty()) {
+                    srcList.add(reviewImg)
+                    reviewTxt = if (reviewImg.contains("TEXT")) {
+                        reviewChar
+                    } else {
+                        srcReplaceChar
                     }
-                    setTypeText(
-                        book,
-                        segment + reviewTxt,
-                        paint,
-                        titlePaintTextHeight,
-                        titlePaintFontMetrics,
-                        imageStyle,
-                        srcList = srcList.ifEmpty { null },
-                        isTitle = true,
-                        emptyContent = contents.isEmpty(),
-                        isVolumeTitle = bookChapter.isVolume
-                    )
-                    pendingTextPage.lines.last().isParagraphEnd = true
-                    stringBuilder.append("\n")
                 }
+                setTypeText(
+                    book,
+                    text + reviewTxt,
+                    titlePaint,
+                    titlePaintTextHeight,
+                    titlePaintFontMetrics,
+                    imageStyle,
+                    srcList = srcList.ifEmpty { null },
+                    isTitle = true,
+                    emptyContent = contents.isEmpty(),
+                    isVolumeTitle = bookChapter.isVolume
+                )
+                pendingTextPage.lines.last().isParagraphEnd = true
+                stringBuilder.append("\n")
             }
             durY += titleBottomSpacing
+
+            // 如果是单图模式且当前页有内容,强制分页
             if (isSingleImageStyle && pendingTextPage.lines.isNotEmpty() && contents.isNotEmpty()) {
                 prepareNextPageIfNeed()
             }
@@ -310,16 +262,26 @@ class TextChapterLayout(
         var wordCount = 0
         contents.forEach { content ->
             currentCoroutineContext().ensureActive()
+            if (adaptSpecialStyle) {
+                val text = content.trim()
+                if (text == "[newpage]") {
+                    prepareNextPageIfNeed()
+                    return@forEach
+                } else if (text.startsWith("<usehtml>")) {
+                    setTypeHtml(book, text.substring(9, text.lastIndexOf("<")))
+                    return@forEach
+                }
+            }
+            var text = content.replace(srcReplaceCharC, srcReplaceCharD)
             if (isTextImageStyle) {
                 //图片样式为文字嵌入类型
-                var text = content.replace(ChapterProvider.srcReplaceChar, "丨") //▣
                 val srcList = LinkedList<String>()
                 sb.setLength(0)
                 val matcher = AppPattern.imgPattern.matcher(text)
                 while (matcher.find()) {
                     matcher.group(1)?.let { src ->
                         srcList.add(src)
-                        matcher.appendReplacement(sb, ChapterProvider.srcReplaceChar)
+                        matcher.appendReplacement(sb, srcReplaceChar)
                     }
                 }
                 matcher.appendTail(sb)
@@ -344,7 +306,6 @@ class TextChapterLayout(
                 sb.setLength(0)
                 var isFirstLine = true
                 if (content.contains("<img")) {
-                    val text = content.replace(ChapterProvider.srcReplaceChar, "丨")
                     val matcher = AppPattern.imgPattern.matcher(text)
                     while (matcher.find()) {
                         currentCoroutineContext().ensureActive()
@@ -355,25 +316,18 @@ class TextChapterLayout(
                         var imgSize: Size? = null
                         if (matchResult != null) {
                             val styleValue = matchResult.groupValues[1].trim()
-                            if (styleValue == "TEXT" && matcher.end() == text.length) {
-                                iStyle = "TEXT"
-                                wordCount--
-                            }
-                            else if (styleValue.equals("text", true)) {
-                                iStyle = "text"
-                            }
-                            else {
+                            if (styleValue.equals("text", true)) { //忽略大小写
+                                iStyle = styleValue
+                            } else {
                                 imgSize = ImageProvider.getImageSize(book, imgSrc, ReadBook.bookSource)
                                 iStyle = styleValue
                                 isSmallImage = false
                             }
-                        }
-                        else {
+                        } else {
                             imgSize = ImageProvider.getImageSize(book, imgSrc, ReadBook.bookSource)
                             if (imgSize.width < 80 && imgSize.height < 80) {
                                 iStyle = "text"
-                            }
-                            else {
+                            } else {
                                 isSmallImage = false
                             }
                         }
@@ -383,9 +337,9 @@ class TextChapterLayout(
                         if (isSmallImage) {
                             sb.append(
                                 if (iStyle == "TEXT")
-                                    ChapterProvider.reviewChar
+                                    reviewChar
                                 else
-                                    ChapterProvider.srcReplaceChar
+                                    srcReplaceChar
                             )
                             srcList.add(imgSrc)
                         } else {
@@ -403,7 +357,7 @@ class TextChapterLayout(
                                     srcList = srcList
                                 )
                                 sb.setLength(0)
-                                isFirstLine=false
+                                isFirstLine = false
                             }
                             setTypeImage(
                                 book,
@@ -425,17 +379,17 @@ class TextChapterLayout(
                     val textAfter = content.substring(start, content.length)
                     sb.append(textAfter)
                 }
-                val text = sb.toString()
+                text = sb.toString()
                 if (text.isNotBlank()) {
                     wordCount += text.replace(noWordCountRegex,"").length
                     setTypeText(
                         book,
-                        if (AppConfig.enableReview) text + ChapterProvider.reviewChar else text,
+                        if (AppConfig.enableReview) text + reviewChar else text,
                         contentPaint,
                         contentPaintTextHeight,
                         contentPaintFontMetrics,
                         "TEXT",
-                        isFirstLine = start == 0,
+                        isFirstLine = isFirstLine,
                         srcList = srcList.ifEmpty { null }
                     )
                 }
@@ -544,6 +498,232 @@ class TextChapterLayout(
         durY += textHeight * paragraphSpacing / 10f
     }
 
+    /**
+     * 排版html样式
+     */
+    private suspend fun setTypeHtml(
+        book: Book,
+        htmlContent: String,
+    ) {
+        val spanned = htmlContent.parseAsHtml(HtmlCompat.FROM_HTML_MODE_COMPACT)
+        val width = visibleWidth
+        val textPaint = contentPaint
+        val textColor = ReadBookConfig.textColor
+        if (textPaint.color != textColor) {
+            textPaint.color = textColor
+        }
+        val staticLayout = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+            StaticLayout.Builder.obtain(spanned, 0, spanned.length, textPaint, width)
+                .setLineSpacing(paragraphSpacing.toFloat(), lineSpacingExtra)
+                .setIncludePad(true)
+                .setUseLineSpacingFromFallbacks(true)
+                .build()
+        } else {
+            @Suppress("DEPRECATION")
+            StaticLayout(
+                spanned,
+                textPaint,
+                width,
+                Layout.Alignment.ALIGN_NORMAL,
+                lineSpacingExtra,
+                paragraphSpacing.toFloat(),
+                true
+            )
+        }
+        val tempPaint = TextPaint(textPaint)
+        for (lineIndex in 0 until staticLayout.lineCount) {
+            val lineStart = staticLayout.getLineStart(lineIndex)
+            val lineEnd = staticLayout.getLineEnd(lineIndex)
+            if (lineStart == lineEnd) { //这一行没有内容,跳过
+                continue
+            }
+            val textLine = TextLine(isHtml = true)
+            val lineText = spanned.subSequence(lineStart, lineEnd).toString()
+            textLine.text = lineText //文本
+            val lineLeft = staticLayout.getLineLeft(lineIndex)
+            textLine.startX = absStartX + lineLeft //x坐标
+            val mLineTop = staticLayout.getLineTop(lineIndex).toFloat()
+            val mLineBottom = staticLayout.getLineBottom(lineIndex).toFloat()
+            val lineHeight = mLineBottom - mLineTop
+            prepareNextPageIfNeed(durY + lineHeight)
+            textLine.upTopBottom(durY, lineHeight, textPaint.fontMetrics) //y坐标
+
+            val columns = mutableListOf<TextHtmlColumn>()
+            var charIndex = lineStart
+            while (charIndex < lineEnd) {
+                val char = spanned[charIndex].toString()
+                if (char == "\n") {
+                    textLine.isParagraphEnd = true
+                    durY += lineHeight * paragraphSpacing / 10f //段距
+                    charIndex++
+                    continue
+                }
+                val charX = staticLayout.getPrimaryHorizontal(charIndex) + lineLeft
+                val textSize = extractTextSize(spanned, charIndex, textPaint.textSize)
+                val textColor = extractTextColor(spanned, charIndex, textPaint.color)
+                val linkUrl = extractLinkUrl(spanned, charIndex)
+
+                val charRight = if (charIndex + 1 < lineEnd) {
+                    staticLayout.getPrimaryHorizontal(charIndex + 1) + lineLeft
+                } else {
+                    tempPaint.textSize = textSize
+                    val charWidth = tempPaint.measureText(char)
+                    charX + charWidth
+                }
+
+                columns.add(
+                    TextHtmlColumn(
+                        absStartX + charX,
+                        absStartX + charRight,
+                        char,
+                        textSize,
+                        textColor,
+                        linkUrl
+                    )
+                )
+                charIndex++
+                if (charIndex == lineEnd && lineIndex == staticLayout.lineCount - 1) {
+                    textLine.isParagraphEnd = true
+                    durY += lineHeight * paragraphSpacing / 10f //段距
+                }
+            }
+            if (textFullJustify && !textLine.isParagraphEnd) {
+                justifyHtmlLine(columns, textLine, visibleWidth)
+            } else {
+                textLine.addColumns(columns)
+            }
+            calcTextLinePosition(textPages, textLine, stringBuilder.length)
+            stringBuilder.append(lineText)
+            val textPage = pendingTextPage
+            textPage.addLine(textLine)
+            durY += lineHeight * lineSpacingExtra //行距
+            if (textPage.height < durY) {
+                textPage.height = durY
+            }
+        }
+    }
+
+    /**
+     * 对HTML行进行两端对齐
+     */
+    private fun justifyHtmlLine(
+        columns: MutableList<TextHtmlColumn>,
+        textLine: TextLine,
+        lineWidth: Int
+    ) {
+        if (columns.isEmpty()) return
+        // 计算当前行的总宽度
+        val firstCol = columns.first()
+        val lastCol = columns.last()
+        val currentWidth = lastCol.end - firstCol.start
+        // 计算剩余空间
+        val residualWidth = lineWidth - currentWidth
+
+        if (residualWidth <= 0) {
+            textLine.addColumns(columns)
+            return
+        }
+
+        // 统计空格数量
+        val spaceCount = columns.count { it.charData == " " }
+
+        if (spaceCount > 1) {
+            // 多个空格:调整单词间距
+            val spaceIncrement = residualWidth / spaceCount
+            textLine.wordSpacing = spaceIncrement
+
+            // 重新计算字符位置
+            var currentX = columns[0].start
+            for (i in columns.indices) {
+                val col = columns[i]
+                val width = col.end - col.start
+
+                if (col.charData == " " && i != columns.lastIndex) {
+                    // 空格,增加额外的间距
+                    col.start = currentX
+                    col.end = currentX + width + spaceIncrement
+                    currentX = col.start
+                } else {
+                    // 非空格或最后一个字符
+                    col.start = currentX
+                    col.end = currentX + width
+                    currentX = col.start
+                }
+
+                textLine.addColumn(col)
+            }
+        } else {
+            // 没有或只有一个空格:调整字符间距
+            val gapCount = columns.lastIndex
+            if (gapCount > 0) {
+                val charIncrement = residualWidth / gapCount
+                var currentX = columns[0].start
+                for (i in columns.indices) {
+                    val col = columns[i]
+                    val width = col.end - col.start
+
+                    if (i != columns.lastIndex) {
+                        // 非最后一个字符,增加额外的间距
+                        col.start = currentX
+                        col.end = currentX + width + charIncrement
+                        currentX = col.end
+                    } else {
+                        // 最后一个字符,不增加额外间距
+                        col.start = currentX
+                        col.end = currentX + width
+                    }
+
+                    textLine.addColumn(col)
+                }
+            } else {
+                // 只有一个字符,不需要调整
+                textLine.addColumns(columns)
+            }
+        }
+    }
+
+    private fun extractTextSize(spanned: Spanned, index: Int, defaultSize: Float): Float {
+        val relativeSpans = spanned.getSpans(index, index + 1, RelativeSizeSpan::class.java)
+        // 如果有 RelativeSizeSpan,基于基准大小计算
+        relativeSpans.firstOrNull()?.let { span ->
+            return defaultSize * span.sizeChange
+        }
+//        val sizeSpans = spanned.getSpans(index, index + 1, AbsoluteSizeSpan::class.java)
+//        sizeSpans.firstOrNull()?.let { span ->
+//            return span.size.toFloat()
+//        }
+        return defaultSize
+    }
+
+    private fun extractTextColor(spanned: Spanned, index: Int, defaultColor: Int): Int {
+        // 检查 ForegroundColorSpan(前景色)
+        val foregroundSpans = spanned.getSpans(index, index + 1, ForegroundColorSpan::class.java)
+        foregroundSpans.firstOrNull()?.let { span ->
+            return span.foregroundColor
+        }
+
+        // 2. 检查自定义的彩色 Span
+//        val customColorSpans = spanned.getSpans(index, index + 1, CharacterStyle::class.java)
+//        customColorSpans.firstOrNull()?.let { span ->
+//            if (span is ForegroundColorSpan) {
+//                return span.foregroundColor
+//            }
+//        }
+
+        // 默认返回 Paint 的颜色
+        return defaultColor
+    }
+
+    private fun extractLinkUrl(spanned: Spanned, index: Int): String? {
+        // 检查URLSpan(超链接)
+        val urlSpans = spanned.getSpans(index, index + 1, URLSpan::class.java)
+        urlSpans.firstOrNull()?.let { span ->
+            return span.url
+        }
+        return null
+    }
+
+
     /**
      * 排版文字
      */
@@ -614,8 +794,8 @@ class TextChapterLayout(
             val (words, widths) = measureTextSplit(lineText, widthsArray, lineStart)
             val desiredWidth = widths.fastSum()
             textLine.text = lineText
-            when {
-                lineIndex == 0 && layout.lineCount > 1 && !isTitle && isFirstLine -> {
+            when (lineIndex) {
+                0 if layout.lineCount > 1 && !isTitle && isFirstLine -> {
                     //多行的第一行 非标题
                     addCharsToLineFirst(
                         book, absStartX, textLine, words, textPaint,
@@ -623,7 +803,7 @@ class TextChapterLayout(
                     )
                 }
 
-                lineIndex == layout.lineCount - 1 -> {
+                layout.lineCount - 1 -> {
                     //最后一行、单行
                     //标题x轴居中
                     val startX = if (
@@ -640,7 +820,6 @@ class TextChapterLayout(
                         startX, !isTitle && lineIndex == 0, widths, srcList
                     )
                 }
-
                 else -> {
                     if (
                         isTitle &&
@@ -670,13 +849,12 @@ class TextChapterLayout(
             textLine.upTopBottom(durY, textHeight, fontMetrics)
             val textPage = pendingTextPage
             textPage.addLine(textLine)
-            durY += textHeight * if (isTitle) titleLineSpacingExtra else lineSpacingExtra
+            durY += textHeight * lineSpacingExtra
             if (textPage.height < durY) {
                 textPage.height = durY
             }
         }
-        if(!isTitle)
-            durY += textHeight * paragraphSpacing / 10f
+        durY += textHeight * paragraphSpacing / 10f
     }
 
     private fun calcTextLinePosition(
@@ -852,7 +1030,7 @@ class TextChapterLayout(
         srcList: LinkedList<String>?
     ) {
         val column = when {
-            !srcList.isNullOrEmpty() && (char == ChapterProvider.srcReplaceChar || char == ChapterProvider.reviewChar && isLineEnd) -> {
+            !srcList.isNullOrEmpty() && (char == srcReplaceChar || char == reviewChar) -> {
                 val src = srcList.removeFirst()
                 ImageProvider.cacheImage(book, src, ReadBook.bookSource)
                 ImageColumn(
@@ -861,13 +1039,13 @@ class TextChapterLayout(
                     src = src
                 )
             }
-            /*isLineEnd && char == ChapterProvider.reviewChar -> {
-                ReviewColumn(
-                    start = absStartX + xStart,
-                    end = absStartX + xEnd,
-                    count = 10
-                )
-            }*/
+//            isLineEnd && char == ChapterProvider.reviewChar -> {
+//                ReviewColumn(
+//                    start = absStartX + xStart,
+//                    end = absStartX + xEnd,
+//                    count = 10
+//                )
+//            }
 
             else -> {
                 TextColumn(
@@ -974,4 +1152,4 @@ class TextChapterLayout(
         return code == 8203 || code == 8204 || code == 8205 || code == 8288
     }
 
-}
+}

+ 6 - 0
app/src/main/res/values-zh-rCN/arrays.xml

@@ -168,6 +168,12 @@
         <item>内容取色</item>
     </string-array>
 
+    <string-array name="composeEngine">
+        <item>Material Design</item>
+        <item>SaltUI</item>
+        <item>Miuix</item>
+    </string-array>
+
     <string-array name="customMode">
         <item>全局</item>
         <item>仅强调色</item>

+ 6 - 0
app/src/main/res/values/array_values.xml

@@ -90,6 +90,12 @@
         <item>content</item>
     </string-array>
 
+    <string-array name="composeEngine_value">
+        <item>material</item>
+        <item>salt</item>
+        <item>miuix</item>
+    </string-array>
+
     <string-array name="customMode_value">
         <item>global</item>
         <item>accent</item>

+ 6 - 0
app/src/main/res/values/arrays.xml

@@ -205,6 +205,12 @@
         <item>Content</item>
     </string-array>
 
+    <string-array name="composeEngine">
+        <item>Material Design</item>
+        <item>SaltUI</item>
+        <item>Miuix</item>
+    </string-array>
+
     <string-array name="customMode">
 		<item>Global</item>
 		<item>Accent</item>

+ 13 - 0
app/src/main/res/xml/pref_config_read.xml

@@ -80,6 +80,13 @@
             android:title="@string/text_bottom_justify"
             app:isBottomBackground="true" />
 
+        <io.legado.app.lib.prefs.SwitchPreference
+            android:defaultValue="true"
+            android:key="adaptSpecialStyle"
+            android:title="@string/adapt_special_style"
+            app:iconSpaceReserved="false"
+            app:isBottomBackground="true" />
+
         <io.legado.app.lib.prefs.SwitchPreference
             android:defaultValue="false"
             android:key="useZhLayout"
@@ -92,6 +99,12 @@
             android:title="@string/show_brightness_view"
             app:isBottomBackground="true" />
 
+        <io.legado.app.lib.prefs.SwitchPreference
+            android:defaultValue="false"
+            android:key="useUnderline"
+            android:title="@string/use_underline"
+            app:isBottomBackground="true" />
+
     </io.legado.app.lib.prefs.PreferenceCategory>
 
     <io.legado.app.lib.prefs.PreferenceCategory