[新增] 新增了可选的文本强调方式

[新增] 新增了Html文本渲染(@Luoyacheng)
[修复] 修复了某些情况下下划线错误渲染的问题 #551
This commit is contained in:
HapeLee
2026-01-17 06:43:18 +08:00
parent bfcb9f88a9
commit de04a1f69d
16 changed files with 501 additions and 137 deletions
@@ -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>
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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