[新增] 正文支持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( @Database(
version = 81, version = 82,
exportSchema = true, exportSchema = true,
entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class, entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class,
ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class, ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class,
@@ -114,7 +114,8 @@ val appDb by lazy {
AutoMigration(from = 77, to = 78), AutoMigration(from = 77, to = 78),
AutoMigration(from = 78, to = 79), AutoMigration(from = 78, to = 79),
AutoMigration(from = 79, to = 80), AutoMigration(from = 79, to = 80),
AutoMigration(from = 80, to = 81) AutoMigration(from = 80, to = 81),
AutoMigration(from = 81, to = 82)
] ]
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@@ -36,7 +36,7 @@ data class BookSource(
var bookSourceName: String = "", var bookSourceName: String = "",
// 分组 // 分组
var bookSourceGroup: String? = null, var bookSourceGroup: String? = null,
// 类型,0 文本,1 音频, 2 图片, 3 文件(指的是类似知轩藏书只提供下载的网站) // 类型,0 文本,1 音频, 2 图片, 3 文件(指的是类似知轩藏书只提供下载的网站), 4 视频
@BookSourceType.Type @BookSourceType.Type
var bookSourceType: Int = 0, var bookSourceType: Int = 0,
// 详情页url正则 // 详情页url正则
@@ -94,7 +94,11 @@ data class BookSource(
// 正文页规则 // 正文页规则
var ruleContent: ContentRule? = null, 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 { ) : Parcelable, BaseSource {
override fun getTag(): String { override fun getTag(): String {
@@ -207,7 +211,10 @@ data class BookSource(
fun getCheckKeyword(default: String): String { fun getCheckKeyword(default: String): String {
ruleSearch?.checkKeyWord?.let { ruleSearch?.checkKeyWord?.let {
if (it.isNotBlank()) { if (it.isNotBlank() && !it.contains("http") && !it.contains("::") && !it.contains("++") && !it.contains(
"--"
)
) {
return it return it
} }
} }
@@ -11,6 +11,7 @@ import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
data class ContentRule( data class ContentRule(
var content: String? = null, var content: String? = null,
var subContent: String? = null, //副文规则,拼接在正文后面或者获取歌词等
var title: String? = null, //有些网站只能在正文中获取标题 var title: String? = null, //有些网站只能在正文中获取标题
var nextContentUrl: String? = null, var nextContentUrl: String? = null,
var webJs: String? = null, var webJs: String? = null,
@@ -19,6 +20,8 @@ data class ContentRule(
var imageStyle: String? = null, //默认大小居中,FULL最大宽度 var imageStyle: String? = null, //默认大小居中,FULL最大宽度
var imageDecode: String? = null, //图片bytes二次解密js, 返回解密后的bytes var imageDecode: String? = null, //图片bytes二次解密js, 返回解密后的bytes
var payAction: String? = null, //购买操作,js或者包含{{js}}的url var payAction: String? = null, //购买操作,js或者包含{{js}}的url
/** 监听到事件后执行的回调js代码 **/
var callBackJs: String? = null
) : Parcelable { ) : Parcelable {
@@ -140,7 +140,7 @@ class ContentProcessor private constructor(
1 -> mContent = ChineseUtils.t2s(mContent) 1 -> mContent = ChineseUtils.t2s(mContent)
2 -> mContent = ChineseUtils.s2t(mContent) 2 -> mContent = ChineseUtils.s2t(mContent)
} }
} catch (e: Exception) { } catch (_: Exception) {
appCtx.toastOnUi("简繁转换出错") appCtx.toastOnUi("简繁转换出错")
} }
} }
@@ -153,6 +153,7 @@ class ContentProcessor private constructor(
} }
} }
if (useReplace && book.getUseReplaceRule()) { if (useReplace && book.getUseReplaceRule()) {
val replaceBook = book.toSearchBook()
//替换 //替换
effectiveReplaceRules = arrayListOf() effectiveReplaceRules = arrayListOf()
mContent = mContent.lines().joinToString("\n") { it.trim() } mContent = mContent.lines().joinToString("\n") { it.trim() }
@@ -165,7 +166,9 @@ class ContentProcessor private constructor(
mContent.replace( mContent.replace(
item.regex, item.regex,
item.replacement, item.replacement,
item.getValidTimeoutMillisecond() item.getValidTimeoutMillisecond(),
chapter,
replaceBook
) )
} else { } else {
mContent.replace(item.pattern, item.replacement) 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) { 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() viewModel.replaceRuleChanged()
} }
} }
private val searchContentActivity = private val searchContentActivity =
registerForActivityResult(StartActivityContract(SearchContentActivity::class.java)) { registerForActivityResult(StartActivityContract(SearchContentActivity::class.java)) {
val data = it.data ?: return@registerForActivityResult val data = it.data ?: return@registerForActivityResult
@@ -1409,8 +1410,7 @@ class ReadBookActivity : BaseReadBookActivity(),
override fun showLogin() { override fun showLogin() {
ReadBook.bookSource?.let { ReadBook.bookSource?.let {
startActivity<SourceLoginActivity> { startActivity<SourceLoginActivity> {
putExtra("type", "bookSource") putExtra("bookType", BookType.text)
putExtra("key", it.bookSourceUrl)
} }
} }
} }
@@ -102,7 +102,7 @@ class TipConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_tip_config
} }
binding.btnTitleSegType.setOnClickListener { binding.btnTitleSegType.setOnClickListener {
val types = arrayOf("不分段", "按字符数分段", "按标志字符串分段") val types = arrayOf("不分段", "按字符数分段", "按标志字符串分段", "正则表达式分段")
val current = ReadBookConfig.titleSegType val current = ReadBookConfig.titleSegType
alert(title = "选择标题分段模式") { 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.ButtonColumn
import io.legado.app.ui.book.read.page.entities.column.ImageColumn 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.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.TextColumn
import io.legado.app.ui.book.read.page.entities.column.TextHtmlColumn import io.legado.app.ui.book.read.page.entities.column.TextHtmlColumn
import io.legado.app.ui.book.read.page.provider.ChapterProvider 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.TextLine
import io.legado.app.ui.book.read.page.entities.TextPage 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.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.ChapterProvider
import io.legado.app.ui.book.read.page.provider.LayoutProgressListener import io.legado.app.ui.book.read.page.provider.LayoutProgressListener
import io.legado.app.ui.book.read.page.provider.TextPageFactory import io.legado.app.ui.book.read.page.provider.TextPageFactory
@@ -371,7 +371,7 @@ class ReadView(context: Context, attrs: AttributeSet) :
return@run return@run
} }
val column = textLine.getColumn(j) val column = textLine.getColumn(j)
if (column is TextColumn) { if (column is TextBaseColumn) {
ci += column.charData.length ci += column.charData.length
} else { } else {
ci++ ci++
@@ -12,8 +12,8 @@ import io.legado.app.help.PaintPool
import io.legado.app.help.config.AppConfig import io.legado.app.help.config.AppConfig
import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.config.ReadBookConfig
import io.legado.app.ui.book.read.page.ContentTextView 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.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.entities.column.TextColumn
import io.legado.app.ui.book.read.page.provider.ChapterProvider import io.legado.app.ui.book.read.page.provider.ChapterProvider
import io.legado.app.utils.canvasrecorder.CanvasRecorderFactory import io.legado.app.utils.canvasrecorder.CanvasRecorderFactory
@@ -1,6 +1,4 @@
package io.legado.app.ui.book.read.page package io.legado.app.ui.book.read.page.entities.column
import io.legado.app.ui.book.read.page.entities.column.BaseColumn
/** /**
* 文字基列 * 文字基列
@@ -16,19 +16,19 @@ import io.legado.app.ui.book.read.page.provider.ChapterProvider
data class TextColumn( data class TextColumn(
override var start: Float, override var start: Float,
override var end: Float, override var end: Float,
val charData: String, override val charData: String,
) : BaseColumn { ) : TextBaseColumn {
override var textLine: TextLine = emptyTextLine override var textLine: TextLine = emptyTextLine
var selected: Boolean = false override var selected: Boolean = false
set(value) { set(value) {
if (field != value) { if (field != value) {
textLine.invalidate() textLine.invalidate()
} }
field = value field = value
} }
var isSearchResult: Boolean = false override var isSearchResult: Boolean = false
set(value) { set(value) {
if (field != value) { if (field != value) {
textLine.invalidate() textLine.invalidate()
@@ -47,15 +47,11 @@ data class TextColumn(
} else { } else {
ChapterProvider.contentPaint ChapterProvider.contentPaint
} }
val textColor = if (!textLine.useUnderline && (textLine.isReadAloud || isSearchResult)) { val textColor = if (textLine.isReadAloud || isSearchResult) {
ReadBookConfig.textAccentColor ReadBookConfig.textAccentColor
} else { } else {
ReadBookConfig.textColor ReadBookConfig.textColor
} }
val originalSize = textPaint.textSize
textLine.titleTextSize?.let {
textPaint.textSize = it
}
if (textPaint.color != textColor) { if (textPaint.color != textColor) {
textPaint.color = textColor textPaint.color = textColor
} }
@@ -70,7 +66,6 @@ data class TextColumn(
if (selected) { if (selected) {
canvas.drawRect(start, 0f, end, textLine.height, view.selectedPaint) 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 androidx.annotation.Keep
import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.config.ReadBookConfig
import io.legado.app.ui.book.read.page.ContentTextView 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
import io.legado.app.ui.book.read.page.entities.TextLine.Companion.emptyTextLine
import io.legado.app.ui.book.read.page.provider.ChapterProvider import io.legado.app.ui.book.read.page.provider.ChapterProvider
/** /**
@@ -19,11 +19,11 @@ data class TextHtmlColumn(
override var end: Float, override var end: Float,
override val charData: String, override val charData: String,
val mTextSize: Float, val mTextSize: Float,
val mTextColor: Int, val mTextColor: Int?,
val linkUrl: String? val linkUrl: String?
) : TextBaseColumn { ) : TextBaseColumn {
override var textLine: TextLine = TextLine.Companion.emptyTextLine override var textLine: TextLine = emptyTextLine
private val textPaint: TextPaint by lazy { private val textPaint: TextPaint by lazy {
TextPaint(ChapterProvider.contentPaint).apply { TextPaint(ChapterProvider.contentPaint).apply {
@@ -66,7 +66,7 @@ data class TextHtmlColumn(
color = if (textLine.isReadAloud || isSearchResult) { color = if (textLine.isReadAloud || isSearchResult) {
ReadBookConfig.textAccentColor ReadBookConfig.textAccentColor
} else { } else {
mTextColor mTextColor ?: ReadBookConfig.textColor
} }
isUnderlineText = false isUnderlineText = false
} }
@@ -7,11 +7,14 @@ import android.text.Spanned
import android.text.StaticLayout import android.text.StaticLayout
import android.text.TextPaint import android.text.TextPaint
import android.text.style.ForegroundColorSpan import android.text.style.ForegroundColorSpan
import android.text.style.ImageSpan
import android.text.style.RelativeSizeSpan import android.text.style.RelativeSizeSpan
import android.text.style.URLSpan import android.text.style.URLSpan
import android.util.Size import android.util.Size
import androidx.core.text.HtmlCompat import androidx.core.text.HtmlCompat
import androidx.core.text.parseAsHtml 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.AppLog
import io.legado.app.constant.AppPattern import io.legado.app.constant.AppPattern
import io.legado.app.constant.AppPattern.noWordCountRegex 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.TextChapter
import io.legado.app.ui.book.read.page.entities.TextLine 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.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.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.TextColumn
import io.legado.app.ui.book.read.page.entities.column.TextHtmlColumn import io.legado.app.ui.book.read.page.entities.column.TextHtmlColumn
import io.legado.app.ui.book.read.page.provider.ChapterProvider.reviewChar import io.legado.app.ui.book.read.page.provider.ChapterProvider.reviewChar
@@ -308,7 +313,7 @@ class TextChapterLayout(
prepareNextPageIfNeed() prepareNextPageIfNeed()
return@forEach return@forEach
} else if (text.startsWith("<usehtml>")) { } else if (text.startsWith("<usehtml>")) {
setTypeHtml(book, text.substring(9, text.lastIndexOf("<"))) setTypeHtml(imageStyle, book, text.substring(9, text.lastIndexOf("<")))
return@forEach return@forEach
} }
} }
@@ -553,6 +558,7 @@ class TextChapterLayout(
* 排版html样式 * 排版html样式
*/ */
private suspend fun setTypeHtml( private suspend fun setTypeHtml(
imageStyle: String?,
book: Book, book: Book,
htmlContent: String, htmlContent: String,
) { ) {
@@ -589,8 +595,7 @@ class TextChapterLayout(
continue continue
} }
val textLine = TextLine(isHtml = true) val textLine = TextLine(isHtml = true)
val lineText = spanned.subSequence(lineStart, lineEnd).toString() val lineText = StringBuilder()
textLine.text = lineText //文本
val lineLeft = staticLayout.getLineLeft(lineIndex) val lineLeft = staticLayout.getLineLeft(lineIndex)
textLine.startX = absStartX + lineLeft //x坐标 textLine.startX = absStartX + lineLeft //x坐标
val mLineTop = staticLayout.getLineTop(lineIndex).toFloat() val mLineTop = staticLayout.getLineTop(lineIndex).toFloat()
@@ -599,45 +604,121 @@ class TextChapterLayout(
prepareNextPageIfNeed(durY + lineHeight) prepareNextPageIfNeed(durY + lineHeight)
textLine.upTopBottom(durY, lineHeight, textPaint.fontMetrics) //y坐标 textLine.upTopBottom(durY, lineHeight, textPaint.fontMetrics) //y坐标
val columns = mutableListOf<TextHtmlColumn>() val columns = mutableListOf<BaseColumn>()
var charIndex = lineStart var charIndex = lineStart
while (charIndex < lineEnd) { while (charIndex < lineEnd) {
val char = spanned[charIndex].toString() val char = spanned[charIndex].toString()
lineText.append(char)
if (char == "\n") { if (char == "\n") {
textLine.isParagraphEnd = true textLine.isParagraphEnd = true
durY += lineHeight * paragraphSpacing / 10f //段距 durY += lineHeight * paragraphSpacing / 10f //段距
charIndex++ charIndex++
continue continue
} }
val charX = staticLayout.getPrimaryHorizontal(charIndex) + lineLeft val charX = staticLayout.getPrimaryHorizontal(charIndex)
val textSize = extractTextSize(spanned, charIndex, textPaint.textSize) 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 linkUrl = extractLinkUrl(spanned, charIndex)
val charRight = if (charIndex + 1 < lineEnd) { val charRight = if (charIndex + 1 < lineEnd) {
staticLayout.getPrimaryHorizontal(charIndex + 1) + lineLeft staticLayout.getPrimaryHorizontal(charIndex + 1)
} else { } else {
tempPaint.textSize = textSize tempPaint.textSize = textSize
val charWidth = tempPaint.measureText(char) val charWidth = tempPaint.measureText(char)
charX + charWidth 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( else -> {
TextHtmlColumn( setTypeImage(
absStartX + charX, book,
absStartX + charRight, source,
char, contentPaintTextHeight,
textSize, iStyle,
textColor, imgSize,
linkUrl 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++ charIndex++
if (charIndex == lineEnd && lineIndex == staticLayout.lineCount - 1) { if (charIndex == lineEnd && lineIndex == staticLayout.lineCount - 1) {
textLine.isParagraphEnd = true textLine.isParagraphEnd = true
durY += lineHeight * paragraphSpacing / 10f //段距 durY += lineHeight * paragraphSpacing / 10f //段距
} }
} }
textLine.text = lineText.toString()
if (textFullJustify && !textLine.isParagraphEnd) { if (textFullJustify && !textLine.isParagraphEnd) {
justifyHtmlLine(columns, textLine, visibleWidth) justifyHtmlLine(columns, textLine, visibleWidth)
} else { } else {
@@ -658,7 +739,7 @@ class TextChapterLayout(
* 对HTML行进行两端对齐 * 对HTML行进行两端对齐
*/ */
private fun justifyHtmlLine( private fun justifyHtmlLine(
columns: MutableList<TextHtmlColumn>, columns: MutableList<BaseColumn>,
textLine: TextLine, textLine: TextLine,
lineWidth: Int 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) { if (spaceCount > 1) {
// 多个空格:调整单词间距 // 多个空格:调整单词间距
@@ -689,7 +772,7 @@ class TextChapterLayout(
val col = columns[i] val col = columns[i]
val width = col.end - col.start val width = col.end - col.start
if (col.charData == " " && i != columns.lastIndex) { if ((col as? TextBaseColumn)?.charData == " " && i != columns.lastIndex) {
// 空格,增加额外的间距 // 空格,增加额外的间距
col.start = currentX col.start = currentX
col.end = currentX + width + spaceIncrement col.end = currentX + width + spaceIncrement
@@ -746,23 +829,9 @@ class TextChapterLayout(
return defaultSize return defaultSize
} }
private fun extractTextColor(spanned: Spanned, index: Int, defaultColor: Int): Int { private fun extractTextColor(spanned: Spanned, index: Int): Int? {
// 检查 ForegroundColorSpan(前景色)
val foregroundSpans = spanned.getSpans(index, index + 1, ForegroundColorSpan::class.java) val foregroundSpans = spanned.getSpans(index, index + 1, ForegroundColorSpan::class.java)
foregroundSpans.firstOrNull()?.let { span -> return foregroundSpans.firstOrNull()?.foregroundColor
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? { private fun extractLinkUrl(spanned: Spanned, index: Int): String? {
@@ -3,6 +3,9 @@ package io.legado.app.utils
import androidx.core.os.postDelayed import androidx.core.os.postDelayed
import com.script.ScriptBindings import com.script.ScriptBindings
import com.script.rhino.RhinoScriptEngine 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.exception.RegexTimeoutException
import io.legado.app.help.CrashHandler import io.legado.app.help.CrashHandler
import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.coroutine.Coroutine
@@ -10,7 +13,6 @@ import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import splitties.init.appCtx import splitties.init.appCtx
import java.util.regex.Matcher
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException 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 charSequence = this@replace
val isJs = replacement.startsWith("@js:") val isJs = replacement.startsWith("@js:")
val replacement1 = if (isJs) replacement.substring(4) else replacement 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 { return runBlocking {
suspendCancellableCoroutine { block -> suspendCancellableCoroutine { block ->
val coroutine = Coroutine.async(executeContext = IO) { 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 jsResult = RhinoScriptEngine.run {
val bindings = ScriptBindings() val bindings = ScriptBindings()
bindings["result"] = matcher.group() bindings["result"] = matcher.group()
bindings["chapter"] = chapter
bindings["book"] = book
eval(replacement1, bindings) eval(replacement1, bindings)
}.toString() }.toString()
val quotedResult = Matcher.quoteReplacement(jsResult) val quotedResult = jsResult.quoteReplacementJs()
matcher.appendReplacement(stringBuffer, quotedResult) matcher.appendReplacement(stringBuffer, quotedResult)
} else { } else {
matcher.appendReplacement(stringBuffer, replacement1) matcher.appendReplacement(stringBuffer, replacement1)
@@ -66,5 +83,4 @@ fun CharSequence.replace(regex: Regex, replacement: String, timeout: Long): Stri
} }
} }
} }
} }