[新增] 新增了可选的文本强调方式
[新增] 新增了Html文本渲染(@Luoyacheng) [修复] 修复了某些情况下下划线错误渲染的问题 #551
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,12 @@ import androidx.annotation.IntDef
|
||||
*/
|
||||
@Suppress("ConstPropertyName")
|
||||
object BookType {
|
||||
|
||||
/**
|
||||
* 4 视频
|
||||
*/
|
||||
const val video = 0b100
|
||||
|
||||
/**
|
||||
* 8 文本
|
||||
*/
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = "꧁"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
//标题非隐藏
|
||||
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
|
||||
}
|
||||
val paint = if (index == 0) titlePaint else {
|
||||
TextPaint(titlePaint).apply {
|
||||
textSize = titlePaint.textSize * titleSegScaling
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user