Просмотр исходного кода

feat: 阅读界面神必问题

HapeLee 1 месяц назад
Родитель
Сommit
9adeb37533
23 измененных файлов с 2010 добавлено и 886 удалено
  1. 256 89
      app/src/main/java/io/legado/app/feature/reader/ReaderCanvasSurface.kt
  2. 26 14
      app/src/main/java/io/legado/app/feature/reader/core/layout/ReaderChapterBlockMeasurer.kt
  3. 724 577
      app/src/main/java/io/legado/app/feature/reader/core/layout/ReaderPaginator.kt
  4. 12 0
      app/src/main/java/io/legado/app/feature/reader/core/model/ReaderPage.kt
  5. 24 8
      app/src/main/java/io/legado/app/feature/reader/core/model/ReaderTipValue.kt
  6. 20 0
      app/src/main/java/io/legado/app/feature/reader/core/navigation/ReaderPageNavigator.kt
  7. 58 0
      app/src/main/java/io/legado/app/feature/reader/core/navigation/ReaderPartialPagePolicy.kt
  8. 6 1
      app/src/main/java/io/legado/app/feature/reader/core/selection/ReaderSelection.kt
  9. 0 2
      app/src/main/java/io/legado/app/feature/reader/core/selection/ReaderSelectionLifecyclePolicy.kt
  10. 0 8
      app/src/main/java/io/legado/app/feature/reader/core/transition/ReaderAutoPagePolicy.kt
  11. 47 38
      app/src/main/java/io/legado/app/feature/reader/legacy/LegacyReaderChapterPaginator.kt
  12. 105 27
      app/src/main/java/io/legado/app/feature/reader/legacy/LegacyReaderPageDecorationFactory.kt
  13. 28 0
      app/src/main/java/io/legado/app/model/ReadBook.kt
  14. 405 56
      app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt
  15. 47 16
      app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt
  16. 7 6
      app/src/test/java/io/legado/app/feature/reader/core/layout/ReaderChapterBlockMeasurerTest.kt
  17. 8 7
      app/src/test/java/io/legado/app/feature/reader/core/layout/ReaderImageOptionsTest.kt
  18. 61 0
      app/src/test/java/io/legado/app/feature/reader/core/layout/ReaderPaginatorTest.kt
  19. 39 0
      app/src/test/java/io/legado/app/feature/reader/core/model/ReaderTipValueFormatterTest.kt
  20. 41 0
      app/src/test/java/io/legado/app/feature/reader/core/navigation/ReaderPageNavigatorTest.kt
  21. 96 0
      app/src/test/java/io/legado/app/feature/reader/core/navigation/ReaderPartialPagePolicyTest.kt
  22. 0 6
      app/src/test/java/io/legado/app/feature/reader/core/selection/ReaderSelectionLifecyclePolicyTest.kt
  23. 0 31
      app/src/test/java/io/legado/app/feature/reader/core/transition/ReaderAutoPagePolicyTest.kt

+ 256 - 89
app/src/main/java/io/legado/app/feature/reader/ReaderCanvasSurface.kt

@@ -5,7 +5,6 @@ import android.graphics.BitmapShader
 import android.graphics.Paint
 import android.graphics.Shader
 import android.graphics.drawable.Drawable
-import android.os.SystemClock
 import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.animation.core.Animatable
 import androidx.compose.animation.core.FastOutSlowInEasing
@@ -159,6 +158,7 @@ import kotlinx.coroutines.launch
 import kotlinx.coroutines.sync.Semaphore
 import kotlinx.coroutines.sync.withPermit
 import kotlinx.coroutines.withContext
+import splitties.init.appCtx
 import kotlin.math.PI
 import kotlin.math.abs
 import kotlin.math.atan2
@@ -221,6 +221,8 @@ fun ReaderCanvasSurface(
     noAnimationScrollPage: Boolean,
     externalPageTurns: Flow<ReaderTurnDirection>,
     externalSelectionCancels: Flow<Unit>,
+    /** 宿主要求建立选区(全文搜索命中):旧 View 的命中就是一次真选区,带手柄与菜单。 */
+    externalSelections: Flow<ReaderSelection>,
     onVisibleBodyTextPositionProvider: ((() -> ReaderVisibleTextPosition?)?) -> Unit,
 ) {
     // 滚动跨页同步换窗:跨页帧内宿主回调直接返回新窗口,先写入 pending 供绘制与
@@ -355,7 +357,6 @@ fun ReaderCanvasSurface(
     var selectionDragHandleCenter by remember { mutableStateOf<Offset?>(null) }
     var selectionDragEndpoint by remember { mutableStateOf<ReaderSelectionEndpoint?>(null) }
     val selectionHandleRadiusPx = with(LocalDensity.current) { SelectionHandleRadius.toPx() }
-    val latestSelectionPausesAutoPage by rememberUpdatedState(textSelection != null)
     var selectionMenuVisible by remember { mutableStateOf(false) }
     var selectionLayoutRevision by remember { mutableLongStateOf(current.layoutRevision) }
     LaunchedEffect(
@@ -719,6 +720,17 @@ fun ReaderCanvasSurface(
             selectionMenuVisible = false
         }
     }
+    LaunchedEffect(externalSelections) {
+        externalSelections.collect { selection ->
+            // 旧 View 的搜索结果即真选区:建选区、带手柄,并按当前窗口弹一次选区菜单。
+            clearSelectionForPageChange(ReaderPageChangeOrigin.PROGRAMMATIC)
+            textSelection = selection
+            selectionMagnifierSource = null
+            selectionDragHandleCenter = null
+            selectionDragEndpoint = null
+            showSelectionMenu(selection, latestPages)
+        }
+    }
     LaunchedEffect(hostPages) {
         // pending 窗口的收尾:宿主回声(与 pending 同实例)到达后解除;外部换窗
         // (其他实例,如跳转/重排)直接丢弃 pending。偏移归零由下方 current.id
@@ -775,33 +787,17 @@ fun ReaderCanvasSurface(
             autoPageRemainingMillis = ReaderAutoPagePolicy.pageDurationMillis(autoReadSpeedSeconds)
             return@LaunchedEffect
         }
-        if (ReaderSelectionLifecyclePolicy.shouldPauseAutoPage(textSelection != null)) {
-            return@LaunchedEffect
-        }
         if (autoPagePaused) return@LaunchedEffect
         // 滚动动画期间挂起:对照旧 View 的 ReadView.onScrollAnimStart/Stop →
         // autoPager.pause()/resume(),避免 fling / 点击步距与自动滚屏叠加。
         if (scrollMotionActive) return@LaunchedEffect
         if (ReaderAutoPagePolicy.visualMode(isEInkMode) == ReaderAutoPageVisualMode.DISCRETE) {
+            // 旧 `AutoPager` 的电子墨水分支:`pause()` 只 removeCallbacks,`resume()` 重新
+            // `postDelayed(this, autoReadSpeed * 1000L)` —— **整段重新计时**,不保留剩余时间。
             autoRevealPx = 0f
-            val plannedMillis = autoPageRemainingMillis
-            val startedAt = SystemClock.uptimeMillis()
-            try {
-                delay(plannedMillis)
-                autoPageRemainingMillis = ReaderAutoPagePolicy.pageDurationMillis(autoReadSpeedSeconds)
-                if (!canTurn(ReaderTurnDirection.NEXT)) latestAutoPageStop() else latestNextPage()
-            } finally {
-                if (ReaderAutoPagePolicy.shouldPreserveRemainingTime(
-                        menuPaused = latestAutoPagePaused,
-                        selectionPaused = latestSelectionPausesAutoPage,
-                    )
-                ) {
-                    autoPageRemainingMillis = ReaderAutoPagePolicy.remainingAfterPause(
-                        plannedMillis,
-                        SystemClock.uptimeMillis() - startedAt,
-                    )
-                }
-            }
+            delay(autoPageRemainingMillis)
+            autoPageRemainingMillis = ReaderAutoPagePolicy.pageDurationMillis(autoReadSpeedSeconds)
+            if (!canTurn(ReaderTurnDirection.NEXT)) latestAutoPageStop() else latestNextPage()
             return@LaunchedEffect
         }
         var previousFrame = withFrameNanos { it }
@@ -1006,31 +1002,31 @@ fun ReaderCanvasSurface(
                     } else dismissSelectionMenu()
                 }
                 val longPressJob = animationScope.launch {
-                    delay(viewConfiguration.longPressTimeoutMillis)
-                    if (!movedPastSlop && !grabbingStart && !grabbingEnd) {
-                        downPage?.let { page ->
-                            val element = page.elementAt(down.position.x, downPageY)
-                            if (element != null && onElementLongPress(
-                                    element,
-                                    down.position.x,
-                                    down.position.y
-                                )
-                            ) {
-                                longPressed = true
-                            } else if (latestSelectionEnabled && !latestAutoPageActive) {
-                                ReaderSelectionPolicy.startWord(page, down.position.x, downPageY)
-                                    ?.let {
-                                        textSelection = it
-                                        selectionMagnifierSource = selectionCursorCenter(
-                                            it,
-                                            ReaderSelectionEndpoint.FOCUS,
-                                        )
-                                        if (latestSelectionHapticsEnabled) {
-                                            hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
-                                        }
-                                        longPressed = true
-                                    }
-                            }
+                    // 旧 `ReadView.longPressTimeout = 600L`(不是平台 longPressTimeout)。
+                    delay(LONG_PRESS_TIMEOUT_MILLIS)
+                    if (movedPastSlop || grabbingStart || grabbingEnd) return@launch
+                    // 旧版长按一旦成立就吞掉这次抬手(`ReadView`: `if (!longPressed && !pressOnTextSelected) onSingleTapUp()`):
+                    // 即使没命中文字/元素,也不该再执行点击分区动作(否则长按空白处会翻页)。
+                    longPressed = true
+                    val page = downPage ?: return@launch
+                    val element = page.elementAt(down.position.x, downPageY)
+                    if (element != null && onElementLongPress(
+                            element,
+                            down.position.x,
+                            down.position.y
+                        )
+                    ) {
+                        return@launch
+                    }
+                    if (!latestSelectionEnabled || latestAutoPageActive) return@launch
+                    ReaderSelectionPolicy.startWord(page, down.position.x, downPageY)?.let {
+                        textSelection = it
+                        selectionMagnifierSource = selectionCursorCenter(
+                            it,
+                            ReaderSelectionEndpoint.FOCUS,
+                        )
+                        if (latestSelectionHapticsEnabled) {
+                            hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
                         }
                     }
                 }
@@ -1422,9 +1418,12 @@ fun ReaderCanvasSurface(
             Box(Modifier
                 .fillMaxSize()
                 .drawWithContent {
-                    // 外扩阴影/斜体溢出,对照旧 View 的 ChapterProvider.visibleRect。
+                    // 外扩阴影/斜体溢出,对照旧 View 的 ChapterProvider.visibleRect:
+                    // 矩形裁剪,四边都按阴影/斜体外扩,右侧到 `viewWidth - paddingRight`。
                     clipRect(
+                        left = current.contentLeftPx - contentClipPad,
                         top = current.contentTopPx - contentClipPad,
+                        right = current.contentRightPx + contentClipPad,
                         bottom = current.contentBottomPx + contentClipPad,
                     ) {
                         this@drawWithContent.drawContent()
@@ -1698,6 +1697,19 @@ private fun pullBookmark(offset: Offset, height: Float, density: Float, mode: Re
 
 private class ReaderScrollBoundaryReached : CancellationException()
 
+/** 旧 `ReadView.longPressTimeout`:长按判定的固定阈值(不是平台 `longPressTimeout`)。 */
+private const val LONG_PRESS_TIMEOUT_MILLIS = 600L
+
+/** 旧 `TextColumn.selectedPaint`(`#63858585`):选区/搜索命中是压在字形上的 39% 灰块。 */
+private val legacySelectionFill = Color(0x63858585)
+
+/** 旧 `BatteryViewOrgin` 经典模式的数字字体(`assets/font/number.ttf`)。 */
+private val batteryClassicTypeface: android.graphics.Typeface? by lazy {
+    runCatching {
+        android.graphics.Typeface.createFromAsset(appCtx.assets, "font/number.ttf")
+    }.getOrNull()
+}
+
 private fun ReaderPage.scrollViewportExtentPx(): Float =
     (contentBottomPx - contentTopPx).coerceAtLeast(1f)
 
@@ -1830,9 +1842,6 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawScrollPageConte
             Size(band.bounds.width, band.bounds.height),
         )
     }
-    selectedBounds.forEach { rect ->
-        drawRect(selection, Offset(rect.left, rect.top), Size(rect.width, rect.height))
-    }
     drawSelectionStylePreview(selectionPreviewStyle, previewBounds, beforeText = true)
     visibleDecorationCache.halfHighlights.forEach { it.draw(native) }
     page.elements.forEach { e -> when (e) {
@@ -1874,6 +1883,10 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawScrollPageConte
         }
         is ReaderElement.Rule -> Unit
     } }
+    // 旧 `TextColumn.draw`:先画字,再把 39% 灰的选区块压上去(字形被冲淡),不是先铺色块。
+    selectedBounds.forEach { rect ->
+        drawRect(legacySelectionFill, Offset(rect.left, rect.top), Size(rect.width, rect.height))
+    }
     page.dynamicEmphasisUnderlineRuns().forEach { run ->
         drawLine(
             color = Color(run.style.colorArgb),
@@ -2247,6 +2260,17 @@ private fun ReaderPageCanvas(
             drawable.alpha = (backgroundImageAlpha.coerceIn(0f, 1f) * 255).roundToInt()
             drawable.draw(native)
         }
+        // 内容按旧 `ChapterProvider.visibleRect` 裁:背景色/背景图不裁(旧 View 里它们在
+        // ContentTextView 之外),九宫格左右外扩与斜体/阴影字缘因此不会画进页边距。
+        // 与 Image/Selection 的绘制共用同一个 native canvas,故用原生 save/clipRect 即可。
+        val contentClipPad = page.contentClipPadPx
+        val contentClipSave = native.save()
+        native.clipRect(
+            page.contentLeftPx - contentClipPad,
+            page.contentTopPx - contentClipPad,
+            page.contentRightPx + contentClipPad,
+            page.contentBottomPx + contentClipPad,
+        )
         textBackgrounds.forEach { run ->
             textBackgroundBitmaps[run.image.source]?.let { bitmap ->
                 drawTextBackground(native, bitmap, run, textBackgroundPaint)
@@ -2259,9 +2283,6 @@ private fun ReaderPageCanvas(
                 Size(band.bounds.width, band.bounds.height),
             )
         }
-        selectedTextBounds.forEach { rect ->
-            drawRect(selection, Offset(rect.left, rect.top), Size(rect.width, rect.height))
-        }
         drawSelectionStylePreview(selectionPreviewStyle, previewBounds, beforeText = true)
         decorationDrawCache.halfHighlights.forEach { it.draw(native) }
         page.elements.forEach { e -> when (e) {
@@ -2270,7 +2291,11 @@ private fun ReaderPageCanvas(
                 paint.color = if (previewing && activeSelection.contains(e, page.id.chapterIndex)) {
                     selectionPreviewStyle.textColor ?: page.previewBaseTextColor(e)
                 } else page.resolvedColorArgb(e, readAloud.toArgb())
-                paint.isUnderlineText = e.style.nativeUnderline || e.drawsLinkUnderline
+                // HTML 原生下划线(<u>)在与规则自定义下划线同时存在时只画自定义那条:
+                // 旧版从不画 HTML 原生下划线,两条线叠在一起是迁移后新增的观感问题。
+                // 链接下划线仍按旧版优先(`TextHtmlColumn.draw` 的 isUnderlineText)。
+                paint.isUnderlineText = (e.style.nativeUnderline && e.style.underline == null) ||
+                        e.drawsLinkUnderline
                 native.drawText(e.value, e.bounds.left, e.baselinePx, paint)
             }
             is ReaderElement.Image -> images[e]?.let { bitmap ->
@@ -2302,6 +2327,14 @@ private fun ReaderPageCanvas(
             }
             is ReaderElement.Rule -> Unit
         } }
+        // 同旧 `TextColumn.draw`:先画字,再把选区块压上去。
+        selectedTextBounds.forEach { rect ->
+            drawRect(
+                legacySelectionFill,
+                Offset(rect.left, rect.top),
+                Size(rect.width, rect.height)
+            )
+        }
         page.dynamicEmphasisUnderlineRuns().forEach { run ->
             drawLine(
                 color = Color(run.style.colorArgb),
@@ -2314,6 +2347,10 @@ private fun ReaderPageCanvas(
         decorationDrawCache.styledUnderlines.forEach { it.draw(native) }
         drawSelectionStylePreview(selectionPreviewStyle, previewBounds, beforeText = false)
         decorationDrawCache.overlayRules.forEach { it.draw(native) }
+        // 页眉页脚与角标必须画在内容裁剪之外:旧 View 里它们属于 PageView(在 ContentTextView
+        // 之外),不受 `visibleRect` 约束;页脚整体位于 `contentBottomPx` 之下、页眉在
+        // `contentTopPx` 之上,若留在裁剪里会被整条裁掉。
+        native.restoreToCount(contentClipSave)
         if (drawDecoration) drawPageDecoration(native, page, tipPaints, badgeImage)
     }
 }
@@ -2436,8 +2473,10 @@ private fun ReaderPage.isSearchResult(text: ReaderElement.Text): Boolean {
     return text.chapterPosition <= maxOf(start, end) && textEnd >= minOf(start, end)
 }
 
+// 旧 `TextPage.upPageAloudSpan` 遍历页内**所有**行(含标题行)设置朗读高亮,因此这里不再
+// 排除 emphasized:标题/标题分段在被朗读时同样高亮。
 private fun ReaderPage.isReadAloud(text: ReaderElement.Text): Boolean =
-    !text.emphasized && readAloudParagraphIndex != null && text.paragraphIndex == readAloudParagraphIndex
+    readAloudParagraphIndex != null && text.paragraphIndex == readAloudParagraphIndex
 
 private fun ReaderPage.resolvedColorArgb(text: ReaderElement.Text, accentColorArgb: Int): Int =
     if (text.link != null || isSearchResult(text) || isReadAloud(text)) accentColorArgb else text.style.colorArgb
@@ -2466,7 +2505,7 @@ private fun ReaderPageDecorationOverlay(page: ReaderPage, modifier: Modifier) {
 private fun createTipPaint(row: ReaderTipRow) = Paint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG).apply {
     color = row.colorArgb
     textSize = row.fontSizePx
-    typeface = ReaderAndroidPaintFactory.loadTypeface(row.fontPath, 400, false)
+    typeface = ReaderAndroidPaintFactory.loadTypeface(row.fontPath, 400, false, row.fontFamily)
 }
 
 internal fun <T> resolveReaderTipResource(
@@ -2487,14 +2526,15 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawPageDecoration(
             canvas,
             row,
             paint,
-            ReaderTipRowLayout.headerBaseline(row.paddingTopPx, paint.fontMetrics.top),
+            // 旧版页眉是 includeFontPadding=false 的 TextView:基线 = −ascent(不含 leading)。
+            ReaderTipRowLayout.headerBaseline(row.paddingTopPx, paint.fontMetrics.ascent),
         )
         row.dividerColorArgb?.let {
             val metrics = paint.fontMetrics
             val dividerY = ReaderTipRowLayout.extent(
-                row.paddingTopPx, metrics.top, metrics.bottom, row.paddingBottomPx,
+                row.paddingTopPx, metrics.ascent, metrics.descent, row.paddingBottomPx,
             )
-            drawLine(Color(it), Offset(0f, dividerY), Offset(size.width, dividerY), 1f)
+            drawReaderTipDivider(row, dividerY, it)
         }
     }
     page.decoration.footer?.takeIf { it.visible }?.let { row ->
@@ -2504,15 +2544,15 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawPageDecoration(
             row,
             paint,
             ReaderTipRowLayout.footerBaseline(
-                size.height, row.paddingBottomPx, paint.fontMetrics.bottom,
+                size.height, row.paddingBottomPx, paint.fontMetrics.descent,
             ),
         )
         row.dividerColorArgb?.let {
             val metrics = paint.fontMetrics
             val dividerY = size.height - ReaderTipRowLayout.extent(
-                row.paddingTopPx, metrics.top, metrics.bottom, row.paddingBottomPx,
+                row.paddingTopPx, metrics.ascent, metrics.descent, row.paddingBottomPx,
             )
-            drawLine(Color(it), Offset(0f, dividerY), Offset(size.width, dividerY), 1f)
+            drawReaderTipDivider(row, dividerY, it)
         }
     }
     page.decoration.bookmarkBadge?.let { badge ->
@@ -2651,40 +2691,110 @@ private fun drawReview(canvas: android.graphics.Canvas, review: ReaderElement.Re
     canvas.drawText(review.count.coerceAtMost(999).toString(), (start + height / 9f + end) / 2f, baseline - height * .23f, paint)
 }
 
+private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawReaderTipDivider(
+    row: ReaderTipRow,
+    dividerY: Float,
+    colorArgb: Int,
+) {
+    // 旧版分隔线是 0.5dp 的 View,且随 `vwRoot` 的刘海 padding 内缩(不是整屏通栏)。
+    val left = row.insetLeftPx
+    val right = size.width - row.insetRightPx
+    if (right <= left) return
+    drawLine(
+        Color(colorArgb),
+        Offset(left, dividerY),
+        Offset(right, dividerY),
+        TIP_DIVIDER_THICKNESS_DP.dp.toPx(),
+    )
+}
+
+private const val TIP_DIVIDER_THICKNESS_DP = 0.5f
+
+private val ellipsizedTipCache = object : LinkedHashMap<String, String>(16, 0.75f, true) {
+    override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, String>?): Boolean =
+        size > 64
+}
+
+/**
+ * 对照旧版页眉页脚 `TextView` 的 `maxLines=1 ellipsize=end`:装不下就在末尾加省略号截断,
+ * 避免长章名/长模板把中、右槽的文案压在一起。结果按(文案、可用宽度、字号、字体)缓存,
+ * 绘制期不重复测量。
+ */
+internal fun ellipsizeTipText(text: String, paint: Paint, maxWidthPx: Float): String {
+    if (text.isEmpty()) return text
+    if (maxWidthPx <= 0f) return ""
+    if (paint.measureText(text) <= maxWidthPx) return text
+    val key =
+        "$text\u0000${maxWidthPx.toInt()}\u0000${paint.textSize}\u0000${paint.typeface?.hashCode()}"
+    ellipsizedTipCache[key]?.let { return it }
+    val ellipsis = "…"
+    val ellipsisWidth = paint.measureText(ellipsis)
+    var end = text.length
+    while (end > 0 && paint.measureText(text, 0, end) + ellipsisWidth > maxWidthPx) end--
+    val result = if (end <= 0) "" else text.substring(0, end) + ellipsis
+    ellipsizedTipCache[key] = result
+    return result
+}
+
 private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawTipRow(
     canvas: android.graphics.Canvas,
     row: ReaderTipRow,
     paint: Paint,
     baseline: Float,
 ) {
+    val leftEdge = row.paddingLeftPx
+    val rightEdge = size.width - row.paddingRightPx
+    val startTip = row.tips.firstOrNull { it.alignment == ReaderTipAlignment.START }
+    val centerTip = row.tips.firstOrNull { it.alignment == ReaderTipAlignment.CENTER }
+    val endTip = row.tips.firstOrNull { it.alignment == ReaderTipAlignment.END }
+    fun tipWidth(tip: ReaderPageTip): Float =
+        if (tip.visual == ReaderTipVisual.TEXT) paint.measureText(tip.text)
+        else visualTipWidthPx(tip, paint)
+    // 对照旧 `view_book_page.xml` 的三槽约束:
+    // - 左槽 `layout_width=0dp` + `constraintHorizontal_weight=1`,右边界是 `barrier`
+    //   (`barrierDirection=start`、`barrierAllowsGoneWidgets=false`)= 可见的中/右槽起始边的最小值,
+    //   GONE(即没有该槽 tip)不参与;所以**只有左槽会被邻槽挤压**并在 barrier 处省略;
+    // - 中槽 `wrap_content` 居中、右槽 `wrap_content` 靠右,两者都不受邻槽约束,只在整行宽度处省略
+    //   (旧版就是这样:中/右槽长文案会压到邻槽上,不额外让位)。
+    val barrierStart = listOfNotNull(
+        centerTip?.let { size.width / 2f - tipWidth(it) / 2f },
+        endTip?.let { rightEdge - tipWidth(it) },
+    ).minOrNull()
     row.tips.forEach { tip ->
         if (tip.visual == ReaderTipVisual.TEXT) {
+            val available = when (tip.alignment) {
+                ReaderTipAlignment.START -> (barrierStart ?: rightEdge) - leftEdge
+                else -> rightEdge - leftEdge
+            }.coerceAtLeast(0f)
+            val text = ellipsizeTipText(tip.text, paint, available)
+            if (text.isEmpty()) return@forEach
             val x = when (tip.alignment) {
-                ReaderTipAlignment.START -> { paint.textAlign = Paint.Align.LEFT; row.paddingLeftPx }
+                ReaderTipAlignment.START -> {
+                    paint.textAlign = Paint.Align.LEFT; leftEdge
+                }
                 ReaderTipAlignment.CENTER -> { paint.textAlign = Paint.Align.CENTER; size.width / 2f }
-                ReaderTipAlignment.END -> { paint.textAlign = Paint.Align.RIGHT; size.width - row.paddingRightPx }
+                ReaderTipAlignment.END -> {
+                    paint.textAlign = Paint.Align.RIGHT; rightEdge
+                }
             }
-            canvas.drawText(tip.text, x, baseline, paint)
+            canvas.drawText(text, x, baseline, paint)
         } else {
             drawVisualTip(canvas, row, tip, paint, baseline)
         }
     }
 }
 
-private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawVisualTip(
-    canvas: android.graphics.Canvas,
-    row: ReaderTipRow,
+/** 非文字 tip(电池/箭头)的绘制宽度:`drawVisualTip` 与左槽 barrier 计算共用同一份几何。 */
+private fun androidx.compose.ui.graphics.drawscope.DrawScope.visualTipWidthPx(
     tip: ReaderPageTip,
     paint: Paint,
-    baseline: Float,
-) {
+): Float {
     val unit = density
     val gap = 4f * unit
     val batteryWidth = 28f * unit
-    val number = tip.batteryPercent.coerceIn(0, 100).toString()
-    val numberWidth = paint.measureText(number)
+    val numberWidth = paint.measureText(tip.batteryPercent.coerceIn(0, 100).toString())
     val textWidth = paint.measureText(tip.text)
-    val visualWidth = when (tip.visual) {
+    return when (tip.visual) {
         ReaderTipVisual.BATTERY_OUTER -> batteryWidth + 2f * unit + numberWidth
         ReaderTipVisual.BATTERY_INNER -> (if (tip.text.isEmpty()) 0f else textWidth + gap) + batteryWidth
         ReaderTipVisual.BATTERY_ICON -> batteryWidth
@@ -2692,6 +2802,22 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawVisualTip(
         ReaderTipVisual.ARROW -> 12f * unit + 8f * unit + textWidth
         ReaderTipVisual.TEXT -> textWidth
     }
+}
+
+private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawVisualTip(
+    canvas: android.graphics.Canvas,
+    row: ReaderTipRow,
+    tip: ReaderPageTip,
+    paint: Paint,
+    baseline: Float,
+) {
+    val unit = density
+    val gap = 4f * unit
+    val batteryWidth = 28f * unit
+    val number = tip.batteryPercent.coerceIn(0, 100).toString()
+    val numberWidth = paint.measureText(number)
+    val textWidth = paint.measureText(tip.text)
+    val visualWidth = visualTipWidthPx(tip, paint)
     val left = when (tip.alignment) {
         ReaderTipAlignment.START -> row.paddingLeftPx
         ReaderTipAlignment.CENTER -> (size.width - visualWidth) / 2f
@@ -2700,7 +2826,15 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawVisualTip(
     paint.textAlign = Paint.Align.LEFT
     when (tip.visual) {
         ReaderTipVisual.BATTERY_OUTER -> {
-            drawBatteryGlyph(canvas, left, baseline, tip.batteryPercent, paint, drawNumberInside = false)
+            drawBatteryGlyph(
+                canvas,
+                left,
+                baseline,
+                tip.batteryPercent,
+                paint,
+                drawNumberInside = false,
+                fillInner = true
+            )
             canvas.drawText(number, left + batteryWidth + 2f * unit, baseline, paint)
         }
         ReaderTipVisual.BATTERY_INNER -> {
@@ -2708,26 +2842,56 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawVisualTip(
                 canvas.drawText(tip.text, left, baseline, paint)
                 left + textWidth + gap
             }
-            drawBatteryGlyph(canvas, batteryLeft, baseline, tip.batteryPercent, paint, drawNumberInside = true)
+            // 旧 `BatteryView`:INNER 模式 `batteryFill.visibility = GONE`,只留外框与框内数字。
+            drawBatteryGlyph(
+                canvas,
+                batteryLeft,
+                baseline,
+                tip.batteryPercent,
+                paint,
+                drawNumberInside = true,
+                fillInner = false,
+            )
         }
         ReaderTipVisual.BATTERY_ICON ->
-            drawBatteryGlyph(canvas, left, baseline, tip.batteryPercent, paint, drawNumberInside = false)
+            drawBatteryGlyph(
+                canvas,
+                left,
+                baseline,
+                tip.batteryPercent,
+                paint,
+                drawNumberInside = false,
+                fillInner = true
+            )
         ReaderTipVisual.BATTERY_CLASSIC -> {
+            // 旧 `BatteryViewOrgin`:经典模式的文字与数字都用 `assets/font/number.ttf`。
+            val numberPaint = Paint(paint).apply {
+                batteryClassicTypeface?.let { typeface = it }
+            }
+            val classicNumberWidth = numberPaint.measureText(number)
             val numberLeft = if (tip.text.isEmpty()) left + 4f * unit else {
-                canvas.drawText(tip.text, left, baseline, paint)
-                left + textWidth + gap + 4f * unit
+                canvas.drawText(tip.text, left, baseline, numberPaint)
+                left + numberPaint.measureText(tip.text) + gap + 4f * unit
+            }
+            canvas.drawText(number, numberLeft, baseline, numberPaint)
+            val top = baseline + numberPaint.fontMetrics.ascent - 2f * unit
+            val bottom = baseline + numberPaint.fontMetrics.descent + 2f * unit
+            val frame = Paint(numberPaint).apply {
+                style = Paint.Style.STROKE; strokeWidth = unit.coerceAtLeast(1f)
             }
-            canvas.drawText(number, numberLeft, baseline, paint)
-            val top = baseline + paint.fontMetrics.ascent - 2f * unit
-            val bottom = baseline + paint.fontMetrics.descent + 2f * unit
-            val frame = Paint(paint).apply { style = Paint.Style.STROKE; strokeWidth = unit.coerceAtLeast(1f) }
-            canvas.drawRect(numberLeft - 2f * unit, top, numberLeft + numberWidth + 2f * unit, bottom, frame)
             canvas.drawRect(
-                numberLeft + numberWidth + 2f * unit,
+                numberLeft - 2f * unit,
+                top,
+                numberLeft + classicNumberWidth + 2f * unit,
+                bottom,
+                frame
+            )
+            canvas.drawRect(
+                numberLeft + classicNumberWidth + 2f * unit,
                 top + (bottom - top) / 3f,
-                numberLeft + numberWidth + 4f * unit,
+                numberLeft + classicNumberWidth + 4f * unit,
                 bottom - (bottom - top) / 3f,
-                Paint(paint).apply { style = Paint.Style.FILL },
+                Paint(numberPaint).apply { style = Paint.Style.FILL },
             )
         }
         ReaderTipVisual.ARROW -> {
@@ -2737,11 +2901,13 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawVisualTip(
                 lineTo(left + 3f * unit, centerY)
                 lineTo(left + 8f * unit, centerY + 5f * unit)
             }
+            // 旧 `BatteryView`:arrowIcon.alpha = 0.76(194/255)。
             canvas.drawPath(arrow, Paint(paint).apply {
                 style = Paint.Style.STROKE
                 strokeWidth = 1.5f * unit
                 strokeCap = Paint.Cap.SQUARE
                 strokeJoin = Paint.Join.MITER
+                alpha = 194
             })
             canvas.drawText(tip.text, left + 20f * unit, baseline, paint)
         }
@@ -2756,6 +2922,7 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawBatteryGlyph(
     batteryPercent: Int,
     paint: Paint,
     drawNumberInside: Boolean,
+    fillInner: Boolean,
 ) {
     val unit = density
     val bodyWidth = 22f * unit
@@ -2777,7 +2944,7 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawBatteryGlyph(
         fill,
     )
     val innerWidth = (bodyWidth - 4f * unit) * batteryPercent.coerceIn(0, 100) / 100f
-    if (innerWidth > 0f) {
+    if (fillInner && innerWidth > 0f) {
         canvas.drawRoundRect(
             bodyLeft + 2f * unit,
             top + 2f * unit,

+ 26 - 14
app/src/main/java/io/legado/app/feature/reader/core/layout/ReaderChapterBlockMeasurer.kt

@@ -92,11 +92,23 @@ class ReaderChapterBlockMeasurer(
     private val htmlSourceResolver: ReaderHtmlSourceResolver = ReaderHtmlSourceResolver { _, _ -> null },
     private val imageOptionsResolver: ReaderImageOptionsResolver = ReaderImageOptionsResolver { null },
 ) {
+    /**
+     * 测量整章。给出 [onBlock] 时每产出一个 block 就立即回调——旧 View
+     * `TextChapterLayout` 边排版边 `channel.trySend`,分页光标因此可以在测量过程中推进,
+     * 首屏不必等整章测完(见 `ReaderPaginationSession`)。
+     */
     suspend fun measure(
         source: ReaderChapterSource,
         style: ReaderChapterMeasureStyle,
+        onBlock: ((ReaderMeasuredBlock) -> Unit)? = null,
     ): ReaderChapterMeasureResult {
-        val blocks = ArrayList<ReaderMeasuredBlock>(source.blocks.size)
+        // `blocks += x` 就是 `add(x)`:覆写 add 即可在每个追加点回调,无需在六处追加点重复。
+        val blocks = object : ArrayList<ReaderMeasuredBlock>(source.blocks.size) {
+            override fun add(element: ReaderMeasuredBlock): Boolean {
+                onBlock?.invoke(element)
+                return super.add(element)
+            }
+        }
         val shapers = mutableMapOf<ReaderTextStyle, ReaderTextShaper>()
         fun shaper(textStyle: ReaderTextStyle) = shapers.getOrPut(textStyle) {
             textShaperFactory.create(textStyle)
@@ -117,7 +129,7 @@ class ReaderChapterBlockMeasurer(
             alignmentOverride: ReaderTextAlignment? = null,
             decorations: List<ReaderParagraphDecoration> = emptyList(),
             justifyAtWordBoundaries: Boolean = false,
-        ): ReaderChapterMeasureResult.Unsupported? {
+        ) {
             val baseStyle = if (isTitle) style.titleStyle.copy(
                 fontSizePx = style.titleStyle.fontSizePx * titleScale,
             ) else style.bodyStyle
@@ -138,7 +150,7 @@ class ReaderChapterBlockMeasurer(
                         style.titleLineSpacingMultiplier
                     } else style.bodyLineSpacingMultiplier,
                 )
-                return null
+                return
             }
             val firstText = items.firstOrNull() as? ReaderChapterInlineSource.Text
             val prefixEnd = firstText?.takeIf {
@@ -275,15 +287,16 @@ class ReaderChapterBlockMeasurer(
                                 pageBreakAfter = mode == ReaderImageLayoutMode.SINGLE_PAGE,
                             )
                         } else {
-                            // 文字嵌入(行内图):与 View 实现一致,图片作为段内占位参与行排版,
-                            // 只允许缩小到不超过当前行高(禁止放大到铺满整页文字区)。行高上限
-                            // 让紧随其后的内容保持与行内占位一致且稳定的几何。
-                            val maxHeight = lineHeight ?: baseStyle.fontSizePx
-                            val scale = minOf(1f, maxHeight / size.heightPx.coerceAtLeast(1f))
+                            // 文字嵌入(行内图):对照旧 `TextChapterLayout` 的 ImageColumn —— 占位宽
+                            // 恒为**一个字符格**(旧版就是替换字 袮/祢 的宽度),高按原图比例从该宽度
+                            // 换算,因此立图可以高于当前行;扁平宽图只占一格宽、很矮。
+                            val cellWidthPx = baseStyle.fontSizePx
+                            val aspect =
+                                size.heightPx.coerceAtLeast(1f) / size.widthPx.coerceAtLeast(1f)
                             inline += ReaderMeasuredInlineItem.Image(
                                 source = item.source,
-                                widthPx = size.widthPx * scale,
-                                heightPx = size.heightPx * scale,
+                                widthPx = cellWidthPx,
+                                heightPx = cellWidthPx * aspect,
                                 chapterPosition = item.chapterPosition,
                                 action = options?.action,
                             )
@@ -293,7 +306,6 @@ class ReaderChapterBlockMeasurer(
                 }
             }
             flushInline(skipBlank = hasStandaloneImage)
-            return null
         }
         source.blocks.forEachIndexed { index, block ->
             when (block) {
@@ -303,7 +315,7 @@ class ReaderChapterBlockMeasurer(
                         block.isTitle,
                         block.fontSizeScale,
                         block.isSubtitle,
-                    )?.let { return it }
+                    )
                     // 旧 TextChapterLayout 在单图样式的标题段排版完(`durY += titleBottomSpacing`
                     // 之后)直接 `prepareNextPageIfNeed()`——无参调用无条件结束当前页,于是标题
                     // 独占一页、正文从下一页开始。标题分成多段时只在最后一段之后断页。
@@ -339,7 +351,7 @@ class ReaderChapterBlockMeasurer(
                     )
                 }
                 is ReaderChapterSourceBlock.Paragraph -> {
-                    addStyledParagraph(block.items, false)?.let { return it }
+                    addStyledParagraph(block.items, false)
                 }
                 is ReaderChapterSourceBlock.Html -> {
                     val paragraphs = try {
@@ -360,7 +372,7 @@ class ReaderChapterBlockMeasurer(
                             alignmentOverride = paragraph.alignment,
                             decorations = paragraph.decorations,
                             justifyAtWordBoundaries = true,
-                        )?.let { return it }
+                        )
                     }
                 }
                 is ReaderChapterSourceBlock.PageBreak -> blocks += ReaderMeasuredBlock.PageBreak

+ 724 - 577
app/src/main/java/io/legado/app/feature/reader/core/layout/ReaderPaginator.kt

@@ -96,6 +96,13 @@ data class ReaderPaginationConfig(
      */
     val chapterEndPaddingPx: Float = 0f,
     val textBottomJustify: Boolean = false,
+    /**
+     * HTML 段落的行盒附加间距,对照旧 `TextChapterLayout.setTypeHtml` 的
+     * `setLineSpacing(paragraphSpacing.toFloat(), lineSpacingExtra)`:行距之外每行再加一次
+     * `paragraphSpacing`(原始设置值),并随后由行距乘数再放大一次(旧 `durY += lineHeight *
+     * lineSpacingExtra`,乘数被算两次)。只作用于 HTML 块(测量期以 `justifyAtWordBoundaries` 标记)。
+     */
+    val htmlLineSpacingAddPx: Float = 0f,
     val pageUnderline: ReaderPageUnderline? = null,
     val inlineImagesPreserveScrollLine: Boolean = true,
     val emphasisUnderlineStyle: ReaderEmphasisUnderline? = null,
@@ -223,94 +230,225 @@ object ReaderPaginator {
         config: ReaderPaginationConfig,
     ): List<ReaderPage> = paginateBlocks(paragraphs.map { ReaderMeasuredBlock.Paragraph(it) }, config)
 
+    /**
+     * 整章批次入口:一次拿回全部页。逐页流出走 [ReaderPaginationSession]——旧 View
+     * `TextChapterLayout.onPageCompleted()` 是每完成一页就 `channel.trySend`,批次只是它的收尾。
+     */
     fun paginateBlocks(
         blocks: List<ReaderMeasuredBlock>,
         config: ReaderPaginationConfig,
     ): List<ReaderPage> {
         if (blocks.isEmpty()) return emptyList()
-        val pages = mutableListOf<MutableList<ReaderElement>>()
-        val pageTexts = mutableListOf<StringBuilder>()
-        val pageExtents = mutableListOf<Float>()
-        var elements = mutableListOf<ReaderElement>()
-        var pageText = StringBuilder()
-        var y = config.paddingTopPx
-        var columnIndex = 0
-        var columnElementStart = 0
-        var columnRows = mutableListOf<ReaderLayoutRow>()
-
-        /**
-         * 上一个视觉行是否画了九宫格框。九宫格上下边要借用邻行的留白:邻行没有框时可以
-         * 用满整个行距,邻行也有框时只能各让半个。空行 / 图片 / 分隔线 / 分页本身就带空隙,
-         * 在这些位置复位。
-         */
-        var previousLineHadFrame = false
-
-        fun columnLeft() = config.paddingLeftPx + columnIndex * config.columnStridePx
-        fun columnHasContent() = elements.size > columnElementStart
-
-        fun addPageUnderline(underlineElementStart: Int, lineBottom: Float) {
-            val underline = config.pageUnderline ?: return
-            if (elements.size <= underlineElementStart) return
-            val rowElements = elements.subList(underlineElementStart, elements.size)
-            val start = if (underline.extendToColumn) columnLeft()
-                else rowElements.minOf { it.bounds.left }
-            val end = if (underline.extendToColumn) columnLeft() + config.contentWidthPx
-                else rowElements.maxOf { it.bounds.right }
-            val y = lineBottom + underline.offsetPx
-            elements += ReaderElement.Rule(
-                bounds = ReaderRect(start, y, end, y),
-                colorArgb = underline.colorArgb,
-                widthPx = underline.widthPx,
-                dashed = underline.dashed,
-                dashOnPx = underline.dashOnPx,
-                dashOffPx = underline.dashOffPx,
-                overlayStyledUnderline = true,
+        val session = ReaderPaginationSession(config)
+        blocks.forEach(session::accept)
+        return session.finish()
+    }
+}
+
+/**
+ * 推送式分页会话:`accept` 推入一个已测量 block,页一成型就经 [onPage] 流出,[finish] 收尾。
+ *
+ * 与旧 View `TextChapterLayout` 的对应:`accept` = 排版游标前进(`prepareNextPageIfNeed` 判切页);
+ * 一页成型 = `onPageCompleted()`(旧版在此 `channel.trySend`)。章末页延迟到 [finish] 才流出,
+ * 因为只有章末才知道它的堆叠高度要加 [ReaderPaginationConfig.chapterEndPaddingPx]
+ * (旧版同样在章末才 `textPage.height = durY + 20dp`)。
+ */
+internal class ReaderPaginationSession(private val config: ReaderPaginationConfig) {
+
+    /** 每页成型即回调;章末页在 [finish] 内回调。 */
+    var onPage: ((ReaderPage) -> Unit)? = null
+
+    private val pages = mutableListOf<MutableList<ReaderElement>>()
+    private val pageTexts = mutableListOf<StringBuilder>()
+    private val pageExtents = mutableListOf<Float>()
+    private val resultPages = mutableListOf<ReaderPage>()
+    private var elements = mutableListOf<ReaderElement>()
+    private var pageText = StringBuilder()
+    private var y = config.paddingTopPx
+    private var columnIndex = 0
+    private var columnElementStart = 0
+    private var columnRows = mutableListOf<ReaderLayoutRow>()
+    private var centeredPageShiftPx = 0f
+
+    /**
+     * 上一个视觉行是否画了九宫格框。九宫格上下边要借用邻行的留白:邻行没有框时可以
+     * 用满整个行距,邻行也有框时只能各让半个。空行 / 图片 / 分隔线 / 分页本身就带空隙,
+     * 在这些位置复位。
+     */
+    private var previousLineHadFrame = false
+
+    /** 1-block 前瞻:标题间距要读下一个 block,与旧 `blocks.getOrNull(index + 1)` 同义。 */
+    private var pendingBlock: ReaderMeasuredBlock? = null
+    private var pendingIndex = -1
+
+    /** 推入一个已测量 block。 */
+    fun accept(block: ReaderMeasuredBlock) {
+        val previous = pendingBlock
+        if (previous != null) {
+            processBlock(
+                previous,
+                pendingIndex,
+                hasFollowingBlock = true,
+                nextIsTitle = block.isTitle()
             )
         }
+        pendingIndex += 1
+        pendingBlock = block
+    }
+
+    /** 收尾:处理最后一个 block、结束末页、返回全部页(章末页在此流出)。 */
+    fun finish(): List<ReaderPage> {
+        pendingBlock?.let {
+            processBlock(
+                it,
+                pendingIndex,
+                hasFollowingBlock = false,
+                nextIsTitle = false
+            )
+        }
+        pendingBlock = null
+        if (pages.isEmpty() && elements.isEmpty()) return emptyList()
+        finishPage()
+        // 旧 `TextChapterLayout.kt:977-1010` 只在"标题单独占本章第一页、页内还没有正文行"时把
+        // 标题块垂直居中,且只对空正文与 `imgStyleSingle` 生效(`else -> durY + titleTopSpacing`
+        // 不居中)。判据是"第一页且页内只有标题",不是"整章只有一页"——后者会把有正文的卷章
+        // 整体下移,又漏掉单图样式里"标题页 + 正文页"的居中。
+        val firstPageElements = pages.firstOrNull()
+        if (config.titlePageCenterVertical &&
+            firstPageElements != null &&
+            firstPageElements.isTitleOnlyPage()
+        ) {
+            // 只有标题块:按字形实际占位整体下移到内容区垂直中点。布局期平移
+            // 保证命中测试、选区与进度映射共用同一几何。
+            val top = firstPageElements.minOf { it.bounds.top }
+            val bottom = firstPageElements.maxOf { it.bounds.bottom }
+            val available = config.contentBottomPx - config.paddingTopPx
+            if (bottom - top < available) {
+                val delta = config.paddingTopPx + (available - (bottom - top)) / 2f - top
+                if (delta != 0f) {
+                    for (elementIndex in firstPageElements.indices) {
+                        firstPageElements[elementIndex] =
+                            shiftElement(firstPageElements[elementIndex], delta)
+                    }
+                    centeredPageShiftPx = delta
+                }
+            }
+        }
+        pages.lastIndex.takeIf { it >= 0 }?.let { emitPage(it, isLastPage = true) }
+        return resultPages
+    }
+
+    /** 该页是否只有标题文字(对照旧 `isTitle && pendingTextPage.lines.isEmpty()`)。 */
+    private fun MutableList<ReaderElement>.isTitleOnlyPage(): Boolean {
+        var hasTitle = false
+        for (element in this) {
+            val text = element as? ReaderElement.Text ?: continue
+            if (text.emphasized) hasTitle = true else return false
+        }
+        return hasTitle
+    }
 
-        fun shiftElement(element: ReaderElement, deltaY: Float): ReaderElement = when (element) {
+    private fun processBlock(
+        block: ReaderMeasuredBlock,
+        index: Int,
+        hasFollowingBlock: Boolean,
+        nextIsTitle: Boolean,
+    ) {
+        val isTitle = block.isTitle()
+        if (isTitle && index == 0) y += config.titleTopSpacingPx
+        when (block) {
+            is ReaderMeasuredBlock.Paragraph -> addParagraph(block.value, index, hasFollowingBlock)
+            is ReaderMeasuredBlock.Image -> addImage(block)
+            is ReaderMeasuredBlock.InlineParagraph -> addInlineParagraph(
+                block,
+                index,
+                hasFollowingBlock
+            )
+
+            is ReaderMeasuredBlock.BlankLine -> addBlankLine(block, index, hasFollowingBlock)
+            is ReaderMeasuredBlock.Rule -> addRule(block)
+            ReaderMeasuredBlock.PageBreak -> {
+                previousLineHadFrame = false
+                advanceColumn()
+            }
+        }
+        if (isTitle) {
+            y += if (nextIsTitle) {
+                config.titleSegmentSpacingPx * ((block as? ReaderMeasuredBlock.InlineParagraph)?.titleSpacingScale
+                    ?: 1f)
+            } else config.titleBottomSpacingPx
+        }
+    }
+
+    private fun columnLeft() = config.paddingLeftPx + columnIndex * config.columnStridePx
+    private fun columnHasContent() = elements.size > columnElementStart
+
+    private fun addPageUnderline(underlineElementStart: Int, lineBottom: Float) {
+        val underline = config.pageUnderline ?: return
+        if (elements.size <= underlineElementStart) return
+        val rowElements = elements.subList(underlineElementStart, elements.size)
+        val start = if (underline.extendToColumn) columnLeft()
+        else rowElements.minOf { it.bounds.left }
+        val end = if (underline.extendToColumn) columnLeft() + config.contentWidthPx
+        else rowElements.maxOf { it.bounds.right }
+        val y = lineBottom + underline.offsetPx
+        elements += ReaderElement.Rule(
+            bounds = ReaderRect(start, y, end, y),
+            colorArgb = underline.colorArgb,
+            widthPx = underline.widthPx,
+            dashed = underline.dashed,
+            dashOnPx = underline.dashOnPx,
+            dashOffPx = underline.dashOffPx,
+            overlayStyledUnderline = true,
+        )
+    }
+
+    private fun shiftElement(element: ReaderElement, deltaY: Float): ReaderElement =
+        when (element) {
             is ReaderElement.Text -> element.copy(
                 bounds = element.bounds.offsetY(deltaY),
                 baselinePx = element.baselinePx + deltaY,
             )
+
             is ReaderElement.Image -> element.copy(bounds = element.bounds.offsetY(deltaY))
             is ReaderElement.Review -> element.copy(
                 bounds = element.bounds.offsetY(deltaY),
                 baselinePx = element.baselinePx + deltaY,
             )
+
             is ReaderElement.Action -> element.copy(bounds = element.bounds.offsetY(deltaY))
             is ReaderElement.Spacer -> element.copy(bounds = element.bounds.offsetY(deltaY))
             is ReaderElement.Rule -> element.copy(bounds = element.bounds.offsetY(deltaY))
             is ReaderElement.ParagraphMarker -> element.copy(bounds = element.bounds.offsetY(deltaY))
         }
 
-        /** 返回最后一行实际下移量,滚动模式必须把它计入页高。 */
-        fun justifyColumnBottom(): Float {
-            if (!config.textBottomJustify || columnRows.size <= 1) return 0f
-            val last = columnRows.last()
-            if (last.standaloneImage) return 0f
-            val lastHeight = last.bottom - last.top
-            val reservedLineSpacing = config.lineHeightPx * config.lineSpacingMultiplier
-            if (config.contentBottomPx - (last.bottom + reservedLineSpacing) >= lastHeight) return 0f
-            val surplus = config.contentBottomPx - last.bottom
-            if (surplus <= 0f) return 0f
-            val gap = surplus / (columnRows.size - 1)
-            columnRows.forEachIndexed { rowIndex, row ->
-                if (rowIndex == 0) return@forEachIndexed
-                val deltaY = gap * rowIndex
-                for (elementIndex in row.elementStart until row.elementEnd) {
-                    elements[elementIndex] = shiftElement(elements[elementIndex], deltaY)
-                }
+    /** 返回最后一行实际下移量,滚动模式必须把它计入页高。 */
+    private fun justifyColumnBottom(): Float {
+        if (!config.textBottomJustify || columnRows.size <= 1) return 0f
+        val last = columnRows.last()
+        if (last.standaloneImage) return 0f
+        val lastHeight = last.bottom - last.top
+        val reservedLineSpacing = config.lineHeightPx * config.lineSpacingMultiplier
+        if (config.contentBottomPx - (last.bottom + reservedLineSpacing) >= lastHeight) return 0f
+        val surplus = config.contentBottomPx - last.bottom
+        if (surplus <= 0f) return 0f
+        val gap = surplus / (columnRows.size - 1)
+        columnRows.forEachIndexed { rowIndex, row ->
+            if (rowIndex == 0) return@forEachIndexed
+            val deltaY = gap * rowIndex
+            for (elementIndex in row.elementStart until row.elementEnd) {
+                elements[elementIndex] = shiftElement(elements[elementIndex], deltaY)
             }
-            return surplus
         }
+        return surplus
+    }
 
-        fun finishPage() {
-            val bottomJustifyShift = justifyColumnBottom()
-            if (elements.isNotEmpty()) {
-                pages += elements
-                pageTexts += pageText
-                pageExtents += if (config.continuousScroll) {
+    private fun finishPage() {
+        val bottomJustifyShift = justifyColumnBottom()
+        if (elements.isNotEmpty()) {
+            pages += elements
+            pageTexts += pageText
+            pageExtents += if (config.continuousScroll) {
                     // Legacy TextChapterLayout records the scroll-page height from its
                     // current layout cursor (durY). In particular, durY already contains
                     // the final line advance and paragraph spacing. Do not expand it to a
@@ -324,6 +462,9 @@ object ReaderPaginator {
                 } else config.viewportHeightPx.toFloat()
                 elements = mutableListOf()
                 pageText = StringBuilder()
+            // 延迟一页流出:刚收尾的页可能是章末页(堆叠高度要加 chapterEndPaddingPx),
+            // 只有下一次收尾或 finish() 才知道它是不是最后一页。
+            if (pages.size >= 2) emitPage(pages.size - 2, isLastPage = false)
             }
             y = config.paddingTopPx
             columnIndex = 0
@@ -331,552 +472,558 @@ object ReaderPaginator {
             columnRows = mutableListOf()
         }
 
-        fun advanceColumn() {
-            if (!columnHasContent()) return
-            // 换栏/换页后上一行已经不在同一屏,页边距本身就是空隙。
-            previousLineHadFrame = false
-            if (columnIndex + 1 < config.columnCount) {
-                justifyColumnBottom()
-                columnIndex++
-                columnElementStart = elements.size
-                columnRows = mutableListOf()
-                y = config.paddingTopPx
-            } else finishPage()
-        }
+    private fun advanceColumn() {
+        if (!columnHasContent()) return
+        // 换栏/换页后上一行已经不在同一屏,页边距本身就是空隙。
+        previousLineHadFrame = false
+        if (columnIndex + 1 < config.columnCount) {
+            justifyColumnBottom()
+            columnIndex++
+            columnElementStart = elements.size
+            columnRows = mutableListOf()
+            y = config.paddingTopPx
+        } else finishPage()
+    }
 
-        fun addParagraph(
-            paragraph: ReaderMeasuredParagraph,
-            paragraphIndex: Int,
-            appendSeparator: Boolean,
-        ) {
-            val lineHeight = paragraph.lineHeightPx ?: config.lineHeightPx
-            val letterSpacing = paragraph.letterSpacingPx ?: config.letterSpacingPx
-            val indentWidth = paragraph.indentWidthPx
-                ?: (paragraph.style.fontSizePx + letterSpacing) * paragraph.indentCharacters
-            val baselineOffset = paragraph.baselineOffsetPx ?: config.baselineOffsetPx
-            val ideographWidth = paragraph.clusterWidthsPx.firstOrNull { it > 0f }
-                ?: paragraph.style.fontSizePx
-            val breaker = ChineseLineBreaker(
-                clusters = paragraph.clusters,
-                // The shared breaker expects advances including spacing; its width limit
-                // compensates for the final, undrawn trailing gap (also used by legacy ZhLayout).
-                widthsPx = paragraph.clusterWidthsPx.map { it + letterSpacing },
-                indentCharacters = 0,
-                widthPx = config.contentWidthPx.toInt(),
-                ideographWidthPx = ideographWidth + letterSpacing,
-                letterSpacingPx = letterSpacing,
-                firstLineWidthPx = (config.contentWidthPx - indentWidth)
-                    .coerceAtLeast(0f).toInt(),
-            )
-            val starts = breaker.lineClusterStarts
-            for (lineIndex in 0 until breaker.lineCount) {
-                if (y + lineHeight > config.contentBottomPx && columnHasContent()) advanceColumn()
-                val from = starts[lineIndex]
-                val until = starts[lineIndex + 1]
-                val widths = paragraph.clusterWidthsPx.subList(from, until)
-                val naturalWidth = widths.sum() + letterSpacing * (widths.size - 1).coerceAtLeast(0)
-                val indent = if (lineIndex == 0) indentWidth else 0f
-                val available = (config.contentWidthPx - indent).coerceAtLeast(0f)
-                val justifyGap = if (
-                    paragraph.alignment == ReaderTextAlignment.JUSTIFY &&
-                    lineIndex < breaker.lineCount - 1 && widths.size > 1
-                ) ((available - naturalWidth) / (widths.size - 1)).coerceAtLeast(0f) else 0f
-                var x = columnLeft() + indent + when (paragraph.alignment) {
-                    ReaderTextAlignment.CENTER -> (available - naturalWidth).coerceAtLeast(0f) / 2f
-                    ReaderTextAlignment.END -> (available - naturalWidth).coerceAtLeast(0f)
-                    else -> 0f
-                }
-                val rowElementStart = elements.size
-                var characterOffset = paragraph.clusters.take(from).sumOf(String::length)
-                for (clusterIndex in from until until) {
-                    val value = paragraph.clusters[clusterIndex]
-                    val width = max(paragraph.clusterWidthsPx[clusterIndex], 0f)
-                    elements += ReaderElement.Text(
-                        bounds = ReaderRect(x, y, x + width, y + lineHeight),
-                        baselinePx = y + baselineOffset,
-                        value = value,
-                        style = paragraph.style,
-                        selected = false,
-                        emphasized = paragraph.isTitle,
-                        link = paragraph.link,
-                        chapterPosition = paragraph.chapterPosition + characterOffset,
-                        paragraphIndex = paragraphIndex,
-                        // 整段共用一个 style:同行内第二字起若带背景图,即与前一字同 run
-                        continuesBackgroundRun = paragraph.style.backgroundImage != null && clusterIndex > from,
-                    )
-                    pageText.append(value)
-                    characterOffset += value.length
-                    x += width + letterSpacing + justifyGap
-                }
-                addPageUnderline(rowElementStart, y + lineHeight)
-                columnRows += ReaderLayoutRow(rowElementStart, elements.size, y, y + lineHeight)
-                y += lineHeight * (paragraph.lineSpacingMultiplier ?: config.lineSpacingMultiplier)
+    private fun addParagraph(
+        paragraph: ReaderMeasuredParagraph,
+        paragraphIndex: Int,
+        appendSeparator: Boolean,
+    ) {
+        val lineHeight = paragraph.lineHeightPx ?: config.lineHeightPx
+        val letterSpacing = paragraph.letterSpacingPx ?: config.letterSpacingPx
+        val indentWidth = paragraph.indentWidthPx
+            ?: (paragraph.style.fontSizePx + letterSpacing) * paragraph.indentCharacters
+        val baselineOffset = paragraph.baselineOffsetPx ?: config.baselineOffsetPx
+        val ideographWidth = paragraph.clusterWidthsPx.firstOrNull { it > 0f }
+            ?: paragraph.style.fontSizePx
+        val breaker = ChineseLineBreaker(
+            clusters = paragraph.clusters,
+            // The shared breaker expects advances including spacing; its width limit
+            // compensates for the final, undrawn trailing gap (also used by legacy ZhLayout).
+            widthsPx = paragraph.clusterWidthsPx.map { it + letterSpacing },
+            indentCharacters = 0,
+            widthPx = config.contentWidthPx.toInt(),
+            ideographWidthPx = ideographWidth + letterSpacing,
+            letterSpacingPx = letterSpacing,
+            firstLineWidthPx = (config.contentWidthPx - indentWidth)
+                .coerceAtLeast(0f).toInt(),
+        )
+        val starts = breaker.lineClusterStarts
+        for (lineIndex in 0 until breaker.lineCount) {
+            if (y + lineHeight > config.contentBottomPx && columnHasContent()) advanceColumn()
+            val from = starts[lineIndex]
+            val until = starts[lineIndex + 1]
+            val widths = paragraph.clusterWidthsPx.subList(from, until)
+            val naturalWidth = widths.sum() + letterSpacing * (widths.size - 1).coerceAtLeast(0)
+            val indent = if (lineIndex == 0) indentWidth else 0f
+            val available = (config.contentWidthPx - indent).coerceAtLeast(0f)
+            val justifyGap = if (
+                paragraph.alignment == ReaderTextAlignment.JUSTIFY &&
+                lineIndex < breaker.lineCount - 1 && widths.size > 1
+            ) ((available - naturalWidth) / (widths.size - 1)).coerceAtLeast(0f) else 0f
+            var x = columnLeft() + indent + when (paragraph.alignment) {
+                ReaderTextAlignment.CENTER -> (available - naturalWidth).coerceAtLeast(0f) / 2f
+                ReaderTextAlignment.END -> (available - naturalWidth).coerceAtLeast(0f)
+                else -> 0f
             }
-            // 这条路径(整段共用一个 style)不参与九宫格预算,但下一行仍要知道本段带框。
-            previousLineHadFrame = paragraph.style.backgroundImage?.fit == 3
-            if (appendSeparator) {
-                pageText.append('\n')
-                y += if (paragraph.isTitle) config.titleParagraphSpacingPx ?: config.paragraphSpacingPx
-                    else config.paragraphSpacingPx
+            val rowElementStart = elements.size
+            var characterOffset = paragraph.clusters.take(from).sumOf(String::length)
+            for (clusterIndex in from until until) {
+                val value = paragraph.clusters[clusterIndex]
+                val width = max(paragraph.clusterWidthsPx[clusterIndex], 0f)
+                elements += ReaderElement.Text(
+                    bounds = ReaderRect(x, y, x + width, y + lineHeight),
+                    baselinePx = y + baselineOffset,
+                    value = value,
+                    style = paragraph.style,
+                    selected = false,
+                    emphasized = paragraph.isTitle,
+                    link = paragraph.link,
+                    chapterPosition = paragraph.chapterPosition + characterOffset,
+                    paragraphIndex = paragraphIndex,
+                    // 整段共用一个 style:同行内第二字起若带背景图,即与前一字同 run
+                    continuesBackgroundRun = paragraph.style.backgroundImage != null && clusterIndex > from,
+                )
+                pageText.append(value)
+                characterOffset += value.length
+                x += width + letterSpacing + justifyGap
             }
+            addPageUnderline(rowElementStart, y + lineHeight)
+            columnRows += ReaderLayoutRow(rowElementStart, elements.size, y, y + lineHeight)
+            y += lineHeight * (paragraph.lineSpacingMultiplier ?: config.lineSpacingMultiplier)
         }
+        // 这条路径(整段共用一个 style)不参与九宫格预算,但下一行仍要知道本段带框。
+        previousLineHadFrame = paragraph.style.backgroundImage?.fit == 3
+        if (appendSeparator) {
+            pageText.append('\n')
+            y += if (paragraph.isTitle) config.titleParagraphSpacingPx ?: config.paragraphSpacingPx
+            else config.paragraphSpacingPx
+        }
+    }
 
-        fun addImage(image: ReaderMeasuredBlock.Image) {
-            previousLineHadFrame = false
-            if (image.pageBreakBefore) advanceColumn()
-            val sourceWidth = image.intrinsicWidthPx.coerceAtLeast(1f)
-            val sourceHeight = image.intrinsicHeightPx.coerceAtLeast(1f)
-            val availableHeight = config.contentBottomPx - config.paddingTopPx
-            val singleImage = image.scaleMode == ReaderImageScaleMode.FIT_PAGE ||
+    private fun addImage(image: ReaderMeasuredBlock.Image) {
+        previousLineHadFrame = false
+        if (image.pageBreakBefore) advanceColumn()
+        val sourceWidth = image.intrinsicWidthPx.coerceAtLeast(1f)
+        val sourceHeight = image.intrinsicHeightPx.coerceAtLeast(1f)
+        val availableHeight = config.contentBottomPx - config.paddingTopPx
+        val singleImage = image.scaleMode == ReaderImageScaleMode.FIT_PAGE ||
                 image.pageBreakBefore && image.pageBreakAfter
-            val continuousFullImage = config.continuousScroll &&
+        val continuousFullImage = config.continuousScroll &&
                 image.scaleMode == ReaderImageScaleMode.FIT_WIDTH
-            val scale = if (singleImage) {
-                minOf(config.contentWidthPx / sourceWidth, availableHeight / sourceHeight)
-            } else if (continuousFullImage) {
-                config.contentWidthPx / sourceWidth
-            } else when (image.scaleMode) {
-                ReaderImageScaleMode.CONTAIN_NO_UPSCALE -> minOf(
-                    config.contentWidthPx / sourceWidth, availableHeight / sourceHeight, 1f)
-                ReaderImageScaleMode.FIT_WIDTH -> minOf(
-                    config.contentWidthPx / sourceWidth, availableHeight / sourceHeight)
-                ReaderImageScaleMode.FIT_PAGE -> error("single image handled above")
-            }
-            val width = sourceWidth * scale
-            val height = sourceHeight * scale
-            if (
-                (if (continuousFullImage) y > config.contentBottomPx else y + height > config.contentBottomPx) &&
-                columnHasContent()
-            ) advanceColumn()
-            if (singleImage) y = config.paddingTopPx + (availableHeight - height) / 2f
-            val x = when (image.horizontalAlignment) {
-                ReaderTextAlignment.START, ReaderTextAlignment.JUSTIFY -> columnLeft()
-                ReaderTextAlignment.CENTER -> columnLeft() + (config.contentWidthPx - width) / 2f
-                ReaderTextAlignment.END -> columnLeft() + config.contentWidthPx - width
-            }
-            val rowElementStart = elements.size
-            elements += ReaderElement.Image(
-                bounds = ReaderRect(x, y, x + width, y + height),
-                source = image.source,
-                action = image.action,
-                chapterPosition = image.chapterPosition,
+        val scale = if (singleImage) {
+            minOf(config.contentWidthPx / sourceWidth, availableHeight / sourceHeight)
+        } else if (continuousFullImage) {
+            config.contentWidthPx / sourceWidth
+        } else when (image.scaleMode) {
+            ReaderImageScaleMode.CONTAIN_NO_UPSCALE -> minOf(
+                config.contentWidthPx / sourceWidth, availableHeight / sourceHeight, 1f
+            )
+
+            ReaderImageScaleMode.FIT_WIDTH -> minOf(
+                config.contentWidthPx / sourceWidth, availableHeight / sourceHeight
             )
-            columnRows += ReaderLayoutRow(rowElementStart, elements.size, y, y + height, standaloneImage = true)
-            pageText.append('\uFFFC')
-            y += height + config.paragraphSpacingPx
-            if (image.pageBreakAfter) advanceColumn()
+
+            ReaderImageScaleMode.FIT_PAGE -> error("single image handled above")
+        }
+        val width = sourceWidth * scale
+        val height = sourceHeight * scale
+        if (
+            (if (continuousFullImage) y > config.contentBottomPx else y + height > config.contentBottomPx) &&
+            columnHasContent()
+        ) advanceColumn()
+        if (singleImage) y = config.paddingTopPx + (availableHeight - height) / 2f
+        val x = when (image.horizontalAlignment) {
+            ReaderTextAlignment.START, ReaderTextAlignment.JUSTIFY -> columnLeft()
+            ReaderTextAlignment.CENTER -> columnLeft() + (config.contentWidthPx - width) / 2f
+            ReaderTextAlignment.END -> columnLeft() + config.contentWidthPx - width
         }
+        val rowElementStart = elements.size
+        elements += ReaderElement.Image(
+            bounds = ReaderRect(x, y, x + width, y + height),
+            source = image.source,
+            action = image.action,
+            chapterPosition = image.chapterPosition,
+        )
+        columnRows += ReaderLayoutRow(
+            rowElementStart,
+            elements.size,
+            y,
+            y + height,
+            standaloneImage = true
+        )
+        pageText.append('\uFFFC')
+        y += height + config.paragraphSpacingPx
+        if (image.pageBreakAfter) advanceColumn()
+    }
 
-        fun addInlineParagraph(
-            paragraph: ReaderMeasuredBlock.InlineParagraph,
-            paragraphIndex: Int,
-            appendSeparator: Boolean,
-        ) {
-            if (paragraph.items.isEmpty()) return
-            val letterSpacing = paragraph.letterSpacingPx ?: config.letterSpacingPx
-            val indentWidth = paragraph.indentWidthPx
-                ?: (paragraph.baseTextSizePx + letterSpacing) * paragraph.indentCharacters
-            val ideographWidth = paragraph.items.filterIsInstance<ReaderMeasuredInlineItem.Text>()
-                .firstOrNull { it.widthPx > 0f }?.widthPx ?: paragraph.lineHeightPx
-            val clusters = paragraph.items.map {
-                when (it) {
-                    is ReaderMeasuredInlineItem.Text -> it.value
-                    is ReaderMeasuredInlineItem.Image -> "\uFFFC"
-                }
-            }
-            val lineGapPx =
-                (paragraph.lineSpacingMultiplier - 1f).coerceAtLeast(0f) * paragraph.lineHeightPx
-            val halfLineGapPx = lineGapPx / 2f
-
-            fun itemFrame(index: Int) =
-                (paragraph.items[index] as? ReaderMeasuredInlineItem.Text)
-                    ?.style?.backgroundImage?.takeIf { it.fit == 3 }
-
-            fun lineHasFrame(from: Int, until: Int): Boolean =
-                (from until until.coerceAtMost(paragraph.items.size)).any { itemFrame(it) != null }
-
-            fun frameOf(index: Int, topBudgetPx: Float, bottomBudgetPx: Float) =
-                itemFrame(index)?.fitIntoLineBudget(topBudgetPx, bottomBudgetPx)
-
-            fun backgroundInsetBefore(
-                index: Int,
-                lineStart: Int,
-                topBudgetPx: Float,
-                bottomBudgetPx: Float,
-            ): Float {
-                val image = frameOf(index, topBudgetPx, bottomBudgetPx) ?: return 0f
-                return if (
-                    index == lineStart ||
-                    frameOf(index - 1, topBudgetPx, bottomBudgetPx) != image
-                ) image.contentInsetLeftPx else 0f
+    private fun addInlineParagraph(
+        paragraph: ReaderMeasuredBlock.InlineParagraph,
+        paragraphIndex: Int,
+        appendSeparator: Boolean,
+    ) {
+        if (paragraph.items.isEmpty()) return
+        val letterSpacing = paragraph.letterSpacingPx ?: config.letterSpacingPx
+        val indentWidth = paragraph.indentWidthPx
+            ?: (paragraph.baseTextSizePx + letterSpacing) * paragraph.indentCharacters
+        val ideographWidth = paragraph.items.filterIsInstance<ReaderMeasuredInlineItem.Text>()
+            .firstOrNull { it.widthPx > 0f }?.widthPx ?: paragraph.lineHeightPx
+        val clusters = paragraph.items.map {
+            when (it) {
+                is ReaderMeasuredInlineItem.Text -> it.value
+                is ReaderMeasuredInlineItem.Image -> "\uFFFC"
             }
+        }
+        val lineGapPx =
+            (paragraph.lineSpacingMultiplier - 1f).coerceAtLeast(0f) * paragraph.lineHeightPx
+        val halfLineGapPx = lineGapPx / 2f
+
+        fun itemFrame(index: Int) =
+            (paragraph.items[index] as? ReaderMeasuredInlineItem.Text)
+                ?.style?.backgroundImage?.takeIf { it.fit == 3 }
+
+        fun lineHasFrame(from: Int, until: Int): Boolean =
+            (from until until.coerceAtMost(paragraph.items.size)).any { itemFrame(it) != null }
+
+        fun frameOf(index: Int, topBudgetPx: Float, bottomBudgetPx: Float) =
+            itemFrame(index)?.fitIntoLineBudget(topBudgetPx, bottomBudgetPx)
+
+        fun backgroundInsetBefore(
+            index: Int,
+            lineStart: Int,
+            topBudgetPx: Float,
+            bottomBudgetPx: Float,
+        ): Float {
+            val image = frameOf(index, topBudgetPx, bottomBudgetPx) ?: return 0f
+            return if (
+                index == lineStart ||
+                frameOf(index - 1, topBudgetPx, bottomBudgetPx) != image
+            ) image.contentInsetLeftPx else 0f
+        }
+
+        fun backgroundInsetAfter(
+            index: Int,
+            lineEnd: Int,
+            topBudgetPx: Float,
+            bottomBudgetPx: Float,
+        ): Float {
+            val image = frameOf(index, topBudgetPx, bottomBudgetPx) ?: return 0f
+            return if (
+                index + 1 == lineEnd ||
+                frameOf(index + 1, topBudgetPx, bottomBudgetPx) != image
+            ) image.contentInsetRightPx else 0f
+        }
+
+        val breaker = ChineseLineBreaker(
+            clusters = clusters,
+            widthsPx = paragraph.items.map { it.widthPx + letterSpacing },
+            indentCharacters = 0,
+            widthPx = (config.contentWidthPx - paragraph.restLineIndentWidthPx)
+                .coerceAtLeast(0f).toInt(),
+            ideographWidthPx = ideographWidth + letterSpacing,
+            letterSpacingPx = letterSpacing,
+            firstLineWidthPx = (config.contentWidthPx - indentWidth)
+                .coerceAtLeast(0f).toInt(),
+        )
+        val originalEnds = breaker.lineClusterStarts.drop(1)
+        val starts = mutableListOf(0)
+        // 每行的纵向预算在断行阶段就定死,绘制阶段直接复用——排版预留与绘制的外框必须
+        // 用同一份 inset,否则框会压到相邻文字上或者留出多余的空档。
+        val lineTopBudgets = mutableListOf<Float>()
+        val lineBottomBudgets = mutableListOf<Float>()
+        // The View reader reserves the left/right pieces around every visual-line run.
+        // Refine the shaped line ends so those pieces cannot overlap adjacent text or
+        // escape the column. Keep a forbidden Chinese break intact even if its frame has
+        // to consume the remaining slack, matching the legacy punctuation priority.
+        while (starts.last() < paragraph.items.size) {
+            val from = starts.last()
+            val lineIndent =
+                if (starts.size == 1) indentWidth else paragraph.restLineIndentWidthPx
+            val available = config.contentWidthPx - lineIndent
+            val topBudgetPx = if (previousLineHadFrame) halfLineGapPx else lineGapPx
+            // 下一行的范围在断行阶段还没最终定下来(本行可能被九宫格宽度挤短,把尾巴
+            // 让给下一行),用原始断行结果近似探测那一行有没有框。
+            val breakerEnd = originalEnds.getOrElse(starts.lastIndex) { paragraph.items.size }
+                .coerceAtLeast(from + 1)
+            val nextBreakerEnd =
+                originalEnds.getOrElse(starts.lastIndex + 1) { paragraph.items.size }
+            val bottomBudgetPx =
+                if (lineHasFrame(breakerEnd, nextBreakerEnd)) halfLineGapPx else lineGapPx
+            // Advance the original visual-line cursor even when a frame forces the
+            // preceding row shorter. Reusing the first end after `from` would strand the
+            // remainder of that row as an unnecessary one-character line.
+            var until = breakerEnd
 
-            fun backgroundInsetAfter(
-                index: Int,
-                lineEnd: Int,
-                topBudgetPx: Float,
-                bottomBudgetPx: Float,
-            ): Float {
-                val image = frameOf(index, topBudgetPx, bottomBudgetPx) ?: return 0f
-                return if (
-                    index + 1 == lineEnd ||
-                    frameOf(index + 1, topBudgetPx, bottomBudgetPx) != image
-                ) image.contentInsetRightPx else 0f
+            fun occupiedWidth(endExclusive: Int): Float =
+                (from until endExclusive).sumOf { index ->
+                    (paragraph.items[index].widthPx +
+                            backgroundInsetBefore(index, from, topBudgetPx, bottomBudgetPx) +
+                            backgroundInsetAfter(
+                                index,
+                                endExclusive,
+                                topBudgetPx,
+                                bottomBudgetPx
+                            )).toDouble()
+                }.toFloat() + letterSpacing * (endExclusive - from - 1).coerceAtLeast(0)
+            while (until - from > 1 && occupiedWidth(until) > available) {
+                val candidate = until - 1
+                if (ChineseLineBreaker.isForbiddenBreak(
+                        clusters[candidate - 1],
+                        clusters[candidate]
+                    )
+                ) break
+                until = candidate
             }
-            val breaker = ChineseLineBreaker(
-                clusters = clusters,
-                widthsPx = paragraph.items.map { it.widthPx + letterSpacing },
-                indentCharacters = 0,
-                widthPx = (config.contentWidthPx - paragraph.restLineIndentWidthPx)
-                    .coerceAtLeast(0f).toInt(),
-                ideographWidthPx = ideographWidth + letterSpacing,
-                letterSpacingPx = letterSpacing,
-                firstLineWidthPx = (config.contentWidthPx - indentWidth)
-                    .coerceAtLeast(0f).toInt(),
+            starts += until
+            lineTopBudgets += topBudgetPx
+            lineBottomBudgets += bottomBudgetPx
+            previousLineHadFrame = lineHasFrame(from, until)
+        }
+        for (lineIndex in 0 until starts.lastIndex) {
+            val from = starts[lineIndex]
+            val until = starts[lineIndex + 1]
+            val lineItems = paragraph.items.subList(from, until)
+            val textItems = lineItems.filterIsInstance<ReaderMeasuredInlineItem.Text>()
+            // Inline HTML may shrink every glyph in a row (<small>, font-size, etc.).
+            // It changes glyph drawing but not the paragraph's base line box; otherwise a
+            // small final row advances less and makes the following paragraph gap collapse.
+            val maxTextScale = maxOf(
+                1f,
+                textItems.maxOfOrNull {
+                    it.style.fontSizePx / paragraph.baseTextSizePx.coerceAtLeast(1f)
+                } ?: 1f,
             )
-            val originalEnds = breaker.lineClusterStarts.drop(1)
-            val starts = mutableListOf(0)
-            // 每行的纵向预算在断行阶段就定死,绘制阶段直接复用——排版预留与绘制的外框必须
-            // 用同一份 inset,否则框会压到相邻文字上或者留出多余的空档。
-            val lineTopBudgets = mutableListOf<Float>()
-            val lineBottomBudgets = mutableListOf<Float>()
-            // The View reader reserves the left/right pieces around every visual-line run.
-            // Refine the shaped line ends so those pieces cannot overlap adjacent text or
-            // escape the column. Keep a forbidden Chinese break intact even if its frame has
-            // to consume the remaining slack, matching the legacy punctuation priority.
-            while (starts.last() < paragraph.items.size) {
-                val from = starts.last()
-                val lineIndent =
-                    if (starts.size == 1) indentWidth else paragraph.restLineIndentWidthPx
-                val available = config.contentWidthPx - lineIndent
-                val topBudgetPx = if (previousLineHadFrame) halfLineGapPx else lineGapPx
-                // 下一行的范围在断行阶段还没最终定下来(本行可能被九宫格宽度挤短,把尾巴
-                // 让给下一行),用原始断行结果近似探测那一行有没有框。
-                val breakerEnd = originalEnds.getOrElse(starts.lastIndex) { paragraph.items.size }
-                    .coerceAtLeast(from + 1)
-                val nextBreakerEnd =
-                    originalEnds.getOrElse(starts.lastIndex + 1) { paragraph.items.size }
-                val bottomBudgetPx =
-                    if (lineHasFrame(breakerEnd, nextBreakerEnd)) halfLineGapPx else lineGapPx
-                // Advance the original visual-line cursor even when a frame forces the
-                // preceding row shorter. Reusing the first end after `from` would strand the
-                // remainder of that row as an unnecessary one-character line.
-                var until = breakerEnd
-
-                fun occupiedWidth(endExclusive: Int): Float =
-                    (from until endExclusive).sumOf { index ->
-                        (paragraph.items[index].widthPx +
-                                backgroundInsetBefore(index, from, topBudgetPx, bottomBudgetPx) +
-                                backgroundInsetAfter(
-                                    index,
-                                    endExclusive,
-                                    topBudgetPx,
-                                    bottomBudgetPx
-                                )).toDouble()
-                    }.toFloat() + letterSpacing * (endExclusive - from - 1).coerceAtLeast(0)
-                while (until - from > 1 && occupiedWidth(until) > available) {
-                    val candidate = until - 1
-                    if (ChineseLineBreaker.isForbiddenBreak(
-                            clusters[candidate - 1],
-                            clusters[candidate]
-                        )
-                    ) break
-                    until = candidate
-                }
-                starts += until
-                lineTopBudgets += topBudgetPx
-                lineBottomBudgets += bottomBudgetPx
-                previousLineHadFrame = lineHasFrame(from, until)
-            }
-            for (lineIndex in 0 until starts.lastIndex) {
-                val from = starts[lineIndex]
-                val until = starts[lineIndex + 1]
-                val lineItems = paragraph.items.subList(from, until)
-                val textItems = lineItems.filterIsInstance<ReaderMeasuredInlineItem.Text>()
-                // Inline HTML may shrink every glyph in a row (<small>, font-size, etc.).
-                // It changes glyph drawing but not the paragraph's base line box; otherwise a
-                // small final row advances less and makes the following paragraph gap collapse.
-                val maxTextScale = maxOf(
-                    1f,
-                    textItems.maxOfOrNull {
-                        it.style.fontSizePx / paragraph.baseTextSizePx.coerceAtLeast(1f)
-                    } ?: 1f,
-                )
-                val fallbackLineHeight = paragraph.lineHeightPx * maxTextScale
-                val fallbackBaseline = paragraph.baselineOffsetPx * maxTextScale
-                // Keep the paragraph's unshifted line box as the minimum. A line containing
-                // only <sup> or only <sub> must not cancel its own visual movement by moving
-                // the shared baseline in the opposite direction.
-                val lineAscent = maxOf(fallbackBaseline, textItems.maxOfOrNull {
-                    (it.baselineOffsetPx ?: fallbackBaseline) - it.baselineShiftPx
-                } ?: fallbackBaseline)
-                val fallbackDescent = fallbackLineHeight - fallbackBaseline
-                val lineDescent = maxOf(fallbackDescent, textItems.maxOfOrNull {
-                    val height = it.lineHeightPx ?: fallbackLineHeight
-                    height - (it.baselineOffsetPx ?: fallbackBaseline) + it.baselineShiftPx
-                } ?: fallbackDescent)
-                val textLineHeight = lineAscent + lineDescent
-                val actualLineHeight = maxOf(
-                    textLineHeight,
-                    lineItems.filterIsInstance<ReaderMeasuredInlineItem.Image>().maxOfOrNull { it.heightPx } ?: 0f,
-                )
-                val lineBaselineOffset = lineAscent + (actualLineHeight - textLineHeight) / 2f
-                if (y + actualLineHeight > config.contentBottomPx && columnHasContent()) advanceColumn()
-                val indent = if (lineIndex == 0) indentWidth else paragraph.restLineIndentWidthPx
-                val available = (config.contentWidthPx - indent).coerceAtLeast(0f)
-                val topBudgetPx = lineTopBudgets[lineIndex]
-                val bottomBudgetPx = lineBottomBudgets[lineIndex]
-                fun backgroundInsetBefore(index: Int) =
-                    backgroundInsetBefore(from + index, from, topBudgetPx, bottomBudgetPx)
-
-                fun backgroundInsetAfter(index: Int) =
-                    backgroundInsetAfter(from + index, until, topBudgetPx, bottomBudgetPx)
-                val naturalWidth = lineItems.sumOf { it.widthPx.toDouble() }.toFloat() +
-                        letterSpacing * (lineItems.size - 1).coerceAtLeast(0) +
-                        lineItems.indices.sumOf {
-                            (backgroundInsetBefore(it) + backgroundInsetAfter(it)).toDouble()
-                        }.toFloat()
-                val indentItems = (paragraph.leadingIndentItems - from).coerceIn(0, lineItems.size)
-                val stretchableGaps = (lineItems.size - indentItems - 1).coerceAtLeast(0)
-                val shouldJustify =
-                    paragraph.alignment == ReaderTextAlignment.JUSTIFY &&
+            val fallbackLineHeight = paragraph.lineHeightPx * maxTextScale
+            val fallbackBaseline = paragraph.baselineOffsetPx * maxTextScale
+            // Keep the paragraph's unshifted line box as the minimum. A line containing
+            // only <sup> or only <sub> must not cancel its own visual movement by moving
+            // the shared baseline in the opposite direction.
+            val lineAscent = maxOf(fallbackBaseline, textItems.maxOfOrNull {
+                (it.baselineOffsetPx ?: fallbackBaseline) - it.baselineShiftPx
+            } ?: fallbackBaseline)
+            val fallbackDescent = fallbackLineHeight - fallbackBaseline
+            val lineDescent = maxOf(fallbackDescent, textItems.maxOfOrNull {
+                val height = it.lineHeightPx ?: fallbackLineHeight
+                height - (it.baselineOffsetPx ?: fallbackBaseline) + it.baselineShiftPx
+            } ?: fallbackDescent)
+            val textLineHeight = lineAscent + lineDescent
+            val actualLineHeight = maxOf(
+                textLineHeight,
+                lineItems.filterIsInstance<ReaderMeasuredInlineItem.Image>()
+                    .maxOfOrNull { it.heightPx } ?: 0f,
+            )
+            val lineBaselineOffset = lineAscent + (actualLineHeight - textLineHeight) / 2f
+            if (y + actualLineHeight > config.contentBottomPx && columnHasContent()) advanceColumn()
+            val indent = if (lineIndex == 0) indentWidth else paragraph.restLineIndentWidthPx
+            val available = (config.contentWidthPx - indent).coerceAtLeast(0f)
+            val topBudgetPx = lineTopBudgets[lineIndex]
+            val bottomBudgetPx = lineBottomBudgets[lineIndex]
+            fun backgroundInsetBefore(index: Int) =
+                backgroundInsetBefore(from + index, from, topBudgetPx, bottomBudgetPx)
+
+            fun backgroundInsetAfter(index: Int) =
+                backgroundInsetAfter(from + index, until, topBudgetPx, bottomBudgetPx)
+
+            val naturalWidth = lineItems.sumOf { it.widthPx.toDouble() }.toFloat() +
+                    letterSpacing * (lineItems.size - 1).coerceAtLeast(0) +
+                    lineItems.indices.sumOf {
+                        (backgroundInsetBefore(it) + backgroundInsetAfter(it)).toDouble()
+                    }.toFloat()
+            val indentItems = (paragraph.leadingIndentItems - from).coerceIn(0, lineItems.size)
+            val stretchableGaps = (lineItems.size - indentItems - 1).coerceAtLeast(0)
+            val shouldJustify =
+                paragraph.alignment == ReaderTextAlignment.JUSTIFY &&
                         lineIndex < starts.lastIndex - 1
-                val residualWidth = if (shouldJustify) {
-                    (available - naturalWidth).coerceAtLeast(0f)
-                } else 0f
-                val wordSpaceCount = if (paragraph.justifyAtWordBoundaries) {
-                    lineItems.count { it is ReaderMeasuredInlineItem.Text && it.value == " " }
-                } else 0
-                val wordSpaceExtra = if (wordSpaceCount > 1) residualWidth / wordSpaceCount else 0f
-                val justifyGap = if (wordSpaceExtra == 0f && stretchableGaps > 0) {
-                    residualWidth / stretchableGaps
-                } else 0f
-                var x = columnLeft() + indent + when (paragraph.alignment) {
-                    ReaderTextAlignment.CENTER -> (available - naturalWidth).coerceAtLeast(0f) / 2f
-                    ReaderTextAlignment.END -> (available - naturalWidth).coerceAtLeast(0f)
-                    else -> 0f
-                }
-                val rowElementStart = elements.size
-                val markerColor = textItems.firstOrNull()?.style?.colorArgb
-                    ?: 0xff000000.toInt()
-                paragraph.decorations.forEach { decoration ->
-                    when (decoration.kind) {
-                        ReaderParagraphDecorationKind.QUOTE -> elements += ReaderElement.ParagraphMarker(
+            val residualWidth = if (shouldJustify) {
+                (available - naturalWidth).coerceAtLeast(0f)
+            } else 0f
+            val wordSpaceCount = if (paragraph.justifyAtWordBoundaries) {
+                lineItems.count { it is ReaderMeasuredInlineItem.Text && it.value == " " }
+            } else 0
+            val wordSpaceExtra = if (wordSpaceCount > 1) residualWidth / wordSpaceCount else 0f
+            val justifyGap = if (wordSpaceExtra == 0f && stretchableGaps > 0) {
+                residualWidth / stretchableGaps
+            } else 0f
+            var x = columnLeft() + indent + when (paragraph.alignment) {
+                ReaderTextAlignment.CENTER -> (available - naturalWidth).coerceAtLeast(0f) / 2f
+                ReaderTextAlignment.END -> (available - naturalWidth).coerceAtLeast(0f)
+                else -> 0f
+            }
+            val rowElementStart = elements.size
+            val markerColor = textItems.firstOrNull()?.style?.colorArgb
+                ?: 0xff000000.toInt()
+            paragraph.decorations.forEach { decoration ->
+                when (decoration.kind) {
+                    ReaderParagraphDecorationKind.QUOTE -> elements += ReaderElement.ParagraphMarker(
+                        bounds = ReaderRect(
+                            columnLeft() + decoration.leadingOffsetPx + decoration.sizePx / 2f,
+                            y,
+                            columnLeft() + decoration.leadingOffsetPx + decoration.sizePx / 2f,
+                            y + actualLineHeight,
+                        ),
+                        colorArgb = decoration.colorArgb ?: markerColor,
+                        strokeWidthPx = decoration.sizePx,
+                        circular = false,
+                    )
+
+                    ReaderParagraphDecorationKind.BULLET -> if (lineIndex == 0) {
+                        elements += ReaderElement.ParagraphMarker(
                             bounds = ReaderRect(
-                                columnLeft() + decoration.leadingOffsetPx + decoration.sizePx / 2f,
-                                y,
-                                columnLeft() + decoration.leadingOffsetPx + decoration.sizePx / 2f,
-                                y + actualLineHeight,
+                                columnLeft() + decoration.leadingOffsetPx + decoration.sizePx,
+                                y + actualLineHeight / 2f,
+                                columnLeft() + decoration.leadingOffsetPx + decoration.sizePx,
+                                y + actualLineHeight / 2f,
                             ),
                             colorArgb = decoration.colorArgb ?: markerColor,
-                            strokeWidthPx = decoration.sizePx,
-                            circular = false,
+                            strokeWidthPx = decoration.sizePx * 2f,
+                            circular = true,
                         )
-                        ReaderParagraphDecorationKind.BULLET -> if (lineIndex == 0) {
-                            elements += ReaderElement.ParagraphMarker(
-                                bounds = ReaderRect(
-                                    columnLeft() + decoration.leadingOffsetPx + decoration.sizePx,
-                                    y + actualLineHeight / 2f,
-                                    columnLeft() + decoration.leadingOffsetPx + decoration.sizePx,
-                                    y + actualLineHeight / 2f,
-                                ),
-                                colorArgb = decoration.colorArgb ?: markerColor,
-                                strokeWidthPx = decoration.sizePx * 2f,
-                                circular = true,
-                            )
-                        }
                     }
                 }
-                // Processed body text can retain its indentation as real leading glyphs.
-                // The View reader started a non-extended underline after those glyphs.
-                val underlineElementStart = elements.size + indentItems
-                lineItems.forEachIndexed { itemIndex, item ->
-                    x += backgroundInsetBefore(itemIndex)
-                    val itemBackground = frameOf(from + itemIndex, topBudgetPx, bottomBudgetPx)
-                    when (item) {
-                        is ReaderMeasuredInlineItem.Text -> {
-                            val expandedWordSpace = if (item.value == " ") wordSpaceExtra else 0f
-                            val itemStyle = if (itemBackground == null) item.style else {
-                                item.style.copy(backgroundImage = itemBackground)
-                            }
-                            elements += ReaderElement.Text(
-                                bounds = ReaderRect(
-                                    x, y, x + item.widthPx + expandedWordSpace, y + actualLineHeight,
-                                ),
-                                baselinePx = y + lineBaselineOffset + item.baselineShiftPx,
-                                value = item.value,
-                                style = itemStyle,
-                                selected = false,
-                                emphasized = paragraph.emphasized,
-                                link = item.link,
-                                markingId = item.markingId,
-                                chapterPosition = item.chapterPosition,
-                                paragraphIndex = paragraphIndex,
-                                // 富文本逐项样式:与前一项同背景图才视作同一 run 的延续
-                                continuesBackgroundRun = itemBackground != null &&
-                                        itemIndex > 0 &&
-                                        frameOf(
-                                            from + itemIndex - 1,
-                                            topBudgetPx,
-                                            bottomBudgetPx
-                                        ) ==
-                                        itemBackground,
-                                backgroundFrameTopPx = itemBackground?.contentInsetTopPx ?: 0f,
-                                backgroundFrameBottomPx = itemBackground?.contentInsetBottomPx
-                                    ?: 0f,
-                            )
-                        }
-                        is ReaderMeasuredInlineItem.Image -> {
-                            val imageTop = y + (actualLineHeight - item.heightPx) / 2f
-                            elements += ReaderElement.Image(
-                                bounds = ReaderRect(x, imageTop, x + item.widthPx, imageTop + item.heightPx),
-                                source = item.source,
-                                action = item.action,
-                                chapterPosition = item.chapterPosition,
-                            )
+            }
+            // Processed body text can retain its indentation as real leading glyphs.
+            // The View reader started a non-extended underline after those glyphs.
+            val underlineElementStart = elements.size + indentItems
+            lineItems.forEachIndexed { itemIndex, item ->
+                x += backgroundInsetBefore(itemIndex)
+                val itemBackground = frameOf(from + itemIndex, topBudgetPx, bottomBudgetPx)
+                when (item) {
+                    is ReaderMeasuredInlineItem.Text -> {
+                        val expandedWordSpace = if (item.value == " ") wordSpaceExtra else 0f
+                        val itemStyle = if (itemBackground == null) item.style else {
+                            item.style.copy(backgroundImage = itemBackground)
                         }
+                        elements += ReaderElement.Text(
+                            bounds = ReaderRect(
+                                x, y, x + item.widthPx + expandedWordSpace, y + actualLineHeight,
+                            ),
+                            baselinePx = y + lineBaselineOffset + item.baselineShiftPx,
+                            value = item.value,
+                            style = itemStyle,
+                            selected = false,
+                            emphasized = paragraph.emphasized,
+                            link = item.link,
+                            markingId = item.markingId,
+                            chapterPosition = item.chapterPosition,
+                            paragraphIndex = paragraphIndex,
+                            // 富文本逐项样式:与前一项同背景图才视作同一 run 的延续
+                            continuesBackgroundRun = itemBackground != null &&
+                                    itemIndex > 0 &&
+                                    frameOf(
+                                        from + itemIndex - 1,
+                                        topBudgetPx,
+                                        bottomBudgetPx
+                                    ) ==
+                                    itemBackground,
+                            backgroundFrameTopPx = itemBackground?.contentInsetTopPx ?: 0f,
+                            backgroundFrameBottomPx = itemBackground?.contentInsetBottomPx
+                                ?: 0f,
+                        )
+                    }
+
+                    is ReaderMeasuredInlineItem.Image -> {
+                        val imageTop = y + (actualLineHeight - item.heightPx) / 2f
+                        elements += ReaderElement.Image(
+                            bounds = ReaderRect(
+                                x,
+                                imageTop,
+                                x + item.widthPx,
+                                imageTop + item.heightPx
+                            ),
+                            source = item.source,
+                            action = item.action,
+                            chapterPosition = item.chapterPosition,
+                        )
                     }
-                    pageText.append(if (item is ReaderMeasuredInlineItem.Text) item.value else '\uFFFC')
-                    x += item.widthPx + backgroundInsetAfter(itemIndex) + letterSpacing +
-                        if (item is ReaderMeasuredInlineItem.Text && item.value == " ") wordSpaceExtra else 0f
-                    x += if (itemIndex >= indentItems) justifyGap else 0f
                 }
-                addPageUnderline(underlineElementStart, y + actualLineHeight)
-                columnRows += ReaderLayoutRow(rowElementStart, elements.size, y, y + actualLineHeight)
-                // 按实际落笔顺序(含换页)记录,供下一段第一行判断上方有没有框。
-                previousLineHadFrame = lineHasFrame(from, until)
-                y += actualLineHeight * paragraph.lineSpacingMultiplier
+                pageText.append(if (item is ReaderMeasuredInlineItem.Text) item.value else '\uFFFC')
+                x += item.widthPx + backgroundInsetAfter(itemIndex) + letterSpacing +
+                        if (item is ReaderMeasuredInlineItem.Text && item.value == " ") wordSpaceExtra else 0f
+                x += if (itemIndex >= indentItems) justifyGap else 0f
             }
-            if (appendSeparator) {
-                pageText.append('\n')
-                y += if (paragraph.emphasized) (config.titleParagraphSpacingPx ?: config.paragraphSpacingPx) * paragraph.titleSpacingScale
-                    else config.paragraphSpacingPx
+            addPageUnderline(underlineElementStart, y + actualLineHeight)
+            columnRows += ReaderLayoutRow(rowElementStart, elements.size, y, y + actualLineHeight)
+            // 按实际落笔顺序(含换页)记录,供下一段第一行判断上方有没有框。
+            previousLineHadFrame = lineHasFrame(from, until)
+            // 旧 `setTypeHtml` 的行推进:行盒含 spacingAdd,且行距乘数被算两次;
+            // 纯文本段落只算一次(`setTypeText`)。HTML 块由测量期标记。
+            val htmlSpacingAddPx =
+                if (paragraph.justifyAtWordBoundaries) config.htmlLineSpacingAddPx else 0f
+            y += if (htmlSpacingAddPx > 0f) {
+                (actualLineHeight * paragraph.lineSpacingMultiplier + htmlSpacingAddPx) *
+                        paragraph.lineSpacingMultiplier
+            } else {
+                actualLineHeight * paragraph.lineSpacingMultiplier
             }
         }
-
-        fun addRule(rule: ReaderMeasuredBlock.Rule) {
-            previousLineHadFrame = false
-            val requiredHeight = rule.verticalPaddingPx * 2f + rule.widthPx
-            if (y + requiredHeight > config.contentBottomPx && columnHasContent()) advanceColumn()
-            val lineY = y + rule.verticalPaddingPx
-            val rowElementStart = elements.size
-            elements += ReaderElement.Rule(
-                bounds = ReaderRect(columnLeft(), lineY, columnLeft() + config.contentWidthPx, lineY + rule.widthPx),
-                colorArgb = rule.colorArgb,
-                widthPx = rule.widthPx,
-                dashed = rule.dashed,
-            )
-            columnRows += ReaderLayoutRow(rowElementStart, elements.size, lineY, lineY + rule.widthPx)
-            y += requiredHeight
+        if (appendSeparator) {
+            pageText.append('\n')
+            y += if (paragraph.emphasized) (config.titleParagraphSpacingPx
+                ?: config.paragraphSpacingPx) * paragraph.titleSpacingScale
+            else config.paragraphSpacingPx
         }
+    }
 
-        fun addBlankLine(
-            blank: ReaderMeasuredBlock.BlankLine,
-            paragraphIndex: Int,
-            appendSeparator: Boolean,
-        ) {
-            val height = blank.lineHeightPx
-            previousLineHadFrame = false
-            if (y + height > config.contentBottomPx && columnHasContent()) advanceColumn()
-            val rowElementStart = elements.size
-            elements += ReaderElement.Spacer(
-                bounds = ReaderRect(columnLeft(), y, columnLeft() + config.contentWidthPx, y + height),
-                chapterPosition = blank.chapterPosition,
-                paragraphIndex = paragraphIndex,
-            )
-            columnRows += ReaderLayoutRow(rowElementStart, elements.size, y, y + height)
-            y += height * blank.lineSpacingMultiplier
-            if (appendSeparator) {
-                pageText.append('\n')
-                y += config.paragraphSpacingPx
-            }
-        }
+    private fun addRule(rule: ReaderMeasuredBlock.Rule) {
+        previousLineHadFrame = false
+        val requiredHeight = rule.verticalPaddingPx * 2f + rule.widthPx
+        if (y + requiredHeight > config.contentBottomPx && columnHasContent()) advanceColumn()
+        val lineY = y + rule.verticalPaddingPx
+        val rowElementStart = elements.size
+        elements += ReaderElement.Rule(
+            bounds = ReaderRect(
+                columnLeft(),
+                lineY,
+                columnLeft() + config.contentWidthPx,
+                lineY + rule.widthPx
+            ),
+            colorArgb = rule.colorArgb,
+            widthPx = rule.widthPx,
+            dashed = rule.dashed,
+        )
+        columnRows += ReaderLayoutRow(rowElementStart, elements.size, lineY, lineY + rule.widthPx)
+        y += requiredHeight
+    }
 
-        fun ReaderMeasuredBlock.isTitle() = when (this) {
-            is ReaderMeasuredBlock.Paragraph -> value.isTitle
-            is ReaderMeasuredBlock.InlineParagraph -> emphasized
-            else -> false
-        }
-        blocks.forEachIndexed { index, block ->
-            val isTitle = block.isTitle()
-            if (isTitle && index == 0) y += config.titleTopSpacingPx
-            when (block) {
-                is ReaderMeasuredBlock.Paragraph -> addParagraph(block.value, index, index < blocks.lastIndex)
-                is ReaderMeasuredBlock.Image -> addImage(block)
-                is ReaderMeasuredBlock.InlineParagraph -> addInlineParagraph(block, index, index < blocks.lastIndex)
-                is ReaderMeasuredBlock.BlankLine -> addBlankLine(block, index, index < blocks.lastIndex)
-                is ReaderMeasuredBlock.Rule -> addRule(block)
-                ReaderMeasuredBlock.PageBreak -> {
-                    previousLineHadFrame = false
-                    advanceColumn()
-                }
-            }
-            if (isTitle) {
-                y += if (blocks.getOrNull(index + 1)?.isTitle() == true) {
-                    config.titleSegmentSpacingPx * ((block as? ReaderMeasuredBlock.InlineParagraph)?.titleSpacingScale ?: 1f)
-                } else config.titleBottomSpacingPx
-            }
-        }
-        finishPage()
-        var centeredPageShiftPx = 0f
-        if (config.titlePageCenterVertical && pages.size == 1) {
-            // 卷页只有标题块:按字形实际占位整体下移到内容区垂直中点。布局期平移
-            // 保证命中测试、选区与进度映射共用同一几何。
-            val pageElements = pages[0]
-            val top = pageElements.minOf { it.bounds.top }
-            val bottom = pageElements.maxOf { it.bounds.bottom }
-            val available = config.contentBottomPx - config.paddingTopPx
-            if (bottom - top < available) {
-                val delta = config.paddingTopPx + (available - (bottom - top)) / 2f - top
-                if (delta != 0f) {
-                    for (elementIndex in pageElements.indices) {
-                        pageElements[elementIndex] = shiftElement(pageElements[elementIndex], delta)
-                    }
-                    centeredPageShiftPx = delta
-                }
-            }
+    private fun addBlankLine(
+        blank: ReaderMeasuredBlock.BlankLine,
+        paragraphIndex: Int,
+        appendSeparator: Boolean,
+    ) {
+        val height = blank.lineHeightPx
+        previousLineHadFrame = false
+        if (y + height > config.contentBottomPx && columnHasContent()) advanceColumn()
+        val rowElementStart = elements.size
+        elements += ReaderElement.Spacer(
+            bounds = ReaderRect(columnLeft(), y, columnLeft() + config.contentWidthPx, y + height),
+            chapterPosition = blank.chapterPosition,
+            paragraphIndex = paragraphIndex,
+        )
+        columnRows += ReaderLayoutRow(rowElementStart, elements.size, y, y + height)
+        y += height * blank.lineSpacingMultiplier
+        if (appendSeparator) {
+            pageText.append('\n')
+            y += config.paragraphSpacingPx
         }
-        return pages.mapIndexed { pageIndex, pageElements ->
-            // 连续滚动模式按 scrollExtentPx 堆叠相邻页,页高一律是排版游标:对照旧
-            // `ContentTextView.drawPage`(下一页画在 `相对偏移 + textPage.height`)与
-            // `TextChapterLayout`(正文页 `textPage.height = durY`)。章末页只额外加
-            // [chapterEndPaddingPx](旧 `height = durY + 20dp`),**不**向内容区高度收口——
-            // 收口会让残页后面的空白顶满一屏,必须滚过整屏空白才接上下一章正文。
-            // 只有提示页才按可见高度收口(旧 `TextPage.format` 的 `isMsgPage`),那条路径
-            // 在 `ReadBookController` 的占位页上,不经过这里。
-            // 卷名/空正文章整页标题的垂直居中位移必须计入覆盖高度([centeredPageShiftPx]),
-            // 否则下一章正文会压在本页卷名上。
-            val contentCoveredExtentPx = pageExtents[pageIndex] + centeredPageShiftPx
-            // 单图样式的正文页恒为一屏:旧 `setTypeText` 逐行收口到 `visibleHeight`,
-            // 保证一屏一页;纯图片页沿用排版游标(`setTypeImage` 只把 durY 移到竖直居中位置,
-            // 不做收口),所以按「本页是否含文本/分隔等内容」区分,而不是整章一律收口。
-            val isFullViewportPage = config.singleImageStyle &&
-                    pageElements.any { it !is ReaderElement.Image }
-            val pageExtentPx = if (isFullViewportPage) {
-                maxOf(contentCoveredExtentPx, config.contentBottomPx - config.paddingTopPx)
-            } else contentCoveredExtentPx
-            val stackedExtentPx = when {
-                !config.continuousScroll -> pageExtents[pageIndex]
-                pageIndex == pages.lastIndex -> pageExtentPx + config.chapterEndPaddingPx
-                else -> pageExtentPx
-            }
-            ReaderPage(
-                id = ReaderPageId(config.chapterIndex, pageIndex),
-                chapterTitle = config.chapterTitle,
-                text = pageTexts[pageIndex].toString(),
-                widthPx = config.viewportWidthPx,
-                heightPx = config.viewportHeightPx,
-                contentTopPx = config.paddingTopPx,
-                contentBottomPx = if (config.continuousScroll) {
-                    config.contentBottomPx
-                } else pageExtents[pageIndex] - config.paddingBottomPx,
-                elements = pageElements,
-                revision = config.revision,
-                scrollExtentPx = stackedExtentPx,
-                decoration = config.decoration,
-                inlineImagesPreserveScrollLine = config.inlineImagesPreserveScrollLine,
-                emphasisUnderlineStyle = config.emphasisUnderlineStyle,
-            )
+    }
+
+    private fun ReaderMeasuredBlock.isTitle() = when (this) {
+        is ReaderMeasuredBlock.Paragraph -> value.isTitle
+        is ReaderMeasuredBlock.InlineParagraph -> emphasized
+        else -> false
+    }
+
+    /** 页成型即沉淀成 [ReaderPage] 并流出;非章末页在下一张页成型时流出(延迟一页)。 */
+    private fun emitPage(pageIndex: Int, isLastPage: Boolean) {
+        val page = buildPage(pageIndex, isLastPage)
+        resultPages += page
+        onPage?.invoke(page)
+    }
+
+    private fun buildPage(pageIndex: Int, isLastPage: Boolean): ReaderPage {
+        val pageElements = pages[pageIndex]
+        // 连续滚动模式按 scrollExtentPx 堆叠相邻页,页高一律是排版游标:对照旧
+        // `ContentTextView.drawPage`(下一页画在 `相对偏移 + textPage.height`)与
+        // `TextChapterLayout`(正文页 `textPage.height = durY`)。章末页只额外加
+        // [chapterEndPaddingPx](旧 `height = durY + 20dp`),**不**向内容区高度收口——
+        // 收口会让残页后面的空白顶满一屏,必须滚过整屏空白才接上下一章正文。
+        // 只有提示页才按可见高度收口(旧 `TextPage.format` 的 `isMsgPage`),那条路径
+        // 在 `ReadBookController` 的占位页上,不经过这里。
+        // 卷名/空正文章整页标题的垂直居中位移必须计入覆盖高度([centeredPageShiftPx]),
+        // 否则下一章正文会压在本页卷名上。
+        val contentCoveredExtentPx = pageExtents[pageIndex] + centeredPageShiftPx
+        // 单图样式的正文页恒为一屏:旧 `setTypeText` 逐行收口到 `visibleHeight`,
+        // 保证一屏一页;纯图片页沿用排版游标(`setTypeImage` 只把 durY 移到竖直居中位置,
+        // 不做收口),所以按「本页是否含文本/分隔等内容」区分,而不是整章一律收口。
+        val isFullViewportPage = config.singleImageStyle &&
+                pageElements.any { it !is ReaderElement.Image }
+        val pageExtentPx = if (isFullViewportPage) {
+            maxOf(contentCoveredExtentPx, config.contentBottomPx - config.paddingTopPx)
+        } else contentCoveredExtentPx
+        val stackedExtentPx = when {
+            !config.continuousScroll -> pageExtents[pageIndex]
+            isLastPage -> pageExtentPx + config.chapterEndPaddingPx
+            else -> pageExtentPx
         }
+        return ReaderPage(
+            id = ReaderPageId(config.chapterIndex, pageIndex),
+            chapterTitle = config.chapterTitle,
+            text = pageTexts[pageIndex].toString(),
+            widthPx = config.viewportWidthPx,
+            heightPx = config.viewportHeightPx,
+            contentTopPx = config.paddingTopPx,
+            contentBottomPx = if (config.continuousScroll) {
+                config.contentBottomPx
+            } else pageExtents[pageIndex] - config.paddingBottomPx,
+            contentLeftPx = config.paddingLeftPx,
+            contentRightPx = config.viewportWidthPx - config.paddingRightPx,
+            elements = pageElements,
+            revision = config.revision,
+            scrollExtentPx = stackedExtentPx,
+            decoration = config.decoration,
+            inlineImagesPreserveScrollLine = config.inlineImagesPreserveScrollLine,
+            emphasisUnderlineStyle = config.emphasisUnderlineStyle,
+        )
     }
 }

+ 12 - 0
app/src/main/java/io/legado/app/feature/reader/core/model/ReaderPage.kt

@@ -181,6 +181,13 @@ data class ReaderPage(
     val readAloudParagraphIndex: Int? = null,
     /** 邻章未装载时预置的"加载中"占位页,分页批次落地后被同 id 真实页替换。 */
     val isPlaceholder: Boolean = false,
+    /**
+     * 内容区左右边界,对照旧 `ChapterProvider.visibleRect` 的左右边
+     * (`paddingLeft` / `viewWidth - paddingRight`)。放在构造参数末尾是为了不破坏按位置
+     * 构造 `ReaderPage` 的既有调用点;默认值等价于"整页宽",即不额外裁剪。
+     */
+    val contentLeftPx: Float = 0f,
+    val contentRightPx: Float = widthPx.toFloat(),
 ) {
     fun elementAt(x: Float, y: Float): ReaderElement? =
         elements.firstOrNull { it.bounds.contains(x, y) }
@@ -227,6 +234,11 @@ data class ReaderTipRow(
     val paddingRightPx: Float,
     val paddingBottomPx: Float,
     val dividerColorArgb: Int?,
+    /** 未设页眉页脚字体时回落正文字体族(旧 `tipTypeface ?: ChapterProvider.typeface`)。 */
+    val fontFamily: String = "sans-serif",
+    /** 根层安全区内缩:分隔线只画在内缩后的宽度里(旧 `vwRoot` 的刘海 padding)。 */
+    val insetLeftPx: Float = 0f,
+    val insetRightPx: Float = 0f,
 )
 
 data class ReaderPageDecoration(

+ 24 - 8
app/src/main/java/io/legado/app/feature/reader/core/model/ReaderTipValue.kt

@@ -27,6 +27,10 @@ data class ReaderTipValueContext(
     val pageIndex: Int,
     val pageCount: Int,
     val readProgress: String,
+    /** 页数文案:章还没排出任何页时是 `-`(对照旧 `PageView.setProgress` 的 `pageSizeInt <= 0` 分支)。 */
+    val pageCountText: String = pageCount.toString(),
+    /** 整书页数文案,已按旧 `whole_book_page_*` 字符串本地化并带 `- / -` 兜底。 */
+    val wholeBookPageText: String = "",
     val wholeBookPageIndex: Int? = null,
     val wholeBookPageCount: Int? = null,
 )
@@ -42,9 +46,9 @@ object ReaderTipValueFormatter {
             ReaderTipValueType.CHAPTER_TITLE -> chapterTitle
             ReaderTipValueType.TIME -> time
             ReaderTipValueType.BATTERY -> "$batteryPercent%"
-            ReaderTipValueType.PAGE -> "${pageIndex + 1}/$pageCount"
+            ReaderTipValueType.PAGE -> "${pageIndex + 1}/$pageCountText"
             ReaderTipValueType.TOTAL_PROGRESS -> readProgress
-            ReaderTipValueType.PAGE_AND_TOTAL -> "${pageIndex + 1}/$pageCount  $readProgress"
+            ReaderTipValueType.PAGE_AND_TOTAL -> "${pageIndex + 1}/$pageCountText  $readProgress"
             ReaderTipValueType.BOOK_NAME -> bookName
             ReaderTipValueType.TIME_BATTERY -> "$time $batteryPercent%"
             ReaderTipValueType.CHAPTER_INDEX_AND_TOTAL -> "${chapterIndex + 1}/$chapterCount"
@@ -55,11 +59,13 @@ object ReaderTipValueFormatter {
         }
     }
 
+    /**
+     * 整书页数文案。生产路径由宿主用 `whole_book_page_*` 字符串本地化后传入(含 `全文 - / -`
+     * 兜底);未提供时退回旧行为,只给页码,绝不让该槽位变成空字符串。
+     */
     private fun ReaderTipValueContext.wholeBookPage(): String =
-        if (wholeBookPageIndex != null && wholeBookPageCount != null) {
-            "$wholeBookPageIndex/$wholeBookPageCount"
-        } else {
-            "${pageIndex + 1}/$pageCount"
+        wholeBookPageText.ifBlank {
+            "${wholeBookPageIndex ?: pageIndex + 1}/${wholeBookPageCount ?: pageCount}"
         }
 
     private fun resolveCustom(template: String, context: ReaderTipValueContext): String {
@@ -73,10 +79,20 @@ object ReaderTipValueFormatter {
             .replace("{ChapterIndex}", (context.chapterIndex + 1).toString())
             .replace("{ChapterSize}", context.chapterCount.toString())
             .replace("{PageIndex}", (context.pageIndex + 1).toString())
-            .replace("{PageSize}", context.pageCount.toString())
-            .replace("{PageRemaining}", (context.pageCount - context.pageIndex - 1).coerceAtLeast(0).toString())
+            .replace("{PageSize}", context.pageCountText)
+            .replace("{PageRemaining}", context.pageRemainingText())
             .replace("{ReadProgress}", context.readProgress)
             .replace("{FullPageIndex}", wholeIndex.toString())
             .replace("{FullPageSize}", wholeCount.toString())
     }
+
+    /**
+     * 对照旧 `formatCustomTipPageRemaining`:只有"章还没排出页、余量无从算起"时给 `-`,
+     * 其余(含排版中但已有页)都给数字。
+     */
+    private fun ReaderTipValueContext.pageRemainingText(): String =
+        if (pageCountText == UNKNOWN_PAGE_COUNT) UNKNOWN_PAGE_COUNT
+        else (pageCount - pageIndex - 1).coerceAtLeast(0).toString()
+
+    const val UNKNOWN_PAGE_COUNT = "-"
 }

+ 20 - 0
app/src/main/java/io/legado/app/feature/reader/core/navigation/ReaderPageNavigator.kt

@@ -52,6 +52,10 @@ object ReaderPageNavigator {
      * 章节边界占位语义(对照 shutiao 的"正在加载中"页):当前真实页的邻章在书中
      * 存在但尚未分页进 [pages] 时,需要预置占位页,让手势层在邻章未装载时也能把
      * 页面拖/滚进"加载中"。占位页本身不再扩展(装载完成前是死端)。
+     *
+     * 判定不看邻章正文是否已缓存:旧 View `TextPageFactory.nextPage/prevPage` 在邻章
+     * 还没有页时一律给出 `R.string.data_loading` 兜底页,而"正文已缓存、只差排版"恰恰
+     * 是最容易读到章末的窗口——缺了占位页,章末残页下方就没有任何页面承接。
      */
     fun missingAdjacentChapters(
         pages: List<ReaderPage>,
@@ -65,6 +69,16 @@ object ReaderPageNavigator {
         }
     }
 
+    /**
+     * 章末第三槽(`ReaderPageWindow.nextPlus`)是否需要"继续滑动以加载下一章…"兜底页,
+     * 对照旧 View `TextPageFactory.nextPlusPage`:本章最后一页的下一章还没有第二页
+     * (只有一页,或该章还没排完)时,旧实现用 `R.string.keep_swipe_tip` 的提示页占住
+     * 第三槽;书末没有下一章也就不需要提示页。滚动模式会把第三槽画出来,缺了它章末
+     * 连续滑动会先看到一段没有页面的空档。
+     */
+    fun needsSwipeTipNextPlus(window: ReaderPageWindow, hasNextChapter: Boolean): Boolean =
+        hasNextChapter && window.next != null && window.nextPlus == null
+
     fun move(pages: List<ReaderPage>, pageIndex: Int, delta: Int): ReaderNavigationResult {
         if (pages.isEmpty()) return ReaderNavigationResult(0, ReaderPageWindow(), true)
         val target = (pageIndex + delta).coerceIn(pages.indices)
@@ -160,6 +174,12 @@ object ReaderPageNavigator {
         )
     }
 
+    /**
+     * 页首章内位置。**排除标题元素**:新模型的标题与正文是两套独立坐标(见 [ReaderSelection]
+     * 的 anchorIsTitle 说明),把标题位置当成正文页首会把书签/朗读定位拖进标题坐标空间。
+     * 旧 View 的 `TextPage.chapterPosition = textLines.first().chapterPosition` 建立在两者同空间的
+     * 前提上,迁移后不再等价。
+     */
     fun pageStart(page: ReaderPage): Int = page.elements.mapNotNull(::elementRange)
         .minOfOrNull { it.first } ?: 0
 

+ 58 - 0
app/src/main/java/io/legado/app/feature/reader/core/navigation/ReaderPartialPagePolicy.kt

@@ -0,0 +1,58 @@
+package io.legado.app.feature.reader.core.navigation
+
+/**
+ * 部分排版章节(页正在逐页流出)的发布时机与翻页护栏,逐条对照旧 View:
+ *
+ * - `TextChapterLayout.onPageCompleted()`(`TextChapterLayout.kt:240-260`)每成型一页就
+ *   `channel.trySend`,宿主 `ReadBook.loadContent`(`7a61ea86d^` `ReadBook.kt:1479-1530`)
+ *   按页消费并决定何时 `upContent`;
+ * - offset = 0(当前章):含 `durChapterPos` 的页成型时重绘;滚动模式另有
+ *   `max(index - 3, 0) < durPageIndex` 的 3 页余量;
+ * - offset = 1(下一章):只在 `page.index <= 1` 时重绘(前两页够顶掉兜底页);
+ * - offset = -1(上一章):不早推,只在收尾时重绘;
+ * - `TextChapter.isLastIndex` 要求 `isCompleted`、`isLastIndexCurrent` 用于拒绝越过还没成型的页,
+ *   `TextPageFactory.moveToPrev`(`TextPageFactory.kt:75-77`)用 `prevChapter.isCompleted == false`
+ *   拒绝退回还没排完的上一章。
+ */
+object ReaderPartialPagePolicy {
+
+    /** 一页成型后是否立刻换窗(旧 View 三条 `upContent(offset)` 触发条件)。 */
+    fun shouldPublishPage(
+        chapterOffset: Int,
+        pageIndex: Int,
+        currentPageIndex: Int,
+        containsReadingPosition: Boolean,
+        continuousScroll: Boolean,
+    ): Boolean = when {
+        // 上一章不早推:旧 loadContent 的 offset=-1 分支只在收尾时 upContent。
+        chapterOffset < 0 -> false
+        chapterOffset > 0 -> pageIndex <= NEXT_CHAPTER_EARLY_PAGE_LIMIT
+        containsReadingPosition -> true
+        continuousScroll -> maxOf(pageIndex - SCROLL_LOOKAHEAD_PAGES, 0) < currentPageIndex
+        else -> false
+    }
+
+    /**
+     * 目标页属于别的章节且该章还在逐页流出时能不能翻过去。
+     *
+     * - 向前:本章还有没成型的页,窗口里的"下一页"其实已经是下一章了——翻过去会跳过本章
+     *   剩余内容(旧 `isLastIndexCurrent` 拒绝,渲染层改画"加载中"页);
+     * - 向后:上一章的末页还没成型,落点必错(旧 `moveToPrev` 等它 `isCompleted`)。
+     */
+    fun allowsChapterMove(
+        fromChapterIndex: Int,
+        targetChapterIndex: Int,
+        fromChapterStreaming: Boolean,
+        targetChapterStreaming: Boolean,
+    ): Boolean = when {
+        targetChapterIndex > fromChapterIndex -> !fromChapterStreaming
+        targetChapterIndex < fromChapterIndex -> !targetChapterStreaming
+        else -> true
+    }
+
+    /** 旧 `ReadBook.loadContent`:下一章排到 `page.index > 1` 就不再提前重绘。 */
+    private const val NEXT_CHAPTER_EARLY_PAGE_LIMIT = 1
+
+    /** 旧 `ReadBook.loadContent` 滚动模式的重绘余量:`max(index - 3, 0) < durPageIndex`。 */
+    private const val SCROLL_LOOKAHEAD_PAGES = 3
+}

+ 6 - 1
app/src/main/java/io/legado/app/feature/reader/core/selection/ReaderSelection.kt

@@ -13,8 +13,13 @@ enum class ReaderSelectionEndpoint {
     FOCUS,
 }
 
+/**
+ * 选区可跨的页:对照旧 `ContentTextView.upSelectChars`(`relativePage(0..2)`)——当前页、
+ * 下一页、下下页,**不含上一页**;滚动模式的连续堆叠允许选到邻章首页(`nextPlusPage` 语义),
+ * 分页模式实际只有当前页可见。
+ */
 fun ReaderPageWindow.selectionPages(): List<ReaderPage> =
-    listOfNotNull(previous, current, next, nextPlus)
+    listOfNotNull(current, next, nextPlus)
 
 @Stable
 data class ReaderSelection(

+ 0 - 2
app/src/main/java/io/legado/app/feature/reader/core/selection/ReaderSelectionLifecyclePolicy.kt

@@ -9,8 +9,6 @@ object ReaderSelectionLifecyclePolicy {
     fun shouldClearForPageChange(origin: ReaderPageChangeOrigin): Boolean =
         origin == ReaderPageChangeOrigin.PROGRAMMATIC
 
-    fun shouldPauseAutoPage(hasSelection: Boolean): Boolean = hasSelection
-
     fun shouldReanchorMenuAfterLayoutChange(
         hasSelection: Boolean,
         menuVisible: Boolean,

+ 0 - 8
app/src/main/java/io/legado/app/feature/reader/core/transition/ReaderAutoPagePolicy.kt

@@ -13,14 +13,6 @@ object ReaderAutoPagePolicy {
     fun pageDurationMillis(speedSeconds: Int): Long =
         speedSeconds.coerceIn(1, 120) * 1_000L
 
-    fun remainingAfterPause(plannedMillis: Long, elapsedMillis: Long): Long =
-        (plannedMillis - elapsedMillis.coerceAtLeast(0L)).coerceAtLeast(1L)
-
-    fun shouldPreserveRemainingTime(
-        menuPaused: Boolean,
-        selectionPaused: Boolean,
-    ): Boolean = menuPaused || selectionPaused
-
     /** Top edge for the legacy one-pixel reveal indicator. */
     fun indicatorTopPx(progressPx: Float, viewportHeightPx: Float): Float =
         (progressPx.coerceIn(0f, viewportHeightPx.coerceAtLeast(0f)) - 1f).coerceAtLeast(0f)

+ 47 - 38
app/src/main/java/io/legado/app/feature/reader/legacy/LegacyReaderChapterPaginator.kt

@@ -10,7 +10,7 @@ import io.legado.app.feature.reader.core.layout.ReaderChapterMeasureStyle
 import io.legado.app.feature.reader.core.layout.ReaderImageDimensions
 import io.legado.app.feature.reader.core.layout.ReaderImageLayoutMode
 import io.legado.app.feature.reader.core.layout.ReaderPaginationConfig
-import io.legado.app.feature.reader.core.layout.ReaderPaginator
+import io.legado.app.feature.reader.core.layout.ReaderPaginationSession
 import io.legado.app.feature.reader.core.layout.ReaderTextAlignment
 import io.legado.app.feature.reader.core.layout.ReaderTextShaperFactory
 import io.legado.app.feature.reader.core.model.ReaderPage
@@ -116,6 +116,8 @@ object LegacyReaderChapterPaginator {
         contentPaddingBottomPx: Int = 0,
         paginationStyle: ReaderAndroidPaginationStyle,
         highlightRules: List<HighlightRule>,
+        /** 每页成型即回调(对照旧 View `TextChapterLayout` 的 `channel.trySend`);为空表示只要整章批次。 */
+        onPage: ((ReaderPage) -> Unit)? = null,
     ): LegacyReaderChapterPaginationResult {
         if (viewportWidthPx <= 0 || viewportHeightPx <= 0) {
             return LegacyReaderChapterPaginationResult.Unsupported("viewport")
@@ -163,6 +165,46 @@ object LegacyReaderChapterPaginator {
             ),
             imageOptionsResolver = LegacyReaderImageOptionsResolver,
         )
+        // 分页会话先于测量建立:块一到就推进排版游标,页成型即经 [onPage] 流出
+        // (旧 View `TextChapterLayout` 也是边排版边 `channel.trySend`)。
+        val paginationConfig = ReaderPaginationConfig(
+            chapterIndex = chapter.index,
+            chapterTitle = displayTitle,
+            columnCount = paginationStyle.columnCount(viewportWidthPx, viewportHeightPx),
+            viewportWidthPx = viewportWidthPx,
+            viewportHeightPx = viewportHeightPx,
+            paddingLeftPx = (paginationStyle.paddingLeftPx + contentPaddingLeftPx).toFloat(),
+            paddingTopPx = (paginationStyle.paddingTopPx + contentPaddingTopPx).toFloat() +
+                    LegacyReaderPageDecorationFactory.headerExtentPx(),
+            paddingRightPx = (paginationStyle.paddingRightPx + contentPaddingRightPx).toFloat(),
+            paddingBottomPx = (paginationStyle.paddingBottomPx + contentPaddingBottomPx).toFloat() +
+                    LegacyReaderPageDecorationFactory.footerExtentPx(),
+            lineHeightPx = paginationStyle.bodyTextHeightPx,
+            baselineOffsetPx = paginationStyle.bodyBaselineOffsetPx,
+            lineSpacingMultiplier = paginationStyle.lineSpacingExtra,
+            continuousScroll = paginationStyle.isScroll,
+            singleImageStyle = singleImage,
+            chapterEndPaddingPx = CHAPTER_END_PADDING_DP.dpToPx(),
+            inlineImagesPreserveScrollLine = imageLayoutMode == ReaderImageLayoutMode.INLINE,
+            textBottomJustify = paginationStyle.textBottomJustify,
+            // 旧 `setTypeHtml` 的 `setLineSpacing(paragraphSpacing.toFloat(), ...)`:用原始设置值。
+            htmlLineSpacingAddPx = paginationStyle.paragraphSpacing.toFloat(),
+            pageUnderline = paginationStyle.pageUnderline,
+            emphasisUnderlineStyle = paginationStyle.emphasisUnderlineStyle,
+            paragraphSpacingPx = paginationStyle.bodyTextHeightPx * paginationStyle.paragraphSpacing / 10f,
+            titleTopSpacingPx = paginationStyle.titleTopSpacingPx,
+            titleBottomSpacingPx = paginationStyle.titleBottomSpacingPx,
+            // 旧 `TextChapterLayout.kt:977-1010` 的居中只发生在"空正文"(`emptyContent`)与
+            // `imgStyleSingle` 两种情形;**卷章有正文时旧版不居中**(走 `durY + titleTopSpacing`),
+            // 所以这里不并入 `chapter.isVolume`。
+            titlePageCenterVertical = content.textList.isEmpty() || singleImage,
+            titleParagraphSpacingPx = paginationStyle.titleTextHeightPx * paginationStyle.paragraphSpacing / 10f,
+            titleSegmentSpacingPx = paginationStyle.titleTextHeightPx * paginationStyle.titleLineSpacingSub,
+            letterSpacingPx = bodyPaint.letterSpacing * bodyPaint.textSize,
+            revision = revision,
+        )
+        val paginationSession = ReaderPaginationSession(paginationConfig)
+        paginationSession.onPage = onPage
         val measured = ReaderPerfTrace.section("pagination.measure") {
             measurer.measure(
             layoutSource,
@@ -195,48 +237,15 @@ object LegacyReaderChapterPaginator {
                 letterSpacingEm = bodyPaint.letterSpacing,
                 styleRanges = styleRanges,
             ),
+                onBlock = { block -> paginationSession.accept(block) },
             )
         }
         if (measured is ReaderChapterMeasureResult.Unsupported) {
+            // 测量失败说明这一章的页不可信:已流出的部分页由调用方按 Unsupported 丢弃。
             return LegacyReaderChapterPaginationResult.Unsupported(measured.reason)
         }
-        val blocks = (measured as ReaderChapterMeasureResult.Success).blocks
-        val pages = ReaderPerfTrace.section("pagination.pages") {
-            ReaderPaginator.paginateBlocks(
-            blocks = blocks,
-            config = ReaderPaginationConfig(
-                chapterIndex = chapter.index,
-                chapterTitle = displayTitle,
-                columnCount = paginationStyle.columnCount(viewportWidthPx, viewportHeightPx),
-                viewportWidthPx = viewportWidthPx,
-                viewportHeightPx = viewportHeightPx,
-                paddingLeftPx = (paginationStyle.paddingLeftPx + contentPaddingLeftPx).toFloat(),
-                paddingTopPx = (paginationStyle.paddingTopPx + contentPaddingTopPx).toFloat() +
-                    LegacyReaderPageDecorationFactory.headerExtentPx(),
-                paddingRightPx = (paginationStyle.paddingRightPx + contentPaddingRightPx).toFloat(),
-                paddingBottomPx = (paginationStyle.paddingBottomPx + contentPaddingBottomPx).toFloat() +
-                    LegacyReaderPageDecorationFactory.footerExtentPx(),
-                lineHeightPx = paginationStyle.bodyTextHeightPx,
-                baselineOffsetPx = paginationStyle.bodyBaselineOffsetPx,
-                lineSpacingMultiplier = paginationStyle.lineSpacingExtra,
-                continuousScroll = paginationStyle.isScroll,
-                singleImageStyle = singleImage,
-                chapterEndPaddingPx = CHAPTER_END_PADDING_DP.dpToPx(),
-                inlineImagesPreserveScrollLine = imageLayoutMode == ReaderImageLayoutMode.INLINE,
-                textBottomJustify = paginationStyle.textBottomJustify,
-                pageUnderline = paginationStyle.pageUnderline,
-                emphasisUnderlineStyle = paginationStyle.emphasisUnderlineStyle,
-                paragraphSpacingPx = paginationStyle.bodyTextHeightPx * paginationStyle.paragraphSpacing / 10f,
-                titleTopSpacingPx = paginationStyle.titleTopSpacingPx,
-                titleBottomSpacingPx = paginationStyle.titleBottomSpacingPx,
-                titlePageCenterVertical = chapter.isVolume || content.textList.isEmpty() || singleImage,
-                titleParagraphSpacingPx = paginationStyle.titleTextHeightPx * paginationStyle.paragraphSpacing / 10f,
-                titleSegmentSpacingPx = paginationStyle.titleTextHeightPx * paginationStyle.titleLineSpacingSub,
-                letterSpacingPx = bodyPaint.letterSpacing * bodyPaint.textSize,
-                revision = revision,
-            ),
-            )
-        }
+        // 测量与分页现在交错进行,"pagination.pages" 只剩收尾(章末页)的开销,两者之和不变。
+        val pages = ReaderPerfTrace.section("pagination.pages") { paginationSession.finish() }
         return LegacyReaderChapterPaginationResult.Success(pages)
     }
 }

+ 105 - 27
app/src/main/java/io/legado/app/feature/reader/legacy/LegacyReaderPageDecorationFactory.kt

@@ -1,42 +1,57 @@
 package io.legado.app.feature.reader.legacy
 
-import androidx.core.content.ContextCompat
 import android.graphics.Paint
+import androidx.core.content.ContextCompat
 import io.legado.app.R
-import io.legado.app.constant.ReadTipType
 import io.legado.app.constant.AppConst.timeFormat
-import io.legado.app.feature.reader.core.model.ReaderPage
-import io.legado.app.feature.reader.core.model.ReaderBookmarkBadge
+import io.legado.app.constant.ReadTipType
 import io.legado.app.domain.gateway.ReadSettingsGateway
+import io.legado.app.feature.reader.core.model.ReaderBookmarkBadge
+import io.legado.app.feature.reader.core.model.ReaderPage
 import io.legado.app.feature.reader.core.model.ReaderPageDecoration
 import io.legado.app.feature.reader.core.model.ReaderPageTip
 import io.legado.app.feature.reader.core.model.ReaderTipAlignment
 import io.legado.app.feature.reader.core.model.ReaderTipRow
-import io.legado.app.feature.reader.core.model.ReaderTipVisual
+import io.legado.app.feature.reader.core.model.ReaderTipRowLayout
 import io.legado.app.feature.reader.core.model.ReaderTipValueContext
 import io.legado.app.feature.reader.core.model.ReaderTipValueFormatter
 import io.legado.app.feature.reader.core.model.ReaderTipValueType
-import io.legado.app.feature.reader.core.model.ReaderTipRowLayout
-import io.legado.app.feature.reader.platform.ReaderAndroidPaintFactory
+import io.legado.app.feature.reader.core.model.ReaderTipVisual
 import io.legado.app.feature.reader.core.model.resolveReaderTipColor
+import io.legado.app.feature.reader.platform.ReaderAndroidPaintFactory
 import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.model.ReadBook
 import io.legado.app.utils.dpToPx
 import io.legado.app.utils.spToPx
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
 import splitties.init.appCtx
+import java.io.File
 import java.text.DecimalFormat
 import java.util.Date
-import java.io.File
-import org.koin.core.component.KoinComponent
-import org.koin.core.component.inject
 
 /** Android settings adapter for the Canvas reader's page header and footer. */
 object LegacyReaderPageDecorationFactory : KoinComponent {
     private val readSettings: ReadSettingsGateway by inject()
+
+    /**
+     * 旧 `PageView` 的页眉页脚字体解析:`tipTypeface ?: ChapterProvider.typeface` —— 没设就回落
+     * **正文字体**(含自定义字体与 serif/monospace 系统字体族),不是系统 sans-serif。
+     */
+    private fun tipFontPath(configured: String): String =
+        configured.ifBlank { ReadBookConfig.textFont }
+
+    private fun tipFontFamily(): String = when (readSettings.currentSettings.systemTypefaces) {
+        1 -> "serif"
+        2 -> "monospace"
+        else -> "sans-serif"
+    }
+
     fun headerExtentPx(): Float = if (headerVisible()) {
         tipRowExtentPx(
             fontSizePx = ReadBookConfig.headerFontSize.toFloat().spToPx(),
-            fontPath = ReadBookConfig.headerFont,
+            fontPath = tipFontPath(ReadBookConfig.headerFont),
+            fontFamily = tipFontFamily(),
             paddingTopPx = ReadBookConfig.headerPaddingTop.dpToPx().toFloat(),
             paddingBottomPx = ReadBookConfig.headerPaddingBottom.dpToPx().toFloat(),
             dividerVisible = ReadBookConfig.showHeaderLine,
@@ -50,7 +65,10 @@ object LegacyReaderPageDecorationFactory : KoinComponent {
             } else {
                 ReadBookConfig.footerFontSize
             }).toFloat().spToPx(),
-            fontPath = if (ReadBookConfig.applyHeaderStyle) ReadBookConfig.headerFont else ReadBookConfig.footerFont,
+            fontPath = tipFontPath(
+                if (ReadBookConfig.applyHeaderStyle) ReadBookConfig.headerFont else ReadBookConfig.footerFont
+            ),
+            fontFamily = tipFontFamily(),
             paddingTopPx = ReadBookConfig.footerPaddingTop.dpToPx().toFloat(),
             paddingBottomPx = ReadBookConfig.footerPaddingBottom.dpToPx().toFloat(),
             dividerVisible = ReadBookConfig.showFooterLine,
@@ -60,23 +78,28 @@ object LegacyReaderPageDecorationFactory : KoinComponent {
     private fun tipRowExtentPx(
         fontSizePx: Float,
         fontPath: String,
+        fontFamily: String,
         paddingTopPx: Float,
         paddingBottomPx: Float,
         dividerVisible: Boolean,
     ): Float {
         val metrics = Paint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG).apply {
             textSize = fontSizePx
-            typeface = ReaderAndroidPaintFactory.loadTypeface(fontPath, 400, false)
+            typeface = ReaderAndroidPaintFactory.loadTypeface(fontPath, 400, false, fontFamily)
         }.fontMetrics
+        // 旧版页眉是 `includeFontPadding="false"` 的 TextView:实测高 = descent − ascent,
+        // 基线 = −ascent。用 top/bottom 会把 leading(含异常 leading)算进预留高度。
         return ReaderTipRowLayout.extent(
             paddingTopPx = paddingTopPx,
-            fontTopPx = metrics.top,
-            fontBottomPx = metrics.bottom,
+            fontTopPx = metrics.ascent,
+            fontBottomPx = metrics.descent,
             paddingBottomPx = paddingBottomPx,
-            dividerExtentPx = if (dividerVisible) 0.5f.dpToPx() else 0f,
+            dividerExtentPx = if (dividerVisible) DIVIDER_THICKNESS_DP.dpToPx() else 0f,
         )
     }
 
+    private const val DIVIDER_THICKNESS_DP = 0.5f
+
     fun create(
         page: ReaderPage,
         chapterPageCount: Int,
@@ -99,7 +122,30 @@ object LegacyReaderPageDecorationFactory : KoinComponent {
             chapterCount = ReadBook.chapterSize.coerceAtLeast(1),
             pageIndex = page.id.pageIndex,
             pageCount = chapterPageCount.coerceAtLeast(1),
+            // 章还没排出任何页(占位/消息页)时页数未知,旧 `PageView.setProgress` 给的是 `-`;
+            // 排版中但已有页时旧版给的是**已排出的页数**,所以这里只看计数是否为零。
+            pageCountText = if (chapterPageCount <= 0) {
+                ReaderTipValueFormatter.UNKNOWN_PAGE_COUNT
+            } else {
+                chapterPageCount.toString()
+            },
             readProgress = readProgress(page.id.chapterIndex, page.id.pageIndex, chapterPageCount),
+            // 对照旧 `PageView.setProgress`/`resolveCustomTemplate`:整书页数文案带本地化前缀,
+            // 精确/估算两种格式,无估算时回落到 `whole_book_page_unavailable`(`全文 - / -`)。
+            wholeBookPageText = when {
+                wholeBook == null -> appCtx.getString(R.string.whole_book_page_unavailable)
+                !wholeBook.estimated && wholeBook.allPreviousChaptersExact -> appCtx.getString(
+                    R.string.whole_book_page_format,
+                    wholeBook.currentPage,
+                    wholeBook.totalPages,
+                )
+
+                else -> appCtx.getString(
+                    R.string.whole_book_page_estimated_format,
+                    wholeBook.currentPage.toString(),
+                    wholeBook.totalPages.toString(),
+                )
+            },
             wholeBookPageIndex = wholeBook?.currentPage,
             wholeBookPageCount = wholeBook?.totalPages,
         )
@@ -134,12 +180,15 @@ object LegacyReaderPageDecorationFactory : KoinComponent {
                     ReadBookConfig.textColor,
                 ),
                 fontSizePx = ReadBookConfig.headerFontSize.toFloat().spToPx(),
-                fontPath = ReadBookConfig.headerFont,
+                fontPath = tipFontPath(ReadBookConfig.headerFont),
                 paddingLeftPx = ReadBookConfig.headerPaddingLeft.dpToPx() + contentPaddingLeftPx.toFloat(),
                 paddingTopPx = ReadBookConfig.headerPaddingTop.dpToPx() + contentPaddingTopPx.toFloat(),
                 paddingRightPx = ReadBookConfig.headerPaddingRight.dpToPx() + contentPaddingRightPx.toFloat(),
                 paddingBottomPx = ReadBookConfig.headerPaddingBottom.dpToPx().toFloat(),
                 dividerColorArgb = dividerColor.takeIf { ReadBookConfig.showHeaderLine },
+                fontFamily = tipFontFamily(),
+                insetLeftPx = contentPaddingLeftPx.toFloat(),
+                insetRightPx = contentPaddingRightPx.toFloat(),
             ),
             footer = ReaderTipRow(
                 visible = footerVisible(),
@@ -158,26 +207,43 @@ object LegacyReaderPageDecorationFactory : KoinComponent {
                 } else {
                     ReadBookConfig.footerFontSize
                 }).toFloat().spToPx(),
-                fontPath = if (ReadBookConfig.applyHeaderStyle) ReadBookConfig.headerFont else ReadBookConfig.footerFont,
+                fontPath = tipFontPath(
+                    if (ReadBookConfig.applyHeaderStyle) ReadBookConfig.headerFont else ReadBookConfig.footerFont
+                ),
                 paddingLeftPx = ReadBookConfig.footerPaddingLeft.dpToPx() + contentPaddingLeftPx.toFloat(),
                 paddingTopPx = ReadBookConfig.footerPaddingTop.dpToPx().toFloat(),
                 paddingRightPx = ReadBookConfig.footerPaddingRight.dpToPx() + contentPaddingRightPx.toFloat(),
                 paddingBottomPx = ReadBookConfig.footerPaddingBottom.dpToPx() + contentPaddingBottomPx.toFloat(),
                 dividerColorArgb = dividerColor.takeIf { ReadBookConfig.showFooterLine },
+                fontFamily = tipFontFamily(),
+                insetLeftPx = contentPaddingLeftPx.toFloat(),
+                insetRightPx = contentPaddingRightPx.toFloat(),
             ),
         )
     }
 
+    /**
+     * 同一类型(含自定义模板)配到多个槽位时,只有**首个**槽位取值,其余留空——对照旧
+     * `PageView.getTipView` 只给第一个命中的 View 赋值。
+     */
     private fun tips(
         context: ReaderTipValueContext,
         left: Pair<Int, String>,
         middle: Pair<Int, String>,
         right: Pair<Int, String>,
-    ): List<ReaderPageTip> = listOf(
-        tip(left, context, ReaderTipAlignment.START),
-        tip(middle, context, ReaderTipAlignment.CENTER),
-        tip(right, context, ReaderTipAlignment.END),
-    ).filter { it.text.isNotEmpty() || it.visual != ReaderTipVisual.TEXT }
+    ): List<ReaderPageTip> {
+        val configured = listOf(left, middle, right)
+        val alignments = listOf(
+            ReaderTipAlignment.START,
+            ReaderTipAlignment.CENTER,
+            ReaderTipAlignment.END,
+        )
+        val seen = mutableSetOf<Pair<Int, String>>()
+        return configured.mapIndexedNotNull { index, config ->
+            if (!seen.add(config)) return@mapIndexedNotNull null
+            tip(config, context, alignments[index])
+        }.filter { it.text.isNotEmpty() || it.visual != ReaderTipVisual.TEXT }
+    }
 
     private fun tip(
         config: Pair<Int, String>,
@@ -240,11 +306,21 @@ object LegacyReaderPageDecorationFactory : KoinComponent {
         else -> ReaderTipValueType.NONE
     }
 
+    /**
+     * 阅读进度,逐条对照旧 `TextPage.readProgress`(`TextPage.kt:249-263`):
+     * `chapterSize == 0` 或"首页且章还没排出页" → `0.0%`;章还没排出页 → 按章计;
+     * 其余按"章 + 页"计,且未真正读完时不允许显示 100%。
+     */
     private fun readProgress(chapterIndex: Int, pageIndex: Int, pageCount: Int): String {
-        val chapterCount = ReadBook.chapterSize.coerceAtLeast(1)
-        var progress = DecimalFormat("0.0%").format(
-            chapterIndex.toDouble() / chapterCount +
-                (pageIndex + 1).toDouble() / chapterCount / pageCount.coerceAtLeast(1),
+        val chapterCount = ReadBook.chapterSize
+        if (chapterCount == 0 || (pageCount == 0 && chapterIndex == 0)) return ZERO_PROGRESS
+        val formatter = DecimalFormat("0.0%")
+        if (pageCount == 0) {
+            return formatter.format((chapterIndex + 1.0f) / chapterCount.toDouble())
+        }
+        var progress = formatter.format(
+            chapterIndex * 1.0f / chapterCount +
+                    1.0f / chapterCount * (pageIndex + 1) / pageCount.toDouble(),
         )
         if (progress == "100.0%" && (chapterIndex + 1 != chapterCount || pageIndex + 1 != pageCount)) {
             progress = "99.9%"
@@ -252,6 +328,8 @@ object LegacyReaderPageDecorationFactory : KoinComponent {
         return progress
     }
 
+    private const val ZERO_PROGRESS = "0.0%"
+
     private fun headerVisible(): Boolean = when (ReadBookConfig.headerMode) {
         1 -> true
         2 -> false

+ 28 - 0
app/src/main/java/io/legado/app/model/ReadBook.kt

@@ -1005,6 +1005,20 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         return followReadAloudAfterManualNavigation()
     }
 
+    /**
+     * Compose 画布手动翻页提交后的旧 `curPageChanged` 副作用:阅读时长、预下载、进度落库。
+     *
+     * 旧 View 的 `moveToNextPage/moveToPrevPage/setPageIndex` 每次翻页都做这三件事
+     * (`saveRead(true)` 内部自带 300ms 合并窗口与尾随写入,正为快速翻页准备)。Compose 画布
+     * 把"发布快照"从热路径摘掉后连带丢了它们,表现为:翻页中崩溃/被杀回到上次切章位置、
+     * 纯静读的阅读时长不计、N+2 章预取推迟。`publishSnapshot()` 仍不发——那是热路径性能决定。
+     */
+    fun onComposeManualPageCommitted() {
+        upReadTime()
+        preDownload()
+        saveRead(pageChanged = true)
+    }
+
     fun moveToNextPage(): Boolean {
         prepareManualNavigation()
         val nextPagePos = readerPagination()?.nextPageStart(durChapterPos) ?: return false
@@ -1095,6 +1109,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         if (durChapterIndex > 0) {
             durChapterPos = if (toLast) {
                 readerPagination(durChapterIndex - 1)?.lastPageStart
+                    ?: previousChapterEndPosition(durChapterIndex - 1)
                     ?: Int.MAX_VALUE
             } else 0
             durChapterIndex--
@@ -1117,6 +1132,19 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         }
     }
 
+    /**
+     * 上一章末页位置的兜底:分页快照被清空(换样式/字号/高亮后重排未完成)时旧实现是现算
+     * `TextChapter.lastReadLength`;这里退用已缓存正文的末尾位置,避免把 `Int.MAX_VALUE`
+     * 落库成脏进度(视觉上都会被钳到末页,但进度同步/书签会带上失真值)。
+     */
+    private fun previousChapterEndPosition(chapterIndex: Int): Int? {
+        val input = readerChapterInputWindow.previous?.takeIf { it.chapter.index == chapterIndex }
+            ?: readerChapterInputWindow.current?.takeIf { it.chapter.index == chapterIndex }
+            ?: return null
+        val length = input.source.semanticContent.length
+        return (length - 1).coerceAtLeast(0)
+    }
+
     fun skipToPage(index: Int, success: (() -> Unit)? = null) {
         prepareManualNavigation()
         durChapterPos = readerPagination()?.pageStart(index) ?: return

+ 405 - 56
app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt

@@ -6,6 +6,8 @@ import android.content.Intent
 import android.content.pm.ActivityInfo
 import android.graphics.drawable.Drawable
 import android.os.Build
+import android.text.Layout
+import android.text.StaticLayout
 import android.util.LruCache
 import android.view.Gravity
 import android.view.HapticFeedbackConstants
@@ -36,6 +38,7 @@ import io.legado.app.feature.reader.core.model.remapThemeColors
 import io.legado.app.feature.reader.core.navigation.ReaderChapterPaginationSnapshot
 import io.legado.app.feature.reader.core.navigation.ReaderPageContext
 import io.legado.app.feature.reader.core.navigation.ReaderPageNavigator
+import io.legado.app.feature.reader.core.navigation.ReaderPartialPagePolicy
 import io.legado.app.feature.reader.core.readaloud.ReaderVisibleTextPosition
 import io.legado.app.feature.reader.core.selection.ReaderSearchMatcher
 import io.legado.app.feature.reader.core.selection.ReaderSearchRequest
@@ -336,6 +339,18 @@ class ReadBookController(
     val composePageTurns = _composePageTurns.asSharedFlow()
     private val _composeSelectionCancels = MutableSharedFlow<Unit>(extraBufferCapacity = 16)
     val composeSelectionCancels = _composeSelectionCancels.asSharedFlow()
+
+    /** 宿主要求画布建立选区(全文搜索命中),对照旧 View 的"搜索结果即真选区"。 */
+    private val _composeSelections = MutableSharedFlow<ReaderSelection>(extraBufferCapacity = 4)
+    val composeSelections = _composeSelections.asSharedFlow()
+
+    /** 触边界提示文案(由阅读页 SnackbarHost 呈现)。 */
+    private val _composeBoundaryMessages = MutableSharedFlow<String>(extraBufferCapacity = 1)
+    val composeBoundaryMessages = _composeBoundaryMessages.asSharedFlow()
+    private var lastBoundaryMessageAt = 0L
+
+    /** 旧 `PageDelegate` 的 `if (!snackBar.isShown)` 等价抑制窗口。 */
+    private val boundaryMessageIntervalMillis = 1_500L
     private var textMenuRequestVersion = 0L
     private var composeSelectedText: String? = null
     private var composeSelection: ReaderSelection? = null
@@ -370,6 +385,18 @@ class ReadBookController(
         }
     }
     private var directReaderChapterPageCounts = emptyMap<Int, Int>()
+
+    /**
+     * 正在逐页流出(部分页已进页表、整章批次还没提交)的章节,对照旧 `TextChapter.isCompleted`:
+     * 这些章的尾部要接"加载中"页,且不允许越过还没成型的页翻页。
+     */
+    private val directReaderStreamingChapters = mutableSetOf<Int>()
+
+    /** 每章已流出但还没提交的页;批次提交时整章替换。 */
+    private val directReaderStreamedPages = mutableMapOf<Int, MutableList<ReaderPage>>()
+
+    /** 重新排版自增:过期的流出回调据此丢弃(旧 View 靠取消排版任务做到同一件事)。 */
+    private var directReaderStreamGeneration = 0L
     private var directReaderPageIndex: Int? = null
     private val menuMutex = Mutex()
     @Volatile
@@ -688,6 +715,8 @@ class ReadBookController(
         directReaderLayoutKey = null
         directReaderPaginationEnvironmentKey = null
         directReaderMayReuseAdjacentPages = false
+        directReaderStreamGeneration += 1
+        clearStreamedReaderChapters()
         ReadBook.clearReaderPagination()
         updateReaderPaginationError(null)
         publishReaderPageWindow()
@@ -697,8 +726,34 @@ class ReadBookController(
         rebuildDirectReaderPages()
     }
 
+    /**
+     * `ReadBook.msg`(加载中/出错)进出时由阅读页重发窗口:旧 View 的消息页是取页器返回的
+     * 普通页,消息一变就重新取页(`TextPageFactory.curPage/nextPage/...` 均优先返回消息页)。
+     */
+    fun onReaderMessageChanged() {
+        publishReaderPageWindow()
+    }
+
     private fun directReaderWindow(index: Int): ReaderPageWindow {
+        // 旧 View 的取页器在 `pageSource.msg != null` 时把 cur/prev/next/nextPlus 全换成消息页
+        // (`TextPageFactory.kt:93-131`):正文让位,页眉页脚照旧。这里同样整窗替换,消息因此
+        // 走普通页面路径(chrome 保留),而不是另起一层浮层。
+        ReadBook.msg?.let { message ->
+            // 四个槽位同文案的不同实例,对照旧 View 每次取页都新建 `TextPage(text = msg)`。
+            val messagePages = List(4) { readerMessagePage(message) }
+            if (messagePages.all { it != null }) {
+                return ReaderPageWindow(
+                    previous = messagePages[0],
+                    current = messagePages[1],
+                    next = messagePages[2],
+                    nextPlus = messagePages[3],
+                )
+            }
+        }
         val window = ReaderPageNavigator.window(directReaderPages, index)
+        // 当前章是否还在逐页流出:决定尾部要不要接"加载中"页。
+        val streamingChapter = window.current?.id?.chapterIndex
+            ?.takeIf { it in directReaderStreamingChapters }
         val selection = searchSelection
         val aloudPosition = readAloudPosition
         val aloudParagraphIndex = aloudPosition?.let { (chapterIndex, chapterPosition) ->
@@ -737,18 +792,52 @@ class ReadBookController(
                         (aloudPosition?.hashCode()?.toLong() ?: 0L),
             )
         }
+        val nextPlus = if (streamingChapter != null && window.nextPlus == null) {
+            // 本章还有没成型的页:第三槽按旧 `TextPageFactory.nextPlusPage` 的 `!isCompleted`
+            // 分支给"加载中"页,而不是"继续滑动以加载下一章…"(那是下一章内容之后才出现的提示)。
+            tailLoadingPage(streamingChapter)
+        } else {
+            highlight(window.nextPlus, index + 2)
+                ?: if (ReadBook.isScroll &&
+                    ReaderPageNavigator.needsSwipeTipNextPlus(window, hasNextComposeChapter())
+                ) {
+                    // 第三槽只在滚动模式被绘制;邻章还没有第二页时用"继续滑动以加载下一章…"
+                    // 兜底,避免章末连续滑动先经过一段没有页面的空档(对照旧 View
+                    // `TextPageFactory.nextPlusPage`)。
+                    window.next?.let {
+                        swipeTipReaderPage(ReaderPageId(it.id.chapterIndex, it.id.pageIndex + 1))
+                    }
+                } else {
+                    null
+                }
+        }
         return ReaderPageWindow(
             previous = highlight(window.previous, index - 1),
             current = highlight(window.current, index),
-            next = highlight(window.next, index + 1),
-            nextPlus = highlight(window.nextPlus, index + 2),
+            // 本章还没排完时,最后一个成型页之后按旧 `nextPage` 给"加载中"页。
+            next = highlight(window.next, index + 1)
+                ?: streamingChapter?.let { tailLoadingPage(it) },
+            nextPlus = nextPlus,
         )
     }
 
+    /**
+     * 部分排版章节的尾部承接页,对照旧 `TextPageFactory.nextPage/nextPlusPage` 在
+     * `currentChapter.isCompleted == false` 时返回的 `R.string.data_loading` 页。
+     */
+    private fun tailLoadingPage(chapterIndex: Int): ReaderPage? = centeredReaderMessagePage(
+        id = ReaderPageId(chapterIndex, directReaderStreamedPages[chapterIndex]?.size ?: 0),
+        text = activity.getString(R.string.data_loading),
+        chapterTitle = readerChapterTitle(chapterIndex),
+    )
+
     fun hasBookmarkOnComposePage(): Boolean = directReaderPageIndex?.let(::hasBookmarkOnComposePage) ?: false
 
     private fun hasBookmarkOnComposePage(index: Int): Boolean {
         val book = ReadBook.book ?: return false
+        // 占位/提示页不是正文页:旧 View 在 `TextPage.isMsgPage` 上同样直接返回 false,
+        // 否则章首书签会让"加载数据中…"页也亮起角标。
+        if (directReaderPages.getOrNull(index)?.isPlaceholder == true) return false
         val page = directReaderPageContext(index) ?: return false
         return io.legado.app.model.ReaderBookmarkState.hasBookmarkInRange(
             bookName = book.name,
@@ -771,6 +860,103 @@ class ReadBookController(
         return updateReaderPageWindow(directReaderWindow(boundedIndex))
     }
 
+    /**
+     * 主线程:登记某章开始逐页流出。已经有一整章页的章节(重排 / 换样式)保持旧行为——
+     * 旧的完整页一直显示到批次提交,避免章页数先塌成"一页 + 加载中"再涨回来。
+     */
+    private fun beginStreamedReaderChapter(chapterIndex: Int, generation: Long) {
+        if (generation != directReaderStreamGeneration) return
+        if (directReaderPages.any { it.id.chapterIndex == chapterIndex && !it.isPlaceholder }) return
+        directReaderStreamingChapters += chapterIndex
+    }
+
+    /** 主线程:接收一页刚成型的页,对照旧 `ReadBook.collectLayoutPages` 的按页消费。 */
+    private fun onReaderPageStreamed(generation: Long, chapterIndex: Int, page: ReaderPage) {
+        activity.lifecycleScope.launch(Main) {
+            if (generation != directReaderStreamGeneration) return@launch
+            if (chapterIndex !in directReaderStreamingChapters) return@launch
+            val streamed = directReaderStreamedPages.getOrPut(chapterIndex) { mutableListOf() }
+            if (streamed.any { it.id == page.id }) return@launch
+            streamed += page
+            val currentPage =
+                directReaderPageIndex?.let { index -> directReaderPages.getOrNull(index) }
+            directReaderPages = directReaderPages
+                .filterNot { it.id.chapterIndex == chapterIndex }
+                .plus(streamed)
+                .sortedWith(compareBy({ it.id.chapterIndex }, { it.id.pageIndex }))
+            currentPage?.let { keep ->
+                val index = directReaderPages.indexOfFirst { it === keep }
+                if (index >= 0) directReaderPageIndex = index
+            }
+            if (shouldPublishStreamedReaderPage(chapterIndex, page)) {
+                publishStreamedReaderWindow(chapterIndex)
+            }
+        }
+    }
+
+    /**
+     * 主线程:重发窗口。当前页还在别的章(邻章提前流出)时只换窗、不动阅读位置,
+     * 否则会把阅读位置提前推进到邻章。
+     */
+    private fun publishStreamedReaderWindow(chapterIndex: Int) {
+        val currentIndex = directReaderPageIndex
+        val currentChapterIndex =
+            currentIndex?.let { directReaderPages.getOrNull(it)?.id?.chapterIndex }
+        val index = if (currentIndex != null && currentChapterIndex != chapterIndex) {
+            currentIndex
+        } else {
+            ReaderPageNavigator.locateOrNull(
+                directReaderPages,
+                chapterIndex,
+                ReadBook.durChapterPos,
+            ) ?: currentIndex ?: return
+        }
+        publishDirectReaderWindow(index)
+    }
+
+    /**
+     * 旧 View 的重绘时机(`ReadBook.loadContent` 的三条 `upContent(offset)`):当前章按
+     * "含 `durChapterPos` 的页成型 + 滚动模式 3 页余量",下一章只到前两页,上一章不早推。
+     */
+    private fun shouldPublishStreamedReaderPage(chapterIndex: Int, page: ReaderPage): Boolean {
+        val currentIndex = directReaderPageIndex
+        val current = currentIndex?.let { directReaderPages.getOrNull(it) }
+        val currentPageIndex = if (current != null && current.id.chapterIndex == chapterIndex) {
+            ReaderPageNavigator.chapterPosition(directReaderPages, currentIndex)?.pageIndex ?: 0
+        } else {
+            // 页表里还没有本章的当前页(刚开书/刚跳章):目标页下标未知,靠
+            // "含 durChapterPos 的页成型"这条兜住,避免先闪首页再跳到目标页。
+            0
+        }
+        val pageStart = ReaderPageNavigator.pageStart(page)
+        val pageEnd = page.elements
+            .filterIsInstance<ReaderElement.Text>()
+            .maxOfOrNull { it.chapterPosition + it.value.length.coerceAtLeast(1) }
+            ?: pageStart
+        return ReaderPartialPagePolicy.shouldPublishPage(
+            chapterOffset = chapterIndex - (current?.id?.chapterIndex ?: ReadBook.durChapterIndex),
+            pageIndex = page.id.pageIndex,
+            currentPageIndex = currentPageIndex,
+            containsReadingPosition = ReadBook.durChapterIndex == chapterIndex &&
+                    ReadBook.durChapterPos in pageStart..pageEnd,
+            continuousScroll = ReadBook.isScroll,
+        )
+    }
+
+    /**
+     * 作废逐页流出状态:丢掉还没提交的部分页与"仍在排版"标记。已经整章提交的页保留——
+     * 重排期间靠它们避免闪"加载中"。
+     */
+    private fun clearStreamedReaderChapters(keepChapterIndex: Int? = null) {
+        val dropping = directReaderStreamedPages.filterKeys { it != keepChapterIndex }
+        if (dropping.isNotEmpty()) {
+            val partialIds = dropping.values.flatten().mapTo(mutableSetOf()) { it.id }
+            directReaderPages = directReaderPages.filterNot { it.id in partialIds }
+        }
+        directReaderStreamedPages.keys.removeAll { it != keepChapterIndex }
+        directReaderStreamingChapters.removeAll { it != keepChapterIndex }
+    }
+
     /**
      * 邻章未分页时预置"加载中"占位页(对照 shutiao 的占位页滚动继续语义):
      * 预置后手势层的 window.next/previous 不再为空,拖拽、点按、滚动都能自然
@@ -786,17 +972,10 @@ class ReadBookController(
             chapterCount = ReadBook.simulatedChapterSize,
         )
         if (missingChapters.isEmpty()) return index
-        val cachedChapterIndexes = listOfNotNull(
-            ReadBook.readerChapterInputWindow.previous,
-            ReadBook.readerChapterInputWindow.current,
-            ReadBook.readerChapterInputWindow.next,
-        ).mapTo(mutableSetOf()) { it.chapter.index }
         val updated = pages.toMutableList()
         var added = 0
         missingChapters.forEach { chapterIndex ->
-            // Match the View reader's three-chapter hand-off: cached chapter content waits for
-            // its Canvas pagination rather than being presented as a network/content load.
-            if (chapterIndex !in cachedChapterIndexes) placeholderReaderPage(chapterIndex)?.let {
+            placeholderReaderPage(chapterIndex)?.let {
                 updated.add(it)
                 added++
             }
@@ -815,6 +994,9 @@ class ReadBookController(
         val chapterPosition = ReaderPageNavigator.pageStart(page)
         // 热路径安静更新:不发布快照(否则每次跨页触发一次全量 UiState 重建落在动画帧上)。
         ReadBook.updateReadingPosition(chapterPosition, publish = false)
+        // 旧 View 每次翻页的其余副作用(阅读时长 / 预下载 / 进度落库)必须在,否则崩溃回到
+        // 上次切章位置、静读时长不计、邻章预取推迟。
+        ReadBook.onComposeManualPageCommitted()
         if (BaseReadAloudService.isRun && ReadBook.onComposeManualPageTurn()) {
             readAloudPosition = page.id.chapterIndex to chapterPosition
         }
@@ -877,12 +1059,13 @@ class ReadBookController(
             return
         }
         pendingSearchNavigation = null
-        searchSelection = ReaderSelection(
+        val selection = ReaderSelection(
             chapterIndex = result.chapterIndex,
             anchor = match.start,
             focus = match.start + match.length - 1,
             anchorIsTitle = match.isTitle,
         )
+        searchSelection = selection
         val bodyPosition = if (match.isTitle) 0 else match.start
         ReadBook.updateReadingPosition(bodyPosition)
         directReaderPages.takeIf { pages ->
@@ -892,6 +1075,10 @@ class ReadBookController(
                 ReaderPageNavigator.locate(pages, result.chapterIndex, bodyPosition)
             )
         }
+        // 旧 View 的全文搜索命中是一次**真选区**(`isSelectingSearchResult` + selectStart/End +
+        // `isTextSelected = true`):有手柄、可拖动、弹出选区菜单。窗口发布之后再推给画布,
+        // 让它能按新窗口算锚点。
+        _composeSelections.tryEmit(selection)
     }
 
     private fun publishDirectReaderPageWindow(
@@ -1026,11 +1213,18 @@ class ReadBookController(
             directReaderPaginationEnvironmentKey = paginationEnvironmentKey
             updateReaderPaginationError(null)
             ReadBook.clearReaderPagination()
+            // 新的排版环境:作废上一轮还挂在页表里的部分页,并让在飞的流出回调失效。
+            directReaderStreamGeneration += 1
+            clearStreamedReaderChapters()
+            val streamGeneration = directReaderStreamGeneration
             directReaderLayoutJob = activity.lifecycleScope.launch(IO) {
                 val highlightRules = HighlightRuleRepository()
                     .loadEnabled(ReadBookConfig.durConfig.name)
                 suspend fun paginate(candidate: ReaderChapterInput, phase: String) =
                     candidate.chapter.index to ReaderPerfTrace.suspendSection("pagination.$phase") {
+                        withContext(Main) {
+                            beginStreamedReaderChapter(candidate.chapter.index, streamGeneration)
+                        }
                         paginateLegacyReaderChapterSafely {
                         LegacyReaderChapterPaginator.paginate(
                                 book = candidate.book,
@@ -1048,6 +1242,13 @@ class ReadBookController(
                                 contentPaddingBottomPx = contentPadding.bottom,
                                 paginationStyle = resolvedPaginationStyle,
                                 highlightRules = highlightRules,
+                            onPage = { page ->
+                                onReaderPageStreamed(
+                                    streamGeneration,
+                                    candidate.chapter.index,
+                                    page
+                                )
+                            },
                             )
                         }
                     }
@@ -1099,11 +1300,17 @@ class ReadBookController(
         }
         if (missing.isEmpty()) return
         directReaderAdjacentLayoutJob?.cancel()
+        // 上一轮相邻章的部分页随旧任务一起作废(当前章自己的流出态保留)。
+        clearStreamedReaderChapters(keepChapterIndex = current.chapter.index)
+        val streamGeneration = directReaderStreamGeneration
         val generation = ReadBook.readerPaginationGeneration
         directReaderAdjacentLayoutJob = activity.lifecycleScope.launch(IO) {
             val rules = HighlightRuleRepository().loadEnabled(ReadBookConfig.durConfig.name)
             val results = missing.map { candidate ->
                 candidate.chapter.index to ReaderPerfTrace.suspendSection("pagination.adjacent") {
+                    withContext(Main) {
+                        beginStreamedReaderChapter(candidate.chapter.index, streamGeneration)
+                    }
                     paginateLegacyReaderChapterSafely {
                         LegacyReaderChapterPaginator.paginate(
                             book = candidate.book,
@@ -1121,6 +1328,13 @@ class ReadBookController(
                             contentPaddingBottomPx = padding.bottom,
                             paginationStyle = style,
                             highlightRules = rules,
+                            onPage = { page ->
+                                onReaderPageStreamed(
+                                    streamGeneration,
+                                    candidate.chapter.index,
+                                    page
+                                )
+                            },
                         )
                     }
                 }
@@ -1178,6 +1392,9 @@ class ReadBookController(
             // chapter-window changes may retain its already-shaped adjacent pages.
             directReaderMayReuseAdjacentPages = true
         }
+            // 批次提交即"这一章排完了"(旧 `TextChapter.isCompleted = true`):撤掉流出态与尾部承接页。
+            directReaderStreamingChapters.removeAll(replacementChapterIndexes)
+            directReaderStreamedPages.keys.removeAll(replacementChapterIndexes)
         // 重排可能改变元素位置与页 endPosition,页上下文缓存全部失效。
         directReaderPageContexts.clear()
         directReaderChapterPageCounts = directReaderPages.groupingBy { it.id.chapterIndex }.eachCount()
@@ -1195,6 +1412,10 @@ class ReadBookController(
         ReadBook.publishReaderPagination(
             directReaderPages.groupBy { it.id.chapterIndex }.mapNotNull { (chapterIndex, pages) ->
                 chapters.firstOrNull { it.chapter.index == chapterIndex } ?: return@mapNotNull null
+                // 占位页不是分页结果:把它当成该章的分页快照会以 pageCount=1 污染整书页数
+                // 估算(wholeBookPageCoordinator.correctChapter 拿 realPageCount 校正)。
+                // 邻章正文已缓存但还没排版时也会预置占位页,必须显式排除。
+                if (pages.all { it.isPlaceholder }) return@mapNotNull null
                 val contentEnd = ReaderPageNavigator.pageContext(
                     pages,
                     pages.lastIndex,
@@ -1841,11 +2062,15 @@ class ReadBookController(
             }
 
             is ReadBookEffect.UpPageAnim -> publishReaderPageWindow()
-            is ReadBookEffect.UpTime -> {
-                directReaderPageIndex?.let(::publishDirectReaderWindow)
-            }
-            is ReadBookEffect.UpBattery -> {
-                directReaderPageIndex?.let(::publishDirectReaderWindow)
+            is ReadBookEffect.UpTime, is ReadBookEffect.UpBattery -> {
+                // 时间/电量是烘进页眉页脚 decoration 的动态信息(`directReaderWindow` 里现建),
+                // 必须重发窗口才会刷新;页表还没落地(装载期整窗占位)时也要强制重建。
+                val index = directReaderPageIndex
+                if (index != null && directReaderPages.isNotEmpty()) {
+                    publishDirectReaderWindow(index)
+                } else {
+                    publishLoadingReaderWindow(force = true)
+                }
             }
             is ReadBookEffect.UpSystemUiVisibility -> upSystemUiVisibility()
             is ReadBookEffect.PageAnimChanged -> {
@@ -2249,10 +2474,23 @@ class ReadBookController(
         }
         val navigation = ReaderPageNavigator.move(directReaderPages, currentIndex, delta)
         if (delta == 0) return null
+        val fromChapterIndex = directReaderPages[currentIndex].id.chapterIndex
+        // 部分排版章节的护栏:本章还有没成型的页,窗口里的"下一页"其实已经是下一章,
+        // 翻过去会跳过本章剩余内容(旧 `isLastIndexCurrent` 拒绝);倒退进还没排完的
+        // 上一章同理(旧 `moveToPrev` 的 `prevChapter.isCompleted == false`)。
+        if (!ReaderPartialPagePolicy.allowsChapterMove(
+                fromChapterIndex = fromChapterIndex,
+                targetChapterIndex = navigation.window.current?.id?.chapterIndex
+                    ?: fromChapterIndex,
+                fromChapterStreaming = fromChapterIndex in directReaderStreamingChapters,
+                targetChapterStreaming = navigation.window.current?.id?.chapterIndex
+                    ?.let { it in directReaderStreamingChapters } == true,
+            )
+        ) return null
         if (navigation.hitBoundary) {
             return crossComposeChapterBoundary(currentIndex, delta)
         }
-        val oldChapterIndex = directReaderPages[currentIndex].id.chapterIndex
+        val oldChapterIndex = fromChapterIndex
         val newChapterIndex = navigation.window.current?.id?.chapterIndex ?: oldChapterIndex
         // 页表已经跨进邻章时按页表切章;若书已停在目标章(上次翻页切了章而页表尚未
         // 同步),不再推进一次,否则会整体跳过一章。
@@ -2294,13 +2532,21 @@ class ReadBookController(
 
     fun hasPreviousComposeChapter(): Boolean = ReadBook.durChapterIndex > 0
 
-    /** Restores the legacy reader's feedback when a page turn reaches a book boundary. */
+    /**
+     * 触边界提示,对照旧 `PageDelegate` 的 Snackbar(`LENGTH_SHORT` + `if (!snackBar.isShown)`):
+     * 由阅读页的 SnackbarHost 呈现,1.5s 内只发一次,避免连点连弹。
+     */
     fun showComposePageBoundary(direction: ReaderTurnDirection) {
-        activity.toastOnUi(
-            when (direction) {
-                ReaderTurnDirection.PREVIOUS -> R.string.no_prev_page
-                ReaderTurnDirection.NEXT -> R.string.no_next_page
-            },
+        val now = android.os.SystemClock.uptimeMillis()
+        if (now - lastBoundaryMessageAt < boundaryMessageIntervalMillis) return
+        lastBoundaryMessageAt = now
+        _composeBoundaryMessages.tryEmit(
+            activity.getString(
+                when (direction) {
+                    ReaderTurnDirection.PREVIOUS -> R.string.no_prev_page
+                    ReaderTurnDirection.NEXT -> R.string.no_next_page
+                },
+            ),
         )
     }
 
@@ -2313,6 +2559,15 @@ class ReadBookController(
     private fun crossComposeChapterBoundary(currentIndex: Int, delta: Int): ReaderPageWindow? {
         val fromChapterIndex = directReaderPages[currentIndex].id.chapterIndex
         val targetChapterIndex = fromChapterIndex + delta
+        // 部分排版章节的护栏(旧 `isLastIndexCurrent` / `moveToPrev` 的 `isCompleted` 检查):
+        // 本章还有没成型的页就不能向前越过,上一章还没排完就不能倒退进去——尾部承接页会给出反馈。
+        if (!ReaderPartialPagePolicy.allowsChapterMove(
+                fromChapterIndex = fromChapterIndex,
+                targetChapterIndex = targetChapterIndex,
+                fromChapterStreaming = fromChapterIndex in directReaderStreamingChapters,
+                targetChapterStreaming = targetChapterIndex in directReaderStreamingChapters,
+            )
+        ) return null
         // 已处于该章占用状态(书已推进到目标章,页表却还停在上一章):这不代表翻页应当
         // 被丢弃,而是目标章的排版还没落地。旧 View 里这一类边界由 moveToNextChapter 的
         // 装载/重绘承接;这里补一次发布请求让该章排版推进,翻页结果由分页批次发布。
@@ -2351,11 +2606,11 @@ class ReadBookController(
                 return window
             }
         // ReadBook has promoted a cached adjacent chapter input, but its Canvas pages may still
-        // be shaping in the background. Start that pagination and retain the completed source
-        // page until it publishes; only an actually missing chapter gets a “loading” page.
+        // be shaping in the background. Start that pagination; the turn itself is carried by the
+        // “加载数据中…” placeholder page below, exactly like the View reader's page factory
+        // fallback(`TextPageFactory.nextPage/prevPage` 在邻章还没有页时给出兜底页)。
         if (ReadBook.readerChapterInputWindow.current?.chapter?.index == targetChapterIndex) {
             publishReaderPageWindow()
-            return null
         }
         val placeholder = placeholderReaderPage(targetChapterIndex) ?: return null
         val pages = directReaderPages.toMutableList()
@@ -2372,53 +2627,147 @@ class ReadBookController(
         return window
     }
 
-    /** 内容装载前的整窗占位:窗口里只有"加载中"占位页,点击/菜单走画布正常路径。 */
-    private fun publishLoadingReaderWindow() {
+    /**
+     * 内容装载前的整窗占位:窗口里只有消息页(`ReadBook.msg` 优先,其次"加载数据中…"),
+     * 点击/菜单走画布正常路径。
+     */
+    private fun publishLoadingReaderWindow(force: Boolean = false) {
+        // 文案变化("加载中…" → "加载正文出错…")必须重绘,否则错误消息会停在上一帧的加载文案上。
+        val text = ReadBook.msg ?: activity.getString(R.string.data_loading)
         val current = _readerPageWindow.value.current
-        if (current?.isPlaceholder == true && current.id.chapterIndex == ReadBook.durChapterIndex) return
-        placeholderReaderPage(ReadBook.durChapterIndex)?.let { page ->
-            updateReaderPageWindow(ReaderPageWindow(current = page))
-        }
+        // force:时间/电量这类"烘进 decoration 的动态信息"变化时必须重建,否则这条幂等判据
+        // 会让装载期的页眉页脚一直停在旧时间/旧电量。
+        if (!force &&
+            current?.isPlaceholder == true &&
+            current.id.chapterIndex == ReadBook.durChapterIndex &&
+            current.text == text
+        ) return
+        val page = ReadBook.msg?.let(::readerMessagePage)
+            ?: placeholderReaderPage(ReadBook.durChapterIndex)
+            ?: return
+        updateReaderPageWindow(ReaderPageWindow(current = page))
     }
 
-    /** 未装载章节的占位页:一屏居中的"加载中"文字,几何与普通页一致以保持滚动连续。 */
-    private fun placeholderReaderPage(chapterIndex: Int): ReaderPage? {
+    /** 未装载章节的占位页:一屏居中的"加载数据中…",几何与普通页一致以保持滚动连续。 */
+    private fun placeholderReaderPage(chapterIndex: Int): ReaderPage? = centeredReaderMessagePage(
+        id = ReaderPageId(chapterIndex, 0),
+        text = activity.getString(R.string.data_loading),
+        chapterTitle = readerChapterTitle(chapterIndex),
+    )
+
+    /**
+     * 消息页(`ReadBook.msg`:正在加载 / 正文出错 / 目录更新中等),对照旧 View
+     * `TextPage(text = msg)`:文案即消息、标题取 `TextPage.title` 的默认值。
+     */
+    private fun readerMessagePage(message: String): ReaderPage? = centeredReaderMessagePage(
+        id = ReaderPageId(ReadBook.durChapterIndex, 0),
+        text = message,
+        chapterTitle = activity.getString(R.string.data_loading),
+    )
+
+    /**
+     * 章末第三槽的"继续滑动以加载下一章…"提示页,对照旧 View `TextPageFactory.nextPlusPage`
+     * 在下一章还没有第二页(只有一页,或该章还没排完)时给出的 `R.string.keep_swipe_tip` 页。
+     */
+    private fun swipeTipReaderPage(slotId: ReaderPageId): ReaderPage? = centeredReaderMessagePage(
+        id = slotId,
+        text = activity.getString(R.string.keep_swipe_tip),
+        chapterTitle = readerChapterTitle(slotId.chapterIndex),
+    )
+
+    /**
+     * 消息页的章节标题,对照旧 View `TextPage(title = it.title)`:归属章正文已缓存时用它的
+     * 显示标题,否则退回 `TextPage.title` 的默认值(同样是 `R.string.data_loading`)。
+     */
+    private fun readerChapterTitle(chapterIndex: Int): String = listOfNotNull(
+        ReadBook.readerChapterInputWindow.previous,
+        ReadBook.readerChapterInputWindow.current,
+        ReadBook.readerChapterInputWindow.next,
+    ).firstOrNull { it.chapter.index == chapterIndex }?.displayTitle
+        ?: activity.getString(R.string.data_loading)
+
+    /**
+     * 整屏居中的消息页(对照旧 View `TextPage.format()` 的 `isMsgPage` 分支):页高取内容区
+     * 高度、`StaticLayout` 折行后逐行居中、整块在内容区垂直居中,因此滚动模式下它的堆叠
+     * 高度与普通页一致。
+     */
+    private fun centeredReaderMessagePage(
+        id: ReaderPageId,
+        text: String,
+        chapterTitle: String,
+    ): ReaderPage? {
         val viewport = layoutController.viewport.value ?: return null
         val paginationStyle = LegacyReaderPaginationStyleFactory.create()
         val contentTop = viewport.contentPadding.top.toFloat()
         val contentBottom = (viewport.heightPx - viewport.contentPadding.bottom).toFloat()
         if (contentBottom - contentTop <= 0f) return null
-        val paint = paginationStyle.bodyPaint
-        val text = activity.getString(R.string.loading)
-        val textWidth = paint.measureText(text)
-        val left = viewport.contentPadding.left.toFloat()
-        val width = viewport.contentWidthPx.toFloat()
-        val x = left + ((width - textWidth) / 2f).coerceAtLeast(0f)
-        val y = contentTop + ((contentBottom - contentTop - paginationStyle.bodyTextHeightPx) / 2f)
-            .coerceAtLeast(0f)
-        val element = ReaderElement.Text(
-            bounds = ReaderRect(x, y, x + textWidth, y + paginationStyle.bodyTextHeightPx),
-            baselinePx = y + paginationStyle.bodyBaselineOffsetPx,
-            value = text,
-            style = paginationStyle.bodyStyle,
-            selected = false,
-            emphasized = true,
-            chapterPosition = 0,
-            paragraphIndex = -1,
-        )
-        return ReaderPage(
-            id = ReaderPageId(chapterIndex, 0),
-            chapterTitle = "",
+        val contentWidth = viewport.contentWidthPx.toFloat()
+        val lineHeight = paginationStyle.bodyTextHeightPx
+        val layout = StaticLayout.Builder
+            .obtain(
+                text,
+                0,
+                text.length,
+                paginationStyle.bodyPaint,
+                viewport.contentWidthPx.coerceAtLeast(1)
+            )
+            .setAlignment(Layout.Alignment.ALIGN_NORMAL)
+            .setLineSpacing(0f, 1f)
+            .setIncludePad(false)
+            .build()
+        val blockTop = contentTop +
+                ((contentBottom - contentTop - layout.height) / 2f).coerceAtLeast(0f)
+        val elements = (0 until layout.lineCount).map { lineIndex ->
+            val lineTop = blockTop + layout.getLineTop(lineIndex)
+            val lineWidth = layout.getLineMax(lineIndex)
+            val x = viewport.contentPadding.left.toFloat() +
+                    ((contentWidth - lineWidth) / 2f).coerceAtLeast(0f)
+            ReaderElement.Text(
+                bounds = ReaderRect(x, lineTop, x + lineWidth, lineTop + lineHeight),
+                baselinePx = blockTop + layout.getLineBaseline(lineIndex),
+                value = text.substring(
+                    layout.getLineStart(lineIndex),
+                    layout.getLineEnd(lineIndex)
+                ),
+                style = paginationStyle.bodyStyle,
+                selected = false,
+                emphasized = true,
+                chapterPosition = 0,
+                paragraphIndex = -1,
+            )
+        }
+        val page = ReaderPage(
+            id = id,
+            chapterTitle = chapterTitle,
             text = text,
             widthPx = viewport.widthPx,
             heightPx = viewport.heightPx,
             contentTopPx = contentTop,
             contentBottomPx = contentBottom,
-            elements = listOf(element),
+            contentLeftPx = viewport.contentPadding.left.toFloat(),
+            contentRightPx = (viewport.widthPx - viewport.contentPadding.right).toFloat(),
+            elements = elements,
             revision = 1L,
             scrollExtentPx = contentBottom - contentTop,
             isPlaceholder = true,
         )
+        // 旧 View 里消息页/占位页就是普通 TextPage(`TextPageFactory` 直接返回),
+        // `PageView.setContent` 照常 `setProgress` → 页眉页脚、页码都在。这里同样给它们
+        // 生成 decoration,否则画布只画 `page.decoration`(空)→ 这些页的 chrome 整体消失。
+        val dynamicState = viewModel.uiState.value
+        return page.copy(
+            decoration = LegacyReaderPageDecorationFactory.create(
+                page = page,
+                chapterPageCount = directReaderChapterPageCounts[page.id.chapterIndex] ?: 0,
+                time = dynamicState.time,
+                batteryPercent = dynamicState.battery,
+                hasBookmark = false,
+                contentPaddingLeftPx = viewport.contentPadding.left,
+                contentPaddingTopPx = viewport.contentPadding.top,
+                contentPaddingRightPx = viewport.contentPadding.right,
+                contentPaddingBottomPx = viewport.contentPadding.bottom,
+            ),
+        )
     }
 
     private fun keyPageDebounce(

+ 47 - 16
app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt

@@ -30,6 +30,9 @@ import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.SnackbarDuration
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.LaunchedEffect
@@ -73,6 +76,7 @@ import io.legado.app.feature.reader.ReaderCanvasSurface
 import io.legado.app.feature.reader.core.gesture.ReaderTapActionGrid
 import io.legado.app.feature.reader.core.model.readerBackgroundAlpha
 import io.legado.app.feature.reader.core.transition.ReaderTransitionMode
+import io.legado.app.feature.reader.core.transition.ReaderViewportLayerPolicy
 import io.legado.app.help.IntentHelp
 import io.legado.app.model.ReadBook
 import io.legado.app.model.SourceCallBack
@@ -582,6 +586,16 @@ fun ReadBookRouteScreen(
     LaunchedEffect(isDarkTheme) {
         controller.onAppThemeChanged(isDarkTheme)
     }
+    // 消息进出时重发窗口:消息页由画布按普通页渲染,画布要在 msg 变化当帧拿到它。
+    LaunchedEffect(state.msg) {
+        controller.onReaderMessageChanged()
+    }
+    val readerSnackbarHostState = remember { SnackbarHostState() }
+    LaunchedEffect(controller) {
+        controller.composeBoundaryMessages.collect { message ->
+            readerSnackbarHostState.showSnackbar(message, duration = SnackbarDuration.Short)
+        }
+    }
     LaunchedEffect(readerPageWindow.current?.id, readerPageWindow.current?.layoutRevision) {
         if (readerPageWindow.current != null) {
             withFrameNanos { }
@@ -624,10 +638,11 @@ fun ReadBookRouteScreen(
     }
     val displayedReaderPageWindow =
         readerPageWindow.takeIf { it.current != null } ?: lastReadablePageWindow
-    // A retained page bridges only a transient chapter-window gap. A real pagination failure
-    // must replace it with the retryable error state instead of leaving stale content visible.
+    // 消息(`state.msg`)由画布按普通页绘制:旧 View 的取页器在 `msg != null` 时整窗返回
+    // 消息页(chrome 保留),所以画布不再因为 msg 隐藏。保留的上一窗只用于跨章瞬间;
+    // 真正的排版失败仍要让位给可重试的错误态,不能留着过期正文。
     val hasReadablePage = displayedReaderPageWindow?.current != null &&
-            state.msg == null && readerPaginationError == null
+            readerPaginationError == null
     var readerContentRevealAllowed by remember(sharedCoverKey) {
         mutableStateOf(sharedCoverKey == null || animatedVisibilityScope == null)
     }
@@ -686,20 +701,29 @@ fun ReadBookRouteScreen(
                     )
                 }
         ) {
-            ReaderBackgroundSurface(
-                backgroundImage = readerBackground.drawable,
-                backgroundImageAlpha = readerBackgroundAlpha(state.styleConfig.bgAlpha),
-                modifier = Modifier.fillMaxSize(),
-                animateAppearance = true,
-            )
+            // 滚动模式的背景由画布内的固定层绘制(画布还要当菜单 haze 的源),根层再画一遍
+            // 会让半透明背景图叠加两次、比设置值更浓,且与分页模式(页面自绘不透明底色挡住
+            // 根层,实际只画一次)观感不一致。画布可见时让出根层,其它状态仍由根层兜底。
+            val readerCanvasVisible = readerContentRevealAllowed && hasReadablePage
+            val readerTransitionMode = ReaderTransitionMode.fromPageAnim(controller.pageAnim)
+            if (!(ReaderViewportLayerPolicy.usesFixedBackground(readerTransitionMode) &&
+                        readerCanvasVisible)
+            ) {
+                ReaderBackgroundSurface(
+                    backgroundImage = readerBackground.drawable,
+                    backgroundImageAlpha = readerBackgroundAlpha(state.styleConfig.bgAlpha),
+                    modifier = Modifier.fillMaxSize(),
+                    animateAppearance = true,
+                )
+            }
             AnimatedVisibility(
-                visible = readerContentRevealAllowed && hasReadablePage,
+                visible = readerCanvasVisible,
                 enter = fadeIn(animationSpec = tween(400)),
                 exit = fadeOut(animationSpec = tween(450)),
             ) {
                 ReaderCanvasSurface(
                     hostPages = displayedReaderPageWindow ?: readerPageWindow,
-                transitionMode = ReaderTransitionMode.fromPageAnim(controller.pageAnim),
+                    transitionMode = readerTransitionMode,
                 backgroundColor = readerSurfaceColor,
                 backgroundImage = readerBackground.drawable,
                 backgroundRevision = readerBackground.revision,
@@ -759,15 +783,16 @@ fun ReadBookRouteScreen(
                 noAnimationScrollPage = readPreferences.noAnimScrollPage,
                 externalPageTurns = controller.composePageTurns,
                 externalSelectionCancels = controller.composeSelectionCancels,
+                    externalSelections = controller.composeSelections,
                     onVisibleBodyTextPositionProvider = controller::setComposeVisibleBodyTextPositionProvider,
                 )
             }
             AnimatedVisibility(
-                // Generic "loading data" duplicated the Canvas placeholder and could flash
-                // before a warm cached chapter page was republished. The body renderer owns
-                // normal loading feedback; this outer layer is reserved for messages/errors.
-                visible = readerEntranceSettled && !hasReadablePage &&
-                        (state.msg != null || readerPaginationError != null),
+                // 旧 View 把消息画成页(chrome 保留);只有画布无从成页(还没有窗口/视口)
+                // 或排版失败需要重试入口时,才退回外层浮层。
+                visible = readerEntranceSettled && (
+                        readerPaginationError != null || (state.msg != null && !hasReadablePage)
+                        ),
                 enter = fadeIn(animationSpec = tween(300)),
                 exit = fadeOut(animationSpec = tween(300)),
             ) {
@@ -797,6 +822,12 @@ fun ReadBookRouteScreen(
                     )
                 }
             }
+            // 触边界提示:对照旧 `PageDelegate` 的 Snackbar(LENGTH_SHORT),不再用 Toast,
+            // 重复由控制器侧 1.5s 抑制窗口挡掉。
+            SnackbarHost(
+                hostState = readerSnackbarHostState,
+                modifier = Modifier.align(Alignment.BottomCenter),
+            )
         }
         ReadBookColorTheme(
             styleConfig = state.styleConfig,

+ 7 - 6
app/src/test/java/io/legado/app/feature/reader/core/layout/ReaderChapterBlockMeasurerTest.kt

@@ -1,19 +1,19 @@
 package io.legado.app.feature.reader.core.layout
 
 import io.legado.app.feature.reader.core.model.ReaderTextStyle
+import io.legado.app.feature.reader.core.source.ReaderChapterInlineSource
 import io.legado.app.feature.reader.core.source.ReaderChapterSource
 import io.legado.app.feature.reader.core.source.ReaderChapterSourceBlock
-import io.legado.app.feature.reader.core.source.ReaderChapterInlineSource
-import io.legado.app.feature.reader.core.source.ReaderInlineSourceStyle
 import io.legado.app.feature.reader.core.source.ReaderChapterSourceParser
+import io.legado.app.feature.reader.core.source.ReaderInlineSourceStyle
 import io.legado.app.feature.reader.core.source.ReaderTitleSegmentation
 import io.legado.app.feature.reader.core.style.ReaderCharacterStyle
 import io.legado.app.feature.reader.core.style.ReaderStyleRange
 import io.legado.app.feature.reader.core.style.ReaderStyleTarget
+import kotlinx.coroutines.runBlocking
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertTrue
 import org.junit.Test
-import kotlinx.coroutines.runBlocking
 
 class ReaderChapterBlockMeasurerTest {
     @Test
@@ -175,7 +175,7 @@ class ReaderChapterBlockMeasurerTest {
     }
 
     @Test
-    fun `missing inline image keeps chapter text and uses line sized placeholder`() = runBlocking {
+    fun `missing inline image keeps chapter text and reserves one character cell`() = runBlocking {
         val source = ReaderChapterSource(1, "", listOf(
             ReaderChapterSourceBlock.Paragraph(listOf(
                 ReaderChapterInlineSource.Text("甲", 0),
@@ -193,8 +193,9 @@ class ReaderChapterBlockMeasurerTest {
         val items = (result.blocks.single() as ReaderMeasuredBlock.InlineParagraph).items
         val image = items[1] as ReaderMeasuredInlineItem.Image
         assertEquals(listOf(0, 1, 2), items.map { it.chapterPosition })
-        assertEquals(24f, image.widthPx, 0f)
-        assertEquals(24f, image.heightPx, 0f)
+        // 行内图占位宽 = 一个字符格(正文字号 10f),高按原图比例;缺失尺寸时按正方形占位。
+        assertEquals(10f, image.widthPx, 0f)
+        assertEquals(10f, image.heightPx, 0f)
     }
 
     @Test

+ 8 - 7
app/src/test/java/io/legado/app/feature/reader/core/layout/ReaderImageOptionsTest.kt

@@ -38,7 +38,7 @@ class ReaderImageOptionsTest {
     }
 
     @Test
-    fun inlineImageStaysWithinParagraphLineNotTextArea() = runBlocking {
+    fun inlineImageUsesOneCharacterCellWidth() = runBlocking {
         val result = measure(
             ReaderImageOptions(ReaderImageLayoutMode.INLINE, action = "run()"),
             baseStyle.copy(
@@ -49,19 +49,20 @@ class ReaderImageOptionsTest {
         )
         val paragraph = result.blocks.single() as ReaderMeasuredBlock.InlineParagraph
         val image = paragraph.items.single() as ReaderMeasuredInlineItem.Image
-        // 文字嵌入图只受当前行高约束(10px 字号),不允许放大到铺满可视文字区。
-        assertEquals(20f, image.widthPx, 0f)
-        assertEquals(10f, image.heightPx, 0f)
+        // 对照旧 `ImageColumn`:占位宽 = 一个字符格(字号 10f),高按原图比例换算(100×50 → 5f)。
+        assertEquals(10f, image.widthPx, 0f)
+        assertEquals(5f, image.heightPx, 0f)
         assertEquals("run()", image.action)
     }
 
     @Test
-    fun requestedFractionAppliesWithinLineCap() = runBlocking {
+    fun requestedWidthFractionIsIgnoredForInlineImages() = runBlocking {
         val result = measure(ReaderImageOptions(requestedWidthFraction = .5f))
         val paragraph = result.blocks.single() as ReaderMeasuredBlock.InlineParagraph
         val image = paragraph.items.single() as ReaderMeasuredInlineItem.Image
-        assertEquals(20f, image.widthPx, 0f)
-        assertEquals(10f, image.heightPx, 0f)
+        // 旧版行内图不使用 width 参数(占位宽恒为一个字符格)。
+        assertEquals(10f, image.widthPx, 0f)
+        assertEquals(5f, image.heightPx, 0f)
     }
 
     @Test

+ 61 - 0
app/src/test/java/io/legado/app/feature/reader/core/layout/ReaderPaginatorTest.kt

@@ -809,4 +809,65 @@ class ReaderPaginatorTest {
         assertEquals(1, runs.size)
         assertEquals(34f, runs.single().contentBounds.right, 0f)
     }
+
+    /**
+     * 流式会话(对照旧 View `TextChapterLayout.onPageCompleted()` 的 `channel.trySend`):
+     * 逐 block 推送得到的页必须与整章批次入口完全一致,流出顺序也与最终列表一致。
+     */
+    @Test
+    fun streamingSessionEmitsExactlyTheBatchPages() {
+        val scrollConfig = config.copy(continuousScroll = true, chapterEndPaddingPx = 7f)
+        val blocks = (0..4).map { index ->
+            ReaderMeasuredBlock.Paragraph(paragraph(index.toString(), index))
+        }
+        val batch = ReaderPaginator.paginateBlocks(blocks, scrollConfig)
+
+        val session = ReaderPaginationSession(scrollConfig)
+        val streamed = mutableListOf<io.legado.app.feature.reader.core.model.ReaderPage>()
+        session.onPage = { streamed += it }
+        blocks.forEach(session::accept)
+        val finished = session.finish()
+
+        assertEquals(batch.size, finished.size)
+        assertEquals(batch.map { it.id }, finished.map { it.id })
+        assertEquals(batch.map { it.text }, finished.map { it.text })
+        assertEquals(batch.map { it.scrollExtentPx }, finished.map { it.scrollExtentPx })
+        // 5 个单行段落排成 3 页(2/2/1):章末留白只加在最后一页(20f 内容 + 7f 留白)。
+        assertEquals(listOf(40f, 40f, 27f), finished.map { it.scrollExtentPx })
+        assertEquals(finished.map { it.id }, streamed.map { it.id })
+        assertEquals(finished.map { it.scrollExtentPx }, streamed.map { it.scrollExtentPx })
+    }
+
+    /**
+     * 章末页延迟到收尾才流出:刚收尾的页只有在下一次收尾(或章末)才知道自己是不是最后一页,
+     * 而最后一页的堆叠高度要加 [ReaderPaginationConfig.chapterEndPaddingPx]。
+     */
+    @Test
+    fun streamingSessionHoldsTheChapterEndPageUntilFinish() {
+        val scrollConfig = config.copy(continuousScroll = true, chapterEndPaddingPx = 7f)
+        // 内容区高 40f、每行 20f:6 个单行段落排成 3 页(2/2/2)。
+        val blocks = (0..5).map { index ->
+            ReaderMeasuredBlock.Paragraph(paragraph(index.toString(), index))
+        }
+        val session = ReaderPaginationSession(scrollConfig)
+        val emitted = mutableListOf<Int>()
+        session.onPage = { emitted += it.id.pageIndex }
+        blocks.forEach(session::accept)
+
+        // 第 2 页成型时第 1 页流出;第 3 页(章末页)要等 finish。
+        assertEquals(listOf(0), emitted)
+        val pages = session.finish()
+        assertEquals(listOf(0, 1, 2), pages.map { it.id.pageIndex })
+        assertEquals(listOf(0, 1, 2), emitted)
+    }
+
+    /** 空章(没有任何 block)不产出页,也不能让流式会话收尾时越界。 */
+    @Test
+    fun streamingSessionWithNoBlocksProducesNoPages() {
+        val session = ReaderPaginationSession(config)
+        val emitted = mutableListOf<Int>()
+        session.onPage = { emitted += it.id.pageIndex }
+        assertEquals(emptyList<Any>(), session.finish())
+        assertEquals(emptyList<Int>(), emitted)
+    }
 }

+ 39 - 0
app/src/test/java/io/legado/app/feature/reader/core/model/ReaderTipValueFormatterTest.kt

@@ -35,4 +35,43 @@ class ReaderTipValueFormatterTest {
             ReaderTipValueFormatter.format(ReaderTipValueType.CUSTOM, context, template),
         )
     }
+
+    /** 宿主(`LegacyReaderPageDecorationFactory`)传入的是带本地化前缀的整书页数文案。 */
+    @Test
+    fun usesLocalizedWholeBookTextWhenProvided() {
+        val localized = context.copy(wholeBookPageText = "全文 42 / 100")
+        assertEquals(
+            "全文 42 / 100",
+            ReaderTipValueFormatter.format(ReaderTipValueType.WHOLE_BOOK_PAGE, localized),
+        )
+        assertEquals(
+            "全文 42 / 100  24.0%",
+            ReaderTipValueFormatter.format(
+                ReaderTipValueType.WHOLE_BOOK_PAGE_AND_PROGRESS,
+                localized
+            ),
+        )
+    }
+
+    /** 章还没排出任何页时页数未知:页码槽与 `{PageSize}`/`{PageRemaining}` 都给 `-`。 */
+    @Test
+    fun unknownChapterPageCountRendersDash() {
+        val unknown = context.copy(
+            pageCountText = ReaderTipValueFormatter.UNKNOWN_PAGE_COUNT,
+            pageCount = 1,
+        )
+        assertEquals("2/-", ReaderTipValueFormatter.format(ReaderTipValueType.PAGE, unknown))
+        assertEquals(
+            "2/-  24.0%",
+            ReaderTipValueFormatter.format(ReaderTipValueType.PAGE_AND_TOTAL, unknown),
+        )
+        assertEquals(
+            "2/-|-",
+            ReaderTipValueFormatter.format(
+                ReaderTipValueType.CUSTOM,
+                unknown,
+                "{PageIndex}/{PageSize}|{PageRemaining}",
+            ),
+        )
+    }
 }

+ 41 - 0
app/src/test/java/io/legado/app/feature/reader/core/navigation/ReaderPageNavigatorTest.kt

@@ -210,6 +210,47 @@ class ReaderPageNavigatorTest {
         assertTrue(!ReaderPageNavigator.canTurnPrevious(ReaderPageWindow(), false))
     }
 
+    @Test
+    fun swipeTipFillsTheThirdSlotOnlyWhenTheNextChapterHasNoSecondPage() {
+        val lastPage = page(1, 20)
+        // 下一章只有一页(或还没排到第二页):第三槽需要"继续滑动以加载下一章…"兜底页。
+        assertTrue(
+            ReaderPageNavigator.needsSwipeTipNextPlus(
+                ReaderPageWindow(
+                    previous = page(0, 0),
+                    current = lastPage,
+                    next = page(0, 0, chapterIndex = 5)
+                ),
+                hasNextChapter = true,
+            )
+        )
+        // 下一章已经有第二页:让真实页占第三槽。
+        assertTrue(
+            !ReaderPageNavigator.needsSwipeTipNextPlus(
+                ReaderPageWindow(
+                    current = lastPage,
+                    next = page(0, 0, chapterIndex = 5),
+                    nextPlus = page(1, 0, chapterIndex = 5),
+                ),
+                hasNextChapter = true,
+            )
+        )
+        // 窗口里没有后继页:由占位页承接,不是提示页的场景。
+        assertTrue(
+            !ReaderPageNavigator.needsSwipeTipNextPlus(
+                ReaderPageWindow(current = lastPage),
+                hasNextChapter = true,
+            )
+        )
+        // 书末没有下一章。
+        assertTrue(
+            !ReaderPageNavigator.needsSwipeTipNextPlus(
+                ReaderPageWindow(current = lastPage, next = page(0, 0, chapterIndex = 5)),
+                hasNextChapter = false,
+            )
+        )
+    }
+
     @Test
     fun locateReportsMissingChapterInsteadOfCollapsingToBookStart() {
         // 章不在页表中时 locate 折叠成 0(全书首页是合法下标),发布该下标会把阅读

+ 96 - 0
app/src/test/java/io/legado/app/feature/reader/core/navigation/ReaderPartialPagePolicyTest.kt

@@ -0,0 +1,96 @@
+package io.legado.app.feature.reader.core.navigation
+
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ReaderPartialPagePolicyTest {
+
+    @Test
+    fun nextChapterPublishesOnlyItsFirstTwoPagesLikeTheViewReader() {
+        // offset = 1:旧 loadContent 在 `page.index > 1` 时停止提前重绘。
+        assertTrue(ReaderPartialPagePolicy.shouldPublishPage(1, 0, 0, false, false))
+        assertTrue(ReaderPartialPagePolicy.shouldPublishPage(1, 1, 0, false, false))
+        assertTrue(!ReaderPartialPagePolicy.shouldPublishPage(1, 2, 0, false, false))
+    }
+
+    @Test
+    fun previousChapterIsNotPublishedEarly() {
+        assertTrue(!ReaderPartialPagePolicy.shouldPublishPage(-1, 0, 0, true, true))
+        assertTrue(!ReaderPartialPagePolicy.shouldPublishPage(-1, 3, 3, false, true))
+    }
+
+    @Test
+    fun currentChapterPublishesThePageThatHoldsTheReadingPosition() {
+        assertTrue(ReaderPartialPagePolicy.shouldPublishPage(0, 7, 0, true, false))
+        // 分页模式没有别的触发条件:不是当前阅读页就不重绘。
+        assertTrue(!ReaderPartialPagePolicy.shouldPublishPage(0, 1, 5, false, false))
+    }
+
+    @Test
+    fun scrollModeKeepsThreePagesOfLookahead() {
+        // 旧 `max(index - 3, 0) < durPageIndex`:当前第 5 页时,成型到第 7 页仍余 3 页。
+        assertTrue(ReaderPartialPagePolicy.shouldPublishPage(0, 7, 5, false, true))
+        assertTrue(!ReaderPartialPagePolicy.shouldPublishPage(0, 8, 5, false, true))
+        // 当前首页时余量从 0 算起:`max(index-3,0) < 0` 恒为假,前几页成型都不触发,
+        // 旧 View 这时只靠"含 durChapterPos 的页成型"这条。
+        assertTrue(!ReaderPartialPagePolicy.shouldPublishPage(0, 0, 0, false, true))
+        assertTrue(!ReaderPartialPagePolicy.shouldPublishPage(0, 3, 0, false, true))
+        // 当前第 1 页时,第 3 页成型仍在余量内(`max(0,0) < 1`)。
+        assertTrue(ReaderPartialPagePolicy.shouldPublishPage(0, 3, 1, false, true))
+        assertTrue(!ReaderPartialPagePolicy.shouldPublishPage(0, 4, 1, false, true))
+    }
+
+    @Test
+    fun streamingChapterRefusesToSkipItsRemainingPages() {
+        // 本章还在排:向前翻会跳过没成型的页。
+        assertTrue(
+            !ReaderPartialPagePolicy.allowsChapterMove(
+                fromChapterIndex = 4,
+                targetChapterIndex = 5,
+                fromChapterStreaming = true,
+                targetChapterStreaming = false,
+            )
+        )
+        assertTrue(
+            ReaderPartialPagePolicy.allowsChapterMove(
+                fromChapterIndex = 4,
+                targetChapterIndex = 5,
+                fromChapterStreaming = false,
+                targetChapterStreaming = false,
+            )
+        )
+    }
+
+    @Test
+    fun streamingPreviousChapterRefusesBackwardEntry() {
+        // 旧 `moveToPrev`:`prevChapter.isCompleted == false` 时拒绝后退。
+        assertTrue(
+            !ReaderPartialPagePolicy.allowsChapterMove(
+                fromChapterIndex = 4,
+                targetChapterIndex = 3,
+                fromChapterStreaming = false,
+                targetChapterStreaming = true,
+            )
+        )
+        assertTrue(
+            ReaderPartialPagePolicy.allowsChapterMove(
+                fromChapterIndex = 4,
+                targetChapterIndex = 3,
+                fromChapterStreaming = false,
+                targetChapterStreaming = false,
+            )
+        )
+    }
+
+    @Test
+    fun withinChapterTurnsAreAlwaysAllowed() {
+        assertTrue(
+            ReaderPartialPagePolicy.allowsChapterMove(
+                fromChapterIndex = 4,
+                targetChapterIndex = 4,
+                fromChapterStreaming = true,
+                targetChapterStreaming = true,
+            )
+        )
+    }
+}

+ 0 - 6
app/src/test/java/io/legado/app/feature/reader/core/selection/ReaderSelectionLifecyclePolicyTest.kt

@@ -24,12 +24,6 @@ class ReaderSelectionLifecyclePolicyTest {
         )
     }
 
-    @Test
-    fun `auto page pauses only while a selection exists`() {
-        assertTrue(ReaderSelectionLifecyclePolicy.shouldPauseAutoPage(hasSelection = true))
-        assertFalse(ReaderSelectionLifecyclePolicy.shouldPauseAutoPage(hasSelection = false))
-    }
-
     @Test
     fun `visible selection menu is reanchored only for real geometry change`() {
         assertTrue(

+ 0 - 31
app/src/test/java/io/legado/app/feature/reader/core/transition/ReaderAutoPagePolicyTest.kt

@@ -1,8 +1,6 @@
 package io.legado.app.feature.reader.core.transition
 
 import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertTrue
 import org.junit.Test
 
 class ReaderAutoPagePolicyTest {
@@ -19,35 +17,6 @@ class ReaderAutoPagePolicyTest {
         assertEquals(120_000L, ReaderAutoPagePolicy.pageDurationMillis(121))
     }
 
-    @Test
-    fun `pausing a discrete timer preserves only its unelapsed duration`() {
-        assertEquals(7_000L, ReaderAutoPagePolicy.remainingAfterPause(10_000L, 3_000L))
-        assertEquals(1L, ReaderAutoPagePolicy.remainingAfterPause(10_000L, 12_000L))
-        assertEquals(10_000L, ReaderAutoPagePolicy.remainingAfterPause(10_000L, -1L))
-    }
-
-    @Test
-    fun `menu and text selection both preserve the discrete timer remainder`() {
-        assertTrue(
-            ReaderAutoPagePolicy.shouldPreserveRemainingTime(
-                menuPaused = true,
-                selectionPaused = false,
-            )
-        )
-        assertTrue(
-            ReaderAutoPagePolicy.shouldPreserveRemainingTime(
-                menuPaused = false,
-                selectionPaused = true,
-            )
-        )
-        assertFalse(
-            ReaderAutoPagePolicy.shouldPreserveRemainingTime(
-                menuPaused = false,
-                selectionPaused = false,
-            )
-        )
-    }
-
     @Test
     fun `reveal indicator occupies the pixel immediately above progress`() {
         assertEquals(0f, ReaderAutoPagePolicy.indicatorTopPx(0f, 800f), 0f)