Horis 2 лет назад
Родитель
Сommit
2eb42780ae

+ 4 - 0
app/src/main/java/io/legado/app/data/entities/Book.kt

@@ -282,6 +282,10 @@ data class Book(
         }
     }
 
+    fun getBookSource(): BookSource? {
+        return appDb.bookSourceDao.getBookSource(origin)
+    }
+
     fun toSearchBook() = SearchBook(
         name = name,
         author = author,

+ 1 - 1
app/src/main/java/io/legado/app/help/book/BookHelp.kt

@@ -124,7 +124,7 @@ object BookHelp {
     ) {
         try {
             saveText(book, bookChapter, content)
-            saveImages(bookSource, book, bookChapter, content)
+            //saveImages(bookSource, book, bookChapter, content)
             postEvent(EventBus.SAVE_CONTENT, Pair(book, bookChapter))
         } catch (e: Exception) {
             e.printStackTrace()

+ 21 - 2
app/src/main/java/io/legado/app/model/ReadBook.kt

@@ -145,6 +145,9 @@ object ReadBook : CoroutineScope by MainScope() {
     }
 
     fun clearTextChapter() {
+        prevTextChapter?.cancelLayout()
+        curTextChapter?.cancelLayout()
+        nextTextChapter?.cancelLayout()
         prevTextChapter = null
         curTextChapter = null
         nextTextChapter = null
@@ -210,6 +213,7 @@ object ReadBook : CoroutineScope by MainScope() {
         if (durChapterIndex < chapterSize - 1) {
             durChapterPos = 0
             durChapterIndex++
+            prevTextChapter?.cancelLayout()
             prevTextChapter = curTextChapter
             curTextChapter = nextTextChapter
             nextTextChapter = null
@@ -226,6 +230,7 @@ object ReadBook : CoroutineScope by MainScope() {
             callBack?.upMenuView()
             AppLog.putDebug("moveToNextChapter-curPageChanged()")
             curPageChanged()
+            curTextChapter?.let { callBack?.onCurrentTextChapterChanged(it) }
             return true
         } else {
             AppLog.putDebug("跳转下一章失败,没有下一章")
@@ -241,6 +246,7 @@ object ReadBook : CoroutineScope by MainScope() {
         if (durChapterIndex > 0) {
             durChapterPos = if (toLast) prevTextChapter?.lastReadLength ?: Int.MAX_VALUE else 0
             durChapterIndex--
+            nextTextChapter?.cancelLayout()
             nextTextChapter = curTextChapter
             curTextChapter = prevTextChapter
             prevTextChapter = null
@@ -254,6 +260,7 @@ object ReadBook : CoroutineScope by MainScope() {
             saveRead()
             callBack?.upMenuView()
             curPageChanged()
+            curTextChapter?.let { callBack?.onCurrentTextChapterChanged(it) }
             return true
         } else {
             return false
@@ -477,23 +484,33 @@ object ReadBook : CoroutineScope by MainScope() {
             )
             val contents = contentProcessor
                 .getContent(book, chapter, content, includeTitle = false)
-            val textChapter = ChapterProvider
-                .getTextChapter(book, chapter, displayTitle, contents, chapterSize)
+            val textChapter = ChapterProvider.getTextChapterAsync(
+                book,
+                chapter,
+                displayTitle,
+                contents,
+                chapterSize,
+                this@ReadBook
+            )
             when (val offset = chapter.index - durChapterIndex) {
                 0 -> {
+                    curTextChapter?.cancelLayout()
                     curTextChapter = textChapter
                     if (upContent) callBack?.upContent(offset, resetPageOffset)
                     callBack?.upMenuView()
                     curPageChanged()
                     callBack?.contentLoadFinish()
+                    callBack?.onCurrentTextChapterChanged(textChapter)
                 }
 
                 -1 -> {
+                    prevTextChapter?.cancelLayout()
                     prevTextChapter = textChapter
                     if (upContent) callBack?.upContent(offset, resetPageOffset)
                 }
 
                 1 -> {
+                    nextTextChapter?.cancelLayout()
                     nextTextChapter = textChapter
                     if (upContent) callBack?.upContent(offset, resetPageOffset)
                 }
@@ -632,6 +649,8 @@ object ReadBook : CoroutineScope by MainScope() {
         fun upPageAnim()
 
         fun notifyBookChanged()
+
+        fun onCurrentTextChapterChanged(textChapter: TextChapter)
     }
 
 }

+ 32 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt

@@ -77,7 +77,10 @@ import io.legado.app.ui.book.read.config.TipConfigDialog.Companion.TIP_DIVIDER_C
 import io.legado.app.ui.book.read.page.ContentTextView
 import io.legado.app.ui.book.read.page.ReadView
 import io.legado.app.ui.book.read.page.entities.PageDirection
+import io.legado.app.ui.book.read.page.entities.TextChapter
+import io.legado.app.ui.book.read.page.entities.TextPage
 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.searchContent.SearchContentActivity
 import io.legado.app.ui.book.searchContent.SearchResult
 import io.legado.app.ui.book.source.edit.BookSourceEditActivity
@@ -112,8 +115,10 @@ import io.legado.app.utils.observeEvent
 import io.legado.app.utils.observeEventSticky
 import io.legado.app.utils.postEvent
 import io.legado.app.utils.showDialogFragment
+import io.legado.app.utils.stackTraceStr
 import io.legado.app.utils.startActivity
 import io.legado.app.utils.sysScreenOffTime
+import io.legado.app.utils.throttle
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.visible
 import kotlinx.coroutines.Dispatchers.IO
@@ -139,7 +144,8 @@ class ReadBookActivity : BaseReadBookActivity(),
     ReadBook.CallBack,
     AutoReadDialog.CallBack,
     TxtTocRuleDialog.CallBack,
-    ColorPickerDialogListener {
+    ColorPickerDialogListener,
+    LayoutProgressListener {
 
     private val tocActivity =
         registerForActivityResult(TocActivityResult()) {
@@ -221,6 +227,12 @@ class ReadBookActivity : BaseReadBookActivity(),
     private val handler by lazy { buildMainHandler() }
     private val screenOffRunnable by lazy { Runnable { keepScreenOn(false) } }
     private val executor = ReadBook.executor
+    private val upSeekBarThrotle = throttle(200) {
+        runOnUiThread {
+            upSeekBarProgress()
+            binding.readMenu.upSeekBar()
+        }
+    }
 
     //恢复跳转前进度对话框的交互结果
     private var confirmRestoreProcess: Boolean? = null
@@ -1357,6 +1369,25 @@ class ReadBookActivity : BaseReadBookActivity(),
         binding.readView.autoPager.resume()
     }
 
+    override fun onCurrentTextChapterChanged(textChapter: TextChapter) {
+        textChapter.setProgressListener(this)
+    }
+
+    override fun onLayoutPageCompleted(index: Int, page: TextPage) {
+        upSeekBarThrotle.invoke()
+        binding.readView.onLayoutPageCompleted(index, page)
+    }
+
+    override fun onLayoutCompleted() {
+        binding.readView.onLayoutCompleted()
+    }
+
+    override fun onLayoutException(e: Throwable) {
+        AppLog.put("ChapterProvider ERROR", e)
+        toastOnUi("ChapterProvider ERROR:\n${e.stackTraceStr}")
+        binding.readView.onLayoutException(e)
+    }
+
     /* 全文搜索跳转 */
     private fun skipToSearch(searchResult: SearchResult) {
         val previousResult = binding.searchMenu.previousSearchResult

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

@@ -655,7 +655,7 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
 
     fun createBookmark(): Bookmark? {
         val page = relativePage(selectStart.relativePagePos)
-        page.getTextChapter()?.let { chapter ->
+        page.getTextChapter().let { chapter ->
             ReadBook.book?.let { book ->
                 return book.createBookMark().apply {
                     chapterIndex = page.chapterIndex

+ 9 - 2
app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt

@@ -309,11 +309,18 @@ class PageView(context: Context) : FrameLayout(context) {
     fun setProgress(textPage: TextPage) = textPage.apply {
         tvBookName?.setTextIfNotEqual(ReadBook.book?.name)
         tvTitle?.setTextIfNotEqual(textPage.title)
-        tvPage?.setTextIfNotEqual("${index.plus(1)}/$pageSize")
         val readProgress = readProgress
         tvTotalProgress?.setTextIfNotEqual(readProgress)
         tvTotalProgress1?.setTextIfNotEqual("${chapterIndex.plus(1)}/${chapterSize}")
-        tvPageAndTotal?.setTextIfNotEqual("${index.plus(1)}/$pageSize  $readProgress")
+        if (textChapter.isCompleted) {
+            tvPageAndTotal?.setTextIfNotEqual("${index.plus(1)}/$pageSize  $readProgress")
+            tvPage?.setTextIfNotEqual("${index.plus(1)}/$pageSize")
+        } else {
+            val pageSizeInt = pageSize - 1
+            val pageSize = if (pageSizeInt <= 0) "-" else "~$pageSizeInt"
+            tvPageAndTotal?.setTextIfNotEqual("${index.plus(1)}/$pageSize  $readProgress")
+            tvPage?.setTextIfNotEqual("${index.plus(1)}/$pageSize")
+        }
     }
 
     fun setAutoPager(autoPager: AutoPager?) {

+ 39 - 1
app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt

@@ -28,10 +28,12 @@ import io.legado.app.ui.book.read.page.entities.TextChapter
 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.provider.ChapterProvider
+import io.legado.app.ui.book.read.page.provider.LayoutProgressListener
 import io.legado.app.ui.book.read.page.provider.TextPageFactory
 import io.legado.app.utils.activity
 import io.legado.app.utils.invisible
 import io.legado.app.utils.showDialogFragment
+import io.legado.app.utils.throttle
 import java.text.BreakIterator
 import java.util.Locale
 import kotlin.math.abs
@@ -41,7 +43,7 @@ import kotlin.math.abs
  */
 class ReadView(context: Context, attrs: AttributeSet) :
     FrameLayout(context, attrs),
-    DataSource {
+    DataSource, LayoutProgressListener {
 
     val callBack: CallBack get() = activity as CallBack
     var pageFactory: TextPageFactory = TextPageFactory(this)
@@ -99,6 +101,7 @@ class ReadView(context: Context, attrs: AttributeSet) :
     private val bcRect = RectF()
     private val brRect = RectF()
     private val boundary by lazy { BreakIterator.getWordInstance(Locale.getDefault()) }
+    private val upProgressThrottle = throttle(200) { post { upProgress() } }
     val autoPager = AutoPager(this)
     val isAutoPage get() = autoPager.isRunning
 
@@ -539,6 +542,10 @@ class ReadView(context: Context, attrs: AttributeSet) :
         callBack.screenOffTimerStart()
     }
 
+    private fun upProgress() {
+        curPage.setProgress(pageFactory.curPage)
+    }
+
     /**
      * 更新滑动距离
      */
@@ -654,6 +661,37 @@ class ReadView(context: Context, attrs: AttributeSet) :
         curPage.submitRenderTask()
     }
 
+    override fun onLayoutPageCompleted(index: Int, page: TextPage) {
+        val line = page.lines.first()
+        val durChapterPos = ReadBook.durChapterPos
+        val startPos = line.chapterPosition
+        val endPos = line.chapterPosition + line.charSize
+        if (durChapterPos in startPos..<endPos) {
+            post {
+                upContent(resetPageOffset = false)
+            }
+        }
+        if (isScroll) {
+            val pageIndex = ReadBook.durPageIndex
+            if (index - 3 < pageIndex) {
+                post {
+                    upContent(resetPageOffset = false)
+                }
+            }
+        }
+        upProgressThrottle.invoke()
+    }
+
+    override fun onLayoutCompleted() {
+        post {
+            upContent(resetPageOffset = false)
+        }
+    }
+
+    override fun onLayoutException(e: Throwable) {
+        // no op
+    }
+
     override val currentChapter: TextChapter?
         get() {
             return if (callBack.isInitFinish) ReadBook.textChapter(0) else null

+ 88 - 9
app/src/main/java/io/legado/app/ui/book/read/page/entities/TextChapter.kt

@@ -2,9 +2,14 @@ package io.legado.app.ui.book.read.page.entities
 
 
 import androidx.annotation.Keep
+import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.ReplaceRule
+import io.legado.app.help.book.BookContent
+import io.legado.app.ui.book.read.page.provider.LayoutProgressListener
+import io.legado.app.ui.book.read.page.provider.TextChapterLayout
 import io.legado.app.utils.fastBinarySearchBy
+import kotlinx.coroutines.CoroutineScope
 import kotlin.math.abs
 import kotlin.math.min
 
@@ -17,14 +22,18 @@ data class TextChapter(
     val chapter: BookChapter,
     val position: Int,
     val title: String,
-    val pages: List<TextPage>,
     val chaptersSize: Int,
     val sameTitleRemoved: Boolean,
     val isVip: Boolean,
     val isPay: Boolean,
     //起效的替换规则
     val effectiveReplaceRules: List<ReplaceRule>?
-) {
+) : LayoutProgressListener {
+
+    private val textPages = arrayListOf<TextPage>()
+    val pages: List<TextPage> get() = textPages
+
+    var layout: TextChapterLayout? = null
 
     fun getPage(index: Int): TextPage? {
         return pages.getOrNull(index)
@@ -42,6 +51,10 @@ data class TextChapter(
 
     val pageSize: Int get() = pages.size
 
+    var listener: LayoutProgressListener? = null
+
+    var isCompleted = false
+
     val paragraphs by lazy {
         paragraphsInternal
     }
@@ -79,7 +92,12 @@ data class TextChapter(
      * @return 是否是最后一页
      */
     fun isLastIndex(index: Int): Boolean {
-        return index >= pages.size - 1
+        return isCompleted && index >= pages.size - 1
+    }
+
+    fun isLastIndexCurrent(index: Int): Boolean {
+        // 未完成排版时,最后一页是正在排版中的,需要去掉
+        return index >= if (isCompleted) pages.size - 1 else pages.size - 2
     }
 
     /**
@@ -190,15 +208,28 @@ data class TextChapter(
      * @return 根据索引位置获取所在页
      */
     fun getPageIndexByCharIndex(charIndex: Int): Int {
-        val index = pages.fastBinarySearchBy(charIndex) {
+        val pageSize = pages.size
+        if (pageSize == 0) {
+            return -1
+        }
+        val size = if (isCompleted) pageSize else pageSize - 1
+        val bIndex = pages.fastBinarySearchBy(charIndex, 0, size) {
             it.lines.first().chapterPosition
         }
-        return if (index >= 0) {
-            index
-        } else {
-            abs(index + 1) - 1
+        val index = abs(bIndex + 1) - 1
+        if (index == -1) {
+            return -1
+        }
+        // 判断是否已经排版到 charIndex ,没有则返回 -1
+        if (!isCompleted && index == size - 1) {
+            val line = pages[index].lines.first()
+            val pageEndPos = line.chapterPosition + line.charSize
+            if (charIndex > pageEndPos) {
+                return -1
+            }
         }
-        /* 相当于以下实现
+        return index
+        /*
         var length = 0
         for (i in pages.indices) {
             val page = pages[i]
@@ -222,4 +253,52 @@ data class TextChapter(
         }
     }
 
+    fun createLayout(scope: CoroutineScope, book: Book, bookContent: BookContent) {
+        textPages.clear()
+        layout = TextChapterLayout(
+            scope,
+            this,
+            textPages,
+            book,
+            bookContent,
+        )
+    }
+
+    fun setProgressListener(l: LayoutProgressListener) {
+        if (isCompleted) {
+            l.onLayoutCompleted()
+        } else {
+            listener = l
+        }
+    }
+
+    override fun onLayoutPageCompleted(index: Int, page: TextPage) {
+        listener?.onLayoutPageCompleted(index, page)
+    }
+
+    override fun onLayoutCompleted() {
+        isCompleted = true
+        listener?.onLayoutCompleted()
+        listener = null
+    }
+
+    override fun onLayoutException(e: Throwable) {
+        listener?.onLayoutException(e)
+        listener = null
+    }
+
+    fun cancelLayout() {
+        layout?.cancel()
+    }
+
+    companion object {
+        val emptyTextChapter = TextChapter(
+            BookChapter(), -1, "emptyTextChapter", -1,
+            sameTitleRemoved = false,
+            isVip = false,
+            isPay = false,
+            null
+        ).apply { isCompleted = true }
+    }
+
 }

+ 9 - 19
app/src/main/java/io/legado/app/ui/book/read/page/entities/TextPage.kt

@@ -7,8 +7,8 @@ import androidx.annotation.Keep
 import androidx.core.graphics.withTranslation
 import io.legado.app.R
 import io.legado.app.help.config.ReadBookConfig
-import io.legado.app.model.ReadBook
 import io.legado.app.ui.book.read.page.ContentTextView
+import io.legado.app.ui.book.read.page.entities.TextChapter.Companion.emptyTextChapter
 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
@@ -27,7 +27,6 @@ data class TextPage(
     var text: String = appCtx.getString(R.string.data_loading),
     var title: String = appCtx.getString(R.string.data_loading),
     private val textLines: ArrayList<TextLine> = arrayListOf(),
-    var pageSize: Int = 0,
     var chapterSize: Int = 0,
     var chapterIndex: Int = 0,
     var height: Float = 0f,
@@ -47,6 +46,10 @@ data class TextPage(
     var canvasRecorder = CanvasRecorderFactory.create(true)
     var doublePage = false
     var paddingTop = 0
+    var isCompleted = false
+    @JvmField
+    var textChapter = emptyTextChapter
+    val pageSize get() = textChapter.pageSize
 
     val paragraphs by lazy {
         paragraphsInternal
@@ -162,6 +165,7 @@ data class TextPage(
             }
             height = ChapterProvider.visibleHeight.toFloat()
             invalidate()
+            isCompleted = true
         }
         return this
     }
@@ -248,23 +252,8 @@ data class TextPage(
     /**
      * @return 页面所在章节
      */
-    fun getTextChapter(): TextChapter? {
-        ReadBook.curTextChapter?.let {
-            if (it.position == chapterIndex) {
-                return it
-            }
-        }
-        ReadBook.nextTextChapter?.let {
-            if (it.position == chapterIndex) {
-                return it
-            }
-        }
-        ReadBook.prevTextChapter?.let {
-            if (it.position == chapterIndex) {
-                return it
-            }
-        }
-        return null
+    fun getTextChapter(): TextChapter {
+        return textChapter
     }
 
     fun draw(view: ContentTextView, canvas: Canvas, relativeOffset: Float) {
@@ -284,6 +273,7 @@ data class TextPage(
     }
 
     fun render(view: ContentTextView): Boolean {
+        if (!isCompleted) return false
         return canvasRecorder.recordIfNeeded(view.width, height.toInt()) {
             drawPage(view, this)
         }

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

@@ -32,6 +32,7 @@ import io.legado.app.utils.postEvent
 import io.legado.app.utils.spToPx
 import io.legado.app.utils.splitNotBlank
 import io.legado.app.utils.textHeight
+import kotlinx.coroutines.CoroutineScope
 import splitties.init.appCtx
 import java.util.LinkedList
 import java.util.Locale
@@ -42,12 +43,12 @@ import java.util.Locale
 @Suppress("DEPRECATION", "ConstPropertyName")
 object ChapterProvider {
     //用于图片字的替换
-    private const val srcReplaceChar = "▩"
+    const val srcReplaceChar = "▩"
 
     //用于评论按钮的替换
-    private const val reviewChar = "▨"
+    const val reviewChar = "▨"
 
-    private const val indentChar = " "
+    const val indentChar = " "
 
     @JvmStatic
     var viewWidth = 0
@@ -94,25 +95,31 @@ object ChapterProvider {
         private set
 
     @JvmStatic
-    private var paragraphSpacing = 0
+    var paragraphSpacing = 0
+        private set
 
     @JvmStatic
-    private var titleTopSpacing = 0
+    var titleTopSpacing = 0
+        private set
 
     @JvmStatic
-    private var titleBottomSpacing = 0
+    var titleBottomSpacing = 0
+        private set
 
     @JvmStatic
-    private var indentCharWidth = 0f
+    var indentCharWidth = 0f
+        private set
 
     @JvmStatic
     var titlePaintTextHeight = 0f
+        private set
 
     @JvmStatic
     var contentPaintTextHeight = 0f
+        private set
 
     @JvmStatic
-    private var titlePaintFontMetrics = FontMetrics()
+    var titlePaintFontMetrics = FontMetrics()
 
     @JvmStatic
     var contentPaintFontMetrics = FontMetrics()
@@ -278,7 +285,7 @@ object ChapterProvider {
         textPage.text = stringBuilder.toString()
         textPages.forEachIndexed { index, item ->
             item.index = index
-            item.pageSize = textPages.size
+            //item.pageSize = textPages.size
             item.chapterIndex = bookChapter.index
             item.chapterSize = chapterSize
             item.title = displayTitle
@@ -290,7 +297,8 @@ object ChapterProvider {
         return TextChapter(
             bookChapter,
             bookChapter.index, displayTitle,
-            textPages, chapterSize,
+            //textPages,
+            chapterSize,
             bookContent.sameTitleRemoved,
             bookChapter.isVip,
             bookChapter.isPay,
@@ -298,6 +306,30 @@ object ChapterProvider {
         )
     }
 
+    fun getTextChapterAsync(
+        book: Book,
+        bookChapter: BookChapter,
+        displayTitle: String,
+        bookContent: BookContent,
+        chapterSize: Int,
+        scope: CoroutineScope
+    ): TextChapter {
+
+        val textChapter = TextChapter(
+            bookChapter,
+            bookChapter.index, displayTitle,
+            chapterSize,
+            bookContent.sameTitleRemoved,
+            bookChapter.isVip,
+            bookChapter.isPay,
+            bookContent.effectiveReplaceRules
+        ).apply {
+            createLayout(scope, book, bookContent)
+        }
+
+        return textChapter
+    }
+
     /**
      * 排版图片
      */

+ 22 - 0
app/src/main/java/io/legado/app/ui/book/read/page/provider/LayoutProgressListener.kt

@@ -0,0 +1,22 @@
+package io.legado.app.ui.book.read.page.provider
+
+import io.legado.app.ui.book.read.page.entities.TextPage
+
+interface LayoutProgressListener {
+
+    /**
+     * 单页排版完成
+     */
+    fun onLayoutPageCompleted(index: Int, page: TextPage)
+
+    /**
+     * 全部排版完成
+     */
+    fun onLayoutCompleted()
+
+    /**
+     * 排版出现异常
+     */
+    fun onLayoutException(e: Throwable)
+
+}

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

@@ -0,0 +1,752 @@
+package io.legado.app.ui.book.read.page.provider
+
+import android.graphics.Paint
+import android.text.Layout
+import android.text.StaticLayout
+import android.text.TextPaint
+import io.legado.app.constant.AppLog
+import io.legado.app.constant.AppPattern
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
+import io.legado.app.help.book.BookContent
+import io.legado.app.help.book.BookHelp
+import io.legado.app.help.config.AppConfig
+import io.legado.app.help.config.ReadBookConfig
+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.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.ReviewColumn
+import io.legado.app.ui.book.read.page.entities.column.TextColumn
+import io.legado.app.utils.dpToPx
+import io.legado.app.utils.fastSum
+import io.legado.app.utils.splitNotBlank
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import java.util.LinkedList
+import java.util.Locale
+
+class TextChapterLayout(
+    scope: CoroutineScope,
+    private val textChapter: TextChapter,
+    private val textPages: ArrayList<TextPage>,
+    private val book: Book,
+    private val bookContent: BookContent,
+) {
+
+    @Volatile
+    private var listener: LayoutProgressListener? = textChapter
+
+    private val paddingLeft = ChapterProvider.paddingLeft
+    private val paddingTop = ChapterProvider.paddingTop
+
+    private val titlePaint = ChapterProvider.titlePaint
+    private val titlePaintTextHeight = ChapterProvider.titlePaintTextHeight
+    private val titlePaintFontMetrics = ChapterProvider.titlePaintFontMetrics
+
+    private val contentPaint = ChapterProvider.contentPaint
+    private val contentPaintTextHeight = ChapterProvider.contentPaintTextHeight
+    private val contentPaintFontMetrics = ChapterProvider.contentPaintFontMetrics
+
+    private val titleTopSpacing = ChapterProvider.titleTopSpacing
+    private val titleBottomSpacing = ChapterProvider.titleBottomSpacing
+    private val lineSpacingExtra = ChapterProvider.lineSpacingExtra
+    private val paragraphSpacing = ChapterProvider.paragraphSpacing
+
+    private val visibleHeight = ChapterProvider.visibleHeight
+    private val visibleWidth = ChapterProvider.visibleWidth
+
+    private val viewWidth = ChapterProvider.viewWidth
+    private val doublePage = ChapterProvider.doublePage
+    private val indentCharWidth = ChapterProvider.indentCharWidth
+    private val stringBuilder = StringBuilder()
+
+    private var isCompleted = false
+    private var exception: Throwable? = null
+    private val job: Coroutine<*>
+    private val bookChapter inline get() = textChapter.chapter
+    private val displayTitle inline get() = textChapter.title
+    private val chaptersSize inline get() = textChapter.chaptersSize
+
+
+    init {
+        job = Coroutine.async(scope) {
+            launch {
+                val bookSource = book.getBookSource() ?: return@launch
+                BookHelp.saveImages(bookSource, book, bookChapter, bookContent.toString())
+            }
+            getTextChapter(book, bookChapter, displayTitle, bookContent)
+        }.onError {
+            exception = it
+            onException(it)
+        }.onFinally {
+            isCompleted = true
+        }
+    }
+
+    fun setProgressListener(l: LayoutProgressListener) {
+        try {
+            if (isCompleted) {
+                l.onLayoutCompleted()
+            } else if (exception != null) {
+                l.onLayoutException(exception!!)
+            } else {
+                listener = l
+            }
+        } catch (e: Exception) {
+            e.printStackTrace()
+            AppLog.put("调用布局进度监听回调出错\n${e.localizedMessage}", e)
+        }
+    }
+
+    fun cancel() {
+        job.cancel()
+    }
+
+    private fun onPageCompleted() {
+        val textPage = textPages.last()
+        textPage.index = textPages.lastIndex
+        textPage.chapterIndex = bookChapter.index
+        textPage.chapterSize = chaptersSize
+        textPage.title = displayTitle
+        textPage.doublePage = doublePage
+        textPage.paddingTop = paddingTop
+        textPage.isCompleted = true
+        textPage.textChapter = textChapter
+        textPage.upLinesPosition()
+        try {
+            listener?.onLayoutPageCompleted(textPages.lastIndex, textPage)
+        } catch (e: Exception) {
+            e.printStackTrace()
+            AppLog.put("调用布局进度监听回调出错\n${e.localizedMessage}", e)
+        }
+    }
+
+    private fun onCompleted() {
+        try {
+            listener?.onLayoutCompleted()
+        } catch (e: Exception) {
+            e.printStackTrace()
+            AppLog.put("调用布局进度监听回调出错\n${e.localizedMessage}", e)
+        } finally {
+            listener = null
+        }
+    }
+
+    private fun onException(e: Throwable) {
+        if (e is CancellationException) {
+            return
+        }
+        try {
+            listener?.onLayoutException(e)
+        } catch (e: Exception) {
+            e.printStackTrace()
+            AppLog.put("调用布局进度监听回调出错\n${e.localizedMessage}", e)
+        } finally {
+            listener = null
+        }
+    }
+
+    /**
+     * 获取拆分完的章节数据
+     */
+    private suspend fun getTextChapter(
+        book: Book,
+        bookChapter: BookChapter,
+        displayTitle: String,
+        bookContent: BookContent,
+    ) {
+        val contents = bookContent.textList
+        var absStartX = paddingLeft
+        var durY = 0f
+        textPages.add(TextPage())
+        if (ReadBookConfig.titleMode != 2 || bookChapter.isVolume) {
+            //标题非隐藏
+            displayTitle.splitNotBlank("\n").forEach { text ->
+                setTypeText(
+                    book, absStartX, durY,
+                    if (AppConfig.enableReview) text + ChapterProvider.reviewChar else text,
+                    titlePaint,
+                    titlePaintTextHeight,
+                    titlePaintFontMetrics,
+                    isTitle = true,
+                    emptyContent = contents.isEmpty(),
+                    isVolumeTitle = bookChapter.isVolume
+                ).let {
+                    absStartX = it.first
+                    durY = it.second
+                }
+            }
+            textPages.last().lines.last().isParagraphEnd = true
+            stringBuilder.append("\n")
+            durY += titleBottomSpacing
+        }
+        val sb = StringBuffer()
+        contents.forEach { content ->
+            if (book.getImageStyle().equals(Book.imgStyleText, true)) {
+                //图片样式为文字嵌入类型
+                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.appendTail(sb)
+                text = sb.toString()
+                setTypeText(
+                    book,
+                    absStartX,
+                    durY,
+                    text,
+                    contentPaint,
+                    contentPaintTextHeight,
+                    contentPaintFontMetrics,
+                    srcList = srcList
+                ).let {
+                    absStartX = it.first
+                    durY = it.second
+                }
+            } else {
+                val matcher = AppPattern.imgPattern.matcher(content)
+                var start = 0
+                while (matcher.find()) {
+                    val text = content.substring(start, matcher.start())
+                    if (text.isNotBlank()) {
+                        setTypeText(
+                            book,
+                            absStartX,
+                            durY,
+                            text,
+                            contentPaint,
+                            contentPaintTextHeight,
+                            contentPaintFontMetrics
+                        ).let {
+                            absStartX = it.first
+                            durY = it.second
+                        }
+                    }
+                    setTypeImage(
+                        book,
+                        matcher.group(1)!!,
+                        absStartX,
+                        durY,
+                        textPages,
+                        contentPaintTextHeight,
+                        stringBuilder,
+                        book.getImageStyle()
+                    ).let {
+                        absStartX = it.first
+                        durY = it.second
+                    }
+                    start = matcher.end()
+                }
+                if (start < content.length) {
+                    val text = content.substring(start, content.length)
+                    if (text.isNotBlank()) {
+                        setTypeText(
+                            book, absStartX, durY,
+                            if (AppConfig.enableReview) text + ChapterProvider.reviewChar else text,
+                            contentPaint,
+                            contentPaintTextHeight,
+                            contentPaintFontMetrics
+                        ).let {
+                            absStartX = it.first
+                            durY = it.second
+                        }
+                    }
+                }
+            }
+            textPages.last().lines.last().isParagraphEnd = true
+            stringBuilder.append("\n")
+        }
+        val textPage = textPages.last()
+        val endPadding = 20.dpToPx()
+        val durYPadding = durY + endPadding
+        if (textPage.height < durYPadding) {
+            textPage.height = durYPadding
+        } else {
+            textPage.height += endPadding
+        }
+        textPage.text = stringBuilder.toString()
+        onPageCompleted()
+        onCompleted()
+    }
+
+    /**
+     * 排版图片
+     */
+    private suspend fun setTypeImage(
+        book: Book,
+        src: String,
+        x: Int,
+        y: Float,
+        textPages: ArrayList<TextPage>,
+        textHeight: Float,
+        stringBuilder: StringBuilder,
+        imageStyle: String?,
+    ): Pair<Int, Float> {
+        var absStartX = x
+        var durY = y
+        val size = ImageProvider.getImageSize(book, src, ReadBook.bookSource)
+        if (size.width > 0 && size.height > 0) {
+            if (durY > visibleHeight) {
+                val textPage = textPages.last()
+                if (textPage.height < durY) {
+                    textPage.height = durY
+                }
+                textPage.text = stringBuilder.toString().ifEmpty { "本页无文字内容" }
+                stringBuilder.clear()
+                onPageCompleted()
+                textPages.add(TextPage())
+                durY = 0f
+            }
+            var height = size.height
+            var width = size.width
+            when (imageStyle?.uppercase(Locale.ROOT)) {
+                Book.imgStyleFull -> {
+                    width = visibleWidth
+                    height = size.height * visibleWidth / size.width
+                }
+
+                else -> {
+                    if (size.width > visibleWidth) {
+                        height = size.height * visibleWidth / size.width
+                        width = visibleWidth
+                    }
+                    if (height > visibleHeight) {
+                        width = width * visibleHeight / height
+                        height = visibleHeight
+                    }
+                    if (durY + height > visibleHeight) {
+                        val textPage = textPages.last()
+                        if (doublePage && absStartX < viewWidth / 2) {
+                            //当前页面左列结束
+                            textPage.leftLineSize = textPage.lineSize
+                            absStartX = viewWidth / 2 + paddingLeft
+                        } else {
+                            //当前页面结束
+                            if (textPage.leftLineSize == 0) {
+                                textPage.leftLineSize = textPage.lineSize
+                            }
+                            textPage.text = stringBuilder.toString().ifEmpty { "本页无文字内容" }
+                            stringBuilder.clear()
+                            onPageCompleted()
+                            textPages.add(TextPage())
+                        }
+                        // 双页的 durY 不正确,可能会小于实际高度
+                        if (textPage.height < durY) {
+                            textPage.height = durY
+                        }
+                        durY = 0f
+                    }
+                }
+            }
+            val textLine = TextLine(isImage = true)
+            textLine.lineTop = durY + paddingTop
+            durY += height
+            textLine.lineBottom = durY + paddingTop
+            val (start, end) = if (visibleWidth > width) {
+                val adjustWidth = (visibleWidth - width) / 2f
+                Pair(adjustWidth, adjustWidth + width)
+            } else {
+                Pair(0f, width.toFloat())
+            }
+            textLine.addColumn(
+                ImageColumn(start = x + start, end = x + end, src = src)
+            )
+            calcTextLinePosition(textPages, textLine, stringBuilder.length)
+            stringBuilder.append(" ") // 确保翻页时索引计算正确
+            textPages.last().addLine(textLine)
+        }
+        return absStartX to durY + textHeight * paragraphSpacing / 10f
+    }
+
+    /**
+     * 排版文字
+     */
+    @Suppress("DEPRECATION")
+    private suspend fun setTypeText(
+        book: Book,
+        x: Int,
+        y: Float,
+        text: String,
+        textPaint: TextPaint,
+        textHeight: Float,
+        fontMetrics: Paint.FontMetrics,
+        isTitle: Boolean = false,
+        emptyContent: Boolean = false,
+        isVolumeTitle: Boolean = false,
+        srcList: LinkedList<String>? = null
+    ): Pair<Int, Float> {
+        var absStartX = x
+        val layout = if (ReadBookConfig.useZhLayout) {
+            ZhLayout(text, textPaint, visibleWidth)
+        } else {
+            StaticLayout(text, textPaint, visibleWidth, Layout.Alignment.ALIGN_NORMAL, 0f, 0f, true)
+        }
+        var durY = when {
+            //标题y轴居中
+            emptyContent && textPages.size == 1 -> {
+                val textPage = textPages.last()
+                if (textPage.lineSize == 0) {
+                    val ty = (visibleHeight - layout.lineCount * textHeight) / 2
+                    if (ty > titleTopSpacing) ty else titleTopSpacing.toFloat()
+                } else {
+                    var textLayoutHeight = layout.lineCount * textHeight
+                    val fistLine = textPage.getLine(0)
+                    if (fistLine.lineTop < textLayoutHeight + titleTopSpacing) {
+                        textLayoutHeight = fistLine.lineTop - titleTopSpacing
+                    }
+                    textPage.lines.forEach {
+                        it.lineTop -= textLayoutHeight
+                        it.lineBase -= textLayoutHeight
+                        it.lineBottom -= textLayoutHeight
+                    }
+                    y - textLayoutHeight
+                }
+            }
+
+            isTitle && textPages.size == 1 && textPages.last().lines.isEmpty() ->
+                y + titleTopSpacing
+
+            else -> y
+        }
+        for (lineIndex in 0 until layout.lineCount) {
+            val textLine = TextLine(isTitle = isTitle)
+            if (durY + textHeight > visibleHeight) {
+                val textPage = textPages.last()
+                if (doublePage && absStartX < viewWidth / 2) {
+                    //当前页面左列结束
+                    textPage.leftLineSize = textPage.lineSize
+                    absStartX = viewWidth / 2 + paddingLeft
+                } else {
+                    //当前页面结束,设置各种值
+                    if (textPage.leftLineSize == 0) {
+                        textPage.leftLineSize = textPage.lineSize
+                    }
+                    textPage.text = stringBuilder.toString()
+                    onPageCompleted()
+                    //新建页面
+                    textPages.add(TextPage())
+                    stringBuilder.clear()
+                    absStartX = paddingLeft
+                }
+                if (textPage.height < durY) {
+                    textPage.height = durY
+                }
+                durY = 0f
+            }
+            val lineStart = layout.getLineStart(lineIndex)
+            val lineEnd = layout.getLineEnd(lineIndex)
+            val lineText = text.substring(lineStart, lineEnd)
+            val (words, widths) = measureTextSplit(lineText, textPaint)
+            val desiredWidth = widths.fastSum()
+            when {
+                lineIndex == 0 && layout.lineCount > 1 && !isTitle -> {
+                    //第一行 非标题
+                    textLine.text = lineText
+                    addCharsToLineFirst(
+                        book, absStartX, textLine, words,
+                        desiredWidth, widths, srcList
+                    )
+                }
+
+                lineIndex == layout.lineCount - 1 -> {
+                    //最后一行
+                    textLine.text = lineText
+                    //标题x轴居中
+                    val startX = if (
+                        isTitle &&
+                        (ReadBookConfig.isMiddleTitle || emptyContent || isVolumeTitle)
+                    ) {
+                        (visibleWidth - desiredWidth) / 2
+                    } else {
+                        0f
+                    }
+                    addCharsToLineNatural(
+                        book, absStartX, textLine, words,
+                        startX, !isTitle && lineIndex == 0, widths, srcList
+                    )
+                }
+
+                else -> {
+                    if (
+                        isTitle &&
+                        (ReadBookConfig.isMiddleTitle || emptyContent || isVolumeTitle)
+                    ) {
+                        //标题居中
+                        val startX = (visibleWidth - desiredWidth) / 2
+                        addCharsToLineNatural(
+                            book, absStartX, textLine, words,
+                            startX, false, widths, srcList
+                        )
+                    } else {
+                        //中间行
+                        textLine.text = lineText
+                        addCharsToLineMiddle(
+                            book, absStartX, textLine, words,
+                            desiredWidth, 0f, widths, srcList
+                        )
+                    }
+                }
+            }
+            if (doublePage) {
+                textLine.isLeftLine = absStartX < viewWidth / 2
+            }
+            calcTextLinePosition(textPages, textLine, stringBuilder.length)
+            stringBuilder.append(lineText)
+            textLine.upTopBottom(durY, textHeight, fontMetrics)
+            val textPage = textPages.last()
+            textPage.addLine(textLine)
+            durY += textHeight * lineSpacingExtra
+            if (textPage.height < durY) {
+                textPage.height = durY
+            }
+        }
+        durY += textHeight * paragraphSpacing / 10f
+        return Pair(absStartX, durY)
+    }
+
+    private fun calcTextLinePosition(
+        textPages: ArrayList<TextPage>,
+        textLine: TextLine,
+        sbLength: Int
+    ) {
+        val lastLine = textPages.last().lines.lastOrNull { it.paragraphNum > 0 }
+            ?: textPages.getOrNull(textPages.lastIndex - 1)?.lines?.lastOrNull { it.paragraphNum > 0 }
+        val paragraphNum = when {
+            lastLine == null -> 1
+            lastLine.isParagraphEnd -> lastLine.paragraphNum + 1
+            else -> lastLine.paragraphNum
+        }
+        textLine.paragraphNum = paragraphNum
+        textLine.chapterPosition =
+            (textPages.getOrNull(textPages.lastIndex - 1)?.lines?.lastOrNull()?.run {
+                chapterPosition + charSize + if (isParagraphEnd) 1 else 0
+            } ?: 0) + sbLength
+        textLine.pagePosition = sbLength
+    }
+
+    /**
+     * 有缩进,两端对齐
+     */
+    private suspend fun addCharsToLineFirst(
+        book: Book,
+        absStartX: Int,
+        textLine: TextLine,
+        words: List<String>,
+        /**自然排版长度**/
+        desiredWidth: Float,
+        textWidths: List<Float>,
+        srcList: LinkedList<String>?
+    ) {
+        var x = 0f
+        if (!ReadBookConfig.textFullJustify) {
+            addCharsToLineNatural(
+                book, absStartX, textLine, words,
+                x, true, textWidths, srcList
+            )
+            return
+        }
+        val bodyIndent = ReadBookConfig.paragraphIndent
+        for (i in bodyIndent.indices) {
+            val x1 = x + indentCharWidth
+            textLine.addColumn(
+                TextColumn(
+                    charData = ChapterProvider.indentChar,
+                    start = absStartX + x,
+                    end = absStartX + x1
+                )
+            )
+            x = x1
+            textLine.indentWidth = x
+        }
+        if (words.size > bodyIndent.length) {
+            val text1 = words.subList(bodyIndent.length, words.size)
+            val textWidths1 = textWidths.subList(bodyIndent.length, textWidths.size)
+            addCharsToLineMiddle(
+                book, absStartX, textLine, text1,
+                desiredWidth, x, textWidths1, srcList
+            )
+        }
+    }
+
+    /**
+     * 无缩进,两端对齐
+     */
+    private suspend fun addCharsToLineMiddle(
+        book: Book,
+        absStartX: Int,
+        textLine: TextLine,
+        words: List<String>,
+        /**自然排版长度**/
+        desiredWidth: Float,
+        /**起始x坐标**/
+        startX: Float,
+        textWidths: List<Float>,
+        srcList: LinkedList<String>?
+    ) {
+        if (!ReadBookConfig.textFullJustify) {
+            addCharsToLineNatural(
+                book, absStartX, textLine, words,
+                startX, false, textWidths, srcList
+            )
+            return
+        }
+        val residualWidth = visibleWidth - desiredWidth
+        val spaceSize = words.count { it == " " }
+        if (spaceSize > 1) {
+            val d = residualWidth / spaceSize
+            var x = startX
+            for (index in words.indices) {
+                val char = words[index]
+                val cw = textWidths[index]
+                val x1 = if (char == " ") {
+                    if (index != words.lastIndex) (x + cw + d) else (x + cw)
+                } else {
+                    (x + cw)
+                }
+                addCharToLine(
+                    book, absStartX, textLine, char,
+                    x, x1, index + 1 == words.size, srcList
+                )
+                x = x1
+            }
+        } else {
+            val gapCount: Int = words.lastIndex
+            val d = residualWidth / gapCount
+            var x = startX
+            for (index in words.indices) {
+                val char = words[index]
+                val cw = textWidths[index]
+                val x1 = if (index != words.lastIndex) (x + cw + d) else (x + cw)
+                addCharToLine(
+                    book, absStartX, textLine, char,
+                    x, x1, index + 1 == words.size, srcList
+                )
+                x = x1
+            }
+        }
+        exceed(absStartX, textLine, words)
+    }
+
+    /**
+     * 自然排列
+     */
+    private suspend fun addCharsToLineNatural(
+        book: Book,
+        absStartX: Int,
+        textLine: TextLine,
+        words: List<String>,
+        startX: Float,
+        hasIndent: Boolean,
+        textWidths: List<Float>,
+        srcList: LinkedList<String>?
+    ) {
+        val indentLength = ReadBookConfig.paragraphIndent.length
+        var x = startX
+        for (index in words.indices) {
+            val char = words[index]
+            val cw = textWidths[index]
+            val x1 = x + cw
+            addCharToLine(book, absStartX, textLine, char, x, x1, index + 1 == words.size, srcList)
+            x = x1
+            if (hasIndent && index == indentLength - 1) {
+                textLine.indentWidth = x
+            }
+        }
+        exceed(absStartX, textLine, words)
+    }
+
+    /**
+     * 添加字符
+     */
+    private suspend fun addCharToLine(
+        book: Book,
+        absStartX: Int,
+        textLine: TextLine,
+        char: String,
+        xStart: Float,
+        xEnd: Float,
+        isLineEnd: Boolean,
+        srcList: LinkedList<String>?
+    ) {
+        val column = when {
+            srcList != null && char == ChapterProvider.srcReplaceChar -> {
+                val src = srcList.removeFirst()
+                ImageProvider.cacheImage(book, src, ReadBook.bookSource)
+                ImageColumn(
+                    start = absStartX + xStart,
+                    end = absStartX + xEnd,
+                    src = src
+                )
+            }
+
+            isLineEnd && char == ChapterProvider.reviewChar -> {
+                ReviewColumn(
+                    start = absStartX + xStart,
+                    end = absStartX + xEnd,
+                    count = 100
+                )
+            }
+
+            else -> {
+                TextColumn(
+                    start = absStartX + xStart,
+                    end = absStartX + xEnd,
+                    charData = char
+                )
+            }
+        }
+        textLine.addColumn(column)
+    }
+
+    /**
+     * 超出边界处理
+     */
+    private fun exceed(absStartX: Int, textLine: TextLine, words: List<String>) {
+        val visibleEnd = absStartX + visibleWidth
+        val endX = textLine.columns.lastOrNull()?.end ?: return
+        if (endX > visibleEnd) {
+            val cc = (endX - visibleEnd) / words.size
+            for (i in 0..words.lastIndex) {
+                textLine.getColumnReverseAt(i).let {
+                    val py = cc * (words.size - i)
+                    it.start -= py
+                    it.end -= py
+                }
+            }
+        }
+    }
+
+    private fun measureTextSplit(
+        text: String,
+        paint: TextPaint
+    ): Pair<ArrayList<String>, ArrayList<Float>> {
+        val length = text.length
+        val widthsArray = FloatArray(length)
+        paint.getTextWidths(text, widthsArray)
+        val clusterCount = widthsArray.count { it > 0f }
+        val widths = ArrayList<Float>(clusterCount)
+        val stringList = ArrayList<String>(clusterCount)
+        var i = 0
+        while (i < length) {
+            val clusterBaseIndex = i++
+            widths.add(widthsArray[clusterBaseIndex])
+            while (i < length && widthsArray[i] == 0f) {
+                i++
+            }
+            stringList.add(text.substring(clusterBaseIndex, i))
+        }
+        return stringList to widths
+    }
+
+}

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

@@ -35,12 +35,16 @@ class TextPageFactory(dataSource: DataSource) : PageFactory<TextPage>(dataSource
 
     override fun moveToNext(upContent: Boolean): Boolean = with(dataSource) {
         return if (hasNext()) {
+            val pageIndex = pageIndex
             if (currentChapter == null || currentChapter?.isLastIndex(pageIndex) == true) {
                 if ((currentChapter == null || isScroll) && nextChapter == null) {
                     return@with false
                 }
                 ReadBook.moveToNextChapter(upContent, false)
             } else {
+                if (pageIndex < 0 || currentChapter?.isLastIndexCurrent(pageIndex) == true) {
+                    return@with false
+                }
                 ReadBook.setPageIndex(pageIndex.plus(1))
             }
             if (upContent) upContent(resetPageOffset = false)
@@ -55,6 +59,9 @@ class TextPageFactory(dataSource: DataSource) : PageFactory<TextPage>(dataSource
                 if (currentChapter == null && prevChapter == null) {
                     return@with false
                 }
+                if (prevChapter != null && prevChapter?.isCompleted == false) {
+                    return@with false
+                }
                 ReadBook.moveToPrevChapter(upContent, upContentInPlace = false)
             } else {
                 if (currentChapter == null) {
@@ -74,7 +81,8 @@ class TextPageFactory(dataSource: DataSource) : PageFactory<TextPage>(dataSource
                 return@with TextPage(text = it).format()
             }
             currentChapter?.let {
-                return@with it.getPage(pageIndex) ?: TextPage(title = it.title).format()
+                return@with it.getPage(pageIndex)
+                    ?: TextPage(title = it.title).apply { textChapter = it }.format()
             }
             return TextPage().format()
         }
@@ -90,6 +98,9 @@ class TextPageFactory(dataSource: DataSource) : PageFactory<TextPage>(dataSource
                     return@with it.getPage(pageIndex + 1)?.removePageAloudSpan()
                         ?: TextPage(title = it.title).format()
                 }
+                if (!it.isCompleted) {
+                    return@with TextPage(title = it.title).format()
+                }
             }
             nextChapter?.let {
                 return@with it.getPage(0)?.removePageAloudSpan()
@@ -109,6 +120,9 @@ class TextPageFactory(dataSource: DataSource) : PageFactory<TextPage>(dataSource
                     return@with it.getPage(pageIndex - 1)?.removePageAloudSpan()
                         ?: TextPage(title = it.title).format()
                 }
+                if (!it.isCompleted) {
+                    return@with TextPage(title = it.title).format()
+                }
             }
             prevChapter?.let {
                 return@with it.lastPage?.removePageAloudSpan()
@@ -125,6 +139,9 @@ class TextPageFactory(dataSource: DataSource) : PageFactory<TextPage>(dataSource
                     return@with it.getPage(pageIndex + 2)?.removePageAloudSpan()
                         ?: TextPage(title = it.title).format()
                 }
+                if (!it.isCompleted) {
+                    return@with TextPage(title = it.title).format()
+                }
                 nextChapter?.let { nc ->
                     if (pageIndex < it.pageSize - 1) {
                         return@with nc.getPage(0)?.removePageAloudSpan()

+ 6 - 0
app/src/main/java/io/legado/app/utils/CollectionExtensions.kt

@@ -13,6 +13,12 @@ inline fun <T> List<T>.fastBinarySearch(
     toIndex: Int = size,
     comparison: (T) -> Int
 ): Int {
+    when {
+        fromIndex > toIndex -> throw IllegalArgumentException("fromIndex ($fromIndex) is greater than toIndex ($toIndex).")
+        fromIndex < 0 -> throw IndexOutOfBoundsException("fromIndex ($fromIndex) is less than zero.")
+        toIndex > size -> throw IndexOutOfBoundsException("toIndex ($toIndex) is greater than size ($size).")
+    }
+
     var low = fromIndex
     var high = toIndex - 1
 

+ 8 - 0
app/src/main/java/io/legado/app/utils/Debounce.kt

@@ -119,3 +119,11 @@ open class Debounce<T>(
     }
 
 }
+
+fun <T> debounce(
+    wait: Long = 0L,
+    maxWait: Long = -1L,
+    leading: Boolean = false,
+    trailing: Boolean = true,
+    func: () -> T
+) = Debounce(wait, maxWait, leading, trailing, func)

+ 7 - 0
app/src/main/java/io/legado/app/utils/Throttle.kt

@@ -7,3 +7,10 @@ class Throttle<T>(
     trailing: Boolean = true,
     func: () -> T
 ) : Debounce<T>(wait, wait, leading, trailing, func)
+
+fun <T> throttle(
+    wait: Long = 0L,
+    leading: Boolean = true,
+    trailing: Boolean = true,
+    func: () -> T
+) = Throttle(wait, leading, trailing, func)