[新增] 正文支持HTML渲染(@Luoyacheng),标题支持正则分割 #598

This commit is contained in:
HapeLee
2026-01-29 19:43:29 +08:00
parent a76c25e450
commit 7638c1b450
16 changed files with 2327 additions and 71 deletions
File diff suppressed because it is too large Load Diff
@@ -67,7 +67,7 @@ val appDb by lazy {
}
@Database(
version = 81,
version = 82,
exportSchema = true,
entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class,
ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class,
@@ -114,7 +114,8 @@ val appDb by lazy {
AutoMigration(from = 77, to = 78),
AutoMigration(from = 78, to = 79),
AutoMigration(from = 79, to = 80),
AutoMigration(from = 80, to = 81)
AutoMigration(from = 80, to = 81),
AutoMigration(from = 81, to = 82)
]
)
abstract class AppDatabase : RoomDatabase() {
@@ -36,7 +36,7 @@ data class BookSource(
var bookSourceName: String = "",
// 分组
var bookSourceGroup: String? = null,
// 类型,0 文本,1 音频, 2 图片, 3 文件(指的是类似知轩藏书只提供下载的网站)
// 类型,0 文本,1 音频, 2 图片, 3 文件(指的是类似知轩藏书只提供下载的网站), 4 视频
@BookSourceType.Type
var bookSourceType: Int = 0,
// 详情页url正则
@@ -94,7 +94,11 @@ data class BookSource(
// 正文页规则
var ruleContent: ContentRule? = null,
// 段评规则
var ruleReview: ReviewRule? = null
var ruleReview: ReviewRule? = null,
@ColumnInfo(defaultValue = "0")
var eventListener: Boolean = false, // 是否监听事件来执行回调规则
@ColumnInfo(defaultValue = "0")
var customButton: Boolean = false //由书源控制的自定义按钮
) : Parcelable, BaseSource {
override fun getTag(): String {
@@ -207,7 +211,10 @@ data class BookSource(
fun getCheckKeyword(default: String): String {
ruleSearch?.checkKeyWord?.let {
if (it.isNotBlank()) {
if (it.isNotBlank() && !it.contains("http") && !it.contains("::") && !it.contains("++") && !it.contains(
"--"
)
) {
return it
}
}
@@ -11,6 +11,7 @@ import kotlinx.parcelize.Parcelize
@Parcelize
data class ContentRule(
var content: String? = null,
var subContent: String? = null, //副文规则,拼接在正文后面或者获取歌词等
var title: String? = null, //有些网站只能在正文中获取标题
var nextContentUrl: String? = null,
var webJs: String? = null,
@@ -19,6 +20,8 @@ data class ContentRule(
var imageStyle: String? = null, //默认大小居中,FULL最大宽度
var imageDecode: String? = null, //图片bytes二次解密js, 返回解密后的bytes
var payAction: String? = null, //购买操作,js或者包含{{js}}的url
/** 监听到事件后执行的回调js代码 **/
var callBackJs: String? = null
) : Parcelable {
@@ -140,7 +140,7 @@ class ContentProcessor private constructor(
1 -> mContent = ChineseUtils.t2s(mContent)
2 -> mContent = ChineseUtils.s2t(mContent)
}
} catch (e: Exception) {
} catch (_: Exception) {
appCtx.toastOnUi("简繁转换出错")
}
}
@@ -153,6 +153,7 @@ class ContentProcessor private constructor(
}
}
if (useReplace && book.getUseReplaceRule()) {
val replaceBook = book.toSearchBook()
//替换
effectiveReplaceRules = arrayListOf()
mContent = mContent.lines().joinToString("\n") { it.trim() }
@@ -165,7 +166,9 @@ class ContentProcessor private constructor(
mContent.replace(
item.regex,
item.replacement,
item.getValidTimeoutMillisecond()
item.getValidTimeoutMillisecond(),
chapter,
replaceBook
)
} else {
mContent.replace(item.pattern, item.replacement)
@@ -185,6 +188,9 @@ class ContentProcessor private constructor(
}
}
}
useHtmlMap.forEach { (placeholder, originalContent) ->
mContent = mContent.replace(placeholder, originalContent)
}
}
if (includeTitle) {
//重新添加标题
@@ -0,0 +1,138 @@
package io.legado.app.model
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.script.rhino.runScriptWithContext
import io.legado.app.constant.AppLog
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookSource
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.ui.login.SourceLoginJsExtensions
import io.legado.app.utils.isTrue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
object SourceCallBack {
const val CLICK_AUTHOR = "clickAuthor"
const val LONG_CLICK_AUTHOR = "longClickAuthor"
const val CLICK_BOOK_NAME = "clickBookName"
const val LONG_CLICK_BOOK_NAME = "longClickBookName"
const val CLICK_CUSTOM_BUTTON = "clickCustomButton"
const val LONG_CLICK_CUSTOM_BUTTON = "longClickCustomButton"
const val CLICK_SHARE_BOOK = "clickShareBook"
const val CLICK_CLEAR_CACHE = "clickClearCache"
const val CLICK_COPY_BOOK_URL = "clickCopyBookUrl"
const val CLICK_COPY_TOC_URL = "clickCopyTocUrl"
const val CLICK_COPY_PLAY_URL = "clickCopyPlayUrl"
const val ADD_BOOK_SHELF = "addBookShelf"
const val DEL_BOOK_SHELF = "delBookShelf"
const val SAVE_READ = "saveRead"
const val START_READ = "startRead"
const val END_READ = "endRead"
const val START_SHELF_REFRESH = "startShelfRefresh"
const val END_SHELF_REFRESH = "endShelfRefresh"
fun callBackBtn(
activity: AppCompatActivity,
event: String,
source: BookSource?,
book: Book,
chapter: BookChapter?,
noCall: (() -> Unit)? = null
) {
if (source == null || !source.eventListener) {
noCall?.invoke()
return
}
val jsStr = source.getContentRule().callBackJs
if (jsStr.isNullOrEmpty()) {
noCall?.invoke()
return
}
activity.lifecycleScope.launch(IO) {
val java = SourceLoginJsExtensions(activity, source)
runCatching {
val result = runScriptWithContext {
source.evalJS(jsStr) {
put("event", event)
put("java", java)
put("result", null)
put("book", book)
put("chapter", chapter)
}.toString()
}
if (!result.isTrue()) {
withContext(Dispatchers.Main) {
noCall?.invoke()
}
}
}.onFailure {
AppLog.put(
"${source.bookSourceName}\n书源执行回调事件${event}出错\n${it.localizedMessage}",
it,
true
)
}
}
}
fun callBackBook(
event: String,
source: BookSource?,
book: Book?,
chapter: BookChapter? = null
) {
if (source == null || book == null || !source.eventListener) return
val jsStr = source.getContentRule().callBackJs
if (jsStr.isNullOrEmpty()) return
Coroutine.async {
withTimeout(60000L) {
runScriptWithContext(coroutineContext) {
source.evalJS(jsStr) {
put("event", event)
put("result", null)
put("book", book)
put("chapter", chapter)
}
}
}
}.onError {
AppLog.put(
"${source.bookSourceName}\n书源执行回调事件${event}出错\n${it.localizedMessage}",
it,
true
)
}
}
fun callBackSource(scope: CoroutineScope, event: String, source: BookSource) {
val jsStr = source.getContentRule().callBackJs
if (jsStr.isNullOrEmpty()) return
scope.launch(IO) {
runCatching {
withTimeout(30000L) {
runScriptWithContext {
source.evalJS(jsStr) {
put("event", event)
put("result", null)
put("book", null)
put("chapter", null)
}
}
}
}.onFailure {
AppLog.put(
"${source.bookSourceName}\n书源执行回调事件${event}出错\n${it.localizedMessage}",
it,
true
)
}
}
}
}
@@ -190,6 +190,7 @@ class ReadBookActivity : BaseReadBookActivity(),
viewModel.replaceRuleChanged()
}
}
private val searchContentActivity =
registerForActivityResult(StartActivityContract(SearchContentActivity::class.java)) {
val data = it.data ?: return@registerForActivityResult
@@ -1409,8 +1410,7 @@ class ReadBookActivity : BaseReadBookActivity(),
override fun showLogin() {
ReadBook.bookSource?.let {
startActivity<SourceLoginActivity> {
putExtra("type", "bookSource")
putExtra("key", it.bookSourceUrl)
putExtra("bookType", BookType.text)
}
}
}
@@ -102,7 +102,7 @@ class TipConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_tip_config
}
binding.btnTitleSegType.setOnClickListener {
val types = arrayOf("不分段", "按字符数分段", "按标志字符串分段")
val types = arrayOf("不分段", "按字符数分段", "按标志字符串分段", "正则表达式分段")
val current = ReadBookConfig.titleSegType
alert(title = "选择标题分段模式") {
@@ -20,6 +20,7 @@ import io.legado.app.ui.book.read.page.entities.column.BaseColumn
import io.legado.app.ui.book.read.page.entities.column.ButtonColumn
import io.legado.app.ui.book.read.page.entities.column.ImageColumn
import io.legado.app.ui.book.read.page.entities.column.ReviewColumn
import io.legado.app.ui.book.read.page.entities.column.TextBaseColumn
import io.legado.app.ui.book.read.page.entities.column.TextColumn
import io.legado.app.ui.book.read.page.entities.column.TextHtmlColumn
import io.legado.app.ui.book.read.page.provider.ChapterProvider
@@ -33,7 +33,7 @@ import io.legado.app.ui.book.read.page.entities.TextChapter
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.TextPos
import io.legado.app.ui.book.read.page.entities.column.TextColumn
import io.legado.app.ui.book.read.page.entities.column.TextBaseColumn
import io.legado.app.ui.book.read.page.provider.ChapterProvider
import io.legado.app.ui.book.read.page.provider.LayoutProgressListener
import io.legado.app.ui.book.read.page.provider.TextPageFactory
@@ -371,7 +371,7 @@ class ReadView(context: Context, attrs: AttributeSet) :
return@run
}
val column = textLine.getColumn(j)
if (column is TextColumn) {
if (column is TextBaseColumn) {
ci += column.charData.length
} else {
ci++
@@ -12,8 +12,8 @@ import io.legado.app.help.PaintPool
import io.legado.app.help.config.AppConfig
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.TextChapter.Companion.emptyTextChapter
import io.legado.app.ui.book.read.page.entities.column.TextBaseColumn
import io.legado.app.ui.book.read.page.entities.column.TextColumn
import io.legado.app.ui.book.read.page.provider.ChapterProvider
import io.legado.app.utils.canvasrecorder.CanvasRecorderFactory
@@ -1,6 +1,4 @@
package io.legado.app.ui.book.read.page
import io.legado.app.ui.book.read.page.entities.column.BaseColumn
package io.legado.app.ui.book.read.page.entities.column
/**
* 文字基列
@@ -16,19 +16,19 @@ import io.legado.app.ui.book.read.page.provider.ChapterProvider
data class TextColumn(
override var start: Float,
override var end: Float,
val charData: String,
) : BaseColumn {
override val charData: String,
) : TextBaseColumn {
override var textLine: TextLine = emptyTextLine
var selected: Boolean = false
override var selected: Boolean = false
set(value) {
if (field != value) {
textLine.invalidate()
}
field = value
}
var isSearchResult: Boolean = false
override var isSearchResult: Boolean = false
set(value) {
if (field != value) {
textLine.invalidate()
@@ -47,15 +47,11 @@ data class TextColumn(
} else {
ChapterProvider.contentPaint
}
val textColor = if (!textLine.useUnderline && (textLine.isReadAloud || isSearchResult)) {
val textColor = if (textLine.isReadAloud || isSearchResult) {
ReadBookConfig.textAccentColor
} else {
ReadBookConfig.textColor
}
val originalSize = textPaint.textSize
textLine.titleTextSize?.let {
textPaint.textSize = it
}
if (textPaint.color != textColor) {
textPaint.color = textColor
}
@@ -70,7 +66,6 @@ data class TextColumn(
if (selected) {
canvas.drawRect(start, 0f, end, textLine.height, view.selectedPaint)
}
textPaint.textSize = originalSize
}
}
@@ -6,8 +6,8 @@ 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
import io.legado.app.ui.book.read.page.entities.TextLine.Companion.emptyTextLine
import io.legado.app.ui.book.read.page.provider.ChapterProvider
/**
@@ -19,11 +19,11 @@ data class TextHtmlColumn(
override var end: Float,
override val charData: String,
val mTextSize: Float,
val mTextColor: Int,
val mTextColor: Int?,
val linkUrl: String?
) : TextBaseColumn {
override var textLine: TextLine = TextLine.Companion.emptyTextLine
override var textLine: TextLine = emptyTextLine
private val textPaint: TextPaint by lazy {
TextPaint(ChapterProvider.contentPaint).apply {
@@ -66,7 +66,7 @@ data class TextHtmlColumn(
color = if (textLine.isReadAloud || isSearchResult) {
ReadBookConfig.textAccentColor
} else {
mTextColor
mTextColor ?: ReadBookConfig.textColor
}
isUnderlineText = false
}
@@ -7,11 +7,14 @@ import android.text.Spanned
import android.text.StaticLayout
import android.text.TextPaint
import android.text.style.ForegroundColorSpan
import android.text.style.ImageSpan
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 androidx.core.util.component1
import androidx.core.util.component2
import io.legado.app.constant.AppLog
import io.legado.app.constant.AppPattern
import io.legado.app.constant.AppPattern.noWordCountRegex
@@ -31,7 +34,9 @@ import io.legado.app.model.analyzeRule.AnalyzeUrl.Companion.paramPattern
import io.legado.app.ui.book.read.page.entities.TextChapter
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.BaseColumn
import io.legado.app.ui.book.read.page.entities.column.ImageColumn
import io.legado.app.ui.book.read.page.entities.column.TextBaseColumn
import io.legado.app.ui.book.read.page.entities.column.TextColumn
import io.legado.app.ui.book.read.page.entities.column.TextHtmlColumn
import io.legado.app.ui.book.read.page.provider.ChapterProvider.reviewChar
@@ -308,7 +313,7 @@ class TextChapterLayout(
prepareNextPageIfNeed()
return@forEach
} else if (text.startsWith("<usehtml>")) {
setTypeHtml(book, text.substring(9, text.lastIndexOf("<")))
setTypeHtml(imageStyle, book, text.substring(9, text.lastIndexOf("<")))
return@forEach
}
}
@@ -553,6 +558,7 @@ class TextChapterLayout(
* 排版html样式
*/
private suspend fun setTypeHtml(
imageStyle: String?,
book: Book,
htmlContent: String,
) {
@@ -589,8 +595,7 @@ class TextChapterLayout(
continue
}
val textLine = TextLine(isHtml = true)
val lineText = spanned.subSequence(lineStart, lineEnd).toString()
textLine.text = lineText //文本
val lineText = StringBuilder()
val lineLeft = staticLayout.getLineLeft(lineIndex)
textLine.startX = absStartX + lineLeft //x坐标
val mLineTop = staticLayout.getLineTop(lineIndex).toFloat()
@@ -599,45 +604,121 @@ class TextChapterLayout(
prepareNextPageIfNeed(durY + lineHeight)
textLine.upTopBottom(durY, lineHeight, textPaint.fontMetrics) //y坐标
val columns = mutableListOf<TextHtmlColumn>()
val columns = mutableListOf<BaseColumn>()
var charIndex = lineStart
while (charIndex < lineEnd) {
val char = spanned[charIndex].toString()
lineText.append(char)
if (char == "\n") {
textLine.isParagraphEnd = true
durY += lineHeight * paragraphSpacing / 10f //段距
charIndex++
continue
}
val charX = staticLayout.getPrimaryHorizontal(charIndex) + lineLeft
val charX = staticLayout.getPrimaryHorizontal(charIndex)
val textSize = extractTextSize(spanned, charIndex, textPaint.textSize)
val textColor = extractTextColor(spanned, charIndex, textPaint.color)
val textColor = extractTextColor(spanned, charIndex)
val linkUrl = extractLinkUrl(spanned, charIndex)
val charRight = if (charIndex + 1 < lineEnd) {
staticLayout.getPrimaryHorizontal(charIndex + 1) + lineLeft
staticLayout.getPrimaryHorizontal(charIndex + 1)
} else {
tempPaint.textSize = textSize
val charWidth = tempPaint.measureText(char)
charX + charWidth
}
var addedImage = false
spanned.getSpans(charIndex, charIndex + 1, ImageSpan::class.java).firstOrNull()
?.let { span -> //处理图片
val source = span.source ?: return@let
val urlMatcher = paramPattern.matcher(source)
if (urlMatcher.find()) {
val urlOptionStr = source.substring(urlMatcher.end())
val style =
GSON.fromJsonObject<Map<String, String>>(urlOptionStr).getOrNull()
?: return@let
var iStyle = style["style"]
val width = style["width"]
val click = style["click"]
var imgSize =
ImageProvider.getImageSize(book, source, ReadBook.bookSource)
width?.let {
if (width.endsWith("%")) {
width.dropLast(1).toIntOrNull()?.let { percentage ->
val imgWidth = visibleWidth * percentage / 100
val (sizeHeight, sizeWidth) = imgSize
imgSize = Size(imgWidth, sizeHeight * imgWidth / sizeWidth)
}
} else {
width.toIntOrNull()?.let { width ->
val (sizeHeight, sizeWidth) = imgSize
imgSize = Size(width, sizeHeight * width / sizeWidth)
}
}
}
if (iStyle == null) {
iStyle = if (imgSize.width < 80 && imgSize.height < 80) {
"text"
} else {
imageStyle
}
}
when (iStyle?.uppercase()) {
"TEXT" -> {
ImageProvider.cacheImage(book, source, ReadBook.bookSource)
columns.add(
ImageColumn(
start = absStartX + charX,
end = absStartX + charRight,
src = source,
click = click
)
)
}
columns.add(
TextHtmlColumn(
absStartX + charX,
absStartX + charRight,
char,
textSize,
textColor,
linkUrl
else -> {
setTypeImage(
book,
source,
contentPaintTextHeight,
iStyle,
imgSize,
click
)
}
}
} else {
val imgSize =
ImageProvider.getImageSize(book, source, ReadBook.bookSource)
setTypeImage(
book,
source,
contentPaintTextHeight,
imageStyle,
imgSize,
null
)
}
addedImage = true
}
if (!addedImage) {
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 //段距
}
}
textLine.text = lineText.toString()
if (textFullJustify && !textLine.isParagraphEnd) {
justifyHtmlLine(columns, textLine, visibleWidth)
} else {
@@ -658,7 +739,7 @@ class TextChapterLayout(
* 对HTML行进行两端对齐
*/
private fun justifyHtmlLine(
columns: MutableList<TextHtmlColumn>,
columns: MutableList<BaseColumn>,
textLine: TextLine,
lineWidth: Int
) {
@@ -676,7 +757,9 @@ class TextChapterLayout(
}
// 统计空格数量
val spaceCount = columns.count { it.charData == " " }
val spaceCount = columns.count {
(it as? TextBaseColumn)?.charData == " "
}
if (spaceCount > 1) {
// 多个空格:调整单词间距
@@ -689,7 +772,7 @@ class TextChapterLayout(
val col = columns[i]
val width = col.end - col.start
if (col.charData == " " && i != columns.lastIndex) {
if ((col as? TextBaseColumn)?.charData == " " && i != columns.lastIndex) {
// 空格,增加额外的间距
col.start = currentX
col.end = currentX + width + spaceIncrement
@@ -746,23 +829,9 @@ class TextChapterLayout(
return defaultSize
}
private fun extractTextColor(spanned: Spanned, index: Int, defaultColor: Int): Int {
// 检查 ForegroundColorSpan(前景色)
private fun extractTextColor(spanned: Spanned, index: Int): Int? {
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
return foregroundSpans.firstOrNull()?.foregroundColor
}
private fun extractLinkUrl(spanned: Spanned, index: Int): String? {
@@ -3,6 +3,9 @@ package io.legado.app.utils
import androidx.core.os.postDelayed
import com.script.ScriptBindings
import com.script.rhino.RhinoScriptEngine
import io.legado.app.data.appDb
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.SearchBook
import io.legado.app.exception.RegexTimeoutException
import io.legado.app.help.CrashHandler
import io.legado.app.help.coroutine.Coroutine
@@ -10,7 +13,6 @@ import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
import splitties.init.appCtx
import java.util.regex.Matcher
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@@ -19,10 +21,23 @@ private val handler by lazy { buildMainHandler() }
/**
* 带有超时检测的正则替换
*/
fun CharSequence.replace(regex: Regex, replacement: String, timeout: Long): String {
fun CharSequence.replace(
regex: Regex,
replacement: String,
timeout: Long,
chapter: BookChapter? = null,
book: SearchBook? = null
): String {
val charSequence = this@replace
val isJs = replacement.startsWith("@js:")
val replacement1 = if (isJs) replacement.substring(4) else replacement
val book = if (isJs) {
book ?: chapter?.bookUrl?.let {
appDb.searchBookDao.getSearchBook(it) ?: appDb.bookDao.getBook(it)?.toSearchBook()
}
} else {
null
}
return runBlocking {
suspendCancellableCoroutine { block ->
val coroutine = Coroutine.async(executeContext = IO) {
@@ -35,9 +50,11 @@ fun CharSequence.replace(regex: Regex, replacement: String, timeout: Long): Stri
val jsResult = RhinoScriptEngine.run {
val bindings = ScriptBindings()
bindings["result"] = matcher.group()
bindings["chapter"] = chapter
bindings["book"] = book
eval(replacement1, bindings)
}.toString()
val quotedResult = Matcher.quoteReplacement(jsResult)
val quotedResult = jsResult.quoteReplacementJs()
matcher.appendReplacement(stringBuffer, quotedResult)
} else {
matcher.appendReplacement(stringBuffer, replacement1)
@@ -66,5 +83,4 @@ fun CharSequence.replace(regex: Regex, replacement: String, timeout: Long): Stri
}
}
}
}
}